1337 lines
59 KiB
TypeScript
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>
|
|
);
|
|
});
|