import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react"; import { createPortal } from "react-dom"; import { ApplicationSidePanel, Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, SegmentedControl, WorkspaceWindow } from "@nodedc/ui-react"; import type { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react"; import type { CameraSpiralState, CesiumMapRendererHandle, MapCameraView, MapGatewayHealth, MapPresentation, MapProviderStatus, GridLodProfile, GridSectorSelection, } from "./CesiumMapRenderer.js"; import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js"; import type { MapRuntimeFact } from "./useMapDataProductRuntime.js"; import { compareMapRuntimeFacts, mapFactMatchesFilters, mapPresentationFacetValueIsEnabled, mapPresentationFacetCounts, mapPresentationProfileForFact, mapRuntimeDisplayLabel, mapRuntimeFactIsRenderable, normalizeMapPresentationFacetSelections, 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"; import { DEFAULT_GRID_LOD_PROFILES, gridLodProfile } from "./mapGridPolicy.mjs"; import { MAX_LOCAL_GRID_INDEX, graticuleSectorAt, graticuleSectorSummary, localSectorAt, localSectorAtGeodetic, localSectorSummary, localVolumeAt, type GraticuleSectorAddress, type LocalSectorAddress, } from "./mapSectorGrid.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; const GRID_MODE_OPTIONS: Array> = [ { value: "3d", label: "3D", description: "Приподнятая пространственная сетка" }, { value: "graticule", label: "Гратикула", description: "Проекция по поверхности" }, ]; type SectorGridLodProfile = GridLodProfile & { majorLinesEnabled: boolean; majorLabelsEnabled: boolean; majorLineWidthMultiplier: number; selectionFillColor: string; selectionFillOpacityPercent: number; selectionOutlineColor: string; selectionOutlineWidthPx: number; selectionOutlineOpacityPercent: number; volumeEnabled: boolean; volumeMinimumHeightMeters: number; volumeMaximumHeightMeters: number; volumeBandHeightMeters: number; }; type GridSectorCopyState = "idle" | "copied" | "error"; const normalizedMajorTileSizeKm = (stepKm: number, requestedTileSizeKm: number) => { const safeStepKm = Math.min(50, Math.max(0.1, stepKm)); const maximumRatio = Math.max(1, Math.floor((50 + Number.EPSILON) / safeStepKm)); const requestedRatio = Math.max(1, Math.ceil((requestedTileSizeKm - Number.EPSILON) / safeStepKm)); const ratio = Math.min(maximumRatio, requestedRatio); return Number((safeStepKm * ratio).toFixed(6)); }; const normalizedGraticuleStepDegrees = (requestedStepDegrees: number) => { const safeStepDegrees = Math.min(10, Math.max(0.1, requestedStepDegrees)); const requestedDivisions = Math.max(1, Math.round(180 / safeStepDegrees)); // Five minor intervals form one major tile and each 90° quadrant must end // on a major boundary. A hemisphere therefore needs a multiple of ten // minor intervals. const hemisphereDivisions = Math.max(10, Math.round(requestedDivisions / 10) * 10); return 180 / hemisphereDivisions; }; const graticuleMajorStepDegrees = (stepDegrees: number) => { const candidate = stepDegrees * 5; const quadrantBands = 90 / candidate; return Math.abs(quadrantBands - Math.round(quadrantBands)) <= 1e-9 * Math.max(1, Math.abs(quadrantBands)) ? candidate : null; }; const normalizeSectorGridLodProfile = (profile: SectorGridLodProfile): SectorGridLodProfile => { const stepKm = profile.mode === "3d" ? Math.min(50, profile.stepKm) : profile.stepKm; const volumeMinimumHeightMeters = profile.volumeMinimumHeightMeters; const volumeMaximumHeightMeters = Math.max(volumeMinimumHeightMeters + 1, profile.volumeMaximumHeightMeters); return { ...profile, stepKm, tileSizeKm: profile.mode === "3d" ? normalizedMajorTileSizeKm(stepKm, Math.max(stepKm, profile.tileSizeKm)) : profile.tileSizeKm, graticuleStepDegrees: profile.mode === "graticule" ? normalizedGraticuleStepDegrees(profile.graticuleStepDegrees) : profile.graticuleStepDegrees, majorLabelsEnabled: profile.majorLinesEnabled && profile.majorLabelsEnabled, volumeEnabled: profile.mode === "3d" && profile.volumeEnabled, volumeMinimumHeightMeters, volumeMaximumHeightMeters, volumeBandHeightMeters: Math.min( volumeMaximumHeightMeters - volumeMinimumHeightMeters, Math.max(1, profile.volumeBandHeightMeters), ), }; }; type GridSectorDirection = "north" | "east" | "south" | "west"; const GRID_SECTOR_DIRECTIONS: Array<{ id: GridSectorDirection; label: string }> = [ { id: "north", label: "Север" }, { id: "east", label: "Восток" }, { id: "south", label: "Юг" }, { id: "west", label: "Запад" }, ]; function localGridSectorSelection( address: LocalSectorAddress, profile: SectorGridLodProfile, origin: { latitude: number; longitude: number }, preferredAltitudeMeters?: number, ): GridSectorSelection { const definition = { lod: address.lod, originLatitude: origin.latitude, originLongitude: origin.longitude, stepMeters: profile.stepKm * 1_000, tileSizeMeters: profile.tileSizeKm * 1_000, }; const summary = localSectorSummary(address, definition); const volumeSpan = profile.volumeMaximumHeightMeters - profile.volumeMinimumHeightMeters; const volume = profile.volumeEnabled && volumeSpan > 0 ? (() => { const altitudeMeters = Math.min( profile.volumeMaximumHeightMeters - Number.EPSILON, Math.max( profile.volumeMinimumHeightMeters, preferredAltitudeMeters ?? profile.volumeMinimumHeightMeters + Math.min(profile.volumeBandHeightMeters, volumeSpan) / 2, ), ); const volumeAddress = localVolumeAt({ ...summary.center, altitudeMeters }, { lod: address.lod, originLatitude: origin.latitude, originLongitude: origin.longitude, stepMeters: profile.stepKm * 1_000, altitudeFloorMeters: profile.volumeMinimumHeightMeters, altitudeCeilingMeters: profile.volumeMaximumHeightMeters, altitudeBandMeters: profile.volumeBandHeightMeters, }); if (!volumeAddress) return null; return { id: volumeAddress.id, index: volumeAddress.bandIndex, floor: Math.max(profile.volumeMinimumHeightMeters, volumeAddress.altitudeFloorMeters), ceiling: Math.min(profile.volumeMaximumHeightMeters, volumeAddress.altitudeCeilingMeters), bandHeight: volumeAddress.altitudeBandMeters, }; })() : null; return { ...summary, mode: "3d", address, units: "meters-enu", volume, }; } function graticuleGridSectorSelection( address: GraticuleSectorAddress, profile: SectorGridLodProfile, ): GridSectorSelection { const majorStepDegrees = profile.majorLinesEnabled ? graticuleMajorStepDegrees(profile.graticuleStepDegrees) ?? undefined : undefined; const summary = graticuleSectorSummary(address, { lod: address.lod, stepDegrees: profile.graticuleStepDegrees, majorStepDegrees, }); return { ...summary, mode: "graticule", address, units: "degrees-wgs84", volume: null, }; } function gridSectorNeighborSelection( selection: GridSectorSelection, direction: GridSectorDirection, profiles: SectorGridLodProfile[], origin: { latitude: number; longitude: number }, ) { const profile = profiles[selection.lod - 1]; if (!profile) return null; if (selection.mode === "3d") { const neighbor = selection.neighbors[direction]; if (!neighbor) return null; const preferredAltitudeMeters = selection.volume ? (selection.volume.floor + selection.volume.ceiling) / 2 : undefined; return localGridSectorSelection(neighbor.address, profile, origin, preferredAltitudeMeters); } const neighbor = selection.neighbors[direction]; return neighbor ? graticuleGridSectorSelection(neighbor.address, profile) : null; } function gridSectorParentLodSelection( selection: GridSectorSelection, profiles: SectorGridLodProfile[], origin: { latitude: number; longitude: number }, ) { const parentProfile = profiles[selection.lod]; if (!parentProfile || parentProfile.mode !== selection.mode) return null; if (selection.mode === "3d") { const address = localSectorAt(selection.center, { lod: selection.lod + 1, originLatitude: origin.latitude, originLongitude: origin.longitude, stepMeters: parentProfile.stepKm * 1_000, }); const preferredAltitudeMeters = selection.volume ? (selection.volume.floor + selection.volume.ceiling) / 2 : undefined; return localGridSectorSelection(address, parentProfile, origin, preferredAltitudeMeters); } const address = graticuleSectorAt(selection.center, { lod: selection.lod + 1, stepDegrees: parentProfile.graticuleStepDegrees, }); return graticuleGridSectorSelection(address, parentProfile); } function gridSectorVolumeNeighborSelection( selection: GridSectorSelection, direction: "above" | "below", profile: SectorGridLodProfile | null, origin: { latitude: number; longitude: number }, ) { if (selection.mode !== "3d" || !selection.volume || !profile?.volumeEnabled) return null; const targetIndex = selection.volume.index + (direction === "above" ? 1 : -1); const targetFloorMeters = profile.volumeMinimumHeightMeters + targetIndex * profile.volumeBandHeightMeters; if (targetIndex < 0 || targetFloorMeters >= profile.volumeMaximumHeightMeters) return null; const targetCeilingMeters = Math.min( profile.volumeMaximumHeightMeters, targetFloorMeters + profile.volumeBandHeightMeters, ); return localGridSectorSelection( selection.address, profile, origin, (targetFloorMeters + targetCeilingMeters) / 2, ); } const formatGridMetric = (value: number, maximumFractionDigits = 1) => value.toLocaleString("ru-RU", { maximumFractionDigits, }); const formatGridSectorArea = (areaSquareMeters: number) => areaSquareMeters >= 1_000_000 ? `${formatGridMetric(areaSquareMeters / 1_000_000, areaSquareMeters >= 1_000_000_000 ? 0 : 2)} км²` : `${formatGridMetric(areaSquareMeters, 0)} м²`; function gridSectorBoundsLabel(selection: GridSectorSelection) { const { west, east, south, north } = selection.bounds; return selection.mode === "3d" ? `E ${formatGridMetric(west)}…${formatGridMetric(east)} м · N ${formatGridMetric(south)}…${formatGridMetric(north)} м` : `λ ${formatGridMetric(west, 6)}…${formatGridMetric(east, 6)}° · φ ${formatGridMetric(south, 6)}…${formatGridMetric(north, 6)}°`; } function gridSectorCenterLabel(selection: GridSectorSelection) { return selection.mode === "3d" ? `E ${formatGridMetric(selection.center.eastMeters)} м · N ${formatGridMetric(selection.center.northMeters)} м` : `${formatGridMetric(selection.center.latitude, 6)}°, ${formatGridMetric(selection.center.longitude, 6)}°`; } const MAP_SCOPE_PROVIDER_FIELD = "position_source"; const MAP_SCOPE_OBJECT_KIND_FIELD = "object_kind"; const MAP_SCOPE_MISSING_VALUE = "__nodedc_missing__"; function normalizedSectorScopeValue(value: unknown) { if (typeof value !== "string") return null; const normalized = value.trim(); if (!normalized || normalized.length > 120 || /[\u0000-\u001f\u007f]/.test(normalized)) return null; return normalized; } function sectorScopeValueLabel(value: string) { if (value === MAP_SCOPE_MISSING_VALUE) return "Не указано"; return value.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim(); } function mapFactSectorScopeValue(fact: MapRuntimeFact, field: string) { return normalizedSectorScopeValue(fact.attributes[field]) ?? MAP_SCOPE_MISSING_VALUE; } function mapFactPointCoordinates(fact: MapRuntimeFact | undefined): [number, number] | null { if (fact?.geometry?.type !== "Point") return null; const [longitude, latitude] = fact.geometry.coordinates; return Number.isFinite(longitude) && longitude >= -180 && longitude <= 180 && Number.isFinite(latitude) && latitude >= -90 && latitude <= 90 ? [longitude, latitude] : null; } function mapFactInsideGridSector( fact: MapRuntimeFact, selection: GridSectorSelection, profiles: SectorGridLodProfile[], origin: { latitude: number; longitude: number }, ) { if (fact.geometry?.type !== "Point") return false; const [longitude, latitude] = fact.geometry.coordinates; const profile = profiles[selection.lod - 1]; if (!profile || profile.mode !== selection.mode) return false; if (selection.mode === "graticule") { return graticuleSectorAt({ longitude, latitude }, { lod: selection.lod, stepDegrees: profile.graticuleStepDegrees, }).id === selection.id; } return localSectorAtGeodetic({ longitude, latitude }, { lod: selection.lod, originLatitude: origin.latitude, originLongitude: origin.longitude, stepMeters: profile.stepKm * 1_000, }).id === selection.id; } 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; }; type MapWorkspaceWindowId = "sector" | "subject-card" | `binding:${string}`; 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, grid3dEnabled: true, gridGraticuleEnabled: true, gridCenterMode: "fixed", gridCenterLatitude: 55.7558, gridCenterLongitude: 37.6173, gridTileSizeKm: 10, gridAutoDisableHeightKm: 10_000, gridRebuildOnMoveEnd: true, gridLegacyMode: false, gridMax3dViewAngleDegrees: 30, gridHeightMeters: 500, gridLod1MaxHeightKm: 10, gridLod1StepKm: 1, gridLod1Mode: "3d", gridLod2MaxHeightKm: 50, gridLod2StepKm: 5, gridLod2Mode: "3d", gridLod3MaxHeightKm: 200, gridLod3StepKm: 25, gridLod3Mode: "3d", gridLod4MaxHeightKm: 800, gridLod4StepKm: 50, gridLod4Mode: "graticule", gridLod5MaxHeightKm: 3_000, gridLod5StepKm: 50, gridLod5Mode: "graticule", gridRadiusKm: 40, gridLineWidth: 1, gridLineDiameterMeters: 7, gridColor: "#9c9c9c", gridOpacity: 12, gridDotsEnabled: true, gridDotsSize: 7, gridDotsDiameterMeters: 10, gridDotsColor: "#9c9c9c", gridDotsOpacity: 58, gridCrossesEnabled: false, gridCrossesLengthMeters: 60, gridCrossesWidthMeters: 10, gridCrossesColor: "#9c9c9c", gridCrossesOpacity: 46, gridLodProfiles: structuredClone(DEFAULT_GRID_LOD_PROFILES) as GridLodProfile[], }; function resolveGridLodProfiles(settings?: Partial): GridLodProfile[] { // A layout saved by the previous flat contract must not lose the values the // operator already tuned. Promote its common visual fields and per-band // height/step/mode values into five authoritative profiles on first read; // the next ordinary page save persists the canonical array. const legacySettings: MapPresentation = { ...initialMapSettings, ...settings, gridLodProfiles: Array.isArray(settings?.gridLodProfiles) ? settings.gridLodProfiles : [], cacheRefresh: false, }; return Array.from({ length: 5 }, (_unused, index) => normalizeSectorGridLodProfile( gridLodProfile(legacySettings, index) as SectorGridLodProfile, )); } // 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 defaultSectorWindowRect: WorkspaceWindowRect = { x: 24, y: 72, width: 380, height: 530, }; const defaultSubjectCardRect: WorkspaceWindowRect = { x: 940, y: 72, width: 390, height: 520, }; function initialSubjectState( bindings: MapDataProductBinding[], saved: MapSubjectState[] | undefined, profiles: MapPresentationProfile[], ) { const savedByBinding = new Map((saved ?? []).map((state) => [state.bindingId, state])); return Object.fromEntries(bindings.map((binding, index) => { const state = savedByBinding.get(binding.id); const profile = mapPresentationProfileForFact( profiles, binding.presentationProfileId, binding.semanticTypes[0] ?? "", ); return [binding.id, state ? { ...state, filters: profile ? normalizeMapPresentationFacetSelections(state.filters, profile) : state.filters, } : { 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 void; }>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId, settingsPanelHost, headerActionsHost, onSettingsPanelOpenChange }, ref) { const workspaceRef = useRef(null); const [selectedId, setSelectedId] = useState(); const [selectedGridSector, setSelectedGridSector] = useState(null); const [gridSectorCopyState, setGridSectorCopyState] = useState("idle"); const [subjectCardOpen, setSubjectCardOpen] = useState(false); const [subjectCardRect, setSubjectCardRect] = useState(defaultSubjectCardRect); const [subjectCardMaximized, setSubjectCardMaximized] = useState(false); const [subjectCardZIndex, setSubjectCardZIndex] = useState(140); 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 [sectorWindowRect, setSectorWindowRect] = useState(defaultSectorWindowRect); const [sectorWindowMaximized, setSectorWindowMaximized] = useState(false); const [sectorWindowZIndex, setSectorWindowZIndex] = useState(142); const [hideObjectsOutsideSector, setHideObjectsOutsideSector] = useState(false); const [sectorExcludedBindingIds, setSectorExcludedBindingIds] = useState([]); const [sectorExcludedProviders, setSectorExcludedProviders] = useState([]); const [sectorExcludedObjectKinds, setSectorExcludedObjectKinds] = useState([]); const [activeWorkspaceWindowId, setActiveWorkspaceWindowId] = useState(); 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, // Camera-relative layouts were decorative and had no stable sector // identity. Opening one performs a deterministic migration to its stored // Moscow origin; the current viewport is never promoted to definition. gridCenterMode: "fixed", gridLodProfiles: resolveGridLodProfiles(initialLayout?.settings), })); const [selectedGridLod, setSelectedGridLod] = useState("0"); 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, presentationProfiles) )); const presentationFilters = useMemo(() => Object.fromEntries( Object.entries(subjectStates).map(([bindingId, state]) => { const binding = dataProductBindings.find((candidate) => candidate.id === bindingId); const profile = mapPresentationProfileForFact( presentationProfiles, binding?.presentationProfileId, binding?.semanticTypes[0] ?? "", ); return [bindingId, { visible: state.visible, facets: profile ? normalizeMapPresentationFacetSelections(state.filters, profile) : state.filters, }]; }), ), [dataProductBindings, presentationProfiles, 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 sectorGridLodProfiles = mapSettings.gridLodProfiles as SectorGridLodProfile[]; const fixedSectorGridOrigin = useMemo(() => ({ latitude: mapSettings.gridCenterLatitude, longitude: mapSettings.gridCenterLongitude, }), [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude]); 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 sectorSpatialEntities = useMemo(() => selectedGridSector ? selectable.filter((entity) => mapFactInsideGridSector( entity.fact, selectedGridSector, sectorGridLodProfiles, fixedSectorGridOrigin, )) : [], [fixedSectorGridOrigin, sectorGridLodProfiles, selectable, selectedGridSector]); const sectorBindingOptions = useMemo(() => [...dataProductBindings] .filter((binding) => !binding.joinToBindingId) .sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id)) .map((binding) => ({ value: binding.id, label: binding.displayName?.trim() || binding.id, count: sectorSpatialEntities.filter((entity) => entity.bindingId === binding.id).length, })), [dataProductBindings, sectorSpatialEntities]); const sectorProviderFacetAvailable = useMemo(() => dataProductBindings.some((binding) => ( !binding.joinToBindingId && binding.fieldProjection.includes(MAP_SCOPE_PROVIDER_FIELD) )), [dataProductBindings]); const sectorObjectKindFacetAvailable = useMemo(() => dataProductBindings.some((binding) => ( !binding.joinToBindingId && binding.fieldProjection.includes(MAP_SCOPE_OBJECT_KIND_FIELD) )), [dataProductBindings]); const sectorProviderOptions = useMemo(() => { if (!sectorProviderFacetAvailable) return []; const counts = new Map(); sectorSpatialEntities.forEach(({ fact }) => { const value = mapFactSectorScopeValue(fact, MAP_SCOPE_PROVIDER_FIELD); counts.set(value, (counts.get(value) ?? 0) + 1); }); return [...counts].map(([value, count]) => ({ value, count, label: sectorScopeValueLabel(value) })) .sort((left, right) => left.label.localeCompare(right.label, "ru")); }, [sectorProviderFacetAvailable, sectorSpatialEntities]); const sectorObjectKindOptions = useMemo(() => { if (!sectorObjectKindFacetAvailable) return []; const counts = new Map(); sectorSpatialEntities.forEach(({ fact }) => { const value = mapFactSectorScopeValue(fact, MAP_SCOPE_OBJECT_KIND_FIELD); counts.set(value, (counts.get(value) ?? 0) + 1); }); return [...counts].map(([value, count]) => ({ value, count, label: sectorScopeValueLabel(value) })) .sort((left, right) => left.label.localeCompare(right.label, "ru")); }, [sectorObjectKindFacetAvailable, sectorSpatialEntities]); const sectorVisibleEntities = useMemo(() => sectorSpatialEntities.filter((entity) => { if (sectorExcludedBindingIds.includes(entity.bindingId)) return false; if (sectorExcludedProviders.includes(mapFactSectorScopeValue(entity.fact, MAP_SCOPE_PROVIDER_FIELD))) return false; if (sectorExcludedObjectKinds.includes(mapFactSectorScopeValue(entity.fact, MAP_SCOPE_OBJECT_KIND_FIELD))) return false; const binding = dataProductBindings.find((candidate) => candidate.id === entity.bindingId); const profile = mapPresentationProfileForFact( presentationProfiles, binding?.presentationProfileId, entity.fact.semanticType, ); return Boolean(profile && mapFactMatchesFilters(entity.fact, profile, presentationFilters, entity.bindingId)); }), [dataProductBindings, presentationFilters, presentationProfiles, sectorExcludedBindingIds, sectorExcludedObjectKinds, sectorExcludedProviders, sectorSpatialEntities]); const sectorScopedPrimaryRuntimeBindings = useMemo(() => { if (!selectedGridSector) return primaryRuntimeBindings; return primaryRuntimeBindings.map((binding) => ({ ...binding, facts: binding.facts.filter((fact) => { if (sectorExcludedBindingIds.includes(binding.bindingId)) return false; if (sectorExcludedProviders.includes(mapFactSectorScopeValue(fact, MAP_SCOPE_PROVIDER_FIELD))) return false; if (sectorExcludedObjectKinds.includes(mapFactSectorScopeValue(fact, MAP_SCOPE_OBJECT_KIND_FIELD))) return false; return !hideObjectsOutsideSector || mapFactInsideGridSector( fact, selectedGridSector, sectorGridLodProfiles, fixedSectorGridOrigin, ); }), })); }, [fixedSectorGridOrigin, hideObjectsOutsideSector, primaryRuntimeBindings, sectorExcludedBindingIds, sectorExcludedObjectKinds, sectorExcludedProviders, sectorGridLodProfiles, selectedGridSector]); 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(() => sectorScopedPrimaryRuntimeBindings.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, sectorScopedPrimaryRuntimeBindings]); 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 selectedGridLodIndex = Math.max(0, Math.min(4, Number.parseInt(selectedGridLod, 10) || 0)); const activeGridLod = (mapSettings.gridLodProfiles[selectedGridLodIndex] ?? DEFAULT_GRID_LOD_PROFILES[selectedGridLodIndex]) as SectorGridLodProfile; const gridSectorDefinitionKey = useMemo(() => JSON.stringify({ origin: [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude], profiles: sectorGridLodProfiles.map((profile) => ({ mode: profile.mode, stepKm: profile.stepKm, tileSizeKm: profile.tileSizeKm, graticuleStepDegrees: profile.graticuleStepDegrees, majorLinesEnabled: profile.majorLinesEnabled, volumeEnabled: profile.volumeEnabled, volumeMinimumHeightMeters: profile.volumeMinimumHeightMeters, volumeMaximumHeightMeters: profile.volumeMaximumHeightMeters, volumeBandHeightMeters: profile.volumeBandHeightMeters, })), }), [mapSettings.gridCenterLatitude, mapSettings.gridCenterLongitude, sectorGridLodProfiles]); const selectedGridParentLod = useMemo(() => selectedGridSector ? gridSectorParentLodSelection(selectedGridSector, sectorGridLodProfiles, fixedSectorGridOrigin) : null, [fixedSectorGridOrigin, sectorGridLodProfiles, selectedGridSector]); const selectedGridNeighborTargets = useMemo(() => Object.fromEntries( GRID_SECTOR_DIRECTIONS.map(({ id }) => [id, selectedGridSector ? gridSectorNeighborSelection(selectedGridSector, id, sectorGridLodProfiles, fixedSectorGridOrigin) : null]), ) as Record, [fixedSectorGridOrigin, sectorGridLodProfiles, selectedGridSector]); const selectedGridSectorProfile = selectedGridSector ? sectorGridLodProfiles[selectedGridSector.lod - 1] ?? null : null; const selectedGridVolumeTargets = useMemo(() => ({ above: selectedGridSector ? gridSectorVolumeNeighborSelection(selectedGridSector, "above", selectedGridSectorProfile, fixedSectorGridOrigin) : null, below: selectedGridSector ? gridSectorVolumeNeighborSelection(selectedGridSector, "below", selectedGridSectorProfile, fixedSectorGridOrigin) : null, }), [fixedSectorGridOrigin, selectedGridSector, selectedGridSectorProfile]); const activeGraticuleMajorStepDegrees = activeGridLod.mode === "graticule" ? graticuleMajorStepDegrees(activeGridLod.graticuleStepDegrees) : null; useEffect(() => { setSelectedGridSector(null); setSectorExcludedBindingIds([]); setSectorExcludedProviders([]); setSectorExcludedObjectKinds([]); setActiveWorkspaceWindowId((current) => current === "sector" ? undefined : current); }, [gridSectorDefinitionKey]); const minimumGridLodHeight = selectedGridLodIndex === 0 ? 0.1 : mapSettings.gridLodProfiles[selectedGridLodIndex - 1].maxHeightKm + 0.1; const maximumGridLodHeight = selectedGridLodIndex === mapSettings.gridLodProfiles.length - 1 ? 20_000 : Math.max(minimumGridLodHeight, mapSettings.gridLodProfiles[selectedGridLodIndex + 1].maxHeightKm - 0.1); const updateGridLod = (patch: Partial) => updateMapSettings({ gridLodProfiles: mapSettings.gridLodProfiles.map((profile, index) => ( index === selectedGridLodIndex ? { ...profile, ...patch } : profile )), }); const updateGridVolumeRange = (patch: Partial>) => { const minimum = patch.volumeMinimumHeightMeters ?? activeGridLod.volumeMinimumHeightMeters; const requestedBandHeight = Math.min( 1_000, Math.max(1, patch.volumeBandHeightMeters ?? activeGridLod.volumeBandHeightMeters), ); let maximum = Math.min( 10_000, Math.max(minimum + 1, patch.volumeMaximumHeightMeters ?? activeGridLod.volumeMaximumHeightMeters), ); if (patch.volumeBandHeightMeters !== undefined) { maximum = Math.min(10_000, Math.max(maximum, minimum + requestedBandHeight)); } const span = maximum - minimum; updateGridLod({ volumeMinimumHeightMeters: minimum, volumeMaximumHeightMeters: maximum, volumeBandHeightMeters: Math.min(span, requestedBandHeight), }); }; 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); }, []); useEffect(() => { setGridSectorCopyState("idle"); }, [selectedGridSector?.id]); const copySelectedGridSectorId = useCallback(async () => { if (!selectedGridSector) return; try { await navigator.clipboard.writeText(selectedGridSector.id); setGridSectorCopyState("copied"); } catch { setGridSectorCopyState("error"); } }, [selectedGridSector]); const focusGridSector = useCallback((sector: GridSectorSelection | null) => { if (!sector || !mapRendererRef.current?.focusGridSector(sector)) return; setSelectedGridSector(sector); }, []); const focusGridMajorTile = useCallback((tile: NonNullable) => { mapRendererRef.current?.focusGridMajorTile(tile); }, []); 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); const normalizedState = summary ? { ...state, filters: normalizeMapPresentationFacetSelections(state.filters, summary.profile) } : state; return summary && !hasSubjectWindowControls(summary.profile) ? { ...normalizedState, window: { ...normalizedState.window, open: false } } : normalizedState; }), }), }), [dataProductBindings, inspectorOpenSections, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, referenceLayers, subjectDetailProfiles, subjectStates]); const updateSubjectState = useCallback((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) }; }); }, [dataProductBindings]); const togglePresentationFilter = (bindingId: string, field: string, value: string, availableValues: string[]) => { updateSubjectState(bindingId, (state) => { const filters = state.visible ? state.filters : {}; return { ...state, visible: true, filters: toggleMapPresentationFacetSelection(filters, field, value, availableValues), }; }); }; const toggleSubjectVisibility = (bindingId: string) => { updateSubjectState(bindingId, (state) => ({ ...state, visible: !state.visible })); }; const activateWorkspaceWindow = useCallback((windowId: MapWorkspaceWindowId) => { if (activeWorkspaceWindowId === windowId) return; const nextZIndex = Math.max( 20, sectorWindowZIndex, subjectCardZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex), ) + 1; if (windowId === "sector") setSectorWindowZIndex(nextZIndex); else if (windowId === "subject-card") setSubjectCardZIndex(nextZIndex); else if (windowId.startsWith("binding:")) { const bindingId = windowId.slice("binding:".length); updateSubjectState(bindingId, (state) => ({ ...state, window: { ...state.window, zIndex: nextZIndex }, })); } setActiveWorkspaceWindowId(windowId); }, [activeWorkspaceWindowId, sectorWindowZIndex, subjectCardZIndex, subjectStates, updateSubjectState]); const clearActiveWorkspaceWindow = (windowId: MapWorkspaceWindowId) => { setActiveWorkspaceWindowId((current) => current === windowId ? undefined : current); }; const openSubjectWindow = (bindingId: string) => { updateSubjectState(bindingId, (state) => ({ ...state, window: { ...state.window, open: true }, })); activateWorkspaceWindow(`binding:${bindingId}`); }; const closeSubjectWindow = (bindingId: string) => { updateSubjectState(bindingId, (state) => ({ ...state, window: { ...state.window, open: false } })); clearActiveWorkspaceWindow(`binding:${bindingId}`); }; const closeSettingsPanel = useCallback(() => setInspectorOpen(false), []); const toggleSettingsPanel = () => setInspectorOpen((current) => !current); useEffect(() => { onSettingsPanelOpenChange?.(inspectorOpen && Boolean(features.inspector)); }, [features.inspector, inspectorOpen, onSettingsPanelOpenChange]); useEffect(() => () => onSettingsPanelOpenChange?.(false), [onSettingsPanelOpenChange]); const deactivateGridSector = () => { setSelectedGridSector(null); setSectorExcludedBindingIds([]); setSectorExcludedProviders([]); setSectorExcludedObjectKinds([]); clearActiveWorkspaceWindow("sector"); }; const handleGridSectorSelect = (selection: GridSectorSelection | null) => { if (!selection) { deactivateGridSector(); return; } setSelectedGridSector(selection); activateWorkspaceWindow("sector"); }; const setSectorBindingEnabled = (bindingId: string, enabled: boolean) => { setSectorExcludedBindingIds((current) => enabled ? current.filter((value) => value !== bindingId) : [...new Set([...current, bindingId])]); }; const setSectorProviderEnabled = (provider: string, enabled: boolean) => { setSectorExcludedProviders((current) => enabled ? current.filter((value) => value !== provider) : [...new Set([...current, provider])]); }; const setSectorObjectKindEnabled = (objectKind: string, enabled: boolean) => { setSectorExcludedObjectKinds((current) => enabled ? current.filter((value) => value !== objectKind) : [...new Set([...current, objectKind])]); }; 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); activateWorkspaceWindow("subject-card"); }, [activateWorkspaceWindow, dataProductBindings, selectable, subjectDetailProfiles]); const focusSubject = useCallback((entityId: string, coordinates?: readonly [number, number]) => { const renderer = mapRendererRef.current; if (!renderer) return false; if (renderer.focusRuntimeEntity(entityId)) return true; const fallbackCoordinates = coordinates ?? mapFactPointCoordinates(selectable.find((entity) => entity.id === entityId)?.fact); return fallbackCoordinates ? renderer.focusSubjectCoordinates(fallbackCoordinates[0], fallbackCoordinates[1]) : false; }, [selectable]); const handleSelectAndFocus = useCallback((entityId: string) => { handleSelect(entityId); focusSubject(entityId); }, [focusSubject, 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 ))); } focusSubject(result.entityId, result.coordinates); setSearchOpen(false); setSearchQuery(""); setRemoteSearchQuery(""); setSearchActiveIndex(0); }, [focusSubject, handleSelect]); 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: "Карта", icon: , 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: "Карта", icon: , 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: "Карта", icon: , 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" ? "Слои" : "Таргеты", icon: , 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: "Таргеты", icon: , 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: "Слои", icon: , content:
Фиксированная московская ENU-адресация задаёт неизменные сектора на LOD 1–3. LOD 4–5 используют глобальную WGS84-гратику́лу; камера выбирает только LOD и видимую область. updateMapSettings({ gridVisible })} /> updateMapSettings({ grid3dEnabled })} /> updateMapSettings({ gridGraticuleEnabled })} /> updateMapSettings({ gridLodEnabled })} /> updateMapSettings({ gridRebuildOnMoveEnd })} /> Fixed ENU · WGS84 value.toFixed(6)} onChange={(gridCenterLatitude) => updateMapSettings({ gridCenterLatitude })} /> value.toFixed(6)} onChange={(gridCenterLongitude) => updateMapSettings({ gridCenterLongitude })} /> value === 0 ? "выкл" : `${value} км`} onChange={(gridAutoDisableHeightKm) => updateMapSettings({ gridAutoDisableHeightKm })} />
({ value: String(index), label: `LOD ${index + 1}` }))} label="Уровень детализации сетки" onChange={setSelectedGridLod} />
`${value} км`} onChange={(maxHeightKm) => updateGridLod({ maxHeightKm })} /> {selectedGridLodIndex === 4 ? Последний LOD остаётся активным выше своего порога до общего автовыключения. : null} updateGridLod({ mode, volumeEnabled: mode === "3d" && activeGridLod.volumeEnabled, ...(mode === "3d" ? { stepKm: Math.min(50, activeGridLod.stepKm), tileSizeKm: normalizedMajorTileSizeKm(Math.min(50, activeGridLod.stepKm), activeGridLod.tileSizeKm), } : { graticuleStepDegrees: normalizedGraticuleStepDegrees(activeGridLod.graticuleStepDegrees), }), })} /> `${value} м`} onChange={(heightMeters) => updateGridLod({ heightMeters })} /> `${value}°`} onChange={(max3dViewAngleDegrees) => updateGridLod({ max3dViewAngleDegrees })} /> `${value} км`} onChange={(stepKm) => updateGridLod({ stepKm, tileSizeKm: normalizedMajorTileSizeKm(stepKm, Math.max(stepKm, activeGridLod.tileSizeKm)), radiusKm: Math.min(activeGridLod.radiusKm, stepKm * MAX_LOCAL_GRID_INDEX), })} /> `${value} км`} onChange={(tileSizeKm) => updateGridLod({ tileSizeKm: normalizedMajorTileSizeKm(activeGridLod.stepKm, tileSizeKm) })} /> Major-тайл содержит целое число ENU-секторов. Для гратикулы major-шаг равен пяти minor-шагам. updateGridLod({ majorLinesEnabled, majorLabelsEnabled: majorLinesEnabled && activeGridLod.majorLabelsEnabled, })} /> updateGridLod({ majorLabelsEnabled })} /> `×${value.toFixed(1)}`} onChange={(majorLineWidthMultiplier) => updateGridLod({ majorLineWidthMultiplier })} /> Прозрачность major-линий наследует прозрачность линий текущего LOD. {activeGridLod.mode === "graticule" && activeGridLod.majorLinesEnabled && activeGraticuleMajorStepDegrees === null ? Major-разметка недоступна для этого шага: пять minor-интервалов должны точно делить 90°-квадрант. : null} `${value} км`} onChange={(radiusKm) => updateGridLod({ radiusKm })} /> `${value} м`} onChange={(lineDiameterMeters) => updateGridLod({ lineDiameterMeters })} /> updateGridLod({ lineColor })} /> `${value}%`} onChange={(lineOpacity) => updateGridLod({ lineOpacity })} /> updateGridLod({ dotsEnabled })} /> `${value} м`} onChange={(dotsDiameterMeters) => updateGridLod({ dotsDiameterMeters })} /> updateGridLod({ dotsColor })} /> `${value}%`} onChange={(dotsOpacity) => updateGridLod({ dotsOpacity })} /> updateGridLod({ crossesEnabled })} /> `${value} м`} onChange={(crossesLengthMeters) => updateGridLod({ crossesLengthMeters })} /> `${value} м`} onChange={(crossesWidthMeters) => updateGridLod({ crossesWidthMeters })} /> updateGridLod({ crossesColor })} /> `${value}%`} onChange={(crossesOpacity) => updateGridLod({ crossesOpacity })} /> `${value}°`} onChange={(graticuleStepDegrees) => updateGridLod({ graticuleStepDegrees: normalizedGraticuleStepDegrees(graticuleStepDegrees) })} /> `${value} px`} onChange={(graticuleLineWidthPx) => updateGridLod({ graticuleLineWidthPx })} /> updateGridLod({ graticuleColor })} /> `${value}%`} onChange={(graticuleOpacity) => updateGridLod({ graticuleOpacity })} /> {activeGridLod.mode === "3d" ? <> updateGridLod({ volumeEnabled })} /> `${value} м WGS84`} onChange={(volumeMinimumHeightMeters) => updateGridVolumeRange({ volumeMinimumHeightMeters })} /> `${value} м WGS84`} onChange={(volumeMaximumHeightMeters) => updateGridVolumeRange({ volumeMaximumHeightMeters })} /> `${value} м`} onChange={(volumeBandHeightMeters) => updateGridVolumeRange({ volumeBandHeightMeters })} /> Горизонтальный ID сектора остаётся стабильным. Высотный band добавляется как отдельный адрес внутри выбранной ENU-ячейки. : null} updateGridLod({ selectionFillColor })} /> `${value}%`} onChange={(selectionFillOpacityPercent) => updateGridLod({ selectionFillOpacityPercent })} /> updateGridLod({ selectionOutlineColor })} /> `${value} px`} onChange={(selectionOutlineWidthPx) => updateGridLod({ selectionOutlineWidthPx })} /> `${value}%`} onChange={(selectionOutlineOpacityPercent) => updateGridLod({ selectionOutlineOpacityPercent })} /> Оформление применяется к выбранному сектору текущего LOD.
{selectedGridSector?.id ?? "Нажмите сектор на карте"} {selectedGridSector ? <> {gridSectorCopyState === "error" ? Не удалось записать ID в буфер обмена. : null}
{selectedGridSector.mode === "3d" ? "Local ENU" : "WGS84 graticule"} · LOD {selectedGridSector.lod} {selectedGridSector.label} {gridSectorBoundsLabel(selectedGridSector)} {gridSectorCenterLabel(selectedGridSector)} {formatGridSectorArea(selectedGridSector.areaSquareMeters)}
{selectedGridSector.parentMajorTile ?
Parent major tile · {selectedGridSector.parentMajorTile.label} {selectedGridSector.parentMajorTile.id} {selectedGridSector.parentMajorTile.minorPerSide} × {selectedGridSector.parentMajorTile.minorPerSide} · {selectedGridSector.parentMajorTile.childCount} дочерних секторов · {formatGridSectorArea(selectedGridSector.parentMajorTile.areaSquareMeters)}
: Parent major tile выключен или недоступен для текущей топологии.}
Следующий LOD {selectedGridParentLod ? <> {selectedGridParentLod.id} : {selectedGridSector.lod >= sectorGridLodProfiles.length ? "Верхний уровень иерархии" : `LOD ${selectedGridSector.lod + 1} меняет систему адресации`}}
{GRID_SECTOR_DIRECTIONS.map(({ id, label }) => { const target = selectedGridNeighborTargets[id]; return
{target?.id ?? "Граница адресного пространства"}
; })}
{selectedGridSector.mode === "3d" && selectedGridSectorProfile ?
{selectedGridSectorProfile.volumeEnabled ? "Включён" : "Выключен"} {selectedGridSector.volume?.floor ?? selectedGridSectorProfile.volumeMinimumHeightMeters} м WGS84 {selectedGridSector.volume?.ceiling ?? selectedGridSectorProfile.volumeMaximumHeightMeters} м WGS84 {selectedGridSector.volume?.bandHeight ?? selectedGridSectorProfile.volumeBandHeightMeters} м {selectedGridSector.volume ? {selectedGridSector.volume.id} : null} {selectedGridSectorProfile.volumeEnabled ?
: null}
: null} : Кликните ячейку, чтобы получить устойчивый адрес, геометрию и навигацию по соседям.}
, }, { id: "map-camera-animation", label: "Анимация камеры", description: "geodesic spiral survey", group: "Камера", icon: , 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: "Хранение", icon: , 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: "Данные", icon: , content: <> {selected?.title ?? "Нет выбора"} {selected?.kind ?? "—"}{selected?.status ? ` · ${selected.status}` : ""} , }, ]; const headerActions = (
{features.toolbar ? setToolbarOpen((value) => !value)}> : null}
); const settingsPanel = inspectorOpen && Boolean(features.inspector) ? ( ) : null; return (
Загрузка карты…
}>
{features.assistant ? (
setAssistantOpen((value) => !value)}>
) : null} {selectedGridSector ? ( activateWorkspaceWindow("sector")} onClose={deactivateGridSector} title={`Активный сектор · LOD ${selectedGridSector.lod}`} subtitle={selectedGridSector.label} status={`${sectorVisibleEntities.length} / ${sectorSpatialEntities.length}`} active={activeWorkspaceWindowId === "sector"} zIndex={sectorWindowZIndex} minWidth={340} minHeight={380} footer={( )} className="catalog-map-fixture__sector-window catalog-map-fixture__map-glass-window" aria-label={`Активный сектор: ${selectedGridSector.id}`} >
{selectedGridSector.id} {gridSectorBoundsLabel(selectedGridSector)} {formatGridSectorArea(selectedGridSector.areaSquareMeters)}
Домены данных {sectorBindingOptions.length}
{sectorBindingOptions.map((option) => ( setSectorBindingEnabled(option.value, enabled)} /> ))}
{sectorProviderOptions.length ? (
Провайдеры {sectorProviderOptions.length}
{sectorProviderOptions.map((option) => ( setSectorProviderEnabled(option.value, enabled)} /> ))}
) : null} {sectorObjectKindOptions.length ? (
Типы объектов {sectorObjectKindOptions.length}
{sectorObjectKindOptions.map((option) => ( setSectorObjectKindEnabled(option.value, enabled)} /> ))}
) : null}
Объекты сектора {sectorVisibleEntities.length} / {sectorSpatialEntities.length}
{sectorVisibleEntities.map((entity) => { const provider = normalizedSectorScopeValue(entity.fact.attributes[MAP_SCOPE_PROVIDER_FIELD]); const objectKind = normalizedSectorScopeValue(entity.fact.attributes[MAP_SCOPE_OBJECT_KIND_FIELD]); return ( ); })} {!sectorVisibleEntities.length ? ( {sectorSpatialEntities.length ? "Объекты скрыты текущими фильтрами." : "В секторе нет точечных объектов подключённых Data Products."} ) : 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}
)}
( )} >
Слои карты Подложка, рельеф и визуальные слои
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}
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={() => activateWorkspaceWindow(`binding:${summary.bindingId}`)} onClose={() => closeSubjectWindow(summary.bindingId)} title={summary.displayName} subtitle={`${summary.total} всего · ${visibleCount} на карте`} active={activeWorkspaceWindowId === `binding:${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 = mapPresentationFacetValueIsEnabled(state.filters, facet.field, item.value); 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 ? ( activateWorkspaceWindow("subject-card")} onClose={() => { setSubjectCardOpen(false); clearActiveWorkspaceWindow("subject-card"); }} title={selectedSubjectCard.title} subtitle={selectedSubjectCard.sourceId} active={activeWorkspaceWindowId === "subject-card"} 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} {settingsPanel ? settingsPanelHost ? createPortal(settingsPanel, settingsPanelHost) :
{settingsPanel}
: null} {headerActionsHost ? createPortal(headerActions, headerActionsHost) : null} ); });