719 lines
44 KiB
TypeScript
719 lines
44 KiB
TypeScript
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
|
||
import { Button, Checker, ColorField, ControlRow, GlassSurface, Icon, IconButton, Inspector, RangeControl, Window } from "@nodedc/ui-react";
|
||
import type {
|
||
CameraSpiralState,
|
||
CesiumMapRendererHandle,
|
||
MapCameraView,
|
||
MapGatewayHealth,
|
||
MapPresentation,
|
||
MapProviderStatus,
|
||
} from "./CesiumMapRenderer.js";
|
||
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
|
||
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
|
||
|
||
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
|
||
|
||
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
|
||
export type MapPageSettings = Omit<MapPresentation, "cacheRefresh">;
|
||
type MapRuntimeConfig = { gatewayHealthUrl?: string | null; resourceProxyBase?: string | null };
|
||
type GatewayCheckState = "idle" | "checking" | "ready" | "stale" | "error";
|
||
type GatewayHealthOrder = { nextEpoch: number; latestStartedEpoch: number };
|
||
const RENDERER_GATEWAY_HEALTH_EPOCH = 1;
|
||
|
||
function beginGatewayHealthEpoch(order: GatewayHealthOrder) {
|
||
order.nextEpoch += 1;
|
||
order.latestStartedEpoch = order.nextEpoch;
|
||
return order.nextEpoch;
|
||
}
|
||
|
||
function isLatestGatewayHealthEpoch(order: GatewayHealthOrder, epoch: number) {
|
||
return order.latestStartedEpoch === epoch;
|
||
}
|
||
|
||
function safeGatewayCheckCode(value: unknown, fallback = "gateway_not_ready") {
|
||
const code = value instanceof Error && value.message ? value.message : fallback;
|
||
return code.replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 80) || fallback;
|
||
}
|
||
|
||
function gatewayCheckMessage(code: string, stale: boolean) {
|
||
if (code === "persistent_cache_unavailable") {
|
||
return "Persistent TileCache не подключён: карта не должна продолжать работу с локальной временной папкой.";
|
||
}
|
||
const prefix = stale ? "Текущая проверка не прошла" : "Проверка Platform Map Gateway не прошла";
|
||
const suffix = stale
|
||
? "Показаны последние успешно полученные данные TileCache."
|
||
: "TileCache и runtime profile не изменялись.";
|
||
return `${prefix} (${code}). ${suffix}`;
|
||
}
|
||
|
||
/**
|
||
* Provider-neutral visual binding. Foundry stores this on an Application page
|
||
* instance; a future data binding resolves the live source behind `source`.
|
||
*/
|
||
export type MapPinBinding = {
|
||
id: string;
|
||
subjectId: string;
|
||
kind: "elevated-spike";
|
||
label: string;
|
||
status: string;
|
||
coordinates: { longitude: number; latitude: number; heightMeters: number };
|
||
source: { entityId: string; streamId: string; displayFields: string[] };
|
||
attributes: Record<string, string | number | boolean>;
|
||
};
|
||
|
||
/**
|
||
* A renderer-neutral declaration of an entity stream assigned to this page.
|
||
*
|
||
* It intentionally contains no provider endpoint, tenant/connection scope,
|
||
* credential reference, or browser token. The Foundry runtime resolves the
|
||
* matching server-side consumer grant from the application/page/binding
|
||
* target before it asks the External Data Plane for a snapshot or patches.
|
||
*/
|
||
export type MapDataProductBinding = {
|
||
id: string;
|
||
dataProductId: string;
|
||
slotId: string;
|
||
delivery: "snapshot+patch";
|
||
semanticTypes: string[];
|
||
fieldProjection: string[];
|
||
};
|
||
|
||
export type MapPageLayout = {
|
||
schemaVersion: 1;
|
||
pageId: "map";
|
||
settings: MapPageSettings;
|
||
mapHeight: number;
|
||
camera: MapCameraView;
|
||
pinBindings: MapPinBinding[];
|
||
dataProductBindings: MapDataProductBinding[];
|
||
savedAt?: string;
|
||
};
|
||
|
||
export type MapFixturePreviewHandle = {
|
||
getLayout: () => MapPageLayout | null;
|
||
};
|
||
|
||
const initialMapSettings: MapPageSettings = {
|
||
imagerySource: "cesium-live",
|
||
imageryVisible: true,
|
||
cacheEnabled: true,
|
||
cacheNoOverwrite: true,
|
||
terrainEnabled: true,
|
||
terrainExaggeration: 1,
|
||
monochrome: false,
|
||
monochromeColor: "#15151b",
|
||
imageryGamma: 100,
|
||
imageryHue: 0,
|
||
imageryAlpha: 100,
|
||
globeColor: "#15151b",
|
||
backgroundColor: "#08090d",
|
||
atmosphereEnabled: false,
|
||
atmosphereHue: 0,
|
||
atmosphereSaturation: 0,
|
||
atmosphereBrightness: 0,
|
||
fogEnabled: true,
|
||
fogDensity: 2,
|
||
sunEnabled: true,
|
||
sunHour: 12,
|
||
sunIntensity: 200,
|
||
shadowsEnabled: true,
|
||
buildingsVisible: true,
|
||
buildingsColor: "#a27aff",
|
||
buildingsOpacity: 0.82,
|
||
buildingsDetail: 16,
|
||
imageryBrightness: 100,
|
||
imageryContrast: 100,
|
||
imagerySaturation: 100,
|
||
gridVisible: true,
|
||
gridLodEnabled: true,
|
||
gridHeightMeters: 500,
|
||
gridLod1MaxHeightKm: 10,
|
||
gridLod1StepKm: 1,
|
||
gridLod2MaxHeightKm: 50,
|
||
gridLod2StepKm: 5,
|
||
gridLod3StepKm: 25,
|
||
gridRadiusKm: 40,
|
||
gridLineWidth: 4,
|
||
gridColor: "#f5f5f5",
|
||
gridOpacity: 12,
|
||
gridDotsEnabled: true,
|
||
gridDotsSize: 7,
|
||
gridDotsColor: "#ffffff",
|
||
gridDotsOpacity: 58,
|
||
};
|
||
|
||
// A valid, deterministic scene view is available before Cesium emits its
|
||
// first move-end event. It makes the page contract immediately saveable;
|
||
// the renderer replaces it with the exact live camera as soon as it is ready.
|
||
const fallbackMapCamera: MapCameraView = {
|
||
longitude: sceneFixture.viewport.center[0],
|
||
latitude: sceneFixture.viewport.center[1],
|
||
height: sceneFixture.viewport.range,
|
||
heading: (sceneFixture.viewport.heading * Math.PI) / 180,
|
||
pitch: (sceneFixture.viewport.pitch * Math.PI) / 180,
|
||
roll: 0,
|
||
};
|
||
|
||
const initialProviderStatus: MapProviderStatus = {
|
||
imagery: "loading",
|
||
terrain: "loading",
|
||
buildings: "loading",
|
||
errors: {},
|
||
};
|
||
|
||
const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
|
||
loading: "загружается",
|
||
ready: "готов",
|
||
error: "недоступен",
|
||
"not-configured": "не настроен",
|
||
};
|
||
|
||
const logarithmicControlValue = (value: number) => Math.log10(Math.max(Number.MIN_VALUE, value));
|
||
const valueFromLogarithmicControl = (value: number) => Math.max(1, Math.round(10 ** value));
|
||
const formatMetricDistance = (value: number) => value >= 1000
|
||
? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: value >= 10_000 ? 0 : 1 })} км`
|
||
: `${Math.round(value)} м`;
|
||
const formatMetricSpeed = (value: number) => value >= 1000
|
||
? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км/с`
|
||
: `${Math.round(value)} м/с`;
|
||
|
||
function spiralStopMessage(reason: CameraSpiralState["reason"]) {
|
||
if (reason === "spiral_extent_limit") return "Режим остановлен у безопасной границы геодезического маршрута.";
|
||
if (reason === "terrain_sampling_error") return "Режим остановлен: не удалось получить высоту terrain для следующего участка.";
|
||
if (reason === "spiral_runtime_error" || reason === "render_error") return "Режим остановлен из-за ошибки рендера камеры.";
|
||
if (reason === "renderer_restarted") return "Режим остановлен после обновления renderer.";
|
||
return null;
|
||
}
|
||
|
||
export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||
features?: PreviewFeatures;
|
||
expanded?: boolean;
|
||
initialLayout?: MapPageLayout | null;
|
||
applicationId?: string;
|
||
pageId?: string;
|
||
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) {
|
||
const fixtureSelectable = useMemo(() => [
|
||
...sceneFixture.scene.movingObjects.map((entity) => ({ id: entity.id, title: entity.label.text, kind: entity.objectType, status: entity.status })),
|
||
...sceneFixture.scene.stations.map((entity) => ({ id: entity.id, title: entity.label.text, kind: `${entity.stationType} station`, status: undefined })),
|
||
], []);
|
||
const [selectedId, setSelectedId] = useState(sceneFixture.selection.entityId ?? fixtureSelectable[0]?.id);
|
||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||
const [layersOpen, setLayersOpen] = useState(false);
|
||
const [toolbarOpen, setToolbarOpen] = useState(Boolean(features.toolbar));
|
||
const [assistantOpen, setAssistantOpen] = useState(false);
|
||
const [mapSettings, setMapSettings] = useState<MapPageSettings>(() => ({
|
||
...initialMapSettings,
|
||
...initialLayout?.settings,
|
||
// Layouts saved before the cache policy field existed retain the safe
|
||
// append-only default when they are opened again.
|
||
cacheNoOverwrite: initialLayout?.settings?.cacheNoOverwrite ?? true,
|
||
}));
|
||
const [mapHeight, setMapHeight] = useState(() => initialLayout?.mapHeight ?? (expanded ? 620 : 470));
|
||
const [mapCamera, setMapCamera] = useState<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera);
|
||
const mapRendererRef = useRef<CesiumMapRendererHandle | null>(null);
|
||
const [mapRendererReady, setMapRendererReady] = useState(false);
|
||
const [animationModeEnabled, setAnimationModeEnabled] = useState(false);
|
||
const [spiralRunning, setSpiralRunning] = useState(false);
|
||
const [spiralHeightMeters, setSpiralHeightMeters] = useState(1500);
|
||
const [spiralSpeedMetersPerSecond, setSpiralSpeedMetersPerSecond] = useState(250);
|
||
const [spiralPitchMetersPerTurn, setSpiralPitchMetersPerTurn] = useState(10_000);
|
||
const [spiralMessage, setSpiralMessage] = useState<string | null>(null);
|
||
// Map pin bindings belong to the application page instance. They are kept
|
||
// intact when a human changes camera or visual settings and presses Save.
|
||
const [pinBindings] = useState<MapPinBinding[]>(() => initialLayout?.pinBindings ?? []);
|
||
// Data-product bindings are provisioned by Foundry MCP / Platform and do
|
||
// not belong to the visual inspector. Preserve them verbatim when a human
|
||
// edits camera or presentation settings and saves the page layout.
|
||
const [dataProductBindings] = useState<MapDataProductBinding[]>(() => initialLayout?.dataProductBindings ?? []);
|
||
const runtimeBindings = useMapDataProductRuntime({
|
||
applicationId,
|
||
pageId,
|
||
bindings: dataProductBindings,
|
||
enabled: Boolean(applicationId && pageId),
|
||
});
|
||
const selectable = useMemo(() => [
|
||
...fixtureSelectable,
|
||
...runtimeBindings.flatMap((binding) => binding.facts.map((fact) => {
|
||
const attributes = fact.attributes;
|
||
const label = [attributes.label, attributes.name, attributes.title, attributes.subject_id]
|
||
.find((value) => typeof value === "string" && value.trim());
|
||
const status = typeof attributes.status === "string" ? attributes.status : undefined;
|
||
return {
|
||
id: mapRuntimeEntityId(binding.bindingId, fact),
|
||
title: typeof label === "string" ? label : fact.sourceId,
|
||
kind: fact.semanticType,
|
||
status,
|
||
};
|
||
})),
|
||
], [fixtureSelectable, runtimeBindings]);
|
||
// The header Save action can be pressed immediately after Cesium finishes
|
||
// constructing the scene. Keep the last camera synchronously as well as in
|
||
// state, so the imperative page-layout contract never waits for React's
|
||
// render cycle to publish a ready camera.
|
||
const mapCameraRef = useRef<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera);
|
||
const [rendererRevision, setRendererRevision] = useState(0);
|
||
const [gatewayHealth, setGatewayHealth] = useState<MapGatewayHealth | null>(null);
|
||
const gatewayHealthRef = useRef<MapGatewayHealth | null>(null);
|
||
// The renderer owns epoch 1 as a bootstrap health source. As soon as the UI
|
||
// starts an explicit verification, its higher epoch becomes authoritative;
|
||
// a slower renderer request can no longer overwrite that newer result.
|
||
const gatewayHealthOrderRef = useRef<GatewayHealthOrder>({
|
||
nextEpoch: RENDERER_GATEWAY_HEALTH_EPOCH,
|
||
latestStartedEpoch: RENDERER_GATEWAY_HEALTH_EPOCH,
|
||
});
|
||
const gatewayCheckRequestRef = useRef<{ id: symbol; controller: AbortController; promise: Promise<void> } | null>(null);
|
||
const [gatewayEndpoint, setGatewayEndpoint] = useState<string | null>(null);
|
||
const [gatewayCheckState, setGatewayCheckState] = useState<GatewayCheckState>("idle");
|
||
const [gatewayCheckError, setGatewayCheckError] = useState<string | null>(null);
|
||
const [gatewayLastVerifiedAt, setGatewayLastVerifiedAt] = useState<Date | null>(null);
|
||
const [providerStatus, setProviderStatus] = useState<MapProviderStatus>(initialProviderStatus);
|
||
const [cacheRefresh, setCacheRefresh] = useState(false);
|
||
const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0];
|
||
const presentation = useMemo<MapPresentation>(
|
||
() => ({ ...mapSettings, cacheRefresh }),
|
||
[cacheRefresh, mapSettings],
|
||
);
|
||
const updateMapSettings = (patch: Partial<MapPageSettings>) => setMapSettings((current) => ({ ...current, ...patch }));
|
||
const setCacheEnabled = (cacheEnabled: boolean) => {
|
||
updateMapSettings({ cacheEnabled });
|
||
setRendererRevision((value) => value + 1);
|
||
};
|
||
const setCacheNoOverwrite = (cacheNoOverwrite: boolean) => {
|
||
updateMapSettings({ cacheNoOverwrite });
|
||
setCacheRefresh(false);
|
||
setRendererRevision((value) => value + 1);
|
||
};
|
||
const refreshCurrentViewport = () => {
|
||
if (!mapSettings.cacheEnabled) return;
|
||
setCacheRefresh(true);
|
||
setRendererRevision((value) => value + 1);
|
||
};
|
||
const handleCacheRefreshConsumed = useCallback(() => {
|
||
setCacheRefresh(false);
|
||
setRendererRevision((value) => value + 1);
|
||
}, []);
|
||
|
||
const handleCameraChange = useCallback((camera: MapCameraView) => {
|
||
mapCameraRef.current = camera;
|
||
setMapCamera(camera);
|
||
}, []);
|
||
|
||
const handleSpiralStateChange = useCallback((state: CameraSpiralState) => {
|
||
setSpiralRunning(state.running);
|
||
setSpiralMessage(state.running ? null : spiralStopMessage(state.reason));
|
||
}, []);
|
||
|
||
const setAnimationMode = (enabled: boolean) => {
|
||
setAnimationModeEnabled(enabled);
|
||
setSpiralMessage(null);
|
||
if (!enabled) {
|
||
mapRendererRef.current?.stopSpiralAnimation("mode_disabled");
|
||
setSpiralRunning(false);
|
||
}
|
||
};
|
||
|
||
const toggleSpiralAnimation = () => {
|
||
if (spiralRunning) {
|
||
mapRendererRef.current?.stopSpiralAnimation("stopped");
|
||
return;
|
||
}
|
||
const started = mapRendererRef.current?.startSpiralAnimation({
|
||
heightAboveGroundMeters: spiralHeightMeters,
|
||
speedMetersPerSecond: spiralSpeedMetersPerSecond,
|
||
pitchMetersPerTurn: spiralPitchMetersPerTurn,
|
||
}) ?? false;
|
||
if (!started) setSpiralMessage("Карта ещё не готова к запуску режима анимации.");
|
||
};
|
||
|
||
useImperativeHandle(ref, () => ({
|
||
getLayout: () => ({
|
||
schemaVersion: 1,
|
||
pageId: "map",
|
||
settings: mapSettings,
|
||
mapHeight: Math.round(mapHeight),
|
||
camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera,
|
||
pinBindings,
|
||
dataProductBindings,
|
||
}),
|
||
}), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings]);
|
||
|
||
const handleSelect = useCallback((entityId: string) => {
|
||
if (!selectable.some((entity) => entity.id === entityId)) return;
|
||
setSelectedId(entityId);
|
||
}, [selectable]);
|
||
|
||
const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => {
|
||
gatewayHealthRef.current = health;
|
||
setGatewayHealth(health);
|
||
setGatewayLastVerifiedAt(new Date());
|
||
}, []);
|
||
|
||
const beginGatewayHealthVerification = useCallback(() => {
|
||
return beginGatewayHealthEpoch(gatewayHealthOrderRef.current);
|
||
}, []);
|
||
|
||
const isLatestGatewayHealthRequest = useCallback((epoch: number) => (
|
||
isLatestGatewayHealthEpoch(gatewayHealthOrderRef.current, epoch)
|
||
), []);
|
||
|
||
const handleRendererGatewayHealth = useCallback((health: MapGatewayHealth | null) => {
|
||
if (!isLatestGatewayHealthRequest(RENDERER_GATEWAY_HEALTH_EPOCH)) return;
|
||
if (health?.cache?.persistent === true) {
|
||
rememberGatewayHealth(health);
|
||
setGatewayCheckError(null);
|
||
setGatewayCheckState("ready");
|
||
return;
|
||
}
|
||
const stale = Boolean(gatewayHealthRef.current);
|
||
const code = health ? "persistent_cache_unavailable" : "gateway_health_unavailable";
|
||
setGatewayCheckError(gatewayCheckMessage(code, stale));
|
||
setGatewayCheckState(stale ? "stale" : "error");
|
||
}, [isLatestGatewayHealthRequest, rememberGatewayHealth]);
|
||
|
||
const verifyGateway = useCallback(() => {
|
||
const pending = gatewayCheckRequestRef.current;
|
||
if (pending) return pending.promise;
|
||
const epoch = beginGatewayHealthVerification();
|
||
const id = Symbol("gateway-check");
|
||
const controller = new AbortController();
|
||
let timedOut = false;
|
||
let stage: "runtime" | "gateway_health" = "runtime";
|
||
setGatewayCheckState("checking");
|
||
setGatewayCheckError(null);
|
||
const timeout = window.setTimeout(() => {
|
||
timedOut = true;
|
||
controller.abort();
|
||
}, 10_000);
|
||
const promise = (async () => {
|
||
try {
|
||
const runtimeResponse = await fetch("/api/map/runtime-config", { cache: "no-store", signal: controller.signal });
|
||
if (!runtimeResponse.ok) throw new Error(`runtime_http_${runtimeResponse.status}`);
|
||
let runtime: MapRuntimeConfig;
|
||
try {
|
||
runtime = await runtimeResponse.json() as MapRuntimeConfig;
|
||
} catch {
|
||
throw new Error("runtime_invalid_response");
|
||
}
|
||
if (!runtime?.gatewayHealthUrl) throw new Error("gateway_not_configured");
|
||
stage = "gateway_health";
|
||
const healthResponse = await fetch(runtime.gatewayHealthUrl, { cache: "no-store", signal: controller.signal });
|
||
if (!healthResponse.ok) throw new Error(`gateway_health_http_${healthResponse.status}`);
|
||
let health: MapGatewayHealth;
|
||
try {
|
||
health = await healthResponse.json() as MapGatewayHealth;
|
||
} catch {
|
||
throw new Error("gateway_health_invalid_response");
|
||
}
|
||
if (health.cache?.persistent !== true) throw new Error("persistent_cache_unavailable");
|
||
if (!isLatestGatewayHealthRequest(epoch)) return;
|
||
rememberGatewayHealth(health);
|
||
// Runtime configuration deliberately exposes a same-origin relative
|
||
// route. Resolve it against the current browser origin before displaying
|
||
// the connection; new URL("/api/…") without a base throws and used to
|
||
// turn a healthy Gateway into a false "unavailable" state.
|
||
setGatewayEndpoint(new URL(runtime.gatewayHealthUrl, window.location.origin).origin);
|
||
setGatewayCheckState("ready");
|
||
} catch (error) {
|
||
if (controller.signal.aborted && !timedOut) return;
|
||
if (!isLatestGatewayHealthRequest(epoch)) return;
|
||
const code = timedOut
|
||
? "gateway_check_timeout"
|
||
: error instanceof TypeError
|
||
? `${stage}_network_error`
|
||
: safeGatewayCheckCode(error);
|
||
const stale = Boolean(gatewayHealthRef.current);
|
||
if (!stale) setGatewayEndpoint(null);
|
||
setGatewayCheckError(gatewayCheckMessage(code, stale));
|
||
setGatewayCheckState(stale ? "stale" : "error");
|
||
} finally {
|
||
window.clearTimeout(timeout);
|
||
if (gatewayCheckRequestRef.current?.id === id) gatewayCheckRequestRef.current = null;
|
||
}
|
||
})();
|
||
gatewayCheckRequestRef.current = { id, controller, promise };
|
||
return promise;
|
||
}, [beginGatewayHealthVerification, isLatestGatewayHealthRequest, rememberGatewayHealth]);
|
||
|
||
useEffect(() => {
|
||
if (!inspectorOpen && !layersOpen) return;
|
||
void verifyGateway();
|
||
const interval = window.setInterval(() => void verifyGateway(), 15_000);
|
||
return () => {
|
||
window.clearInterval(interval);
|
||
const pending = gatewayCheckRequestRef.current;
|
||
if (pending) {
|
||
gatewayCheckRequestRef.current = null;
|
||
pending.controller.abort();
|
||
}
|
||
};
|
||
}, [inspectorOpen, layersOpen, verifyGateway]);
|
||
|
||
const liveCacheStatus = gatewayHealth?.cache;
|
||
const liveCacheSummary = liveCacheStatus
|
||
? `${liveCacheStatus.entries ?? 0} объектов · ${Math.round((liveCacheStatus.bytes ?? 0) / 1024 / 1024)} / ${Math.round((liveCacheStatus.maxBytes ?? 0) / 1024 / 1024) || "?"} MB`
|
||
: "индекс ещё не получен";
|
||
const transportDiagnostic = gatewayHealth?.diagnostics?.lastFailure
|
||
? `Последняя transport-ошибка: ${gatewayHealth.diagnostics.lastFailure}${gatewayHealth.diagnostics.lastFailureAt ? ` · ${new Date(gatewayHealth.diagnostics.lastFailureAt).toLocaleTimeString()}` : ""}`
|
||
: null;
|
||
const gatewayHealthAge = gatewayCheckState === "stale" && gatewayLastVerifiedAt
|
||
? `Последняя успешная проверка: ${gatewayLastVerifiedAt.toLocaleTimeString()}`
|
||
: null;
|
||
|
||
const startResize = (event: PointerEvent<HTMLButtonElement>) => {
|
||
event.preventDefault();
|
||
const startY = event.clientY;
|
||
const startHeight = mapHeight;
|
||
const onMove = (move: globalThis.PointerEvent) => {
|
||
const maximum = Math.max(380, Math.round(window.innerHeight * 0.78));
|
||
setMapHeight(Math.max(360, Math.min(maximum, startHeight + move.clientY - startY)));
|
||
};
|
||
const onEnd = () => {
|
||
window.removeEventListener("pointermove", onMove);
|
||
window.removeEventListener("pointerup", onEnd);
|
||
window.removeEventListener("pointercancel", onEnd);
|
||
};
|
||
window.addEventListener("pointermove", onMove);
|
||
window.addEventListener("pointerup", onEnd);
|
||
window.addEventListener("pointercancel", onEnd);
|
||
};
|
||
|
||
const inspectorSections = [
|
||
{
|
||
id: "map-base",
|
||
label: "Подложка и terrain",
|
||
description: "provider-neutral surface",
|
||
group: "Карта",
|
||
content: <>
|
||
<ControlRow label="Подложка"><strong>Cesium World Imagery</strong></ControlRow>
|
||
<small className="catalog-map-inspector__note">Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform.</small>
|
||
<ControlRow label="Live providers"><span>Imagery: {providerStateLabel[providerStatus.imagery]} · Terrain: {providerStateLabel[providerStatus.terrain]} · 3D: {providerStateLabel[providerStatus.buildings]}</span></ControlRow>
|
||
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
|
||
<small className="catalog-map-inspector__note">Рельеф — отдельный слой под imagery.</small>
|
||
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
|
||
<RangeControl label="Вертикальное преувеличение рельефа" value={mapSettings.terrainExaggeration * 100} min={25} max={300} formatValue={(value) => `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} />
|
||
<Checker checked={mapSettings.monochrome} label="Монохромная поверхность" onChange={(monochrome) => updateMapSettings({ monochrome })} />
|
||
<ControlRow label="Цвет монохрома"><ColorField label="Цвет монохромной поверхности" value={mapSettings.monochromeColor} onChange={(monochromeColor) => updateMapSettings({ monochromeColor })} /></ControlRow>
|
||
<RangeControl label="Яркость" value={mapSettings.imageryBrightness} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imageryBrightness) => updateMapSettings({ imageryBrightness })} />
|
||
<RangeControl label="Контраст" value={mapSettings.imageryContrast} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imageryContrast) => updateMapSettings({ imageryContrast })} />
|
||
<RangeControl label="Насыщенность" value={mapSettings.imagerySaturation} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imagerySaturation) => updateMapSettings({ imagerySaturation })} />
|
||
<RangeControl label="Гамма" value={mapSettings.imageryGamma} min={0} max={300} formatValue={(value) => `${value}%`} onChange={(imageryGamma) => updateMapSettings({ imageryGamma })} />
|
||
<RangeControl label="Оттенок" value={mapSettings.imageryHue} min={-180} max={180} formatValue={(value) => `${value}°`} onChange={(imageryHue) => updateMapSettings({ imageryHue })} />
|
||
<RangeControl label="Прозрачность imagery" value={mapSettings.imageryAlpha} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(imageryAlpha) => updateMapSettings({ imageryAlpha })} />
|
||
<ControlRow label="Цвет планеты"><ColorField label="Цвет terrain без imagery" value={mapSettings.globeColor} onChange={(globeColor) => updateMapSettings({ globeColor })} /></ControlRow>
|
||
<ControlRow label="Фон сцены"><ColorField label="Цвет фона сцены" value={mapSettings.backgroundColor} onChange={(backgroundColor) => updateMapSettings({ backgroundColor })} /></ControlRow>
|
||
</>,
|
||
},
|
||
{
|
||
id: "map-atmosphere",
|
||
label: "Атмосфера и освещение",
|
||
description: "scene / color correction",
|
||
group: "Карта",
|
||
content: <>
|
||
<Checker checked={mapSettings.atmosphereEnabled} label="Показывать атмосферу" onChange={(atmosphereEnabled) => updateMapSettings({ atmosphereEnabled })} />
|
||
<RangeControl label="Атмосфера: оттенок" value={mapSettings.atmosphereHue} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereHue) => updateMapSettings({ atmosphereHue })} />
|
||
<RangeControl label="Атмосфера: насыщенность" value={mapSettings.atmosphereSaturation} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereSaturation) => updateMapSettings({ atmosphereSaturation })} />
|
||
<RangeControl label="Атмосфера: яркость" value={mapSettings.atmosphereBrightness} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereBrightness) => updateMapSettings({ atmosphereBrightness })} />
|
||
<Checker checked={mapSettings.fogEnabled} label="Туман" onChange={(fogEnabled) => updateMapSettings({ fogEnabled })} />
|
||
<RangeControl label="Плотность тумана" value={mapSettings.fogDensity} min={0} max={100} formatValue={(value) => `${(value / 10000).toFixed(4)}`} onChange={(fogDensity) => updateMapSettings({ fogDensity })} />
|
||
<Checker checked={mapSettings.sunEnabled} label="Солнечное освещение" onChange={(sunEnabled) => updateMapSettings({ sunEnabled })} />
|
||
<RangeControl label="Час солнца" value={mapSettings.sunHour} min={0} max={24} formatValue={(value) => `${value}:00 UTC`} onChange={(sunHour) => updateMapSettings({ sunHour })} />
|
||
<RangeControl label="Интенсивность света" value={mapSettings.sunIntensity} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(sunIntensity) => updateMapSettings({ sunIntensity })} />
|
||
<Checker checked={mapSettings.shadowsEnabled} label="Тени" onChange={(shadowsEnabled) => updateMapSettings({ shadowsEnabled })} />
|
||
</>,
|
||
},
|
||
{
|
||
id: "map-buildings",
|
||
label: "3D здания",
|
||
description: "3D Tiles / detail",
|
||
group: "Карта",
|
||
content: <>
|
||
<Checker checked={mapSettings.buildingsVisible} label="Показывать 3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
|
||
<ControlRow label="Цвет"><ColorField label="Цвет зданий" value={mapSettings.buildingsColor} onChange={(buildingsColor) => updateMapSettings({ buildingsColor })} /></ControlRow>
|
||
<RangeControl label="Прозрачность" value={Math.round(mapSettings.buildingsOpacity * 100)} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(value) => updateMapSettings({ buildingsOpacity: value / 100 })} />
|
||
<RangeControl label="Детализация" value={mapSettings.buildingsDetail} min={4} max={32} formatValue={(value) => `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} />
|
||
</>,
|
||
},
|
||
{
|
||
id: "map-grid",
|
||
label: "Сетка и LOD",
|
||
description: "first adapter control",
|
||
group: "Слои",
|
||
content: <>
|
||
<small className="catalog-map-inspector__note">Сетка размещается над поверхностью и меняет шаг по высоте камеры.</small>
|
||
<Checker checked={mapSettings.gridVisible} label="3D-сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
|
||
<Checker checked={mapSettings.gridLodEnabled} label="LOD по высоте камеры" onChange={(gridLodEnabled) => updateMapSettings({ gridLodEnabled })} />
|
||
<RangeControl label="Высота над поверхностью" value={mapSettings.gridHeightMeters} min={0} max={1000} formatValue={(value) => `${value} м`} onChange={(gridHeightMeters) => updateMapSettings({ gridHeightMeters })} />
|
||
<RangeControl label="LOD 1: до высоты" value={mapSettings.gridLod1MaxHeightKm} min={1} max={50} formatValue={(value) => `${value} км`} onChange={(gridLod1MaxHeightKm) => updateMapSettings({ gridLod1MaxHeightKm })} />
|
||
<RangeControl label="LOD 1: шаг" value={mapSettings.gridLod1StepKm} min={1} max={10} formatValue={(value) => `${value} км`} onChange={(gridLod1StepKm) => updateMapSettings({ gridLod1StepKm })} />
|
||
<RangeControl label="LOD 2: до высоты" value={mapSettings.gridLod2MaxHeightKm} min={10} max={200} formatValue={(value) => `${value} км`} onChange={(gridLod2MaxHeightKm) => updateMapSettings({ gridLod2MaxHeightKm })} />
|
||
<RangeControl label="LOD 2: шаг" value={mapSettings.gridLod2StepKm} min={1} max={25} formatValue={(value) => `${value} км`} onChange={(gridLod2StepKm) => updateMapSettings({ gridLod2StepKm })} />
|
||
<RangeControl label="LOD 3: шаг" value={mapSettings.gridLod3StepKm} min={5} max={100} formatValue={(value) => `${value} км`} onChange={(gridLod3StepKm) => updateMapSettings({ gridLod3StepKm })} />
|
||
<RangeControl label="Радиус сетки" value={mapSettings.gridRadiusKm} min={5} max={150} formatValue={(value) => `${value} км`} onChange={(gridRadiusKm) => updateMapSettings({ gridRadiusKm })} />
|
||
<ControlRow label="Цвет линий"><ColorField label="Цвет линий сетки" value={mapSettings.gridColor} onChange={(gridColor) => updateMapSettings({ gridColor })} /></ControlRow>
|
||
<RangeControl label="Толщина линий" value={mapSettings.gridLineWidth} min={1} max={8} formatValue={(value) => `${value} px`} onChange={(gridLineWidth) => updateMapSettings({ gridLineWidth })} />
|
||
<RangeControl label="Прозрачность сетки" value={mapSettings.gridOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(gridOpacity) => updateMapSettings({ gridOpacity })} />
|
||
<Checker checked={mapSettings.gridDotsEnabled} label="Точки в пересечениях" onChange={(gridDotsEnabled) => updateMapSettings({ gridDotsEnabled })} />
|
||
<RangeControl label="Размер точки" value={mapSettings.gridDotsSize} min={2} max={28} formatValue={(value) => `${value} px`} onChange={(gridDotsSize) => updateMapSettings({ gridDotsSize })} />
|
||
<ControlRow label="Цвет точек"><ColorField label="Цвет точек сетки" value={mapSettings.gridDotsColor} onChange={(gridDotsColor) => updateMapSettings({ gridDotsColor })} /></ControlRow>
|
||
<RangeControl label="Прозрачность точек" value={mapSettings.gridDotsOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(gridDotsOpacity) => updateMapSettings({ gridDotsOpacity })} />
|
||
</>,
|
||
},
|
||
{
|
||
id: "map-camera-animation",
|
||
label: "Анимация камеры",
|
||
description: "geodesic spiral survey",
|
||
group: "Камера",
|
||
content: <>
|
||
<Checker checked={animationModeEnabled} label="Режим анимации" onChange={setAnimationMode} />
|
||
{animationModeEnabled ? <>
|
||
<small className="catalog-map-inspector__note">Стартовая точка берётся из текущей позиции камеры. Высота считается над terrain; при догрузке следующего участка маршрут ждёт данные, а не подменяет поверхность нулём. Движение идёт по региональной геодезической спирали WGS84 до 250 км от старта.</small>
|
||
<RangeControl
|
||
label="Высота над землёй"
|
||
value={logarithmicControlValue(spiralHeightMeters)}
|
||
min={logarithmicControlValue(10)}
|
||
max={logarithmicControlValue(100_000)}
|
||
step={0.01}
|
||
disabled={spiralRunning}
|
||
formatValue={(value) => formatMetricDistance(10 ** value)}
|
||
onChange={(value) => setSpiralHeightMeters(valueFromLogarithmicControl(value))}
|
||
/>
|
||
<RangeControl
|
||
label="Скорость камеры"
|
||
value={logarithmicControlValue(spiralSpeedMetersPerSecond)}
|
||
min={logarithmicControlValue(1)}
|
||
max={logarithmicControlValue(5_000)}
|
||
step={0.01}
|
||
disabled={spiralRunning}
|
||
formatValue={(value) => formatMetricSpeed(10 ** value)}
|
||
onChange={(value) => setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value))}
|
||
/>
|
||
<RangeControl
|
||
label="Шаг спирали"
|
||
value={logarithmicControlValue(spiralPitchMetersPerTurn)}
|
||
min={logarithmicControlValue(20)}
|
||
max={logarithmicControlValue(100_000)}
|
||
step={0.01}
|
||
disabled={spiralRunning}
|
||
formatValue={(value) => formatMetricDistance(10 ** value)}
|
||
onChange={(value) => setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value))}
|
||
/>
|
||
<Button variant="secondary" shape="pill" onClick={toggleSpiralAnimation} disabled={!mapRendererReady}>{spiralRunning ? "Остановить" : "Запустить режим анимации"}</Button>
|
||
{spiralRunning ? <small className="catalog-map-inspector__note">Камера движется от исходной точки. Выключение режима, уход со страницы или reload остановят сессию.</small> : null}
|
||
{spiralMessage ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="status">{spiralMessage}</small> : null}
|
||
</> : null}
|
||
</>,
|
||
},
|
||
{
|
||
id: "map-cache",
|
||
label: "TileCache",
|
||
description: "Platform Map Gateway",
|
||
group: "Хранение",
|
||
content: <>
|
||
<small className="catalog-map-inspector__note">Общий persistent cache Platform: он не принадлежит приложению, странице или пользователю.</small>
|
||
<Checker checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
|
||
<small className="catalog-map-inspector__note">Cache hit отдаётся как есть; новый tile записывается только при miss.</small>
|
||
<Checker checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать уже полученное" onChange={setCacheNoOverwrite} />
|
||
<ControlRow className="catalog-map-inspector__cache-fact" label="Режим"><span>{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"}</span></ControlRow>
|
||
<ControlRow className="catalog-map-inspector__cache-fact" label="Хранилище"><span>Platform Map Gateway</span></ControlRow>
|
||
<ControlRow className="catalog-map-inspector__cache-fact" label="Подключение"><span>{gatewayEndpoint ?? "runtime profile · не проверено"}</span></ControlRow>
|
||
<ControlRow className="catalog-map-inspector__cache-fact" label="Записано"><span>{liveCacheSummary}</span></ControlRow>
|
||
<ControlRow className="catalog-map-inspector__cache-fact" label="Политика"><span>{gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"}</span></ControlRow>
|
||
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={refreshCurrentViewport} disabled={!mapSettings.cacheEnabled || cacheRefresh}> {cacheRefresh ? "Обновляем viewport…" : "Обновить текущий viewport"}</Button>
|
||
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={() => void verifyGateway()} disabled={gatewayCheckState === "checking"}>{gatewayCheckState === "checking" ? "Проверяем Gateway…" : "Проверить подключение"}</Button>
|
||
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"}</small>
|
||
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
|
||
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
|
||
<small className="catalog-map-inspector__note">{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Новые miss дописываются; при заполнении объёма Gateway продолжит live-маршрут без удаления прежних tiles." : "Новые запросы этого Application могут явно обновлять уже записанные tiles." : "Real-time: provider остаётся официальным, чтение и запись persistent cache выключены."}</small>
|
||
{gatewayHealth?.cache?.atCapacity ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">TileCache заполнен: новые tiles показываются live, но не записываются. Существующий cache не удаляется.</small> : null}
|
||
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
|
||
</>,
|
||
},
|
||
{
|
||
id: "map-selection",
|
||
label: "Выбранная сущность",
|
||
description: "selection contract",
|
||
group: "Данные",
|
||
content: <>
|
||
<ControlRow label="Сущность"><strong>{selected?.title ?? "Нет выбора"}</strong></ControlRow>
|
||
<ControlRow label="Тип"><span>{selected?.kind ?? "—"}{selected?.status ? ` · ${selected.status}` : ""}</span></ControlRow>
|
||
</>,
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div
|
||
className={`catalog-map-fixture${expanded ? " catalog-map-fixture--expanded" : ""}`}
|
||
style={{ "--catalog-map-height": `${mapHeight}px` } as CSSProperties}
|
||
aria-label="Map Page Cesium adapter"
|
||
>
|
||
<Suspense fallback={<div className="catalog-map-fixture__loading">Загрузка карты…</div>}>
|
||
<CesiumMapRenderer
|
||
key={rendererRevision}
|
||
ref={mapRendererRef}
|
||
onSelect={handleSelect}
|
||
onGatewayHealth={handleRendererGatewayHealth}
|
||
onProviderStatus={setProviderStatus}
|
||
onCameraChange={handleCameraChange}
|
||
onCacheRefreshConsumed={handleCacheRefreshConsumed}
|
||
onReadyChange={setMapRendererReady}
|
||
onSpiralStateChange={handleSpiralStateChange}
|
||
initialCamera={mapCamera ?? undefined}
|
||
presentation={presentation}
|
||
runtimeBindings={runtimeBindings}
|
||
/>
|
||
</Suspense>
|
||
|
||
<div className="catalog-map-fixture__actions">
|
||
<IconButton label="Настройки карты" aria-pressed={inspectorOpen} data-active={inspectorOpen || undefined} onClick={() => setInspectorOpen(true)}><Icon name="settings" /></IconButton>
|
||
<IconButton label="Слои карты" aria-pressed={layersOpen} data-active={layersOpen || undefined} onClick={() => setLayersOpen((value) => !value)}><Icon name="grid" /></IconButton>
|
||
{features.toolbar ? <IconButton label="Toolbar" aria-pressed={toolbarOpen} data-active={toolbarOpen || undefined} onClick={() => setToolbarOpen((value) => !value)}><Icon name="panel" /></IconButton> : null}
|
||
{features.assistant ? <IconButton label="Assistant" aria-pressed={assistantOpen} data-active={assistantOpen || undefined} onClick={() => setAssistantOpen((value) => !value)}><Icon name="apps" /></IconButton> : null}
|
||
</div>
|
||
|
||
{layersOpen ? (
|
||
<GlassSurface className="catalog-map-fixture__layers" tone="strong" radius="card" padding="sm" aria-label="Настройки слоёв карты">
|
||
<div className="catalog-map-fixture__layers-head"><strong>Слои карты</strong><IconButton label="Закрыть слои" onClick={() => setLayersOpen(false)}><Icon name="close" /></IconButton></div>
|
||
<div className="catalog-map-fixture__provider">
|
||
<strong>Cesium World Imagery</strong>
|
||
<small>официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]}</small>
|
||
</div>
|
||
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
|
||
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
|
||
<Checker checked={mapSettings.buildingsVisible} label="3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
|
||
<Checker checked={mapSettings.gridVisible} label="Планетарная сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
|
||
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary}</small>
|
||
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
|
||
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
|
||
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
|
||
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
|
||
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать cache" onChange={setCacheNoOverwrite} />
|
||
</GlassSurface>
|
||
) : null}
|
||
|
||
{toolbarOpen ? (
|
||
<div className="catalog-map-fixture__toolbar" aria-label="Map toolbar">
|
||
<IconButton label="Обзор"><Icon name="globe" /></IconButton>
|
||
<IconButton label="Поиск"><Icon name="search" /></IconButton>
|
||
</div>
|
||
) : null}
|
||
|
||
{assistantOpen ? <div className="catalog-map-fixture__assistant"><strong>NODE.DC Assistant</strong><span>Контекст выбранной сущности готов к передаче.</span></div> : null}
|
||
<button type="button" className="catalog-map-fixture__resize" aria-label="Изменить высоту карты" onPointerDown={startResize}><span /></button>
|
||
|
||
<Window
|
||
open={inspectorOpen && Boolean(features.inspector)}
|
||
title="Настройки карты"
|
||
subtitle="MAP / draggable inspector"
|
||
placement="end"
|
||
draggable
|
||
closeOnBackdrop={false}
|
||
lockBodyScroll={false}
|
||
trapFocus={false}
|
||
onClose={() => setInspectorOpen(false)}
|
||
>
|
||
<Inspector sections={inspectorSections} defaultOpen={["map-base"]} singleOpen />
|
||
</Window>
|
||
</div>
|
||
);
|
||
});
|