import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useReducer, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react"; import { createPortal } from "react-dom"; import { ApplicationSidePanel, Icon, IconButton, Inspector } from "@nodedc/ui-react"; import type { SelectOption } from "@nodedc/ui-react"; import type { CameraSpiralState, CesiumMapRendererHandle, MapCameraView, MapGatewayHealth, MapPresentation, MapProviderStatus, GridSectorSelection, } from "./mapRendererContract.js"; import { useMapDataProductRuntime } from "./useMapDataProductRuntime.js"; import { mapPresentationProfileForFact, normalizeMapPresentationFacetSelections, normalizeClientMapPresentationProfiles, type MapPresentationFilters, type MapPresentationProfile, } from "./mapPresentationProfile.js"; import { CAMERA_SURVEY_PRESETS, DEFAULT_CAMERA_SURVEY_PRESET, findCameraSurveyPreset, type CameraSurveySelection, } from "./mapCameraPresets.js"; import { buildMapSubjectCardModel, DEFAULT_MAP_SUBJECT_DETAIL_PROFILE } from "./mapSubjectCard.mjs"; import { ensureMapReferencePresentationProfiles, initialMapReferenceLayers, type MapReferenceLayer, } from "./mapReferenceStations.js"; import { useMapReferenceRuntime, useMapReferenceSearch } from "./useMapReferenceRuntime.js"; import { buildMapSearchIndex, searchMapSubjects } from "./mapSearch.mjs"; import { DEFAULT_GRID_LOD_PROFILES } from "./mapGridPolicy.mjs"; import { createDefaultMapPageLayout, defaultSectorWindowRect, defaultSubjectCardRect, defaultSubjectWindowState, fallbackMapCamera, initialMapSettings, } from "./mapPageContract.js"; import type { MapDataProductBinding, MapFixturePreviewHandle, MapPageLayout, MapPageSettings, MapPinBinding, MapSubjectDetailProfile, MapSubjectState, MapWorkspaceWindowId, } from "./mapPageContract.js"; import { GRID_SECTOR_DIRECTIONS, MAP_SCOPE_OBJECT_KIND_FIELD, MAP_SCOPE_PROVIDER_FIELD, graticuleMajorStepDegrees, gridSectorNeighborSelection, gridSectorParentLodSelection, gridSectorVolumeNeighborSelection, mapFactPointCoordinates, normalizeSectorGridLodProfile, resolveGridLodProfiles, type GridSectorDirection, type SectorGridLodProfile, } from "./mapSectorWorkspace.js"; import { bindingProjectsField, buildFilteredMapTargets, buildMapPresentationFilters, buildMapPresentationSummaries, buildSectorBindingOptions, buildSectorScopeOptions, buildSectorSpatialEntities, buildSectorVisibleEntities, buildSelectableMapEntities, mapProfileHasSubjectWindowControls, planMapSubjectReveal, primaryMapRuntimeBindings as selectPrimaryMapRuntimeBindings, scopeMapRuntimeBindings, } from "./mapWorkspaceModel.mjs"; import { createMapWorkspaceState, mapWorkspaceReducer } from "./mapWorkspaceState.mjs"; import { MapSectorWorkspaceWindow } from "./MapSectorWorkspaceWindow.js"; import { MapSubjectWorkspaceWindows } from "./MapSubjectWorkspaceWindows.js"; import { MapWorkspaceToolbar } from "./MapWorkspaceToolbar.js"; import { buildMapPresentationInspectorSections, buildMapRuntimeInspectorSections, buildMapSurfaceInspectorSections, formatMetricDistance, } from "./mapInspectorSections.js"; import { buildMapGridInspectorSections } from "./mapGridInspectorSections.js"; const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer }))); export { createDefaultMapPageLayout } from "./mapPageContract.js"; export type { MapDataProductBinding, MapFixturePreviewHandle, MapPageLayout, MapPageSettings, MapPinBinding, MapSubjectDetailProfile, MapSubjectState, MapSubjectWindowState, } from "./mapPageContract.js"; type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean }; 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; type GridSectorCopyState = "idle" | "copied" | "error"; 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; } const initialProviderStatus: MapProviderStatus = { imagery: "loading", terrain: "loading", buildings: "loading", errors: {}, }; 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 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 [gridSectorCopyState, setGridSectorCopyState] = useState("idle"); const [expandedFacetRows, setExpandedFacetRows] = useState>({}); const [inspectorOpen, setInspectorOpen] = useState(false); const [inspectorOpenSections, setInspectorOpenSections] = useState(() => ( initialLayout?.inspectorOpenSections ?? ["map-base"] )); const [layersOpen, setLayersOpen] = useState(false); const [toolbarOpen, setToolbarOpen] = useState(Boolean(features.toolbar)); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [remoteSearchQuery, setRemoteSearchQuery] = useState(""); const [remoteSearchEpoch, setRemoteSearchEpoch] = useState(0); const [searchActiveIndex, setSearchActiveIndex] = useState(0); const searchInputRef = useRef(null); const [assistantOpen, setAssistantOpen] = useState(false); const [mapSettings, setMapSettings] = useState(() => ({ ...initialMapSettings, ...initialLayout?.settings, // Layouts saved before the cache policy field existed retain the safe // append-only default when they are opened again. cacheNoOverwrite: initialLayout?.settings?.cacheNoOverwrite ?? true, // 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 [workspaceState, dispatchWorkspace] = useReducer( mapWorkspaceReducer, null, () => createMapWorkspaceState({ subjectStates: initialSubjectState( initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates, presentationProfiles, ), sectorWindowRect: defaultSectorWindowRect, subjectCardRect: defaultSubjectCardRect, }), ); const selectedId = workspaceState.selectedEntityId; const selectedGridSector = workspaceState.selectedSector; const subjectStates = workspaceState.subjectStates; const activeWorkspaceWindowId = workspaceState.activeWindowId; const { rect: sectorWindowRect, maximized: sectorWindowMaximized, zIndex: sectorWindowZIndex, } = workspaceState.sector.window; const hideObjectsOutsideSector = workspaceState.sector.hideOutside; const sectorScope = workspaceState.sector.scope; const { excludedBindingIds: sectorExcludedBindingIds, excludedProviders: sectorExcludedProviders, excludedObjectKinds: sectorExcludedObjectKinds, } = sectorScope; const presentationFilters = useMemo(() => buildMapPresentationFilters( subjectStates, dataProductBindings, presentationProfiles, ), [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 primaryRuntimeBindings = useMemo(() => selectPrimaryMapRuntimeBindings( runtimeBindings, dataProductBindings, ), [dataProductBindings, 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(() => buildSelectableMapEntities( runtimeBindings, dataProductBindings, presentationProfiles, ), [dataProductBindings, presentationProfiles, runtimeBindings]); const sectorSpatialEntities = useMemo(() => buildSectorSpatialEntities( selectable, selectedGridSector, sectorGridLodProfiles, fixedSectorGridOrigin, ), [fixedSectorGridOrigin, sectorGridLodProfiles, selectable, selectedGridSector]); const sectorBindingOptions = useMemo(() => buildSectorBindingOptions( dataProductBindings, sectorSpatialEntities, ), [dataProductBindings, sectorSpatialEntities]); const sectorProviderFacetAvailable = useMemo(() => bindingProjectsField( dataProductBindings, MAP_SCOPE_PROVIDER_FIELD, ), [dataProductBindings]); const sectorObjectKindFacetAvailable = useMemo(() => bindingProjectsField( dataProductBindings, MAP_SCOPE_OBJECT_KIND_FIELD, ), [dataProductBindings]); const sectorProviderOptions = useMemo(() => buildSectorScopeOptions( sectorSpatialEntities, MAP_SCOPE_PROVIDER_FIELD, sectorProviderFacetAvailable, ), [sectorProviderFacetAvailable, sectorSpatialEntities]); const sectorObjectKindOptions = useMemo(() => buildSectorScopeOptions( sectorSpatialEntities, MAP_SCOPE_OBJECT_KIND_FIELD, sectorObjectKindFacetAvailable, ), [sectorObjectKindFacetAvailable, sectorSpatialEntities]); const sectorVisibleEntities = useMemo(() => buildSectorVisibleEntities({ sectorEntities: sectorSpatialEntities, bindingConfigs: dataProductBindings, presentationProfiles, presentationFilters, scope: sectorScope, }), [dataProductBindings, presentationFilters, presentationProfiles, sectorScope, sectorSpatialEntities]); const sectorScopedPrimaryRuntimeBindings = useMemo(() => scopeMapRuntimeBindings({ runtimeBindings: primaryRuntimeBindings, selection: selectedGridSector, gridProfiles: sectorGridLodProfiles, origin: fixedSectorGridOrigin, hideOutsideSector: hideObjectsOutsideSector, scope: sectorScope, }), [fixedSectorGridOrigin, hideObjectsOutsideSector, primaryRuntimeBindings, sectorGridLodProfiles, sectorScope, selectedGridSector]); const presentationSummaries = useMemo(() => buildMapPresentationSummaries( dataProductBindings, runtimeBindings, presentationProfiles, ), [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 filteredTargets = useMemo(() => buildFilteredMapTargets( sectorScopedPrimaryRuntimeBindings, dataProductBindings, presentationProfiles, presentationFilters, ), [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(() => { dispatchWorkspace({ type: "reset-sector-definition" }); }, [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; dispatchWorkspace({ type: "set-sector-selection", selection: 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 && !mapProfileHasSubjectWindowControls(summary.profile) ? { ...normalizedState, window: { ...normalizedState.window, open: false } } : normalizedState; }), }), }), [dataProductBindings, inspectorOpenSections, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, referenceLayers, subjectDetailProfiles, subjectStates]); const togglePresentationFilter = (bindingId: string, field: string, value: string, availableValues: string[]) => { dispatchWorkspace({ type: "toggle-subject-filter", bindingId, field, value, availableValues }); }; const toggleSubjectVisibility = (bindingId: string) => { dispatchWorkspace({ type: "toggle-subject-visibility", bindingId }); }; const activateWorkspaceWindow = useCallback((windowId: MapWorkspaceWindowId) => { dispatchWorkspace({ type: "activate-window", windowId }); }, []); const openSubjectWindow = (bindingId: string) => { dispatchWorkspace({ type: "open-subject-window", 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 = () => { dispatchWorkspace({ type: "deactivate-sector" }); }; const handleGridSectorSelect = (selection: GridSectorSelection | null) => { if (!selection) { deactivateGridSector(); return; } dispatchWorkspace({ type: "select-sector", selection }); }; const setSectorBindingEnabled = (bindingId: string, enabled: boolean) => { dispatchWorkspace({ type: "set-sector-scope-enabled", dimension: "binding", value: bindingId, enabled }); }; const setSectorProviderEnabled = (provider: string, enabled: boolean) => { dispatchWorkspace({ type: "set-sector-scope-enabled", dimension: "provider", value: provider, enabled }); }; const setSectorObjectKindEnabled = (objectKind: string, enabled: boolean) => { dispatchWorkspace({ type: "set-sector-scope-enabled", dimension: "object-kind", value: objectKind, enabled }); }; 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; 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 ?? "")); dispatchWorkspace({ type: "select-entity", entityId, validTabIds: profile?.tabs.map((tab) => tab.id) ?? ["overview"], defaultTabId: profile?.defaultTabId ?? "overview", }); }, [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) { const entity = selectable.find((candidate) => candidate.id === result.entityId); const state = subjectStates[result.bindingId]; if (entity && state) { const binding = dataProductBindings.find((candidate) => candidate.id === entity.bindingId); const profile = mapPresentationProfileForFact( presentationProfiles, binding?.presentationProfileId, entity.fact.semanticType, ); const reveal = planMapSubjectReveal({ entity, subjectState: state, profile, selectedSector: selectedGridSector, gridProfiles: sectorGridLodProfiles, origin: fixedSectorGridOrigin, hideOutsideSector: hideObjectsOutsideSector, scope: sectorScope, }); dispatchWorkspace({ type: "reveal-subject", bindingId: result.bindingId, subjectState: reveal.subjectState, hideOutsideSector: reveal.hideOutsideSector, scope: reveal.scope, }); } else { const currentState = subjectStates[result.bindingId]; if (currentState) dispatchWorkspace({ type: "replace-subject-state", bindingId: result.bindingId, subjectState: { ...currentState, visible: true }, }); } 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); }, [dataProductBindings, fixedSectorGridOrigin, focusSubject, handleSelect, hideObjectsOutsideSector, presentationProfiles, sectorGridLodProfiles, sectorScope, selectable, selectedGridSector, subjectStates]); 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 = [ ...buildMapSurfaceInspectorSections({ mapSettings, providerStatus, updateMapSettings, }), ...buildMapPresentationInspectorSections({ presentationProfiles, referenceLayers, setReferenceLayers, updatePresentationProfile, updatePresentationStyle, }), ...buildMapGridInspectorSections({ mapSettings, updateMapSettings, selectedGridLod, setSelectedGridLod, selectedGridLodIndex, activeGridLod, minimumGridLodHeight, maximumGridLodHeight, updateGridLod, updateGridVolumeRange, activeGraticuleMajorStepDegrees, selectedGridSector, gridSectorCopyState, copySelectedGridSectorId, mapRendererReady, focusGridMajorTile, selectedGridParentLod, sectorGridLodProfiles, focusGridSector, selectedGridNeighborTargets, selectedGridSectorProfile, selectedGridVolumeTargets, }), ...buildMapRuntimeInspectorSections({ animationModeEnabled, setAnimationMode, spiralPresetId, spiralPresetOptions, spiralRunning, selectSpiralPreset, spiralHeightMeters, setSpiralHeightMeters, spiralSpeedMetersPerSecond, setSpiralSpeedMetersPerSecond, spiralPitchMetersPerTurn, setSpiralPitchMetersPerTurn, spiralTargetRadiusMeters, setSpiralTargetRadiusMeters, setSpiralPresetId, spiralCanStart, spiralTileCacheReady, providerStatus, gatewayHealth, gatewayCheckState, toggleSpiralAnimation, spiralMessage, mapSettings, setCacheEnabled, setCacheNoOverwrite, gatewayEndpoint, liveCacheSummary, refreshCurrentViewport, cacheRefresh, verifyGateway, transportDiagnostic, gatewayHealthAge, gatewayCheckError, selected, }), ]; const headerActions = (
{features.toolbar ? setToolbarOpen((value) => !value)}> : null}
); const settingsPanel = inspectorOpen && Boolean(features.inspector) ? ( ) : null; return (
Загрузка карты…
}>
{features.assistant ? (
setAssistantOpen((value) => !value)}>
) : null} {selectedGridSector ? ( dispatchWorkspace({ type: "set-sector-window-rect", rect })} onMaximizedChange={(value) => dispatchWorkspace({ type: "set-sector-window-maximized", value })} onActivate={() => activateWorkspaceWindow("sector")} onDeactivate={deactivateGridSector} onCopyStableId={() => void copySelectedGridSectorId()} onHideOutsideChange={(value) => dispatchWorkspace({ type: "set-sector-hide-outside", value })} onBindingEnabledChange={setSectorBindingEnabled} onProviderEnabledChange={setSectorProviderEnabled} onObjectKindEnabledChange={setSectorObjectKindEnabled} onSelectEntity={handleSelectAndFocus} /> ) : null} {toolbarOpen ? ( setReferenceLayers((current) => current.map((layer) => ( layer.id === layerId ? { ...layer, visible: !layer.visible } : layer )))} onLayersOpenChange={setLayersOpen} onTerrainChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} onBuildingsVisibleChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} onGridVisibleChange={(gridVisible) => updateMapSettings({ gridVisible })} onCacheEnabledChange={setCacheEnabled} onCacheNoOverwriteChange={setCacheNoOverwrite} onFitVisibleTargets={() => mapRendererRef.current?.fitRuntimeEntities(visibleTargetEntityIds)} onToggleSearch={() => { setSearchOpen((current) => !current); if (searchOpen) { setSearchQuery(""); setRemoteSearchQuery(""); setSearchActiveIndex(0); } }} onSearchQueryChange={(value) => { setSearchQuery(value); setRemoteSearchQuery(""); }} onSearchKeyDown={handleSearchKeyDown} onSearchActiveIndexChange={setSearchActiveIndex} onSearchResult={handleSearchResult} /> ) : null} setExpandedFacetRows((current) => ({ ...current, [rowId]: !current[rowId] }))} onTogglePresentationFilter={togglePresentationFilter} onSelectEntity={handleSelectAndFocus} /> {assistantOpen ?
NODE.DC AssistantКонтекст выбранной сущности готов к передаче.
: null} {settingsPanel ? settingsPanelHost ? createPortal(settingsPanel, settingsPanelHost) :
{settingsPanel}
: null} {headerActionsHost ? createPortal(headerActions, headerActionsHost) : null} ); });