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; 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; }; /** * 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 = { 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(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(() => ({ ...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(initialLayout?.camera ?? fallbackMapCamera); const mapRendererRef = useRef(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(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(() => 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(() => 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(initialLayout?.camera ?? fallbackMapCamera); const [rendererRevision, setRendererRevision] = useState(0); const [gatewayHealth, setGatewayHealth] = useState(null); const gatewayHealthRef = useRef(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({ nextEpoch: RENDERER_GATEWAY_HEALTH_EPOCH, latestStartedEpoch: RENDERER_GATEWAY_HEALTH_EPOCH, }); const gatewayCheckRequestRef = useRef<{ id: symbol; controller: AbortController; promise: Promise } | null>(null); const [gatewayEndpoint, setGatewayEndpoint] = useState(null); const [gatewayCheckState, setGatewayCheckState] = useState("idle"); const [gatewayCheckError, setGatewayCheckError] = useState(null); const [gatewayLastVerifiedAt, setGatewayLastVerifiedAt] = useState(null); const [providerStatus, setProviderStatus] = useState(initialProviderStatus); const [cacheRefresh, setCacheRefresh] = useState(false); const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0]; const presentation = useMemo( () => ({ ...mapSettings, cacheRefresh }), [cacheRefresh, mapSettings], ); const updateMapSettings = (patch: Partial) => 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) => { 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: <> Cesium World Imagery Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform. Imagery: {providerStateLabel[providerStatus.imagery]} · Terrain: {providerStateLabel[providerStatus.terrain]} · 3D: {providerStateLabel[providerStatus.buildings]} {Object.entries(providerStatus.errors).map(([provider, error]) => {provider}: {error})} Рельеф — отдельный слой под imagery. updateMapSettings({ terrainEnabled })} /> `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} /> updateMapSettings({ monochrome })} /> updateMapSettings({ monochromeColor })} /> `${value}%`} onChange={(imageryBrightness) => updateMapSettings({ imageryBrightness })} /> `${value}%`} onChange={(imageryContrast) => updateMapSettings({ imageryContrast })} /> `${value}%`} onChange={(imagerySaturation) => updateMapSettings({ imagerySaturation })} /> `${value}%`} onChange={(imageryGamma) => updateMapSettings({ imageryGamma })} /> `${value}°`} onChange={(imageryHue) => updateMapSettings({ imageryHue })} /> `${value}%`} onChange={(imageryAlpha) => updateMapSettings({ imageryAlpha })} /> updateMapSettings({ globeColor })} /> updateMapSettings({ backgroundColor })} /> , }, { id: "map-atmosphere", label: "Атмосфера и освещение", description: "scene / color correction", group: "Карта", content: <> updateMapSettings({ atmosphereEnabled })} /> `${value}%`} onChange={(atmosphereHue) => updateMapSettings({ atmosphereHue })} /> `${value}%`} onChange={(atmosphereSaturation) => updateMapSettings({ atmosphereSaturation })} /> `${value}%`} onChange={(atmosphereBrightness) => updateMapSettings({ atmosphereBrightness })} /> updateMapSettings({ fogEnabled })} /> `${(value / 10000).toFixed(4)}`} onChange={(fogDensity) => updateMapSettings({ fogDensity })} /> updateMapSettings({ sunEnabled })} /> `${value}:00 UTC`} onChange={(sunHour) => updateMapSettings({ sunHour })} /> `${value}%`} onChange={(sunIntensity) => updateMapSettings({ sunIntensity })} /> updateMapSettings({ shadowsEnabled })} /> , }, { id: "map-buildings", label: "3D здания", description: "3D Tiles / detail", group: "Карта", content: <> updateMapSettings({ buildingsVisible })} /> updateMapSettings({ buildingsColor })} /> `${value}%`} onChange={(value) => updateMapSettings({ buildingsOpacity: value / 100 })} /> `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} /> , }, { id: "map-grid", label: "Сетка и LOD", description: "first adapter control", group: "Слои", content: <> Сетка размещается над поверхностью и меняет шаг по высоте камеры. updateMapSettings({ gridVisible })} /> updateMapSettings({ gridLodEnabled })} /> `${value} м`} onChange={(gridHeightMeters) => updateMapSettings({ gridHeightMeters })} /> `${value} км`} onChange={(gridLod1MaxHeightKm) => updateMapSettings({ gridLod1MaxHeightKm })} /> `${value} км`} onChange={(gridLod1StepKm) => updateMapSettings({ gridLod1StepKm })} /> `${value} км`} onChange={(gridLod2MaxHeightKm) => updateMapSettings({ gridLod2MaxHeightKm })} /> `${value} км`} onChange={(gridLod2StepKm) => updateMapSettings({ gridLod2StepKm })} /> `${value} км`} onChange={(gridLod3StepKm) => updateMapSettings({ gridLod3StepKm })} /> `${value} км`} onChange={(gridRadiusKm) => updateMapSettings({ gridRadiusKm })} /> updateMapSettings({ gridColor })} /> `${value} px`} onChange={(gridLineWidth) => updateMapSettings({ gridLineWidth })} /> `${value}%`} onChange={(gridOpacity) => updateMapSettings({ gridOpacity })} /> updateMapSettings({ gridDotsEnabled })} /> `${value} px`} onChange={(gridDotsSize) => updateMapSettings({ gridDotsSize })} /> updateMapSettings({ gridDotsColor })} /> `${value}%`} onChange={(gridDotsOpacity) => updateMapSettings({ gridDotsOpacity })} /> , }, { id: "map-camera-animation", label: "Анимация камеры", description: "geodesic spiral survey", group: "Камера", content: <> {animationModeEnabled ? <> Стартовая точка берётся из текущей позиции камеры. Высота считается над terrain; при догрузке следующего участка маршрут ждёт данные, а не подменяет поверхность нулём. Движение идёт по региональной геодезической спирали WGS84 до 250 км от старта. formatMetricDistance(10 ** value)} onChange={(value) => setSpiralHeightMeters(valueFromLogarithmicControl(value))} /> formatMetricSpeed(10 ** value)} onChange={(value) => setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value))} /> formatMetricDistance(10 ** value)} onChange={(value) => setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value))} /> {spiralRunning ? Камера движется от исходной точки. Выключение режима, уход со страницы или reload остановят сессию. : null} {spiralMessage ? {spiralMessage} : null} : null} , }, { id: "map-cache", label: "TileCache", description: "Platform Map Gateway", group: "Хранение", content: <> Общий persistent cache Platform: он не принадлежит приложению, странице или пользователю. Cache hit отдаётся как есть; новый tile записывается только при miss. {mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"} Platform Map Gateway {gatewayEndpoint ?? "runtime profile · не проверено"} {liveCacheSummary} {gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"} Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"} {transportDiagnostic ? {transportDiagnostic} : null} {gatewayHealthAge ? {gatewayHealthAge} : null} {mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Новые miss дописываются; при заполнении объёма Gateway продолжит live-маршрут без удаления прежних tiles." : "Новые запросы этого Application могут явно обновлять уже записанные tiles." : "Real-time: provider остаётся официальным, чтение и запись persistent cache выключены."} {gatewayHealth?.cache?.atCapacity ? TileCache заполнен: новые tiles показываются live, но не записываются. Существующий cache не удаляется. : null} {gatewayCheckState === "error" || gatewayCheckState === "stale" ? {gatewayCheckError} : null} , }, { id: "map-selection", label: "Выбранная сущность", description: "selection contract", group: "Данные", content: <> {selected?.title ?? "Нет выбора"} {selected?.kind ?? "—"}{selected?.status ? ` · ${selected.status}` : ""} , }, ]; return (
Загрузка карты…
}>
setInspectorOpen(true)}> setLayersOpen((value) => !value)}> {features.toolbar ? setToolbarOpen((value) => !value)}> : null} {features.assistant ? setAssistantOpen((value) => !value)}> : null}
{layersOpen ? (
Слои карты setLayersOpen(false)}>
Cesium World Imagery официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]}
{Object.entries(providerStatus.errors).map(([provider, error]) => {provider}: {error})} updateMapSettings({ terrainEnabled })} /> updateMapSettings({ buildingsVisible })} /> updateMapSettings({ gridVisible })} /> Live Cache: {liveCacheSummary} {transportDiagnostic ? {transportDiagnostic} : null} {gatewayHealthAge ? {gatewayHealthAge} : null} {gatewayCheckState === "error" || gatewayCheckState === "stale" ? {gatewayCheckError} : null}
) : null} {toolbarOpen ? (
) : null} {assistantOpen ?
NODE.DC AssistantКонтекст выбранной сущности готов к передаче.
: null} setInspectorOpen(false)} > ); });