import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react"; import { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, SegmentedControl, Window, WorkspaceWindow } from "@nodedc/ui-react"; import type { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react"; import type { CameraSpiralState, CesiumMapRendererHandle, MapCameraView, MapGatewayHealth, MapPresentation, MapProviderStatus, } from "./CesiumMapRenderer.js"; import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js"; import { compareMapRuntimeFacts, mapFactMatchesFilters, mapPresentationFacetCounts, mapPresentationProfileForFact, mapRuntimeDisplayLabel, mapRuntimeFactIsRenderable, normalizeClientMapPresentationProfiles, resolveMapPresentationClass, toggleMapPresentationFacetSelection, type MapPresentationFilters, type MapPresentationProfile, } from "./mapPresentationProfile.js"; import { CAMERA_SURVEY_PRESETS, DEFAULT_CAMERA_SURVEY_PRESET, OSM_BUILDINGS_OBSERVED_BAND_COUNT, cameraSurveySpiralDistance, findCameraSurveyPreset, type CameraSurveySelection, } from "./mapCameraPresets.js"; import { buildMapSubjectCardModel, DEFAULT_MAP_SUBJECT_DETAIL_PROFILE } from "./mapSubjectCard.mjs"; import { ensureMapReferencePresentationProfiles, initialMapReferenceLayers, isMapReferencePresentationProfile, type MapReferenceLayer, } from "./mapReferenceStations.js"; import { useMapReferenceRuntime, useMapReferenceSearch } from "./useMapReferenceRuntime.js"; import { buildMapSearchIndex, searchMapSubjects } from "./mapSearch.mjs"; const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer }))); type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean }; export type MapPageSettings = Omit; type SurveySettingsSnapshot = Pick< MapPageSettings, "imageryVisible" | "monochrome" | "cacheEnabled" | "cacheNoOverwrite" | "terrainEnabled" | "buildingsVisible" | "buildingsDetail" >; 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; const SURVEY_GATEWAY_HEALTH_MAX_AGE_MS = 30_000; 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}`; } function isWritableAppendOnlyTileCache(health: MapGatewayHealth | null) { return health?.cache?.persistent === true && health.cache.mode === "readwrite" && health.cache.writePolicy === "append-only-no-eviction" && health.cache.atCapacity === false; } /** * 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; displayName?: string; order?: number; dataProductId: string; slotId: string; delivery: "snapshot+patch"; semanticTypes: string[]; fieldProjection: string[]; presentationProfileId?: string; subjectDetailProfileId?: string; aspectId?: string; joinToBindingId?: string; dataClass?: "operational" | "restricted"; }; export type MapSubjectDetailProfile = { id: string; version: string; title: string; semanticTypes: string[]; defaultTabId: string; tabs: Array<{ id: string; label: string; emptyMessage: string; sections: Array<{ id: string; label: string; fields: Array<{ id: string; aspectId?: string; source: "fact" | "attribute" | "geometry" | "context"; field: string; label: string; format: "text" | "number" | "timestamp" | "boolean" | "coordinate" | "signal_state" | "movement_state" | "string_list" | "telemetry_readings"; unit?: string; allowedReadingIds?: string[]; }>; }>; }>; }; export type MapSubjectWindowState = { open: boolean; rect: WorkspaceWindowRect; maximized: boolean; zIndex: number; }; export type MapSubjectState = { bindingId: string; visible: boolean; /** Missing facet means unconstrained; an explicit empty list means no matches. */ filters: Record; window: MapSubjectWindowState; }; export type MapPageLayout = { schemaVersion: 1; pageId: "map"; settings: MapPageSettings; mapHeight: number; camera: MapCameraView; pinBindings: MapPinBinding[]; presentationProfiles: MapPresentationProfile[]; subjectDetailProfiles: MapSubjectDetailProfile[]; dataProductBindings: MapDataProductBinding[]; subjectStates: MapSubjectState[]; referenceLayers: MapReferenceLayer[]; inspectorOpenSections: string[]; 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: 57, imageryHue: 13, imageryAlpha: 27, 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: 1, buildingsDetail: 4, imageryBrightness: 118, imageryContrast: 102, imagerySaturation: 0, 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: 37.618423, latitude: 55.751244, height: 40_000, heading: 0, pitch: -0.9, roll: 0, }; export function createDefaultMapPageLayout(expanded = false): MapPageLayout { return { schemaVersion: 1, pageId: "map", settings: structuredClone(initialMapSettings), mapHeight: expanded ? 620 : 470, camera: { ...fallbackMapCamera }, pinBindings: [], presentationProfiles: ensureMapReferencePresentationProfiles([]), subjectDetailProfiles: [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile], dataProductBindings: [], subjectStates: [], referenceLayers: initialMapReferenceLayers(), inspectorOpenSections: ["map-base"], }; } const initialProviderStatus: MapProviderStatus = { imagery: "loading", terrain: "loading", buildings: "loading", errors: {}, }; const providerStateLabel: Record = { loading: "загружается", ready: "готов", error: "недоступен", "not-configured": "не настроен", }; function defaultSubjectWindowState(index: number): MapSubjectWindowState { return { open: false, rect: { x: 24 + (index % 5) * 28, y: 56 + (index % 5) * 28, width: 280, height: 260, }, maximized: false, zIndex: 20 + index, }; } const defaultLayersWindowRect: WorkspaceWindowRect = { x: 1024, y: 72, width: 336, height: 500, }; const defaultSubjectCardRect: WorkspaceWindowRect = { x: 940, y: 52, width: 390, height: 560, }; function initialSubjectState(bindings: MapDataProductBinding[], saved: MapSubjectState[] | undefined) { const savedByBinding = new Map((saved ?? []).map((state) => [state.bindingId, state])); return Object.fromEntries(bindings.map((binding, index) => { const state = savedByBinding.get(binding.id); return [binding.id, state ?? { bindingId: binding.id, visible: true, filters: {}, window: defaultSubjectWindowState(index), }]; })) as Record; } function hasSubjectWindowControls(profile: MapPresentationProfile) { return profile.facets.some((facet) => facet.counter || facet.filterable); } 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)} м/с`; const formatDuration = (seconds: number) => { if (seconds >= 86_400) return `${(seconds / 86_400).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} сут`; if (seconds >= 3_600) return `${(seconds / 3_600).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} ч`; if (seconds >= 60) return `${Math.round(seconds / 60)} мин`; return `${Math.max(1, Math.round(seconds))} с`; }; function spiralStopMessage(reason: CameraSpiralState["reason"]) { if (reason === "target_radius_reached") return "Проход завершён: камера достигла выбранного радиуса."; if (reason === "spiral_extent_limit") return "Режим остановлен у безопасной границы геодезического маршрута."; if (reason === "terrain_sampling_error") return "Режим остановлен: не удалось получить высоту terrain для следующего участка."; if (reason === "tile_loading_timeout") return "Режим остановлен: текущий набор Imagery, Terrain или OSM Buildings не загрузился за отведённое время."; if (reason === "tile_loading_error") return "Режим остановлен: Cesium сообщил об ошибке tile в Imagery, Terrain или OSM Buildings. Неполный участок не засчитан."; 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 workspaceRef = useRef(null); const [selectedId, setSelectedId] = useState(); const [subjectCardOpen, setSubjectCardOpen] = useState(false); const [subjectCardRect, setSubjectCardRect] = useState(defaultSubjectCardRect); const [subjectCardMaximized, setSubjectCardMaximized] = useState(false); const [subjectCardZIndex, setSubjectCardZIndex] = useState(140); const [subjectCardActive, setSubjectCardActive] = useState(false); const [subjectCardTabId, setSubjectCardTabId] = useState("overview"); const [expandedFacetRows, setExpandedFacetRows] = useState>({}); const [inspectorOpen, setInspectorOpen] = useState(false); const [inspectorOpenSections, setInspectorOpenSections] = useState(() => ( initialLayout?.inspectorOpenSections ?? ["map-base"] )); const [layersOpen, setLayersOpen] = useState(false); const [layersWindowRect, setLayersWindowRect] = useState(defaultLayersWindowRect); const [layersWindowMaximized, setLayersWindowMaximized] = useState(false); const [layersWindowZIndex, setLayersWindowZIndex] = useState(12); const [layersWindowActive, setLayersWindowActive] = useState(false); const [toolbarOpen, setToolbarOpen] = useState(Boolean(features.toolbar)); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [remoteSearchQuery, setRemoteSearchQuery] = useState(""); const [remoteSearchEpoch, setRemoteSearchEpoch] = useState(0); const [searchActiveIndex, setSearchActiveIndex] = useState(0); const searchInputRef = useRef(null); 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 animationSettingsSnapshotRef = useRef(null); const [spiralRunning, setSpiralRunning] = useState(false); const [spiralPresetId, setSpiralPresetId] = useState(DEFAULT_CAMERA_SURVEY_PRESET.id); const [spiralHeightMeters, setSpiralHeightMeters] = useState(DEFAULT_CAMERA_SURVEY_PRESET.heightAboveGroundMeters); const [spiralSpeedMetersPerSecond, setSpiralSpeedMetersPerSecond] = useState(DEFAULT_CAMERA_SURVEY_PRESET.speedMetersPerSecond); const [spiralPitchMetersPerTurn, setSpiralPitchMetersPerTurn] = useState(DEFAULT_CAMERA_SURVEY_PRESET.pitchMetersPerTurn); const [spiralTargetRadiusMeters, setSpiralTargetRadiusMeters] = useState(DEFAULT_CAMERA_SURVEY_PRESET.targetRadiusMeters); 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 ?? []); // Presentation profiles are application/page-owned, versioned map.style_profile // values. A human camera/settings save must preserve profiles provisioned by MCP. const [presentationProfiles, setPresentationProfiles] = useState(() => ( ensureMapReferencePresentationProfiles(normalizeClientMapPresentationProfiles(initialLayout?.presentationProfiles ?? [])) )); const [subjectDetailProfiles] = useState(() => ( initialLayout?.subjectDetailProfiles?.length ? initialLayout.subjectDetailProfiles : [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile] )); // 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 [referenceLayers, setReferenceLayers] = useState(() => ( initialMapReferenceLayers(initialLayout?.referenceLayers) )); const [subjectStates, setSubjectStates] = useState>(() => ( initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates) )); const [activeSubjectBindingId, setActiveSubjectBindingId] = useState(); const presentationFilters = useMemo(() => Object.fromEntries( Object.entries(subjectStates).map(([bindingId, state]) => [bindingId, { visible: state.visible, facets: state.filters, }]), ), [subjectStates]); const runtimeBindings = useMapDataProductRuntime({ applicationId, pageId, bindings: dataProductBindings, enabled: Boolean(applicationId && pageId), }); const referenceRuntimeBindings = useMapReferenceRuntime(referenceLayers, mapCamera, true); const { bindings: referenceSearchBindings, state: referenceSearchState, } = useMapReferenceSearch(referenceLayers, remoteSearchQuery, remoteSearchEpoch, searchOpen); const mapRuntimeBindings = useMemo(() => ( [...runtimeBindings, ...referenceRuntimeBindings] ), [referenceRuntimeBindings, runtimeBindings]); const mapSearchRuntimeBindings = useMemo(() => ( [...mapRuntimeBindings, ...referenceSearchBindings] ), [mapRuntimeBindings, referenceSearchBindings]); const referencePresentationFilters = useMemo(() => Object.fromEntries( referenceLayers.map((layer) => [layer.id, { visible: layer.visible, facets: {} }]), ), [referenceLayers]); const rendererPresentationFilters = useMemo(() => ({ ...presentationFilters, ...referencePresentationFilters, }), [presentationFilters, referencePresentationFilters]); const primaryBindingIds = useMemo(() => new Set( dataProductBindings.filter((binding) => !binding.joinToBindingId).map((binding) => binding.id), ), [dataProductBindings]); const primaryRuntimeBindings = useMemo(() => ( runtimeBindings.filter((binding) => primaryBindingIds.has(binding.bindingId)) ), [primaryBindingIds, runtimeBindings]); const mapSearchIndex = useMemo(() => buildMapSearchIndex({ runtimeBindings: mapSearchRuntimeBindings, bindingConfigs: dataProductBindings, presentationProfiles, }), [dataProductBindings, mapSearchRuntimeBindings, presentationProfiles]); const mapSearchResults = useMemo(() => ( searchMapSubjects(mapSearchIndex, searchQuery, 8) ), [mapSearchIndex, searchQuery]); const selectable = useMemo(() => ( primaryRuntimeBindings.flatMap((binding) => { const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId); const facts = [...binding.facts]; const primaryProfile = mapPresentationProfileForFact( presentationProfiles, bindingConfig?.presentationProfileId, bindingConfig?.semanticTypes[0] ?? facts[0]?.semanticType ?? "", ); if (primaryProfile) facts.sort((left, right) => compareMapRuntimeFacts(left, right, primaryProfile)); return facts.map((fact) => { const profile = mapPresentationProfileForFact(presentationProfiles, bindingConfig?.presentationProfileId, fact.semanticType); const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined; return { id: mapRuntimeEntityId(binding.bindingId, fact), title: mapRuntimeDisplayLabel(fact, profile), kind: fact.semanticType, status: presentationClass?.label ?? fact.presentationStatus, bindingId: binding.bindingId, dataProductId: binding.dataProductId, fact, }; }); }) ), [dataProductBindings, presentationProfiles, primaryRuntimeBindings]); const presentationSummaries = useMemo(() => [...dataProductBindings] .filter((binding) => !binding.joinToBindingId) .sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id)) .flatMap((bindingConfig) => { const binding = runtimeBindings.find((candidate) => candidate.bindingId === bindingConfig.id); const facts = binding?.facts ?? []; const semanticType = bindingConfig.semanticTypes[0] ?? facts[0]?.semanticType ?? ""; const profile = mapPresentationProfileForFact(presentationProfiles, bindingConfig?.presentationProfileId, semanticType); if (!profile) return []; return [{ bindingId: bindingConfig.id, displayName: bindingConfig.displayName?.trim() || profile.title || bindingConfig.id, profile, total: facts.length, counts: mapPresentationFacetCounts(facts, profile), }]; }), [dataProductBindings, presentationProfiles, runtimeBindings]); const referenceObjectSummaries = useMemo(() => referenceLayers.flatMap((layer) => { const profile = presentationProfiles.find((candidate) => candidate.id === layer.presentationProfileId); if (!profile) return []; const runtime = referenceRuntimeBindings.find((candidate) => candidate.bindingId === layer.id); return [{ layer, displayName: profile.title, total: runtime?.facts.length ?? 0, }]; }), [presentationProfiles, referenceLayers, referenceRuntimeBindings]); const objectLayerCount = presentationSummaries.length + referenceObjectSummaries.length; const filteredTargets = useMemo(() => primaryRuntimeBindings.flatMap((binding) => { const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId); return binding.facts.flatMap((fact) => { const profile = mapPresentationProfileForFact( presentationProfiles, bindingConfig?.presentationProfileId, fact.semanticType, ); if (!profile || !mapFactMatchesFilters(fact, profile, presentationFilters, binding.bindingId)) return []; const presentationClass = resolveMapPresentationClass(fact, profile); return [{ bindingId: binding.bindingId, entityId: mapRuntimeEntityId(binding.bindingId, fact), title: mapRuntimeDisplayLabel(fact, profile), status: presentationClass?.label ?? "", renderable: mapRuntimeFactIsRenderable(fact, profile), }]; }); }).sort((left, right) => left.title.localeCompare(right.title, "ru")), [dataProductBindings, presentationFilters, presentationProfiles, primaryRuntimeBindings]); const visibleTargetEntityIds = useMemo(() => ( filteredTargets.filter((target) => target.renderable).map((target) => target.entityId) ), [filteredTargets]); // 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 spiralPresetOptions = useMemo>>(() => [ ...CAMERA_SURVEY_PRESETS.map((preset) => ({ value: preset.id, label: preset.label, description: `шаг ${formatMetricDistance(preset.pitchMetersPerTurn)} · до ${formatMetricDistance(preset.targetRadiusMeters)}`, })), ...(spiralPresetId === "custom" ? [{ value: "custom" as const, label: "Пользовательский", description: "Значения изменены вручную", }] : []), ], [spiralPresetId]); const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0]; const selectedSubjectCard = useMemo(() => { const entity = selectable.find((candidate) => candidate.id === selectedId); if (!entity) return null; const primaryBinding = dataProductBindings.find((binding) => binding.id === entity.bindingId); const profile = subjectDetailProfiles.find((candidate) => candidate.id === primaryBinding?.subjectDetailProfileId) ?? subjectDetailProfiles.find((candidate) => candidate.semanticTypes.includes(entity.fact.semanticType)) ?? DEFAULT_MAP_SUBJECT_DETAIL_PROFILE; const aspects = Object.fromEntries(dataProductBindings .filter((binding) => binding.id === entity.bindingId || binding.joinToBindingId === entity.bindingId) .flatMap((binding) => { const runtime = runtimeBindings.find((candidate) => candidate.bindingId === binding.id); const fact = binding.id === entity.bindingId ? entity.fact : runtime?.facts.find((candidate) => candidate.sourceId === entity.fact.sourceId); if (!fact) return []; return [[binding.id === entity.bindingId ? "primary" : (binding.aspectId ?? binding.id), { fact, bindingId: binding.id, dataProductId: binding.dataProductId, dataClass: binding.dataClass ?? "operational", }]]; })); return buildMapSubjectCardModel(entity.fact, { title: entity.title, bindingId: entity.bindingId, dataProductId: entity.dataProductId, profile, aspects, }); }, [dataProductBindings, runtimeBindings, selectable, selectedId, subjectDetailProfiles]); 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 prepareAnimationSurvey = () => { const failedProviderNeedsRetry = [providerStatus.imagery, providerStatus.terrain, providerStatus.buildings] .some((state) => state === "error" || state === "not-configured"); const rendererRestartRequired = !mapSettings.cacheEnabled || !mapSettings.cacheNoOverwrite || cacheRefresh || failedProviderNeedsRetry; updateMapSettings({ imageryVisible: true, // Monochrome deliberately hides the imagery layer. A cache survey must // render it so Cesium actually requests every visible imagery tile. monochrome: false, cacheEnabled: true, cacheNoOverwrite: true, terrainEnabled: true, buildingsVisible: true, // The measured OSM hierarchy and presets use the canonical SSE value. buildingsDetail: 16, }); setCacheRefresh(false); if (rendererRestartRequired) { setMapRendererReady(false); setProviderStatus(initialProviderStatus); setRendererRevision((value) => value + 1); } }; const setAnimationMode = (enabled: boolean) => { if (enabled === animationModeEnabled) return; setAnimationModeEnabled(enabled); setSpiralMessage(null); if (enabled) { // Survey preparation is transient. Save the page presentation before // forcing all cacheable providers on, and restore it when the mode ends. animationSettingsSnapshotRef.current = { imageryVisible: mapSettings.imageryVisible, monochrome: mapSettings.monochrome, cacheEnabled: mapSettings.cacheEnabled, cacheNoOverwrite: mapSettings.cacheNoOverwrite, terrainEnabled: mapSettings.terrainEnabled, buildingsVisible: mapSettings.buildingsVisible, buildingsDetail: mapSettings.buildingsDetail, }; prepareAnimationSurvey(); void verifyGateway(); } else { mapRendererRef.current?.stopSpiralAnimation("mode_disabled"); setSpiralRunning(false); const snapshot = animationSettingsSnapshotRef.current; animationSettingsSnapshotRef.current = null; if (snapshot) { const rendererRestartRequired = snapshot.cacheEnabled !== mapSettings.cacheEnabled || snapshot.cacheNoOverwrite !== mapSettings.cacheNoOverwrite; // Restore only fields owned by the survey overlay. Any unrelated // Inspector edits made while the mode was open remain intact. setMapSettings((current) => ({ ...current, ...snapshot })); setCacheRefresh(false); if (rendererRestartRequired) { setMapRendererReady(false); setProviderStatus(initialProviderStatus); setRendererRevision((value) => value + 1); } } } }; const selectSpiralPreset = (presetId: CameraSurveySelection) => { if (presetId === "custom") return; const preset = findCameraSurveyPreset(presetId); if (!preset) return; setSpiralPresetId(preset.id); setSpiralHeightMeters(preset.heightAboveGroundMeters); setSpiralSpeedMetersPerSecond(preset.speedMetersPerSecond); setSpiralPitchMetersPerTurn(preset.pitchMetersPerTurn); setSpiralTargetRadiusMeters(preset.targetRadiusMeters); setSpiralMessage(null); }; const spiralSurveyConfigured = mapSettings.cacheEnabled && mapSettings.cacheNoOverwrite && mapSettings.imageryVisible && !mapSettings.monochrome && mapSettings.terrainEnabled && mapSettings.buildingsVisible && mapSettings.buildingsDetail === 16; const spiralProvidersReady = providerStatus.imagery === "ready" && providerStatus.terrain === "ready" && providerStatus.buildings === "ready"; const spiralGatewayHealthFresh = Boolean( gatewayLastVerifiedAt && Date.now() - gatewayLastVerifiedAt.getTime() <= SURVEY_GATEWAY_HEALTH_MAX_AGE_MS && gatewayCheckState !== "error" && gatewayCheckState !== "stale", ); const spiralTileCacheReady = spiralGatewayHealthFresh && isWritableAppendOnlyTileCache(gatewayHealth); const spiralCanStart = mapRendererReady && spiralSurveyConfigured && spiralProvidersReady && spiralTileCacheReady; useEffect(() => { if (!spiralRunning || (spiralSurveyConfigured && spiralTileCacheReady)) return; mapRendererRef.current?.stopSpiralAnimation("mode_disabled"); }, [spiralRunning, spiralSurveyConfigured, spiralTileCacheReady]); const toggleSpiralAnimation = () => { if (spiralRunning) { mapRendererRef.current?.stopSpiralAnimation("stopped"); return; } if (!spiralCanStart) { setSpiralMessage("Подождите готовности Imagery, Terrain, OSM Buildings и свежей проверки writable append-only TileCache."); return; } const started = mapRendererRef.current?.startSpiralAnimation({ heightAboveGroundMeters: spiralHeightMeters, speedMetersPerSecond: spiralSpeedMetersPerSecond, pitchMetersPerTurn: spiralPitchMetersPerTurn, targetRadiusMeters: spiralTargetRadiusMeters, viewPitchRadians: -Math.PI / 2 + 0.01, waitForTiles: true, }) ?? false; if (!started) setSpiralMessage("Карта ещё не готова к запуску режима анимации."); }; useImperativeHandle(ref, () => ({ getLayout: () => ({ schemaVersion: 1, pageId: "map", // Survey-only layer overrides never leak into a saved page layout. settings: animationSettingsSnapshotRef.current ? { ...mapSettings, ...animationSettingsSnapshotRef.current } : mapSettings, mapHeight: Math.round(mapHeight), camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera, pinBindings, presentationProfiles, subjectDetailProfiles, dataProductBindings, referenceLayers, inspectorOpenSections, subjectStates: dataProductBindings.map((binding) => subjectStates[binding.id] ?? { bindingId: binding.id, visible: true, filters: {}, window: defaultSubjectWindowState(0), }).map((state) => { const summary = presentationSummaries.find((candidate) => candidate.bindingId === state.bindingId); return summary && !hasSubjectWindowControls(summary.profile) ? { ...state, window: { ...state.window, open: false } } : state; }), }), }), [dataProductBindings, inspectorOpenSections, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, referenceLayers, subjectDetailProfiles, subjectStates]); const updateSubjectState = (bindingId: string, update: (state: MapSubjectState) => MapSubjectState) => { setSubjectStates((current) => { const index = dataProductBindings.findIndex((binding) => binding.id === bindingId); const state = current[bindingId] ?? { bindingId, visible: true, filters: {}, window: defaultSubjectWindowState(Math.max(0, index)), }; return { ...current, [bindingId]: update(state) }; }); }; const togglePresentationFilter = (bindingId: string, field: string, value: string) => { updateSubjectState(bindingId, (state) => { const filters = state.visible ? state.filters : {}; return { ...state, visible: true, filters: toggleMapPresentationFacetSelection(filters, field, value), }; }); }; const toggleSubjectVisibility = (bindingId: string) => { updateSubjectState(bindingId, (state) => ({ ...state, visible: !state.visible })); }; const openSubjectWindow = (bindingId: string) => { const nextZIndex = Math.max(20, layersWindowZIndex, subjectCardZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1; updateSubjectState(bindingId, (state) => ({ ...state, window: { ...state.window, open: true, zIndex: nextZIndex }, })); setLayersWindowActive(false); setSubjectCardActive(false); setActiveSubjectBindingId(bindingId); }; const closeSubjectWindow = (bindingId: string) => { updateSubjectState(bindingId, (state) => ({ ...state, window: { ...state.window, open: false } })); setActiveSubjectBindingId((current) => current === bindingId ? undefined : current); }; const activateLayersWindow = () => { const nextZIndex = Math.max(20, layersWindowZIndex, subjectCardZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1; setLayersWindowZIndex(nextZIndex); setLayersWindowActive(true); setSubjectCardActive(false); setActiveSubjectBindingId(undefined); }; const toggleLayersWindow = () => { if (layersOpen) { setLayersOpen(false); setLayersWindowActive(false); return; } setLayersOpen(true); activateLayersWindow(); }; const updatePresentationProfile = ( profileId: string, update: (profile: MapPresentationProfile) => MapPresentationProfile, ) => setPresentationProfiles((current) => current.map((profile) => ( profile.id === profileId ? update(profile) : profile ))); const updatePresentationStyle = (profileId: string, styleId: string, patch: Partial) => { updatePresentationProfile(profileId, (profile) => ({ ...profile, styles: profile.styles.map((style) => style.id === styleId ? { ...style, ...patch } : style), })); }; const handleSelect = useCallback((entityId: string) => { if (!selectable.some((entity) => entity.id === entityId)) return; setSelectedId(entityId); const entity = selectable.find((candidate) => candidate.id === entityId); const binding = dataProductBindings.find((candidate) => candidate.id === entity?.bindingId); const profile = subjectDetailProfiles.find((candidate) => candidate.id === binding?.subjectDetailProfileId) ?? subjectDetailProfiles.find((candidate) => candidate.semanticTypes.includes(entity?.fact.semanticType ?? "")); setSubjectCardTabId((current) => ( profile?.tabs.some((tab) => tab.id === current) ? current : (profile?.defaultTabId ?? "overview") )); setSubjectCardOpen(true); setSubjectCardActive(true); setLayersWindowActive(false); setActiveSubjectBindingId(undefined); setSubjectCardZIndex((current) => Math.max(current, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1); }, [dataProductBindings, layersWindowZIndex, selectable, subjectDetailProfiles, subjectStates]); const handleSelectAndFocus = useCallback((entityId: string) => { handleSelect(entityId); mapRendererRef.current?.focusRuntimeEntity(entityId); }, [handleSelect]); const handleSearchResult = useCallback((result: (typeof mapSearchResults)[number]) => { if (result.selectable) { setSubjectStates((current) => { const state = current[result.bindingId]; return state ? { ...current, [result.bindingId]: { ...state, visible: true } } : current; }); handleSelect(result.entityId); } else { setReferenceLayers((current) => current.map((layer) => ( layer.id === result.bindingId ? { ...layer, visible: true } : layer ))); } if (!mapRendererRef.current?.focusRuntimeEntity(result.entityId)) { mapRendererRef.current?.focusCoordinates(result.coordinates[0], result.coordinates[1]); } setSearchOpen(false); setSearchQuery(""); setRemoteSearchQuery(""); setSearchActiveIndex(0); }, [handleSelect, mapSearchResults]); const handleSearchKeyDown = useCallback((event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); setSearchOpen(false); setSearchQuery(""); setRemoteSearchQuery(""); setSearchActiveIndex(0); return; } if (!mapSearchResults.length) { if (event.key === "Enter" && searchQuery.trim().length >= 2) { event.preventDefault(); setRemoteSearchQuery(searchQuery.trim()); setRemoteSearchEpoch((current) => current + 1); } return; } if (event.key === "ArrowDown") { event.preventDefault(); setSearchActiveIndex((current) => (current + 1) % mapSearchResults.length); return; } if (event.key === "ArrowUp") { event.preventDefault(); setSearchActiveIndex((current) => (current - 1 + mapSearchResults.length) % mapSearchResults.length); return; } if (event.key === "Enter") { event.preventDefault(); const result = mapSearchResults[Math.min(searchActiveIndex, mapSearchResults.length - 1)]; if (result) handleSearchResult(result); } }, [handleSearchResult, mapSearchResults, searchActiveIndex, searchQuery]); useEffect(() => { if (!searchOpen) return; const frame = window.requestAnimationFrame(() => searchInputRef.current?.focus()); return () => window.cancelAnimationFrame(frame); }, [searchOpen]); useEffect(() => { setSearchActiveIndex(0); }, [searchQuery]); 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 && !animationModeEnabled) 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(); } }; }, [animationModeEnabled, 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 transportReferenceStatus = gatewayHealth?.referenceSources?.transportStations; const transportDiagnostic = transportReferenceStatus?.lastFailure ? `Последняя ошибка справочных станций: ${transportReferenceStatus.lastFailure}${transportReferenceStatus.lastFailureAt ? ` · ${new Date(transportReferenceStatus.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 })} /> , }, ...presentationProfiles.flatMap((profile) => { const referenceProfile = isMapReferencePresentationProfile(profile); const referenceLayer = referenceLayers.find((layer) => layer.presentationProfileId === profile.id); return [ { id: `map-target-${profile.id}`, label: referenceProfile ? profile.title : profile.target.variant === "surface-fill" ? "HGeoZone" : "Таргет", description: profile.target.variant === "surface-fill" ? `проекция · ${profile.title}` : profile.title, group: referenceProfile ? "Станции" : profile.target.variant === "surface-fill" ? "Слои" : "Таргеты", content: <> Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует. {referenceLayer ? ( setReferenceLayers((current) => current.map((layer) => ( layer.id === referenceLayer.id ? { ...layer, visible } : layer )))} /> ) : null} {profile.target.variant === "surface-fill" && <> HGeoZone · ground projection {profile.styles.map((style) => { const classLabels = profile.classes.filter((item) => item.styleId === style.id).map((item) => item.label); const label = classLabels.length ? classLabels.join(" · ") : style.id; return
updatePresentationStyle(profile.id, style.id, { color })} /> `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} />
; })} updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineColor } }) : current)} /> `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }) : current)} /> `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineWidthPx } }) : current)} /> } {profile.target.variant === "elevated-spike" && <> `${value} м`} onChange={(stemHeightMeters) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemHeightMeters } }) : current)} /> `${value} px`} onChange={(headSizePx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, headSizePx } }) : current)} /> `${value} px`} onChange={(stemWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemWidthPx } }) : current)} /> } updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, mode } }))} /> `${value} px`} onChange={(sizePx) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, sizePx } }))} /> `${value} px`} onChange={(offsetX) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetX } }))} /> `${value} px`} onChange={(offsetY) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetY } }))} /> `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, hideCameraHeightMeters } }))} /> `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} /> updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundColor } }))} /> `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundOpacity: value / 100 } }))} /> , }, ...(profile.target.variant === "surface-fill" || referenceProfile ? [] : [{ id: `map-state-classes-${profile.id}`, label: "Классы состояния", description: "нормализованные фасеты онтологии", group: "Таргеты", content: <> Цвета назначены семантическим классам после нормализации данных. Здесь нет названий provider-статусов и привязки к транспорту. {profile.styles.map((style) => { const classLabels = profile.classes.filter((item) => item.styleId === style.id).map((item) => item.label); const label = classLabels.length ? classLabels.join(" · ") : style.id; return
updatePresentationStyle(profile.id, style.id, { color })} /> `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} />
; })} , }]), ]; }), { 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 ? <> Стартовая точка берётся из текущей позиции камеры. Камера смотрит почти в надир, а маршрут ждёт текущие tiles перед продолжением. Движение идёт по региональной геодезической спирали WGS84 до выбранного радиуса. У текущего OSM Buildings подтверждено {OSM_BUILDINGS_OBSERVED_BAND_COUNT} иерархических bands. Десять профилей управляют высотой и покрытием; фактический LOD Cesium выбирает по SSE, viewport и расстоянию. Imagery · Terrain · OSM Buildings formatMetricDistance(10 ** value)} onChange={(value) => { setSpiralPresetId("custom"); setSpiralHeightMeters(valueFromLogarithmicControl(value)); }} /> formatMetricSpeed(10 ** value)} onChange={(value) => { setSpiralPresetId("custom"); setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value)); }} /> formatMetricDistance(10 ** value)} onChange={(value) => { setSpiralPresetId("custom"); setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value)); }} /> formatMetricDistance(10 ** value)} onChange={(value) => { setSpiralPresetId("custom"); setSpiralTargetRadiusMeters(valueFromLogarithmicControl(value)); }} /> Расчётное движение без ожидания сети: {formatDuration(cameraSurveySpiralDistance(spiralTargetRadiusMeters, spiralPitchMetersPerTurn) / spiralSpeedMetersPerSecond)}. Tile waits и автоматическое сужение шага под viewport увеличат фактическое время. {!spiralCanStart && !spiralRunning ? Подготовка: imagery — {providerStateLabel[providerStatus.imagery]}, terrain — {providerStateLabel[providerStatus.terrain]}, OSM Buildings — {providerStateLabel[providerStatus.buildings]}, TileCache — {spiralTileCacheReady ? "готов" : gatewayHealth?.cache?.atCapacity ? "заполнен" : gatewayCheckState === "checking" ? "проверяется" : "недоступен для записи"}. : null} {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)}> {features.toolbar ? setToolbarOpen((value) => !value)}> : null} {features.assistant ? setAssistantOpen((value) => !value)}> : null}
{layersOpen ? ( { setLayersOpen(false); setLayersWindowActive(false); }} title="Слои карты" active={layersWindowActive} zIndex={layersWindowZIndex} minWidth={320} minHeight={360} className="catalog-map-fixture__layers catalog-map-fixture__map-glass-window" aria-label="Настройки слоёв карты" >
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 ? (
( )} > {({ close }) => (
Объекты {objectLayerCount} {objectLayerCount === 1 ? "группа" : "групп"}
{presentationSummaries.map((summary) => { const state = subjectStates[summary.bindingId]; const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length; const visible = state?.visible !== false; const hasControls = hasSubjectWindowControls(summary.profile); return (
); })} {referenceObjectSummaries.map(({ layer, displayName, total }) => (
))} {!objectLayerCount ? Нет подключённых объектов. : null}
)}
mapRendererRef.current?.fitRuntimeEntities(visibleTargetEntityIds)}> { setSearchOpen((current) => !current); if (searchOpen) { setSearchQuery(""); setRemoteSearchQuery(""); setSearchActiveIndex(0); } }} >
{searchQuery.trim() ? (
{mapSearchResults.map((result, index) => ( ))} {!mapSearchResults.length ? ( {remoteSearchQuery === searchQuery.trim() ? (referenceSearchState === "loading" ? "Ищем станцию в OSM…" : referenceSearchState === "error" ? "Поиск OSM временно недоступен; локальные данные сохранены." : "Станции с таким точным названием не найдены.") : "Совпадений нет. Enter — найти станцию по точному названию в OSM."} ) : null}
) : null}
) : null} {presentationSummaries.map((summary) => { const state = subjectStates[summary.bindingId]; if (!state?.window.open || !hasSubjectWindowControls(summary.profile)) return null; const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length; return ( updateSubjectState(summary.bindingId, (current) => ({ ...current, window: { ...current.window, rect }, }))} maximized={state.window.maximized} onMaximizedChange={(maximized) => updateSubjectState(summary.bindingId, (current) => ({ ...current, window: { ...current.window, maximized }, }))} onActivate={() => openSubjectWindow(summary.bindingId)} onClose={() => closeSubjectWindow(summary.bindingId)} title={summary.displayName} subtitle={`${summary.total} всего · ${visibleCount} на карте`} active={activeSubjectBindingId === summary.bindingId} zIndex={state.window.zIndex} minWidth={240} minHeight={220} autoHeight className="catalog-map-fixture__subject-window catalog-map-fixture__map-glass-window" >
{summary.profile.facets.filter((facet) => facet.counter || facet.filterable).flatMap((facet) => ( facet.values.map((item) => { const active = state.filters[facet.field]?.includes(item.value) ?? false; const rowId = `${summary.bindingId}:${facet.field}:${item.value}`; const expanded = Boolean(expandedFacetRows[rowId]); const matchingEntities = selectable.filter((entity) => ( entity.bindingId === summary.bindingId && mapFactMatchesFilters( entity.fact, summary.profile, { [summary.bindingId]: { visible: true, facets: { [facet.field]: [item.value] }, }, }, summary.bindingId, ) )); return (
{expanded ? (
{matchingEntities.map((entity) => ( ))} {!matchingEntities.length ? Нет объектов в группе. : null}
) : null}
); }) ))}
); })} {subjectCardOpen && selectedSubjectCard ? ( { const nextZIndex = Math.max(subjectCardZIndex, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1; setSubjectCardZIndex(nextZIndex); setSubjectCardActive(true); setLayersWindowActive(false); setActiveSubjectBindingId(undefined); }} onClose={() => { setSubjectCardOpen(false); setSubjectCardActive(false); }} title={selectedSubjectCard.title} subtitle={selectedSubjectCard.sourceId} active={subjectCardActive} zIndex={subjectCardZIndex} minWidth={320} minHeight={320} className="catalog-map-fixture__subject-card catalog-map-fixture__map-glass-window" aria-label={`Карточка объекта: ${selectedSubjectCard.title}`} >
tab.id === subjectCardTabId) ? subjectCardTabId : selectedSubjectCard.defaultTabId} items={selectedSubjectCard.tabs.map((tab) => ({ value: tab.id, label: tab.label }))} label="Разделы карточки объекта" onChange={setSubjectCardTabId} />
{selectedSubjectCard.tabs.filter((tab) => tab.id === ( selectedSubjectCard.tabs.some((candidate) => candidate.id === subjectCardTabId) ? subjectCardTabId : selectedSubjectCard.defaultTabId )).map((tab) => (
{tab.empty ?
{tab.emptyMessage}
: null} {tab.sections.map((section) => (

{section.label}

{section.rows.length ? (
{section.rows.map((row) => (
{row.label}
{row.value}
))}
) : null} {section.readings.length ? (
{section.readings.map((reading) => (
{reading.label} {reading.value} {reading.observedAt ? : null}
))}
) : null}
))}
))}
) : null} {assistantOpen ?
NODE.DC AssistantКонтекст выбранной сущности готов к передаче.
: null} setInspectorOpen(false)} > ); });