Files
NODEDC_DESIGN_GUIDELINE/apps/catalog/src/MapFixturePreview.tsx
T

1337 lines
59 KiB
TypeScript

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<string, MapSubjectState>;
}
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<MapFixturePreviewHandle, {
features?: PreviewFeatures;
expanded?: boolean;
initialLayout?: MapPageLayout | null;
applicationId?: string;
pageId?: string;
settingsPanelHost?: HTMLElement | null;
headerActionsHost?: HTMLElement | null;
onSettingsPanelOpenChange?: (open: boolean) => void;
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId, settingsPanelHost, headerActionsHost, onSettingsPanelOpenChange }, ref) {
const workspaceRef = useRef<HTMLDivElement>(null);
const [gridSectorCopyState, setGridSectorCopyState] = useState<GridSectorCopyState>("idle");
const [expandedFacetRows, setExpandedFacetRows] = useState<Record<string, boolean>>({});
const [inspectorOpen, setInspectorOpen] = useState(false);
const [inspectorOpenSections, setInspectorOpenSections] = useState<string[]>(() => (
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<HTMLInputElement>(null);
const [assistantOpen, setAssistantOpen] = useState(false);
const [mapSettings, setMapSettings] = useState<MapPageSettings>(() => ({
...initialMapSettings,
...initialLayout?.settings,
// Layouts saved before the cache policy field existed retain the safe
// append-only default when they are opened again.
cacheNoOverwrite: initialLayout?.settings?.cacheNoOverwrite ?? true,
// 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<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera);
const mapRendererRef = useRef<CesiumMapRendererHandle | null>(null);
const [mapRendererReady, setMapRendererReady] = useState(false);
const [animationModeEnabled, setAnimationModeEnabled] = useState(false);
const animationSettingsSnapshotRef = useRef<SurveySettingsSnapshot | null>(null);
const [spiralRunning, setSpiralRunning] = useState(false);
const [spiralPresetId, setSpiralPresetId] = useState<CameraSurveySelection>(DEFAULT_CAMERA_SURVEY_PRESET.id);
const [spiralHeightMeters, setSpiralHeightMeters] = useState<number>(DEFAULT_CAMERA_SURVEY_PRESET.heightAboveGroundMeters);
const [spiralSpeedMetersPerSecond, setSpiralSpeedMetersPerSecond] = useState<number>(DEFAULT_CAMERA_SURVEY_PRESET.speedMetersPerSecond);
const [spiralPitchMetersPerTurn, setSpiralPitchMetersPerTurn] = useState<number>(DEFAULT_CAMERA_SURVEY_PRESET.pitchMetersPerTurn);
const [spiralTargetRadiusMeters, setSpiralTargetRadiusMeters] = useState<number>(DEFAULT_CAMERA_SURVEY_PRESET.targetRadiusMeters);
const [spiralMessage, setSpiralMessage] = useState<string | null>(null);
// Map pin bindings belong to the application page instance. They are kept
// intact when a human changes camera or visual settings and presses Save.
const [pinBindings] = useState<MapPinBinding[]>(() => initialLayout?.pinBindings ?? []);
// 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<MapPresentationProfile[]>(() => (
ensureMapReferencePresentationProfiles(normalizeClientMapPresentationProfiles(initialLayout?.presentationProfiles ?? []))
));
const [subjectDetailProfiles] = useState<MapSubjectDetailProfile[]>(() => (
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<MapDataProductBinding[]>(() => initialLayout?.dataProductBindings ?? []);
const [referenceLayers, setReferenceLayers] = useState<MapReferenceLayer[]>(() => (
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<MapPresentationFilters>(() => 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<MapPresentationFilters>(() => Object.fromEntries(
referenceLayers.map((layer) => [layer.id, { visible: layer.visible, facets: {} }]),
), [referenceLayers]);
const rendererPresentationFilters = useMemo<MapPresentationFilters>(() => ({
...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<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera);
const [rendererRevision, setRendererRevision] = useState(0);
const [gatewayHealth, setGatewayHealth] = useState<MapGatewayHealth | null>(null);
const gatewayHealthRef = useRef<MapGatewayHealth | null>(null);
// The renderer owns epoch 1 as a bootstrap health source. As soon as the UI
// starts an explicit verification, its higher epoch becomes authoritative;
// a slower renderer request can no longer overwrite that newer result.
const gatewayHealthOrderRef = useRef<GatewayHealthOrder>({
nextEpoch: RENDERER_GATEWAY_HEALTH_EPOCH,
latestStartedEpoch: RENDERER_GATEWAY_HEALTH_EPOCH,
});
const gatewayCheckRequestRef = useRef<{ id: symbol; controller: AbortController; promise: Promise<void> } | null>(null);
const [gatewayEndpoint, setGatewayEndpoint] = useState<string | null>(null);
const [gatewayCheckState, setGatewayCheckState] = useState<GatewayCheckState>("idle");
const [gatewayCheckError, setGatewayCheckError] = useState<string | null>(null);
const [gatewayLastVerifiedAt, setGatewayLastVerifiedAt] = useState<Date | null>(null);
const [providerStatus, setProviderStatus] = useState<MapProviderStatus>(initialProviderStatus);
const [cacheRefresh, setCacheRefresh] = useState(false);
const spiralPresetOptions = useMemo<Array<SelectOption<CameraSurveySelection>>>(() => [
...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<MapPresentation>(
() => ({ ...mapSettings, cacheRefresh }),
[cacheRefresh, mapSettings],
);
const updateMapSettings = (patch: Partial<MapPageSettings>) => 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<GridSectorDirection, GridSectorSelection | null>, [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<SectorGridLodProfile>) => updateMapSettings({
gridLodProfiles: mapSettings.gridLodProfiles.map((profile, index) => (
index === selectedGridLodIndex ? { ...profile, ...patch } : profile
)),
});
const updateGridVolumeRange = (patch: Partial<Pick<SectorGridLodProfile,
"volumeMinimumHeightMeters" | "volumeMaximumHeightMeters" | "volumeBandHeightMeters">>) => {
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<GridSectorSelection["parentMajorTile"]>) => {
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<MapPresentationProfile["styles"][number]>) => {
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<HTMLInputElement>) => {
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<HTMLButtonElement>) => {
event.preventDefault();
const startY = event.clientY;
const startHeight = mapHeight;
const onMove = (move: globalThis.PointerEvent) => {
const maximum = Math.max(380, Math.round(window.innerHeight * 0.78));
setMapHeight(Math.max(360, Math.min(maximum, startHeight + move.clientY - startY)));
};
const onEnd = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onEnd);
window.removeEventListener("pointercancel", onEnd);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onEnd);
window.addEventListener("pointercancel", onEnd);
};
const inspectorSections = [
...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 = (
<div className="catalog-map-header-actions" aria-label="Действия карты">
<IconButton className="catalog-map-header-action" label="Настройки карты" aria-pressed={inspectorOpen} data-active={inspectorOpen || undefined} onClick={toggleSettingsPanel}><Icon name="settings" /></IconButton>
{features.toolbar ? <IconButton className="catalog-map-header-action" label="Toolbar" aria-pressed={toolbarOpen} data-active={toolbarOpen || undefined} onClick={() => setToolbarOpen((value) => !value)}><Icon name="panel" /></IconButton> : null}
</div>
);
const settingsPanel = inspectorOpen && Boolean(features.inspector) ? (
<ApplicationSidePanel
eyebrow="MAP / SETTINGS"
title="Настройки карты"
description="Application-owned layout"
onClose={closeSettingsPanel}
aria-label="Настройки карты"
>
<Inspector
variant="panel"
sections={inspectorSections}
openSections={inspectorOpenSections}
singleOpen
onOpenSectionsChange={setInspectorOpenSections}
/>
</ApplicationSidePanel>
) : null;
return (
<div
ref={workspaceRef}
className={`catalog-map-fixture${expanded ? " catalog-map-fixture--expanded" : ""}`}
style={{ "--catalog-map-height": `${mapHeight}px` } as CSSProperties}
aria-label="Map Page Cesium adapter"
>
<div className="catalog-map-fixture__renderer">
<Suspense fallback={<div className="catalog-map-fixture__loading">Загрузка карты…</div>}>
<CesiumMapRenderer
key={rendererRevision}
ref={mapRendererRef}
onSelect={handleSelect}
onGridSectorSelect={handleGridSectorSelect}
selectedGridSector={selectedGridSector}
onGatewayHealth={handleRendererGatewayHealth}
onProviderStatus={setProviderStatus}
onCameraChange={handleCameraChange}
onCacheRefreshConsumed={handleCacheRefreshConsumed}
onReadyChange={setMapRendererReady}
onSpiralStateChange={handleSpiralStateChange}
initialCamera={mapCamera ?? undefined}
presentation={presentation}
runtimeBindings={[...sectorScopedPrimaryRuntimeBindings, ...referenceRuntimeBindings]}
presentationProfiles={presentationProfiles}
presentationFilters={rendererPresentationFilters}
/>
</Suspense>
</div>
{features.assistant ? (
<div className="catalog-map-fixture__actions">
<IconButton label="Assistant" aria-pressed={assistantOpen} data-active={assistantOpen || undefined} onClick={() => setAssistantOpen((value) => !value)}><Icon name="apps" /></IconButton>
</div>
) : null}
{selectedGridSector ? (
<MapSectorWorkspaceWindow
boundsRef={workspaceRef}
selection={selectedGridSector}
rect={sectorWindowRect}
maximized={sectorWindowMaximized}
active={activeWorkspaceWindowId === "sector"}
zIndex={sectorWindowZIndex}
copyState={gridSectorCopyState}
spatialEntities={sectorSpatialEntities}
visibleEntities={sectorVisibleEntities}
bindingOptions={sectorBindingOptions}
providerOptions={sectorProviderOptions}
objectKindOptions={sectorObjectKindOptions}
excludedBindingIds={sectorExcludedBindingIds}
excludedProviders={sectorExcludedProviders}
excludedObjectKinds={sectorExcludedObjectKinds}
hideOutside={hideObjectsOutsideSector}
selectedEntityId={selectedId}
onRectChange={(rect) => 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 ? (
<MapWorkspaceToolbar
searchOpen={searchOpen}
searchQuery={searchQuery}
searchInputRef={searchInputRef}
searchResults={mapSearchResults}
searchActiveIndex={searchActiveIndex}
remoteSearchQuery={remoteSearchQuery}
referenceSearchState={referenceSearchState}
summaries={presentationSummaries}
referenceSummaries={referenceObjectSummaries}
subjectStates={subjectStates}
filteredTargets={filteredTargets}
providerStatus={providerStatus}
mapSettings={mapSettings}
liveCacheSummary={liveCacheSummary}
transportDiagnostic={transportDiagnostic}
gatewayHealthAge={gatewayHealthAge}
gatewayCheckState={gatewayCheckState}
gatewayCheckError={gatewayCheckError}
onOpenSubjectWindow={openSubjectWindow}
onToggleSubjectVisibility={toggleSubjectVisibility}
onToggleReferenceLayer={(layerId) => 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}
<MapSubjectWorkspaceWindows
boundsRef={workspaceRef}
workspaceState={workspaceState}
dispatch={dispatchWorkspace}
summaries={presentationSummaries}
filteredTargets={filteredTargets}
selectable={selectable}
expandedFacetRows={expandedFacetRows}
selectedSubjectCard={selectedSubjectCard}
onToggleFacetRow={(rowId) => setExpandedFacetRows((current) => ({ ...current, [rowId]: !current[rowId] }))}
onTogglePresentationFilter={togglePresentationFilter}
onSelectEntity={handleSelectAndFocus}
/>
{assistantOpen ? <div className="catalog-map-fixture__assistant"><strong>NODE.DC Assistant</strong><span>Контекст выбранной сущности готов к передаче.</span></div> : null}
<button type="button" className="catalog-map-fixture__resize" aria-label="Изменить высоту карты" onPointerDown={startResize}><span /></button>
{settingsPanel
? settingsPanelHost
? createPortal(settingsPanel, settingsPanelHost)
: <div className="catalog-map-fixture__settings-panel-fallback">{settingsPanel}</div>
: null}
{headerActionsHost ? createPortal(headerActions, headerActionsHost) : null}
</div>
);
});