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

2801 lines
151 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import { createPortal } from "react-dom";
import { ApplicationSidePanel, Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, SegmentedControl, WorkspaceWindow } from "@nodedc/ui-react";
import type { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react";
import type {
CameraSpiralState,
CesiumMapRendererHandle,
MapCameraView,
MapGatewayHealth,
MapPresentation,
MapProviderStatus,
GridLodProfile,
GridSectorSelection,
} from "./CesiumMapRenderer.js";
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
import {
compareMapRuntimeFacts,
mapFactMatchesFilters,
mapPresentationFacetValueIsEnabled,
mapPresentationFacetCounts,
mapPresentationProfileForFact,
mapRuntimeDisplayLabel,
mapRuntimeFactIsRenderable,
normalizeMapPresentationFacetSelections,
normalizeClientMapPresentationProfiles,
resolveMapPresentationClass,
toggleMapPresentationFacetSelection,
type MapPresentationFilters,
type MapPresentationProfile,
} from "./mapPresentationProfile.js";
import {
CAMERA_SURVEY_PRESETS,
DEFAULT_CAMERA_SURVEY_PRESET,
OSM_BUILDINGS_OBSERVED_BAND_COUNT,
cameraSurveySpiralDistance,
findCameraSurveyPreset,
type CameraSurveySelection,
} from "./mapCameraPresets.js";
import { buildMapSubjectCardModel, DEFAULT_MAP_SUBJECT_DETAIL_PROFILE } from "./mapSubjectCard.mjs";
import {
ensureMapReferencePresentationProfiles,
initialMapReferenceLayers,
isMapReferencePresentationProfile,
type MapReferenceLayer,
} from "./mapReferenceStations.js";
import { useMapReferenceRuntime, useMapReferenceSearch } from "./useMapReferenceRuntime.js";
import { buildMapSearchIndex, searchMapSubjects } from "./mapSearch.mjs";
import { DEFAULT_GRID_LOD_PROFILES, gridLodProfile } from "./mapGridPolicy.mjs";
import {
MAX_LOCAL_GRID_INDEX,
graticuleSectorAt,
graticuleSectorSummary,
localSectorAt,
localSectorAtGeodetic,
localSectorSummary,
localVolumeAt,
type GraticuleSectorAddress,
type LocalSectorAddress,
} from "./mapSectorGrid.mjs";
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
export type MapPageSettings = Omit<MapPresentation, "cacheRefresh">;
type SurveySettingsSnapshot = Pick<
MapPageSettings,
"imageryVisible" | "monochrome" | "cacheEnabled" | "cacheNoOverwrite" | "terrainEnabled" | "buildingsVisible" | "buildingsDetail"
>;
type MapRuntimeConfig = { gatewayHealthUrl?: string | null; resourceProxyBase?: string | null };
type GatewayCheckState = "idle" | "checking" | "ready" | "stale" | "error";
type GatewayHealthOrder = { nextEpoch: number; latestStartedEpoch: number };
const RENDERER_GATEWAY_HEALTH_EPOCH = 1;
const SURVEY_GATEWAY_HEALTH_MAX_AGE_MS = 30_000;
const GRID_MODE_OPTIONS: Array<SelectOption<"3d" | "graticule">> = [
{ value: "3d", label: "3D", description: "Приподнятая пространственная сетка" },
{ value: "graticule", label: "Гратикула", description: "Проекция по поверхности" },
];
type SectorGridLodProfile = GridLodProfile & {
majorLinesEnabled: boolean;
majorLabelsEnabled: boolean;
majorLineWidthMultiplier: number;
selectionFillColor: string;
selectionFillOpacityPercent: number;
selectionOutlineColor: string;
selectionOutlineWidthPx: number;
selectionOutlineOpacityPercent: number;
volumeEnabled: boolean;
volumeMinimumHeightMeters: number;
volumeMaximumHeightMeters: number;
volumeBandHeightMeters: number;
};
type GridSectorCopyState = "idle" | "copied" | "error";
const normalizedMajorTileSizeKm = (stepKm: number, requestedTileSizeKm: number) => {
const safeStepKm = Math.min(50, Math.max(0.1, stepKm));
const maximumRatio = Math.max(1, Math.floor((50 + Number.EPSILON) / safeStepKm));
const requestedRatio = Math.max(1, Math.ceil((requestedTileSizeKm - Number.EPSILON) / safeStepKm));
const ratio = Math.min(maximumRatio, requestedRatio);
return Number((safeStepKm * ratio).toFixed(6));
};
const normalizedGraticuleStepDegrees = (requestedStepDegrees: number) => {
const safeStepDegrees = Math.min(10, Math.max(0.1, requestedStepDegrees));
const requestedDivisions = Math.max(1, Math.round(180 / safeStepDegrees));
// Five minor intervals form one major tile and each 90° quadrant must end
// on a major boundary. A hemisphere therefore needs a multiple of ten
// minor intervals.
const hemisphereDivisions = Math.max(10, Math.round(requestedDivisions / 10) * 10);
return 180 / hemisphereDivisions;
};
const graticuleMajorStepDegrees = (stepDegrees: number) => {
const candidate = stepDegrees * 5;
const quadrantBands = 90 / candidate;
return Math.abs(quadrantBands - Math.round(quadrantBands)) <= 1e-9 * Math.max(1, Math.abs(quadrantBands))
? candidate
: null;
};
const normalizeSectorGridLodProfile = (profile: SectorGridLodProfile): SectorGridLodProfile => {
const stepKm = profile.mode === "3d" ? Math.min(50, profile.stepKm) : profile.stepKm;
const volumeMinimumHeightMeters = profile.volumeMinimumHeightMeters;
const volumeMaximumHeightMeters = Math.max(volumeMinimumHeightMeters + 1, profile.volumeMaximumHeightMeters);
return {
...profile,
stepKm,
tileSizeKm: profile.mode === "3d"
? normalizedMajorTileSizeKm(stepKm, Math.max(stepKm, profile.tileSizeKm))
: profile.tileSizeKm,
graticuleStepDegrees: profile.mode === "graticule"
? normalizedGraticuleStepDegrees(profile.graticuleStepDegrees)
: profile.graticuleStepDegrees,
majorLabelsEnabled: profile.majorLinesEnabled && profile.majorLabelsEnabled,
volumeEnabled: profile.mode === "3d" && profile.volumeEnabled,
volumeMinimumHeightMeters,
volumeMaximumHeightMeters,
volumeBandHeightMeters: Math.min(
volumeMaximumHeightMeters - volumeMinimumHeightMeters,
Math.max(1, profile.volumeBandHeightMeters),
),
};
};
type GridSectorDirection = "north" | "east" | "south" | "west";
const GRID_SECTOR_DIRECTIONS: Array<{ id: GridSectorDirection; label: string }> = [
{ id: "north", label: "Север" },
{ id: "east", label: "Восток" },
{ id: "south", label: "Юг" },
{ id: "west", label: "Запад" },
];
function localGridSectorSelection(
address: LocalSectorAddress,
profile: SectorGridLodProfile,
origin: { latitude: number; longitude: number },
preferredAltitudeMeters?: number,
): GridSectorSelection {
const definition = {
lod: address.lod,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: profile.stepKm * 1_000,
tileSizeMeters: profile.tileSizeKm * 1_000,
};
const summary = localSectorSummary(address, definition);
const volumeSpan = profile.volumeMaximumHeightMeters - profile.volumeMinimumHeightMeters;
const volume = profile.volumeEnabled && volumeSpan > 0
? (() => {
const altitudeMeters = Math.min(
profile.volumeMaximumHeightMeters - Number.EPSILON,
Math.max(
profile.volumeMinimumHeightMeters,
preferredAltitudeMeters ?? profile.volumeMinimumHeightMeters + Math.min(profile.volumeBandHeightMeters, volumeSpan) / 2,
),
);
const volumeAddress = localVolumeAt({ ...summary.center, altitudeMeters }, {
lod: address.lod,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: profile.stepKm * 1_000,
altitudeFloorMeters: profile.volumeMinimumHeightMeters,
altitudeCeilingMeters: profile.volumeMaximumHeightMeters,
altitudeBandMeters: profile.volumeBandHeightMeters,
});
if (!volumeAddress) return null;
return {
id: volumeAddress.id,
index: volumeAddress.bandIndex,
floor: Math.max(profile.volumeMinimumHeightMeters, volumeAddress.altitudeFloorMeters),
ceiling: Math.min(profile.volumeMaximumHeightMeters, volumeAddress.altitudeCeilingMeters),
bandHeight: volumeAddress.altitudeBandMeters,
};
})()
: null;
return {
...summary,
mode: "3d",
address,
units: "meters-enu",
volume,
};
}
function graticuleGridSectorSelection(
address: GraticuleSectorAddress,
profile: SectorGridLodProfile,
): GridSectorSelection {
const majorStepDegrees = profile.majorLinesEnabled
? graticuleMajorStepDegrees(profile.graticuleStepDegrees) ?? undefined
: undefined;
const summary = graticuleSectorSummary(address, {
lod: address.lod,
stepDegrees: profile.graticuleStepDegrees,
majorStepDegrees,
});
return {
...summary,
mode: "graticule",
address,
units: "degrees-wgs84",
volume: null,
};
}
function gridSectorNeighborSelection(
selection: GridSectorSelection,
direction: GridSectorDirection,
profiles: SectorGridLodProfile[],
origin: { latitude: number; longitude: number },
) {
const profile = profiles[selection.lod - 1];
if (!profile) return null;
if (selection.mode === "3d") {
const neighbor = selection.neighbors[direction];
if (!neighbor) return null;
const preferredAltitudeMeters = selection.volume
? (selection.volume.floor + selection.volume.ceiling) / 2
: undefined;
return localGridSectorSelection(neighbor.address, profile, origin, preferredAltitudeMeters);
}
const neighbor = selection.neighbors[direction];
return neighbor ? graticuleGridSectorSelection(neighbor.address, profile) : null;
}
function gridSectorParentLodSelection(
selection: GridSectorSelection,
profiles: SectorGridLodProfile[],
origin: { latitude: number; longitude: number },
) {
const parentProfile = profiles[selection.lod];
if (!parentProfile || parentProfile.mode !== selection.mode) return null;
if (selection.mode === "3d") {
const address = localSectorAt(selection.center, {
lod: selection.lod + 1,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: parentProfile.stepKm * 1_000,
});
const preferredAltitudeMeters = selection.volume
? (selection.volume.floor + selection.volume.ceiling) / 2
: undefined;
return localGridSectorSelection(address, parentProfile, origin, preferredAltitudeMeters);
}
const address = graticuleSectorAt(selection.center, {
lod: selection.lod + 1,
stepDegrees: parentProfile.graticuleStepDegrees,
});
return graticuleGridSectorSelection(address, parentProfile);
}
function gridSectorVolumeNeighborSelection(
selection: GridSectorSelection,
direction: "above" | "below",
profile: SectorGridLodProfile | null,
origin: { latitude: number; longitude: number },
) {
if (selection.mode !== "3d" || !selection.volume || !profile?.volumeEnabled) return null;
const targetIndex = selection.volume.index + (direction === "above" ? 1 : -1);
const targetFloorMeters = profile.volumeMinimumHeightMeters + targetIndex * profile.volumeBandHeightMeters;
if (targetIndex < 0 || targetFloorMeters >= profile.volumeMaximumHeightMeters) return null;
const targetCeilingMeters = Math.min(
profile.volumeMaximumHeightMeters,
targetFloorMeters + profile.volumeBandHeightMeters,
);
return localGridSectorSelection(
selection.address,
profile,
origin,
(targetFloorMeters + targetCeilingMeters) / 2,
);
}
const formatGridMetric = (value: number, maximumFractionDigits = 1) => value.toLocaleString("ru-RU", {
maximumFractionDigits,
});
const formatGridSectorArea = (areaSquareMeters: number) => areaSquareMeters >= 1_000_000
? `${formatGridMetric(areaSquareMeters / 1_000_000, areaSquareMeters >= 1_000_000_000 ? 0 : 2)} км²`
: `${formatGridMetric(areaSquareMeters, 0)} м²`;
function gridSectorBoundsLabel(selection: GridSectorSelection) {
const { west, east, south, north } = selection.bounds;
return selection.mode === "3d"
? `E ${formatGridMetric(west)}${formatGridMetric(east)} м · N ${formatGridMetric(south)}${formatGridMetric(north)} м`
: ${formatGridMetric(west, 6)}${formatGridMetric(east, 6)}° · φ ${formatGridMetric(south, 6)}${formatGridMetric(north, 6)}°`;
}
function gridSectorCenterLabel(selection: GridSectorSelection) {
return selection.mode === "3d"
? `E ${formatGridMetric(selection.center.eastMeters)} м · N ${formatGridMetric(selection.center.northMeters)} м`
: `${formatGridMetric(selection.center.latitude, 6)}°, ${formatGridMetric(selection.center.longitude, 6)}°`;
}
const MAP_SCOPE_PROVIDER_FIELD = "position_source";
const MAP_SCOPE_OBJECT_KIND_FIELD = "object_kind";
const MAP_SCOPE_MISSING_VALUE = "__nodedc_missing__";
function normalizedSectorScopeValue(value: unknown) {
if (typeof value !== "string") return null;
const normalized = value.trim();
if (!normalized || normalized.length > 120 || /[\u0000-\u001f\u007f]/.test(normalized)) return null;
return normalized;
}
function sectorScopeValueLabel(value: string) {
if (value === MAP_SCOPE_MISSING_VALUE) return "Не указано";
return value.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
}
function mapFactSectorScopeValue(fact: MapRuntimeFact, field: string) {
return normalizedSectorScopeValue(fact.attributes[field]) ?? MAP_SCOPE_MISSING_VALUE;
}
function mapFactPointCoordinates(fact: MapRuntimeFact | undefined): [number, number] | null {
if (fact?.geometry?.type !== "Point") return null;
const [longitude, latitude] = fact.geometry.coordinates;
return Number.isFinite(longitude) && longitude >= -180 && longitude <= 180
&& Number.isFinite(latitude) && latitude >= -90 && latitude <= 90
? [longitude, latitude]
: null;
}
function mapFactInsideGridSector(
fact: MapRuntimeFact,
selection: GridSectorSelection,
profiles: SectorGridLodProfile[],
origin: { latitude: number; longitude: number },
) {
if (fact.geometry?.type !== "Point") return false;
const [longitude, latitude] = fact.geometry.coordinates;
const profile = profiles[selection.lod - 1];
if (!profile || profile.mode !== selection.mode) return false;
if (selection.mode === "graticule") {
return graticuleSectorAt({ longitude, latitude }, {
lod: selection.lod,
stepDegrees: profile.graticuleStepDegrees,
}).id === selection.id;
}
return localSectorAtGeodetic({ longitude, latitude }, {
lod: selection.lod,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: profile.stepKm * 1_000,
}).id === selection.id;
}
function beginGatewayHealthEpoch(order: GatewayHealthOrder) {
order.nextEpoch += 1;
order.latestStartedEpoch = order.nextEpoch;
return order.nextEpoch;
}
function isLatestGatewayHealthEpoch(order: GatewayHealthOrder, epoch: number) {
return order.latestStartedEpoch === epoch;
}
function safeGatewayCheckCode(value: unknown, fallback = "gateway_not_ready") {
const code = value instanceof Error && value.message ? value.message : fallback;
return code.replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 80) || fallback;
}
function gatewayCheckMessage(code: string, stale: boolean) {
if (code === "persistent_cache_unavailable") {
return "Persistent TileCache не подключён: карта не должна продолжать работу с локальной временной папкой.";
}
const prefix = stale ? "Текущая проверка не прошла" : "Проверка Platform Map Gateway не прошла";
const suffix = stale
? "Показаны последние успешно полученные данные TileCache."
: "TileCache и runtime profile не изменялись.";
return `${prefix} (${code}). ${suffix}`;
}
function isWritableAppendOnlyTileCache(health: MapGatewayHealth | null) {
return health?.cache?.persistent === true
&& health.cache.mode === "readwrite"
&& health.cache.writePolicy === "append-only-no-eviction"
&& health.cache.atCapacity === false;
}
/**
* Provider-neutral visual binding. Foundry stores this on an Application page
* instance; a future data binding resolves the live source behind `source`.
*/
export type MapPinBinding = {
id: string;
subjectId: string;
kind: "elevated-spike";
label: string;
status: string;
coordinates: { longitude: number; latitude: number; heightMeters: number };
source: { entityId: string; streamId: string; displayFields: string[] };
attributes: Record<string, string | number | boolean>;
};
/**
* A renderer-neutral declaration of an entity stream assigned to this page.
*
* It intentionally contains no provider endpoint, tenant/connection scope,
* credential reference, or browser token. The Foundry runtime resolves the
* matching server-side consumer grant from the application/page/binding
* target before it asks the External Data Plane for a snapshot or patches.
*/
export type MapDataProductBinding = {
id: string;
displayName?: string;
order?: number;
dataProductId: string;
slotId: string;
delivery: "snapshot+patch";
semanticTypes: string[];
fieldProjection: string[];
presentationProfileId?: string;
subjectDetailProfileId?: string;
aspectId?: string;
joinToBindingId?: string;
dataClass?: "operational" | "restricted";
};
export type MapSubjectDetailProfile = {
id: string;
version: string;
title: string;
semanticTypes: string[];
defaultTabId: string;
tabs: Array<{
id: string;
label: string;
emptyMessage: string;
sections: Array<{
id: string;
label: string;
fields: Array<{
id: string;
aspectId?: string;
source: "fact" | "attribute" | "geometry" | "context";
field: string;
label: string;
format: "text" | "number" | "timestamp" | "boolean" | "coordinate" | "signal_state" | "movement_state" | "string_list" | "telemetry_readings";
unit?: string;
allowedReadingIds?: string[];
}>;
}>;
}>;
};
export type MapSubjectWindowState = {
open: boolean;
rect: WorkspaceWindowRect;
maximized: boolean;
zIndex: number;
};
type MapWorkspaceWindowId = "sector" | "subject-card" | `binding:${string}`;
export type MapSubjectState = {
bindingId: string;
visible: boolean;
/** Missing facet means unconstrained; an explicit empty list means no matches. */
filters: Record<string, string[]>;
window: MapSubjectWindowState;
};
export type MapPageLayout = {
schemaVersion: 1;
pageId: "map";
settings: MapPageSettings;
mapHeight: number;
camera: MapCameraView;
pinBindings: MapPinBinding[];
presentationProfiles: MapPresentationProfile[];
subjectDetailProfiles: MapSubjectDetailProfile[];
dataProductBindings: MapDataProductBinding[];
subjectStates: MapSubjectState[];
referenceLayers: MapReferenceLayer[];
inspectorOpenSections: string[];
savedAt?: string;
};
export type MapFixturePreviewHandle = {
getLayout: () => MapPageLayout | null;
};
const initialMapSettings: MapPageSettings = {
imagerySource: "cesium-live",
imageryVisible: true,
cacheEnabled: true,
cacheNoOverwrite: true,
terrainEnabled: true,
terrainExaggeration: 1,
monochrome: false,
monochromeColor: "#15151b",
imageryGamma: 57,
imageryHue: 13,
imageryAlpha: 27,
globeColor: "#15151b",
backgroundColor: "#08090d",
atmosphereEnabled: false,
atmosphereHue: 0,
atmosphereSaturation: 0,
atmosphereBrightness: 0,
fogEnabled: true,
fogDensity: 2,
sunEnabled: true,
sunHour: 12,
sunIntensity: 200,
shadowsEnabled: true,
buildingsVisible: true,
buildingsColor: "#a27aff",
buildingsOpacity: 1,
buildingsDetail: 4,
imageryBrightness: 118,
imageryContrast: 102,
imagerySaturation: 0,
gridVisible: true,
gridLodEnabled: true,
grid3dEnabled: true,
gridGraticuleEnabled: true,
gridCenterMode: "fixed",
gridCenterLatitude: 55.7558,
gridCenterLongitude: 37.6173,
gridTileSizeKm: 10,
gridAutoDisableHeightKm: 10_000,
gridRebuildOnMoveEnd: true,
gridLegacyMode: false,
gridMax3dViewAngleDegrees: 30,
gridHeightMeters: 500,
gridLod1MaxHeightKm: 10,
gridLod1StepKm: 1,
gridLod1Mode: "3d",
gridLod2MaxHeightKm: 50,
gridLod2StepKm: 5,
gridLod2Mode: "3d",
gridLod3MaxHeightKm: 200,
gridLod3StepKm: 25,
gridLod3Mode: "3d",
gridLod4MaxHeightKm: 800,
gridLod4StepKm: 50,
gridLod4Mode: "graticule",
gridLod5MaxHeightKm: 3_000,
gridLod5StepKm: 50,
gridLod5Mode: "graticule",
gridRadiusKm: 40,
gridLineWidth: 1,
gridLineDiameterMeters: 7,
gridColor: "#9c9c9c",
gridOpacity: 12,
gridDotsEnabled: true,
gridDotsSize: 7,
gridDotsDiameterMeters: 10,
gridDotsColor: "#9c9c9c",
gridDotsOpacity: 58,
gridCrossesEnabled: false,
gridCrossesLengthMeters: 60,
gridCrossesWidthMeters: 10,
gridCrossesColor: "#9c9c9c",
gridCrossesOpacity: 46,
gridLodProfiles: structuredClone(DEFAULT_GRID_LOD_PROFILES) as GridLodProfile[],
};
function resolveGridLodProfiles(settings?: Partial<MapPageSettings>): GridLodProfile[] {
// A layout saved by the previous flat contract must not lose the values the
// operator already tuned. Promote its common visual fields and per-band
// height/step/mode values into five authoritative profiles on first read;
// the next ordinary page save persists the canonical array.
const legacySettings: MapPresentation = {
...initialMapSettings,
...settings,
gridLodProfiles: Array.isArray(settings?.gridLodProfiles) ? settings.gridLodProfiles : [],
cacheRefresh: false,
};
return Array.from({ length: 5 }, (_unused, index) => normalizeSectorGridLodProfile(
gridLodProfile(legacySettings, index) as SectorGridLodProfile,
));
}
// A valid, deterministic scene view is available before Cesium emits its
// first move-end event. It makes the page contract immediately saveable;
// the renderer replaces it with the exact live camera as soon as it is ready.
const fallbackMapCamera: MapCameraView = {
longitude: 37.618423,
latitude: 55.751244,
height: 40_000,
heading: 0,
pitch: -0.9,
roll: 0,
};
export function createDefaultMapPageLayout(expanded = false): MapPageLayout {
return {
schemaVersion: 1,
pageId: "map",
settings: structuredClone(initialMapSettings),
mapHeight: expanded ? 620 : 470,
camera: { ...fallbackMapCamera },
pinBindings: [],
presentationProfiles: ensureMapReferencePresentationProfiles([]),
subjectDetailProfiles: [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile],
dataProductBindings: [],
subjectStates: [],
referenceLayers: initialMapReferenceLayers(),
inspectorOpenSections: ["map-base"],
};
}
const initialProviderStatus: MapProviderStatus = {
imagery: "loading",
terrain: "loading",
buildings: "loading",
errors: {},
};
const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
loading: "загружается",
ready: "готов",
error: "недоступен",
"not-configured": "не настроен",
};
function defaultSubjectWindowState(index: number): MapSubjectWindowState {
return {
open: false,
rect: {
x: 24 + (index % 5) * 28,
y: 56 + (index % 5) * 28,
width: 280,
height: 260,
},
maximized: false,
zIndex: 20 + index,
};
}
const defaultSectorWindowRect: WorkspaceWindowRect = {
x: 24,
y: 72,
width: 380,
height: 530,
};
const defaultSubjectCardRect: WorkspaceWindowRect = {
x: 940,
y: 72,
width: 390,
height: 520,
};
function initialSubjectState(
bindings: MapDataProductBinding[],
saved: MapSubjectState[] | undefined,
profiles: MapPresentationProfile[],
) {
const savedByBinding = new Map((saved ?? []).map((state) => [state.bindingId, state]));
return Object.fromEntries(bindings.map((binding, index) => {
const state = savedByBinding.get(binding.id);
const profile = mapPresentationProfileForFact(
profiles,
binding.presentationProfileId,
binding.semanticTypes[0] ?? "",
);
return [binding.id, state ? {
...state,
filters: profile ? normalizeMapPresentationFacetSelections(state.filters, profile) : state.filters,
} : {
bindingId: binding.id,
visible: true,
filters: {},
window: defaultSubjectWindowState(index),
}];
})) as Record<string, MapSubjectState>;
}
function hasSubjectWindowControls(profile: MapPresentationProfile) {
return profile.facets.some((facet) => facet.counter || facet.filterable);
}
const logarithmicControlValue = (value: number) => Math.log10(Math.max(Number.MIN_VALUE, value));
const valueFromLogarithmicControl = (value: number) => Math.max(1, Math.round(10 ** value));
const formatMetricDistance = (value: number) => value >= 1000
? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: value >= 10_000 ? 0 : 1 })} км`
: `${Math.round(value)} м`;
const formatMetricSpeed = (value: number) => value >= 1000
? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км/с`
: `${Math.round(value)} м/с`;
const formatDuration = (seconds: number) => {
if (seconds >= 86_400) return `${(seconds / 86_400).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} сут`;
if (seconds >= 3_600) return `${(seconds / 3_600).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} ч`;
if (seconds >= 60) return `${Math.round(seconds / 60)} мин`;
return `${Math.max(1, Math.round(seconds))} с`;
};
function spiralStopMessage(reason: CameraSpiralState["reason"]) {
if (reason === "target_radius_reached") return "Проход завершён: камера достигла выбранного радиуса.";
if (reason === "spiral_extent_limit") return "Режим остановлен у безопасной границы геодезического маршрута.";
if (reason === "terrain_sampling_error") return "Режим остановлен: не удалось получить высоту terrain для следующего участка.";
if (reason === "tile_loading_timeout") return "Режим остановлен: текущий набор Imagery, Terrain или OSM Buildings не загрузился за отведённое время.";
if (reason === "tile_loading_error") return "Режим остановлен: Cesium сообщил об ошибке tile в Imagery, Terrain или OSM Buildings. Неполный участок не засчитан.";
if (reason === "spiral_runtime_error" || reason === "render_error") return "Режим остановлен из-за ошибки рендера камеры.";
if (reason === "renderer_restarted") return "Режим остановлен после обновления renderer.";
return null;
}
export const MapFixturePreview = forwardRef<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 [selectedId, setSelectedId] = useState<string>();
const [selectedGridSector, setSelectedGridSector] = useState<GridSectorSelection | null>(null);
const [gridSectorCopyState, setGridSectorCopyState] = useState<GridSectorCopyState>("idle");
const [subjectCardOpen, setSubjectCardOpen] = useState(false);
const [subjectCardRect, setSubjectCardRect] = useState<WorkspaceWindowRect>(defaultSubjectCardRect);
const [subjectCardMaximized, setSubjectCardMaximized] = useState(false);
const [subjectCardZIndex, setSubjectCardZIndex] = useState(140);
const [subjectCardTabId, setSubjectCardTabId] = useState("overview");
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 [sectorWindowRect, setSectorWindowRect] = useState<WorkspaceWindowRect>(defaultSectorWindowRect);
const [sectorWindowMaximized, setSectorWindowMaximized] = useState(false);
const [sectorWindowZIndex, setSectorWindowZIndex] = useState(142);
const [hideObjectsOutsideSector, setHideObjectsOutsideSector] = useState(false);
const [sectorExcludedBindingIds, setSectorExcludedBindingIds] = useState<string[]>([]);
const [sectorExcludedProviders, setSectorExcludedProviders] = useState<string[]>([]);
const [sectorExcludedObjectKinds, setSectorExcludedObjectKinds] = useState<string[]>([]);
const [activeWorkspaceWindowId, setActiveWorkspaceWindowId] = useState<MapWorkspaceWindowId>();
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 [subjectStates, setSubjectStates] = useState<Record<string, MapSubjectState>>(() => (
initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates, presentationProfiles)
));
const presentationFilters = useMemo<MapPresentationFilters>(() => Object.fromEntries(
Object.entries(subjectStates).map(([bindingId, state]) => {
const binding = dataProductBindings.find((candidate) => candidate.id === bindingId);
const profile = mapPresentationProfileForFact(
presentationProfiles,
binding?.presentationProfileId,
binding?.semanticTypes[0] ?? "",
);
return [bindingId, {
visible: state.visible,
facets: profile ? normalizeMapPresentationFacetSelections(state.filters, profile) : state.filters,
}];
}),
), [dataProductBindings, presentationProfiles, subjectStates]);
const runtimeBindings = useMapDataProductRuntime({
applicationId,
pageId,
bindings: dataProductBindings,
enabled: Boolean(applicationId && pageId),
});
const referenceRuntimeBindings = useMapReferenceRuntime(referenceLayers, mapCamera, true);
const {
bindings: referenceSearchBindings,
state: referenceSearchState,
} = useMapReferenceSearch(referenceLayers, remoteSearchQuery, remoteSearchEpoch, searchOpen);
const mapRuntimeBindings = useMemo(() => (
[...runtimeBindings, ...referenceRuntimeBindings]
), [referenceRuntimeBindings, runtimeBindings]);
const mapSearchRuntimeBindings = useMemo(() => (
[...mapRuntimeBindings, ...referenceSearchBindings]
), [mapRuntimeBindings, referenceSearchBindings]);
const referencePresentationFilters = useMemo<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 primaryBindingIds = useMemo(() => new Set(
dataProductBindings.filter((binding) => !binding.joinToBindingId).map((binding) => binding.id),
), [dataProductBindings]);
const primaryRuntimeBindings = useMemo(() => (
runtimeBindings.filter((binding) => primaryBindingIds.has(binding.bindingId))
), [primaryBindingIds, runtimeBindings]);
const mapSearchIndex = useMemo(() => buildMapSearchIndex({
runtimeBindings: mapSearchRuntimeBindings,
bindingConfigs: dataProductBindings,
presentationProfiles,
}), [dataProductBindings, mapSearchRuntimeBindings, presentationProfiles]);
const mapSearchResults = useMemo(() => (
searchMapSubjects(mapSearchIndex, searchQuery, 8)
), [mapSearchIndex, searchQuery]);
const selectable = useMemo(() => (
primaryRuntimeBindings.flatMap((binding) => {
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
const facts = [...binding.facts];
const primaryProfile = mapPresentationProfileForFact(
presentationProfiles,
bindingConfig?.presentationProfileId,
bindingConfig?.semanticTypes[0] ?? facts[0]?.semanticType ?? "",
);
if (primaryProfile) facts.sort((left, right) => compareMapRuntimeFacts(left, right, primaryProfile));
return facts.map((fact) => {
const profile = mapPresentationProfileForFact(presentationProfiles, bindingConfig?.presentationProfileId, fact.semanticType);
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
return {
id: mapRuntimeEntityId(binding.bindingId, fact),
title: mapRuntimeDisplayLabel(fact, profile),
kind: fact.semanticType,
status: presentationClass?.label ?? fact.presentationStatus,
bindingId: binding.bindingId,
dataProductId: binding.dataProductId,
fact,
};
});
})
), [dataProductBindings, presentationProfiles, primaryRuntimeBindings]);
const sectorSpatialEntities = useMemo(() => selectedGridSector
? selectable.filter((entity) => mapFactInsideGridSector(
entity.fact,
selectedGridSector,
sectorGridLodProfiles,
fixedSectorGridOrigin,
))
: [], [fixedSectorGridOrigin, sectorGridLodProfiles, selectable, selectedGridSector]);
const sectorBindingOptions = useMemo(() => [...dataProductBindings]
.filter((binding) => !binding.joinToBindingId)
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id))
.map((binding) => ({
value: binding.id,
label: binding.displayName?.trim() || binding.id,
count: sectorSpatialEntities.filter((entity) => entity.bindingId === binding.id).length,
})), [dataProductBindings, sectorSpatialEntities]);
const sectorProviderFacetAvailable = useMemo(() => dataProductBindings.some((binding) => (
!binding.joinToBindingId && binding.fieldProjection.includes(MAP_SCOPE_PROVIDER_FIELD)
)), [dataProductBindings]);
const sectorObjectKindFacetAvailable = useMemo(() => dataProductBindings.some((binding) => (
!binding.joinToBindingId && binding.fieldProjection.includes(MAP_SCOPE_OBJECT_KIND_FIELD)
)), [dataProductBindings]);
const sectorProviderOptions = useMemo(() => {
if (!sectorProviderFacetAvailable) return [];
const counts = new Map<string, number>();
sectorSpatialEntities.forEach(({ fact }) => {
const value = mapFactSectorScopeValue(fact, MAP_SCOPE_PROVIDER_FIELD);
counts.set(value, (counts.get(value) ?? 0) + 1);
});
return [...counts].map(([value, count]) => ({ value, count, label: sectorScopeValueLabel(value) }))
.sort((left, right) => left.label.localeCompare(right.label, "ru"));
}, [sectorProviderFacetAvailable, sectorSpatialEntities]);
const sectorObjectKindOptions = useMemo(() => {
if (!sectorObjectKindFacetAvailable) return [];
const counts = new Map<string, number>();
sectorSpatialEntities.forEach(({ fact }) => {
const value = mapFactSectorScopeValue(fact, MAP_SCOPE_OBJECT_KIND_FIELD);
counts.set(value, (counts.get(value) ?? 0) + 1);
});
return [...counts].map(([value, count]) => ({ value, count, label: sectorScopeValueLabel(value) }))
.sort((left, right) => left.label.localeCompare(right.label, "ru"));
}, [sectorObjectKindFacetAvailable, sectorSpatialEntities]);
const sectorVisibleEntities = useMemo(() => sectorSpatialEntities.filter((entity) => {
if (sectorExcludedBindingIds.includes(entity.bindingId)) return false;
if (sectorExcludedProviders.includes(mapFactSectorScopeValue(entity.fact, MAP_SCOPE_PROVIDER_FIELD))) return false;
if (sectorExcludedObjectKinds.includes(mapFactSectorScopeValue(entity.fact, MAP_SCOPE_OBJECT_KIND_FIELD))) return false;
const binding = dataProductBindings.find((candidate) => candidate.id === entity.bindingId);
const profile = mapPresentationProfileForFact(
presentationProfiles,
binding?.presentationProfileId,
entity.fact.semanticType,
);
return Boolean(profile && mapFactMatchesFilters(entity.fact, profile, presentationFilters, entity.bindingId));
}), [dataProductBindings, presentationFilters, presentationProfiles, sectorExcludedBindingIds, sectorExcludedObjectKinds, sectorExcludedProviders, sectorSpatialEntities]);
const sectorScopedPrimaryRuntimeBindings = useMemo(() => {
if (!selectedGridSector) return primaryRuntimeBindings;
return primaryRuntimeBindings.map((binding) => ({
...binding,
facts: binding.facts.filter((fact) => {
if (sectorExcludedBindingIds.includes(binding.bindingId)) return false;
if (sectorExcludedProviders.includes(mapFactSectorScopeValue(fact, MAP_SCOPE_PROVIDER_FIELD))) return false;
if (sectorExcludedObjectKinds.includes(mapFactSectorScopeValue(fact, MAP_SCOPE_OBJECT_KIND_FIELD))) return false;
return !hideObjectsOutsideSector || mapFactInsideGridSector(
fact,
selectedGridSector,
sectorGridLodProfiles,
fixedSectorGridOrigin,
);
}),
}));
}, [fixedSectorGridOrigin, hideObjectsOutsideSector, primaryRuntimeBindings, sectorExcludedBindingIds, sectorExcludedObjectKinds, sectorExcludedProviders, sectorGridLodProfiles, selectedGridSector]);
const presentationSummaries = useMemo(() => [...dataProductBindings]
.filter((binding) => !binding.joinToBindingId)
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id))
.flatMap((bindingConfig) => {
const binding = runtimeBindings.find((candidate) => candidate.bindingId === bindingConfig.id);
const facts = binding?.facts ?? [];
const semanticType = bindingConfig.semanticTypes[0] ?? facts[0]?.semanticType ?? "";
const profile = mapPresentationProfileForFact(presentationProfiles, bindingConfig?.presentationProfileId, semanticType);
if (!profile) return [];
return [{
bindingId: bindingConfig.id,
displayName: bindingConfig.displayName?.trim() || profile.title || bindingConfig.id,
profile,
total: facts.length,
counts: mapPresentationFacetCounts(facts, profile),
}];
}), [dataProductBindings, presentationProfiles, runtimeBindings]);
const referenceObjectSummaries = useMemo(() => referenceLayers.flatMap((layer) => {
const profile = presentationProfiles.find((candidate) => candidate.id === layer.presentationProfileId);
if (!profile) return [];
const runtime = referenceRuntimeBindings.find((candidate) => candidate.bindingId === layer.id);
return [{
layer,
displayName: profile.title,
total: runtime?.facts.length ?? 0,
}];
}), [presentationProfiles, referenceLayers, referenceRuntimeBindings]);
const objectLayerCount = presentationSummaries.length + referenceObjectSummaries.length;
const filteredTargets = useMemo(() => sectorScopedPrimaryRuntimeBindings.flatMap((binding) => {
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
return binding.facts.flatMap((fact) => {
const profile = mapPresentationProfileForFact(
presentationProfiles,
bindingConfig?.presentationProfileId,
fact.semanticType,
);
if (!profile || !mapFactMatchesFilters(fact, profile, presentationFilters, binding.bindingId)) return [];
const presentationClass = resolveMapPresentationClass(fact, profile);
return [{
bindingId: binding.bindingId,
entityId: mapRuntimeEntityId(binding.bindingId, fact),
title: mapRuntimeDisplayLabel(fact, profile),
status: presentationClass?.label ?? "",
renderable: mapRuntimeFactIsRenderable(fact, profile),
}];
});
}).sort((left, right) => left.title.localeCompare(right.title, "ru")), [dataProductBindings, presentationFilters, presentationProfiles, sectorScopedPrimaryRuntimeBindings]);
const visibleTargetEntityIds = useMemo(() => (
filteredTargets.filter((target) => target.renderable).map((target) => target.entityId)
), [filteredTargets]);
// The header Save action can be pressed immediately after Cesium finishes
// constructing the scene. Keep the last camera synchronously as well as in
// state, so the imperative page-layout contract never waits for React's
// render cycle to publish a ready camera.
const mapCameraRef = useRef<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(() => {
setSelectedGridSector(null);
setSectorExcludedBindingIds([]);
setSectorExcludedProviders([]);
setSectorExcludedObjectKinds([]);
setActiveWorkspaceWindowId((current) => current === "sector" ? undefined : current);
}, [gridSectorDefinitionKey]);
const minimumGridLodHeight = selectedGridLodIndex === 0
? 0.1
: mapSettings.gridLodProfiles[selectedGridLodIndex - 1].maxHeightKm + 0.1;
const maximumGridLodHeight = selectedGridLodIndex === mapSettings.gridLodProfiles.length - 1
? 20_000
: Math.max(minimumGridLodHeight, mapSettings.gridLodProfiles[selectedGridLodIndex + 1].maxHeightKm - 0.1);
const updateGridLod = (patch: Partial<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;
setSelectedGridSector(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 && !hasSubjectWindowControls(summary.profile)
? { ...normalizedState, window: { ...normalizedState.window, open: false } }
: normalizedState;
}),
}),
}), [dataProductBindings, inspectorOpenSections, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, referenceLayers, subjectDetailProfiles, subjectStates]);
const updateSubjectState = useCallback((bindingId: string, update: (state: MapSubjectState) => MapSubjectState) => {
setSubjectStates((current) => {
const index = dataProductBindings.findIndex((binding) => binding.id === bindingId);
const state = current[bindingId] ?? {
bindingId,
visible: true,
filters: {},
window: defaultSubjectWindowState(Math.max(0, index)),
};
return { ...current, [bindingId]: update(state) };
});
}, [dataProductBindings]);
const togglePresentationFilter = (bindingId: string, field: string, value: string, availableValues: string[]) => {
updateSubjectState(bindingId, (state) => {
const filters = state.visible ? state.filters : {};
return {
...state,
visible: true,
filters: toggleMapPresentationFacetSelection(filters, field, value, availableValues),
};
});
};
const toggleSubjectVisibility = (bindingId: string) => {
updateSubjectState(bindingId, (state) => ({ ...state, visible: !state.visible }));
};
const activateWorkspaceWindow = useCallback((windowId: MapWorkspaceWindowId) => {
if (activeWorkspaceWindowId === windowId) return;
const nextZIndex = Math.max(
20,
sectorWindowZIndex,
subjectCardZIndex,
...Object.values(subjectStates).map((state) => state.window.zIndex),
) + 1;
if (windowId === "sector") setSectorWindowZIndex(nextZIndex);
else if (windowId === "subject-card") setSubjectCardZIndex(nextZIndex);
else if (windowId.startsWith("binding:")) {
const bindingId = windowId.slice("binding:".length);
updateSubjectState(bindingId, (state) => ({
...state,
window: { ...state.window, zIndex: nextZIndex },
}));
}
setActiveWorkspaceWindowId(windowId);
}, [activeWorkspaceWindowId, sectorWindowZIndex, subjectCardZIndex, subjectStates, updateSubjectState]);
const clearActiveWorkspaceWindow = (windowId: MapWorkspaceWindowId) => {
setActiveWorkspaceWindowId((current) => current === windowId ? undefined : current);
};
const openSubjectWindow = (bindingId: string) => {
updateSubjectState(bindingId, (state) => ({
...state,
window: { ...state.window, open: true },
}));
activateWorkspaceWindow(`binding:${bindingId}`);
};
const closeSubjectWindow = (bindingId: string) => {
updateSubjectState(bindingId, (state) => ({ ...state, window: { ...state.window, open: false } }));
clearActiveWorkspaceWindow(`binding:${bindingId}`);
};
const closeSettingsPanel = useCallback(() => setInspectorOpen(false), []);
const toggleSettingsPanel = () => setInspectorOpen((current) => !current);
useEffect(() => {
onSettingsPanelOpenChange?.(inspectorOpen && Boolean(features.inspector));
}, [features.inspector, inspectorOpen, onSettingsPanelOpenChange]);
useEffect(() => () => onSettingsPanelOpenChange?.(false), [onSettingsPanelOpenChange]);
const deactivateGridSector = () => {
setSelectedGridSector(null);
setSectorExcludedBindingIds([]);
setSectorExcludedProviders([]);
setSectorExcludedObjectKinds([]);
clearActiveWorkspaceWindow("sector");
};
const handleGridSectorSelect = (selection: GridSectorSelection | null) => {
if (!selection) {
deactivateGridSector();
return;
}
setSelectedGridSector(selection);
activateWorkspaceWindow("sector");
};
const setSectorBindingEnabled = (bindingId: string, enabled: boolean) => {
setSectorExcludedBindingIds((current) => enabled
? current.filter((value) => value !== bindingId)
: [...new Set([...current, bindingId])]);
};
const setSectorProviderEnabled = (provider: string, enabled: boolean) => {
setSectorExcludedProviders((current) => enabled
? current.filter((value) => value !== provider)
: [...new Set([...current, provider])]);
};
const setSectorObjectKindEnabled = (objectKind: string, enabled: boolean) => {
setSectorExcludedObjectKinds((current) => enabled
? current.filter((value) => value !== objectKind)
: [...new Set([...current, objectKind])]);
};
const updatePresentationProfile = (
profileId: string,
update: (profile: MapPresentationProfile) => MapPresentationProfile,
) => setPresentationProfiles((current) => current.map((profile) => (
profile.id === profileId ? update(profile) : profile
)));
const updatePresentationStyle = (profileId: string, styleId: string, patch: Partial<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;
setSelectedId(entityId);
const entity = selectable.find((candidate) => candidate.id === entityId);
const binding = dataProductBindings.find((candidate) => candidate.id === entity?.bindingId);
const profile = subjectDetailProfiles.find((candidate) => candidate.id === binding?.subjectDetailProfileId)
?? subjectDetailProfiles.find((candidate) => candidate.semanticTypes.includes(entity?.fact.semanticType ?? ""));
setSubjectCardTabId((current) => (
profile?.tabs.some((tab) => tab.id === current)
? current
: (profile?.defaultTabId ?? "overview")
));
setSubjectCardOpen(true);
activateWorkspaceWindow("subject-card");
}, [activateWorkspaceWindow, dataProductBindings, selectable, subjectDetailProfiles]);
const focusSubject = useCallback((entityId: string, coordinates?: readonly [number, number]) => {
const renderer = mapRendererRef.current;
if (!renderer) return false;
if (renderer.focusRuntimeEntity(entityId)) return true;
const fallbackCoordinates = coordinates
?? mapFactPointCoordinates(selectable.find((entity) => entity.id === entityId)?.fact);
return fallbackCoordinates
? renderer.focusSubjectCoordinates(fallbackCoordinates[0], fallbackCoordinates[1])
: false;
}, [selectable]);
const handleSelectAndFocus = useCallback((entityId: string) => {
handleSelect(entityId);
focusSubject(entityId);
}, [focusSubject, handleSelect]);
const handleSearchResult = useCallback((result: (typeof mapSearchResults)[number]) => {
if (result.selectable) {
setSubjectStates((current) => {
const state = current[result.bindingId];
return state ? { ...current, [result.bindingId]: { ...state, visible: true } } : current;
});
handleSelect(result.entityId);
} else {
setReferenceLayers((current) => current.map((layer) => (
layer.id === result.bindingId ? { ...layer, visible: true } : layer
)));
}
focusSubject(result.entityId, result.coordinates);
setSearchOpen(false);
setSearchQuery("");
setRemoteSearchQuery("");
setSearchActiveIndex(0);
}, [focusSubject, handleSelect]);
const handleSearchKeyDown = useCallback((event: KeyboardEvent<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 = [
{
id: "map-base",
label: "Подложка и terrain",
description: "provider-neutral surface",
group: "Карта",
icon: <Icon name="globe" />,
content: <>
<ControlRow label="Подложка"><strong>Cesium World Imagery</strong></ControlRow>
<small className="catalog-map-inspector__note">Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform.</small>
<ControlRow label="Live providers"><span>Imagery: {providerStateLabel[providerStatus.imagery]} · Terrain: {providerStateLabel[providerStatus.terrain]} · 3D: {providerStateLabel[providerStatus.buildings]}</span></ControlRow>
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
<small className="catalog-map-inspector__note">Рельеф отдельный слой под imagery.</small>
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
<RangeControl label="Вертикальное преувеличение рельефа" value={mapSettings.terrainExaggeration * 100} min={25} max={300} formatValue={(value) => `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} />
<Checker checked={mapSettings.monochrome} label="Монохромная поверхность" onChange={(monochrome) => updateMapSettings({ monochrome })} />
<ControlRow label="Цвет монохрома"><ColorField label="Цвет монохромной поверхности" value={mapSettings.monochromeColor} onChange={(monochromeColor) => updateMapSettings({ monochromeColor })} /></ControlRow>
<RangeControl label="Яркость" value={mapSettings.imageryBrightness} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imageryBrightness) => updateMapSettings({ imageryBrightness })} />
<RangeControl label="Контраст" value={mapSettings.imageryContrast} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imageryContrast) => updateMapSettings({ imageryContrast })} />
<RangeControl label="Насыщенность" value={mapSettings.imagerySaturation} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imagerySaturation) => updateMapSettings({ imagerySaturation })} />
<RangeControl label="Гамма" value={mapSettings.imageryGamma} min={0} max={300} formatValue={(value) => `${value}%`} onChange={(imageryGamma) => updateMapSettings({ imageryGamma })} />
<RangeControl label="Оттенок" value={mapSettings.imageryHue} min={-180} max={180} formatValue={(value) => `${value}°`} onChange={(imageryHue) => updateMapSettings({ imageryHue })} />
<RangeControl label="Прозрачность imagery" value={mapSettings.imageryAlpha} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(imageryAlpha) => updateMapSettings({ imageryAlpha })} />
<ControlRow label="Цвет планеты"><ColorField label="Цвет terrain без imagery" value={mapSettings.globeColor} onChange={(globeColor) => updateMapSettings({ globeColor })} /></ControlRow>
<ControlRow label="Фон сцены"><ColorField label="Цвет фона сцены" value={mapSettings.backgroundColor} onChange={(backgroundColor) => updateMapSettings({ backgroundColor })} /></ControlRow>
</>,
},
{
id: "map-atmosphere",
label: "Атмосфера и освещение",
description: "scene / color correction",
group: "Карта",
icon: <Icon name="activity" />,
content: <>
<Checker checked={mapSettings.atmosphereEnabled} label="Показывать атмосферу" onChange={(atmosphereEnabled) => updateMapSettings({ atmosphereEnabled })} />
<RangeControl label="Атмосфера: оттенок" value={mapSettings.atmosphereHue} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereHue) => updateMapSettings({ atmosphereHue })} />
<RangeControl label="Атмосфера: насыщенность" value={mapSettings.atmosphereSaturation} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereSaturation) => updateMapSettings({ atmosphereSaturation })} />
<RangeControl label="Атмосфера: яркость" value={mapSettings.atmosphereBrightness} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereBrightness) => updateMapSettings({ atmosphereBrightness })} />
<Checker checked={mapSettings.fogEnabled} label="Туман" onChange={(fogEnabled) => updateMapSettings({ fogEnabled })} />
<RangeControl label="Плотность тумана" value={mapSettings.fogDensity} min={0} max={100} formatValue={(value) => `${(value / 10000).toFixed(4)}`} onChange={(fogDensity) => updateMapSettings({ fogDensity })} />
<Checker checked={mapSettings.sunEnabled} label="Солнечное освещение" onChange={(sunEnabled) => updateMapSettings({ sunEnabled })} />
<RangeControl label="Час солнца" value={mapSettings.sunHour} min={0} max={24} formatValue={(value) => `${value}:00 UTC`} onChange={(sunHour) => updateMapSettings({ sunHour })} />
<RangeControl label="Интенсивность света" value={mapSettings.sunIntensity} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(sunIntensity) => updateMapSettings({ sunIntensity })} />
<Checker checked={mapSettings.shadowsEnabled} label="Тени" onChange={(shadowsEnabled) => updateMapSettings({ shadowsEnabled })} />
</>,
},
{
id: "map-buildings",
label: "3D здания",
description: "3D Tiles / detail",
group: "Карта",
icon: <Icon name="building" />,
content: <>
<Checker checked={mapSettings.buildingsVisible} label="Показывать 3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
<ControlRow label="Цвет"><ColorField label="Цвет зданий" value={mapSettings.buildingsColor} onChange={(buildingsColor) => updateMapSettings({ buildingsColor })} /></ControlRow>
<RangeControl label="Прозрачность" value={Math.round(mapSettings.buildingsOpacity * 100)} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(value) => updateMapSettings({ buildingsOpacity: value / 100 })} />
<RangeControl label="Детализация" value={mapSettings.buildingsDetail} min={4} max={32} formatValue={(value) => `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} />
</>,
},
...presentationProfiles.flatMap((profile) => {
const referenceProfile = isMapReferencePresentationProfile(profile);
const referenceLayer = referenceLayers.find((layer) => layer.presentationProfileId === profile.id);
return [
{
id: `map-target-${profile.id}`,
label: referenceProfile ? profile.title : profile.target.variant === "surface-fill" ? "HGeoZone" : "Таргет",
description: profile.target.variant === "surface-fill" ? `проекция · ${profile.title}` : profile.title,
group: referenceProfile ? "Станции" : profile.target.variant === "surface-fill" ? "Слои" : "Таргеты",
icon: <Icon name={referenceProfile ? "globe" : profile.target.variant === "surface-fill" ? "grid" : "target"} />,
content: <>
<small className="catalog-map-inspector__note">Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует.</small>
{referenceLayer ? (
<Checker
checked={referenceLayer.visible}
label={`Показывать слой «${profile.title}»`}
onChange={(visible) => setReferenceLayers((current) => current.map((layer) => (
layer.id === referenceLayer.id ? { ...layer, visible } : layer
)))}
/>
) : null}
{profile.target.variant === "surface-fill" && <>
<ControlRow label="Тип слоя"><strong>HGeoZone · ground projection</strong></ControlRow>
{profile.styles.map((style) => {
const classLabels = profile.classes.filter((item) => item.styleId === style.id).map((item) => item.label);
const label = classLabels.length ? classLabels.join(" · ") : style.id;
return <div className="catalog-map-inspector__style" key={style.id}>
<ControlRow label={`Заливка · ${label}`}><ColorField label={`Цвет заливки: ${label}`} value={style.color} onChange={(color) => updatePresentationStyle(profile.id, style.id, { color })} /></ControlRow>
<RangeControl label={`Прозрачность заливки · ${label}`} value={Math.round(style.opacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} />
</div>;
})}
<ControlRow label="Граница"><ColorField label="Цвет границы HGeoZone" value={profile.target.outlineColor} onChange={(outlineColor) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineColor } }) : current)} /></ControlRow>
<RangeControl label="Прозрачность границы" value={Math.round(profile.target.outlineOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }) : current)} />
<RangeControl label="Толщина границы" value={profile.target.outlineWidthPx} min={0} max={8} step={0.5} formatValue={(value) => `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineWidthPx } }) : current)} />
</>}
{profile.target.variant === "elevated-spike" && <>
<RangeControl label="Высота таргета" value={profile.target.stemHeightMeters} min={100} max={10_000} step={50} formatValue={(value) => `${value} м`} onChange={(stemHeightMeters) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemHeightMeters } }) : current)} />
<RangeControl label="Размер головки" value={profile.target.headSizePx} min={1} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(headSizePx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, headSizePx } }) : current)} />
<RangeControl label="Толщина стержня" value={profile.target.stemWidthPx} min={0.25} max={12} step={0.25} formatValue={(value) => `${value} px`} onChange={(stemWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemWidthPx } }) : current)} />
</>}
<InspectorSelectField
label="Подпись"
value={profile.label.mode}
options={[
{ value: "subject_id", label: "ID", description: "Стабильный идентификатор сущности" },
{ value: "attributes", label: "Имя", description: "Первое доступное display-поле" },
{ value: "none", label: "Нет", description: "Не показывать плашку" },
]}
onChange={(mode) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, mode } }))}
/>
<RangeControl label="Размер подписи" value={profile.label.sizePx} min={8} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(sizePx) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, sizePx } }))} />
<RangeControl label="Смещение подписи X" value={profile.label.offsetX} min={-100} max={100} step={1} formatValue={(value) => `${value} px`} onChange={(offsetX) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetX } }))} />
<RangeControl label="Смещение подписи Y" value={profile.label.offsetY} min={-100} max={100} step={1} formatValue={(value) => `${value} px`} onChange={(offsetY) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetY } }))} />
<RangeControl label="Скрывать подпись выше" value={profile.label.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, hideCameraHeightMeters } }))} />
<RangeControl label={profile.target.variant === "surface-fill" ? "Скрывать HGeoZone выше" : "Скрывать таргет выше"} value={profile.target.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} />
<ControlRow label="Фон плашки"><ColorField label="Цвет фона подписи" value={profile.label.backgroundColor} onChange={(backgroundColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundColor } }))} /></ControlRow>
<RangeControl label="Прозрачность плашки" value={Math.round(profile.label.backgroundOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundOpacity: value / 100 } }))} />
</>,
},
...(profile.target.variant === "surface-fill" || referenceProfile ? [] : [{
id: `map-state-classes-${profile.id}`,
label: "Классы состояния",
description: "нормализованные фасеты онтологии",
group: "Таргеты",
icon: <Icon name="sliders" />,
content: <>
<small className="catalog-map-inspector__note">Цвета назначены семантическим классам после нормализации данных. Здесь нет названий provider-статусов и привязки к транспорту.</small>
{profile.styles.map((style) => {
const classLabels = profile.classes.filter((item) => item.styleId === style.id).map((item) => item.label);
const label = classLabels.length ? classLabels.join(" · ") : style.id;
return <div className="catalog-map-inspector__style" key={style.id}>
<ControlRow label={label}><ColorField label={`Цвет: ${label}`} value={style.color} onChange={(color) => updatePresentationStyle(profile.id, style.id, { color })} /></ControlRow>
<RangeControl label={`${label}: прозрачность`} value={Math.round(style.opacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} />
</div>;
})}
</>,
}]),
];
}),
{
id: "map-grid",
label: "Сетка и LOD",
description: "first adapter control",
group: "Слои",
icon: <Icon name="grid" />,
content: <div className="catalog-map-grid-inspector">
<small className="catalog-map-inspector__note">Фиксированная московская ENU-адресация задаёт неизменные сектора на LOD 13. LOD 45 используют глобальную WGS84-гратику́лу; камера выбирает только LOD и видимую область.</small>
<Checker checked={mapSettings.gridVisible} label="Сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
<Checker checked={mapSettings.grid3dEnabled} label="3D-сетка" onChange={(grid3dEnabled) => updateMapSettings({ grid3dEnabled })} />
<Checker checked={mapSettings.gridGraticuleEnabled} label="Гратикула" onChange={(gridGraticuleEnabled) => updateMapSettings({ gridGraticuleEnabled })} />
<Checker checked={mapSettings.gridLodEnabled} label="LOD по высоте камеры" onChange={(gridLodEnabled) => updateMapSettings({ gridLodEnabled })} />
<Checker checked={mapSettings.gridRebuildOnMoveEnd} label="Перестраивать после движения" onChange={(gridRebuildOnMoveEnd) => updateMapSettings({ gridRebuildOnMoveEnd })} />
<ControlRow label="Система координат"><strong>Fixed ENU · WGS84</strong></ControlRow>
<RangeControl label="Origin: широта" value={mapSettings.gridCenterLatitude} min={-89.9} max={89.9} step={0.000001} formatValue={(value) => value.toFixed(6)} onChange={(gridCenterLatitude) => updateMapSettings({ gridCenterLatitude })} />
<RangeControl label="Origin: долгота" value={mapSettings.gridCenterLongitude} min={-180} max={180} step={0.000001} formatValue={(value) => value.toFixed(6)} onChange={(gridCenterLongitude) => updateMapSettings({ gridCenterLongitude })} />
<RangeControl label="Автовыключение выше" value={mapSettings.gridAutoDisableHeightKm} min={0} max={50_000} step={100} formatValue={(value) => value === 0 ? "выкл" : `${value} км`} onChange={(gridAutoDisableHeightKm) => updateMapSettings({ gridAutoDisableHeightKm })} />
<div className="catalog-map-grid-lod-tabs">
<SegmentedControl value={selectedGridLod} items={DEFAULT_GRID_LOD_PROFILES.map((_profile, index) => ({ value: String(index), label: `LOD ${index + 1}` }))} label="Уровень детализации сетки" onChange={setSelectedGridLod} />
</div>
<RangeControl label={selectedGridLodIndex === 4 ? "Порог профиля" : "До высоты"} value={activeGridLod.maxHeightKm} min={minimumGridLodHeight} max={maximumGridLodHeight} step={0.1} formatValue={(value) => `${value} км`} onChange={(maxHeightKm) => updateGridLod({ maxHeightKm })} />
{selectedGridLodIndex === 4 ? <small className="catalog-map-inspector__note">Последний LOD остаётся активным выше своего порога до общего автовыключения.</small> : null}
<InspectorSelectField
label="Режим"
value={activeGridLod.mode}
options={GRID_MODE_OPTIONS}
onChange={(mode) => updateGridLod({
mode,
volumeEnabled: mode === "3d" && activeGridLod.volumeEnabled,
...(mode === "3d" ? {
stepKm: Math.min(50, activeGridLod.stepKm),
tileSizeKm: normalizedMajorTileSizeKm(Math.min(50, activeGridLod.stepKm), activeGridLod.tileSizeKm),
} : {
graticuleStepDegrees: normalizedGraticuleStepDegrees(activeGridLod.graticuleStepDegrees),
}),
})}
/>
<RangeControl label="Высота WGS84" value={activeGridLod.heightMeters} min={0} max={5_000} step={10} formatValue={(value) => `${value} м`} onChange={(heightMeters) => updateGridLod({ heightMeters })} />
<RangeControl label="Конус видимости 3D" value={activeGridLod.max3dViewAngleDegrees} min={30} max={170} step={1} formatValue={(value) => `${value}°`} onChange={(max3dViewAngleDegrees) => updateGridLod({ max3dViewAngleDegrees })} />
<RangeControl
label="Шаг ENU-секторов"
value={activeGridLod.stepKm}
min={0.1}
max={activeGridLod.mode === "3d" ? 50 : 5_000}
step={0.1}
formatValue={(value) => `${value} км`}
onChange={(stepKm) => updateGridLod({
stepKm,
tileSizeKm: normalizedMajorTileSizeKm(stepKm, Math.max(stepKm, activeGridLod.tileSizeKm)),
radiusKm: Math.min(activeGridLod.radiusKm, stepKm * MAX_LOCAL_GRID_INDEX),
})}
/>
<RangeControl
label="Размер major-тайла ENU"
value={activeGridLod.tileSizeKm}
min={activeGridLod.stepKm}
max={Math.max(activeGridLod.stepKm, 50)}
step={activeGridLod.stepKm}
formatValue={(value) => `${value} км`}
onChange={(tileSizeKm) => updateGridLod({ tileSizeKm: normalizedMajorTileSizeKm(activeGridLod.stepKm, tileSizeKm) })}
/>
<small className="catalog-map-inspector__note">Major-тайл содержит целое число ENU-секторов. Для гратикулы major-шаг равен пяти minor-шагам.</small>
<Checker
checked={activeGridLod.majorLinesEnabled}
label="Major-линии"
onChange={(majorLinesEnabled) => updateGridLod({
majorLinesEnabled,
majorLabelsEnabled: majorLinesEnabled && activeGridLod.majorLabelsEnabled,
})}
/>
<Checker checked={activeGridLod.majorLabelsEnabled} disabled={!activeGridLod.majorLinesEnabled} label="Подписи major-тайлов" onChange={(majorLabelsEnabled) => updateGridLod({ majorLabelsEnabled })} />
<RangeControl label="Толщина major-линий" value={activeGridLod.majorLineWidthMultiplier} min={1} max={8} step={0.1} formatValue={(value) => `×${value.toFixed(1)}`} onChange={(majorLineWidthMultiplier) => updateGridLod({ majorLineWidthMultiplier })} />
<small className="catalog-map-inspector__note">Прозрачность major-линий наследует прозрачность линий текущего LOD.</small>
{activeGridLod.mode === "graticule" && activeGridLod.majorLinesEnabled && activeGraticuleMajorStepDegrees === null
? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">Major-разметка недоступна для этого шага: пять minor-интервалов должны точно делить 90°-квадрант.</small>
: null}
<RangeControl label="Радиус ENU-поля" value={activeGridLod.radiusKm} min={1} max={Math.min(100_000, activeGridLod.stepKm * MAX_LOCAL_GRID_INDEX)} step={1} formatValue={(value) => `${value} км`} onChange={(radiusKm) => updateGridLod({ radiusKm })} />
<RangeControl label="Диаметр 3D-линий" value={activeGridLod.lineDiameterMeters} min={1} max={100} step={1} formatValue={(value) => `${value} м`} onChange={(lineDiameterMeters) => updateGridLod({ lineDiameterMeters })} />
<ControlRow label="Цвет 3D-линий"><ColorField label="Цвет линий ENU-сетки" value={activeGridLod.lineColor} onChange={(lineColor) => updateGridLod({ lineColor })} /></ControlRow>
<RangeControl label="Прозрачность 3D-линий" value={activeGridLod.lineOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(lineOpacity) => updateGridLod({ lineOpacity })} />
<Checker checked={activeGridLod.dotsEnabled} label="Кружки" onChange={(dotsEnabled) => updateGridLod({ dotsEnabled })} />
<RangeControl label="Кружки: диаметр" value={activeGridLod.dotsDiameterMeters} min={1} max={1_000} step={1} formatValue={(value) => `${value} м`} onChange={(dotsDiameterMeters) => updateGridLod({ dotsDiameterMeters })} />
<ControlRow label="Кружки: цвет"><ColorField label="Цвет кружков сетки" value={activeGridLod.dotsColor} onChange={(dotsColor) => updateGridLod({ dotsColor })} /></ControlRow>
<RangeControl label="Кружки: прозрачность" value={activeGridLod.dotsOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(dotsOpacity) => updateGridLod({ dotsOpacity })} />
<Checker checked={activeGridLod.crossesEnabled} label="Кресты" onChange={(crossesEnabled) => updateGridLod({ crossesEnabled })} />
<RangeControl label="Кресты: длина" value={activeGridLod.crossesLengthMeters} min={2} max={5_000} step={2} formatValue={(value) => `${value} м`} onChange={(crossesLengthMeters) => updateGridLod({ crossesLengthMeters })} />
<RangeControl label="Кресты: ширина" value={activeGridLod.crossesWidthMeters} min={1} max={500} step={1} formatValue={(value) => `${value} м`} onChange={(crossesWidthMeters) => updateGridLod({ crossesWidthMeters })} />
<ControlRow label="Кресты: цвет"><ColorField label="Цвет крестов сетки" value={activeGridLod.crossesColor} onChange={(crossesColor) => updateGridLod({ crossesColor })} /></ControlRow>
<RangeControl label="Кресты: прозрачность" value={activeGridLod.crossesOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(crossesOpacity) => updateGridLod({ crossesOpacity })} />
<RangeControl label="Шаг гратикулы" value={activeGridLod.graticuleStepDegrees} min={0.1} max={10} step={0.05} formatValue={(value) => `${value}°`} onChange={(graticuleStepDegrees) => updateGridLod({ graticuleStepDegrees: normalizedGraticuleStepDegrees(graticuleStepDegrees) })} />
<RangeControl label="Толщина гратикулы" value={activeGridLod.graticuleLineWidthPx} min={1} max={3} step={1} formatValue={(value) => `${value} px`} onChange={(graticuleLineWidthPx) => updateGridLod({ graticuleLineWidthPx })} />
<ControlRow label="Цвет гратикулы"><ColorField label="Цвет WGS84-гратику́лы" value={activeGridLod.graticuleColor} onChange={(graticuleColor) => updateGridLod({ graticuleColor })} /></ControlRow>
<RangeControl label="Прозрачность гратикулы" value={activeGridLod.graticuleOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(graticuleOpacity) => updateGridLod({ graticuleOpacity })} />
{activeGridLod.mode === "3d" ? <>
<Checker checked={activeGridLod.volumeEnabled} label="Объёмный выбор сектора" onChange={(volumeEnabled) => updateGridLod({ volumeEnabled })} />
<RangeControl
label="Нижняя отметка объёма"
value={activeGridLod.volumeMinimumHeightMeters}
min={-1_000}
max={activeGridLod.volumeMaximumHeightMeters - 1}
step={10}
formatValue={(value) => `${value} м WGS84`}
onChange={(volumeMinimumHeightMeters) => updateGridVolumeRange({ volumeMinimumHeightMeters })}
/>
<RangeControl
label="Верхняя отметка объёма"
value={activeGridLod.volumeMaximumHeightMeters}
min={activeGridLod.volumeMinimumHeightMeters + 1}
max={10_000}
step={10}
formatValue={(value) => `${value} м WGS84`}
onChange={(volumeMaximumHeightMeters) => updateGridVolumeRange({ volumeMaximumHeightMeters })}
/>
<RangeControl
label="Высота адресного диапазона"
value={activeGridLod.volumeBandHeightMeters}
min={1}
max={1_000}
step={10}
formatValue={(value) => `${value} м`}
onChange={(volumeBandHeightMeters) => updateGridVolumeRange({ volumeBandHeightMeters })}
/>
<small className="catalog-map-inspector__note">Горизонтальный ID сектора остаётся стабильным. Высотный band добавляется как отдельный адрес внутри выбранной ENU-ячейки.</small>
</> : null}
<ControlRow label="Цвет заливки сектора">
<ColorField
label="Цвет заливки выбранного сектора"
value={activeGridLod.selectionFillColor}
onChange={(selectionFillColor) => updateGridLod({ selectionFillColor })}
/>
</ControlRow>
<RangeControl
label="Прозрачность заливки"
value={activeGridLod.selectionFillOpacityPercent}
min={0}
max={100}
step={1}
formatValue={(value) => `${value}%`}
onChange={(selectionFillOpacityPercent) => updateGridLod({ selectionFillOpacityPercent })}
/>
<ControlRow label="Цвет линии сектора">
<ColorField
label="Цвет линии выбранного сектора"
value={activeGridLod.selectionOutlineColor}
onChange={(selectionOutlineColor) => updateGridLod({ selectionOutlineColor })}
/>
</ControlRow>
<RangeControl
label="Толщина линии"
value={activeGridLod.selectionOutlineWidthPx}
min={1}
max={12}
step={0.25}
formatValue={(value) => `${value} px`}
onChange={(selectionOutlineWidthPx) => updateGridLod({ selectionOutlineWidthPx })}
/>
<RangeControl
label="Прозрачность линии"
value={activeGridLod.selectionOutlineOpacityPercent}
min={0}
max={100}
step={1}
formatValue={(value) => `${value}%`}
onChange={(selectionOutlineOpacityPercent) => updateGridLod({ selectionOutlineOpacityPercent })}
/>
<small className="catalog-map-inspector__note">Оформление применяется к выбранному сектору текущего LOD.</small>
<section className="catalog-map-grid-sector" aria-label="Выбранный сектор">
<ControlRow label="Выбранный сектор"><strong className="catalog-map-grid-sector-id">{selectedGridSector?.id ?? "Нажмите сектор на карте"}</strong></ControlRow>
{selectedGridSector ? <>
<Button
variant="secondary"
size="compact"
width="full"
shape="pill"
icon={<Icon name={gridSectorCopyState === "copied" ? "check" : "copy"} />}
onClick={() => void copySelectedGridSectorId()}
>{gridSectorCopyState === "copied" ? "ID скопирован" : "Копировать stable ID"}</Button>
{gridSectorCopyState === "error" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">Не удалось записать ID в буфер обмена.</small> : null}
<div className="catalog-map-grid-sector__facts">
<ControlRow label="Family / LOD"><strong>{selectedGridSector.mode === "3d" ? "Local ENU" : "WGS84 graticule"} · LOD {selectedGridSector.lod}</strong></ControlRow>
<ControlRow label="Адрес"><span>{selectedGridSector.label}</span></ControlRow>
<ControlRow label="Границы"><span>{gridSectorBoundsLabel(selectedGridSector)}</span></ControlRow>
<ControlRow label="Центр"><span>{gridSectorCenterLabel(selectedGridSector)}</span></ControlRow>
<ControlRow label="Площадь"><strong>{formatGridSectorArea(selectedGridSector.areaSquareMeters)}</strong></ControlRow>
</div>
{selectedGridSector.parentMajorTile ? <div className="catalog-map-grid-sector__relation">
<small>Parent major tile · {selectedGridSector.parentMajorTile.label}</small>
<code title={selectedGridSector.parentMajorTile.id}>{selectedGridSector.parentMajorTile.id}</code>
<small>{selectedGridSector.parentMajorTile.minorPerSide} × {selectedGridSector.parentMajorTile.minorPerSide} · {selectedGridSector.parentMajorTile.childCount} дочерних секторов · {formatGridSectorArea(selectedGridSector.parentMajorTile.areaSquareMeters)}</small>
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent="parent-major"
disabled={!mapRendererReady}
onClick={() => focusGridMajorTile(selectedGridSector.parentMajorTile!)}
>Фокус major-тайла</Button>
</div> : <small className="catalog-map-inspector__note">Parent major tile выключен или недоступен для текущей топологии.</small>}
<div className="catalog-map-grid-sector__relation">
<small>Следующий LOD</small>
{selectedGridParentLod ? <>
<code title={selectedGridParentLod.id}>{selectedGridParentLod.id}</code>
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent="next-lod"
disabled={!mapRendererReady}
onClick={() => focusGridSector(selectedGridParentLod)}
>Перейти в LOD {selectedGridParentLod.lod}</Button>
</> : <span>{selectedGridSector.lod >= sectorGridLodProfiles.length
? "Верхний уровень иерархии"
: `LOD ${selectedGridSector.lod + 1} меняет систему адресации`}</span>}
</div>
<div className="catalog-map-grid-sector__neighbors" aria-label="Соседние сектора">
{GRID_SECTOR_DIRECTIONS.map(({ id, label }) => {
const target = selectedGridNeighborTargets[id];
return <div className="catalog-map-grid-sector__neighbor" key={id}>
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent={id}
disabled={!target || !mapRendererReady}
onClick={() => focusGridSector(target)}
>{label}</Button>
<code title={target?.id}>{target?.id ?? "Граница адресного пространства"}</code>
</div>;
})}
</div>
{selectedGridSector.mode === "3d" && selectedGridSectorProfile ? <div className="catalog-map-grid-sector__volume" data-enabled={selectedGridSectorProfile.volumeEnabled || undefined}>
<ControlRow label="Высотный выбор"><strong>{selectedGridSectorProfile.volumeEnabled ? "Включён" : "Выключен"}</strong></ControlRow>
<ControlRow label="Floor"><span>{selectedGridSector.volume?.floor ?? selectedGridSectorProfile.volumeMinimumHeightMeters} м WGS84</span></ControlRow>
<ControlRow label="Ceiling"><span>{selectedGridSector.volume?.ceiling ?? selectedGridSectorProfile.volumeMaximumHeightMeters} м WGS84</span></ControlRow>
<ControlRow label="Height band"><span>{selectedGridSector.volume?.bandHeight ?? selectedGridSectorProfile.volumeBandHeightMeters} м</span></ControlRow>
{selectedGridSector.volume ? <code title={selectedGridSector.volume.id}>{selectedGridSector.volume.id}</code> : null}
{selectedGridSectorProfile.volumeEnabled ? <div className="catalog-map-grid-sector__volume-actions">
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent="below"
disabled={!selectedGridVolumeTargets.below || !mapRendererReady}
onClick={() => focusGridSector(selectedGridVolumeTargets.below)}
>Ниже</Button>
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent="above"
disabled={!selectedGridVolumeTargets.above || !mapRendererReady}
onClick={() => focusGridSector(selectedGridVolumeTargets.above)}
>Выше</Button>
</div> : null}
</div> : null}
</> : <small className="catalog-map-inspector__note">Кликните ячейку, чтобы получить устойчивый адрес, геометрию и навигацию по соседям.</small>}
</section>
</div>,
},
{
id: "map-camera-animation",
label: "Анимация камеры",
description: "geodesic spiral survey",
group: "Камера",
icon: <Icon name="activity" />,
content: <>
<Checker checked={animationModeEnabled} label="Режим анимации" onChange={setAnimationMode} />
{animationModeEnabled ? <>
<small className="catalog-map-inspector__note">Стартовая точка берётся из текущей позиции камеры. Камера смотрит почти в надир, а маршрут ждёт текущие tiles перед продолжением. Движение идёт по региональной геодезической спирали WGS84 до выбранного радиуса.</small>
<InspectorSelectField
label="Профиль покрытия"
value={spiralPresetId}
options={spiralPresetOptions}
disabled={spiralRunning}
onChange={selectSpiralPreset}
/>
<small className="catalog-map-inspector__note">У текущего OSM Buildings подтверждено {OSM_BUILDINGS_OBSERVED_BAND_COUNT} иерархических bands. Десять профилей управляют высотой и покрытием; фактический LOD Cesium выбирает по SSE, viewport и расстоянию.</small>
<ControlRow label="Слои прохода"><small>Imagery · Terrain · OSM Buildings</small></ControlRow>
<RangeControl
label="Высота над землёй"
value={logarithmicControlValue(spiralHeightMeters)}
min={logarithmicControlValue(10)}
max={logarithmicControlValue(100_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralHeightMeters(valueFromLogarithmicControl(value));
}}
/>
<RangeControl
label="Скорость камеры"
value={logarithmicControlValue(spiralSpeedMetersPerSecond)}
min={logarithmicControlValue(1)}
max={logarithmicControlValue(5_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricSpeed(10 ** value)}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value));
}}
/>
<RangeControl
label="Шаг спирали"
value={logarithmicControlValue(spiralPitchMetersPerTurn)}
min={logarithmicControlValue(20)}
max={logarithmicControlValue(100_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value));
}}
/>
<RangeControl
label="Радиус прохода"
value={logarithmicControlValue(spiralTargetRadiusMeters)}
min={logarithmicControlValue(1_000)}
max={logarithmicControlValue(250_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralTargetRadiusMeters(valueFromLogarithmicControl(value));
}}
/>
<small className="catalog-map-inspector__note">Расчётное движение без ожидания сети: {formatDuration(cameraSurveySpiralDistance(spiralTargetRadiusMeters, spiralPitchMetersPerTurn) / spiralSpeedMetersPerSecond)}. Tile waits и автоматическое сужение шага под viewport увеличат фактическое время.</small>
{!spiralCanStart && !spiralRunning ? <small className="catalog-map-inspector__note" role="status">Подготовка: imagery {providerStateLabel[providerStatus.imagery]}, terrain {providerStateLabel[providerStatus.terrain]}, OSM Buildings {providerStateLabel[providerStatus.buildings]}, TileCache {spiralTileCacheReady ? "готов" : gatewayHealth?.cache?.atCapacity ? "заполнен" : gatewayCheckState === "checking" ? "проверяется" : "недоступен для записи"}.</small> : null}
<Button variant="secondary" shape="pill" onClick={toggleSpiralAnimation} disabled={!spiralRunning && !spiralCanStart}>{spiralRunning ? "Остановить" : "Запустить режим анимации"}</Button>
{spiralRunning ? <small className="catalog-map-inspector__note">Камера движется от исходной точки. Выключение режима, уход со страницы или reload остановят сессию.</small> : null}
{spiralMessage ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="status">{spiralMessage}</small> : null}
</> : null}
</>,
},
{
id: "map-cache",
label: "TileCache",
description: "Platform Map Gateway",
group: "Хранение",
icon: <Icon name="database" />,
content: <>
<small className="catalog-map-inspector__note">Общий persistent cache Platform: он не принадлежит приложению, странице или пользователю.</small>
<Checker checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
<small className="catalog-map-inspector__note">Cache hit отдаётся как есть; новый tile записывается только при miss.</small>
<Checker checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать уже полученное" onChange={setCacheNoOverwrite} />
<ControlRow className="catalog-map-inspector__cache-fact" label="Режим"><span>{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"}</span></ControlRow>
<ControlRow className="catalog-map-inspector__cache-fact" label="Хранилище"><span>Platform Map Gateway</span></ControlRow>
<ControlRow className="catalog-map-inspector__cache-fact" label="Подключение"><span>{gatewayEndpoint ?? "runtime profile · не проверено"}</span></ControlRow>
<ControlRow className="catalog-map-inspector__cache-fact" label="Записано"><span>{liveCacheSummary}</span></ControlRow>
<ControlRow className="catalog-map-inspector__cache-fact" label="Политика"><span>{gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"}</span></ControlRow>
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={refreshCurrentViewport} disabled={!mapSettings.cacheEnabled || cacheRefresh}> {cacheRefresh ? "Обновляем viewport…" : "Обновить текущий viewport"}</Button>
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={() => void verifyGateway()} disabled={gatewayCheckState === "checking"}>{gatewayCheckState === "checking" ? "Проверяем Gateway…" : "Проверить подключение"}</Button>
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"}</small>
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
<small className="catalog-map-inspector__note">{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Новые miss дописываются; при заполнении объёма Gateway продолжит live-маршрут без удаления прежних tiles." : "Новые запросы этого Application могут явно обновлять уже записанные tiles." : "Real-time: provider остаётся официальным, чтение и запись persistent cache выключены."}</small>
{gatewayHealth?.cache?.atCapacity ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">TileCache заполнен: новые tiles показываются live, но не записываются. Существующий cache не удаляется.</small> : null}
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
</>,
},
{
id: "map-selection",
label: "Выбранная сущность",
description: "selection contract",
group: "Данные",
icon: <Icon name="target" />,
content: <>
<ControlRow label="Сущность"><strong>{selected?.title ?? "Нет выбора"}</strong></ControlRow>
<ControlRow label="Тип"><span>{selected?.kind ?? "—"}{selected?.status ? ` · ${selected.status}` : ""}</span></ControlRow>
</>,
},
];
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 ? (
<WorkspaceWindow
boundsRef={workspaceRef}
rect={sectorWindowRect}
onRectChange={setSectorWindowRect}
maximized={sectorWindowMaximized}
onMaximizedChange={setSectorWindowMaximized}
onActivate={() => activateWorkspaceWindow("sector")}
onClose={deactivateGridSector}
title={`Активный сектор · LOD ${selectedGridSector.lod}`}
subtitle={selectedGridSector.label}
status={`${sectorVisibleEntities.length} / ${sectorSpatialEntities.length}`}
active={activeWorkspaceWindowId === "sector"}
zIndex={sectorWindowZIndex}
minWidth={340}
minHeight={380}
footer={(
<Button variant="secondary" size="compact" width="full" onClick={deactivateGridSector}>
Деактивировать сектор
</Button>
)}
className="catalog-map-fixture__sector-window catalog-map-fixture__map-glass-window"
aria-label={`Активный сектор: ${selectedGridSector.id}`}
>
<div className="catalog-map-sector-window">
<section className="catalog-map-sector-window__summary" aria-label="Параметры сектора">
<code title={selectedGridSector.id}>{selectedGridSector.id}</code>
<span>{gridSectorBoundsLabel(selectedGridSector)}</span>
<span>{formatGridSectorArea(selectedGridSector.areaSquareMeters)}</span>
<Button
variant="secondary"
size="compact"
width="full"
icon={<Icon name={gridSectorCopyState === "copied" ? "check" : "copy"} />}
onClick={() => void copySelectedGridSectorId()}
>{gridSectorCopyState === "copied" ? "ID скопирован" : "Копировать stable ID"}</Button>
</section>
<Checker
checked={hideObjectsOutsideSector}
label="Скрыть объекты за сектором"
onChange={setHideObjectsOutsideSector}
/>
<section className="catalog-map-sector-window__filters" aria-labelledby="map-sector-domains-title">
<div className="catalog-map-sector-window__section-title">
<strong id="map-sector-domains-title">Домены данных</strong>
<small>{sectorBindingOptions.length}</small>
</div>
{sectorBindingOptions.map((option) => (
<Checker
key={option.value}
checked={!sectorExcludedBindingIds.includes(option.value)}
label={`${option.label} · ${option.count}`}
onChange={(enabled) => setSectorBindingEnabled(option.value, enabled)}
/>
))}
</section>
{sectorProviderOptions.length ? (
<section className="catalog-map-sector-window__filters" aria-labelledby="map-sector-providers-title">
<div className="catalog-map-sector-window__section-title">
<strong id="map-sector-providers-title">Провайдеры</strong>
<small>{sectorProviderOptions.length}</small>
</div>
{sectorProviderOptions.map((option) => (
<Checker
key={option.value}
checked={!sectorExcludedProviders.includes(option.value)}
label={`${option.label} · ${option.count}`}
title={option.value === MAP_SCOPE_MISSING_VALUE ? undefined : option.value}
onChange={(enabled) => setSectorProviderEnabled(option.value, enabled)}
/>
))}
</section>
) : null}
{sectorObjectKindOptions.length ? (
<section className="catalog-map-sector-window__filters" aria-labelledby="map-sector-kinds-title">
<div className="catalog-map-sector-window__section-title">
<strong id="map-sector-kinds-title">Типы объектов</strong>
<small>{sectorObjectKindOptions.length}</small>
</div>
{sectorObjectKindOptions.map((option) => (
<Checker
key={option.value}
checked={!sectorExcludedObjectKinds.includes(option.value)}
label={`${option.label} · ${option.count}`}
title={option.value === MAP_SCOPE_MISSING_VALUE ? undefined : option.value}
onChange={(enabled) => setSectorObjectKindEnabled(option.value, enabled)}
/>
))}
</section>
) : null}
<section className="catalog-map-sector-window__objects" aria-labelledby="map-sector-objects-title">
<div className="catalog-map-sector-window__section-title">
<strong id="map-sector-objects-title">Объекты сектора</strong>
<small>{sectorVisibleEntities.length} / {sectorSpatialEntities.length}</small>
</div>
<div className="catalog-map-sector-window__object-list">
{sectorVisibleEntities.map((entity) => {
const provider = normalizedSectorScopeValue(entity.fact.attributes[MAP_SCOPE_PROVIDER_FIELD]);
const objectKind = normalizedSectorScopeValue(entity.fact.attributes[MAP_SCOPE_OBJECT_KIND_FIELD]);
return (
<button
type="button"
key={entity.id}
data-selected={entity.id === selectedId || undefined}
onClick={() => handleSelectAndFocus(entity.id)}
>
<span>{entity.title}</span>
<code>{entity.fact.sourceId}</code>
<small>{[provider, objectKind, entity.status].filter(Boolean).join(" · ")}</small>
</button>
);
})}
{!sectorVisibleEntities.length ? (
<small className="catalog-map-sector-window__empty">
{sectorSpatialEntities.length
? "Объекты скрыты текущими фильтрами."
: "В секторе нет точечных объектов подключённых Data Products."}
</small>
) : null}
</div>
</section>
</div>
</WorkspaceWindow>
) : null}
{toolbarOpen ? (
<div className="catalog-map-fixture__toolbar" aria-label="Map toolbar" data-search-open={searchOpen || undefined}>
<Dropdown
placement="top-start"
width={320}
minWidth={240}
offset={10}
surfaceRole="menu"
surfaceClassName="catalog-map-fixture__objects-menu nodedc-map-glass"
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
<IconButton ref={setTriggerRef} label="Объекты" aria-controls={surfaceId} aria-expanded={open} aria-pressed={open} data-active={open || undefined} onClick={toggle}><Icon name="target" /></IconButton>
)}
>
{({ close }) => (
<div className="catalog-map-fixture__objects-menu-list">
<div className="catalog-map-fixture__objects-menu-head">
<strong>Объекты</strong>
<small>{objectLayerCount} {objectLayerCount === 1 ? "группа" : "групп"}</small>
</div>
{presentationSummaries.map((summary) => {
const state = subjectStates[summary.bindingId];
const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
const visible = state?.visible !== false;
const hasControls = hasSubjectWindowControls(summary.profile);
return (
<div
className="catalog-map-fixture__objects-menu-item"
key={summary.bindingId}
data-visible={visible || undefined}
data-open={hasControls && state?.window.open || undefined}
>
<button
type="button"
role={hasControls ? "menuitem" : "menuitemcheckbox"}
aria-checked={hasControls ? undefined : visible}
className="catalog-map-fixture__objects-menu-toggle"
onClick={() => {
if (hasControls) {
openSubjectWindow(summary.bindingId);
close();
return;
}
toggleSubjectVisibility(summary.bindingId);
}}
>
<span>{summary.displayName}</span>
<small>{visible ? `на карте: ${visibleCount}` : `слой скрыт · ${summary.total} объектов`}</small>
</button>
</div>
);
})}
{referenceObjectSummaries.map(({ layer, displayName, total }) => (
<div
className="catalog-map-fixture__objects-menu-item"
key={layer.id}
data-visible={layer.visible || undefined}
>
<button
type="button"
role="menuitemcheckbox"
aria-checked={layer.visible}
className="catalog-map-fixture__objects-menu-toggle"
onClick={() => setReferenceLayers((current) => current.map((candidate) => (
candidate.id === layer.id ? { ...candidate, visible: !candidate.visible } : candidate
)))}
>
<span>{displayName}</span>
<small>{layer.visible ? `на карте: ${total}` : `слой скрыт · ${total} объектов`}</small>
</button>
</div>
))}
{!objectLayerCount ? <small className="catalog-map-fixture__objects-menu-empty">Нет подключённых объектов.</small> : null}
</div>
)}
</Dropdown>
<Dropdown
placement="top-start"
width={320}
minWidth={240}
offset={10}
surfaceRole="dialog"
surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"
onOpenChange={setLayersOpen}
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
<IconButton ref={setTriggerRef} label="Слои карты" aria-controls={surfaceId} aria-expanded={open} aria-pressed={open} data-active={open || undefined} onClick={toggle}><Icon name="grid" /></IconButton>
)}
>
<div className="catalog-map-fixture__layers-content" aria-label="Настройки слоёв карты">
<div className="catalog-map-fixture__objects-menu-head">
<strong>Слои карты</strong>
<small>Подложка, рельеф и визуальные слои</small>
</div>
<div className="catalog-map-fixture__provider">
<strong>Cesium World Imagery</strong>
<small>официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]}</small>
</div>
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
<Checker checked={mapSettings.buildingsVisible} label="3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
<Checker checked={mapSettings.gridVisible} label="Планетарная сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary}</small>
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать cache" onChange={setCacheNoOverwrite} />
</div>
</Dropdown>
<IconButton label="Обзор объектов" onClick={() => mapRendererRef.current?.fitRuntimeEntities(visibleTargetEntityIds)}><Icon name="globe" /></IconButton>
<IconButton
label={searchOpen ? "Закрыть поиск" : "Поиск"}
aria-expanded={searchOpen}
aria-controls="map-subject-search"
data-active={searchOpen || undefined}
onClick={() => {
setSearchOpen((current) => !current);
if (searchOpen) {
setSearchQuery("");
setRemoteSearchQuery("");
setSearchActiveIndex(0);
}
}}
><Icon name="search" /></IconButton>
<div className="catalog-map-search" data-open={searchOpen || undefined}>
<label className="catalog-map-search__field" htmlFor="map-subject-search">
<Icon name="search" />
<input
ref={searchInputRef}
id="map-subject-search"
type="search"
value={searchQuery}
autoComplete="off"
spellCheck={false}
placeholder="Название, ID объекта или трекера"
aria-label="Поиск объектов карты"
aria-controls="map-subject-search-results"
aria-activedescendant={mapSearchResults.length ? `map-subject-search-result-${searchActiveIndex}` : undefined}
onChange={(event) => {
setSearchQuery(event.target.value);
setRemoteSearchQuery("");
}}
onKeyDown={handleSearchKeyDown}
/>
</label>
{searchQuery.trim() ? (
<div id="map-subject-search-results" className="catalog-map-search__results nodedc-map-glass" role="listbox" aria-label="Результаты поиска">
{mapSearchResults.map((result, index) => (
<button
key={`${result.bindingId}:${result.sourceId}`}
id={`map-subject-search-result-${index}`}
type="button"
role="option"
aria-selected={index === searchActiveIndex}
data-active={index === searchActiveIndex || undefined}
onPointerEnter={() => setSearchActiveIndex(index)}
onClick={() => handleSearchResult(result)}
>
<span>{result.title}</span>
<small>{result.groupTitle}</small>
</button>
))}
{!mapSearchResults.length ? (
<small className="catalog-map-search__empty">
{remoteSearchQuery === searchQuery.trim()
? (referenceSearchState === "loading"
? "Ищем станцию в OSM…"
: referenceSearchState === "error"
? "Поиск OSM временно недоступен; локальные данные сохранены."
: "Станции с таким точным названием не найдены.")
: "Совпадений нет. Enter — найти станцию по точному названию в OSM."}
</small>
) : null}
</div>
) : null}
</div>
</div>
) : null}
{presentationSummaries.map((summary) => {
const state = subjectStates[summary.bindingId];
if (!state?.window.open || !hasSubjectWindowControls(summary.profile)) return null;
const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
return (
<WorkspaceWindow
key={summary.bindingId}
boundsRef={workspaceRef}
rect={state.window.rect}
onRectChange={(rect) => updateSubjectState(summary.bindingId, (current) => ({
...current,
window: { ...current.window, rect },
}))}
maximized={state.window.maximized}
onMaximizedChange={(maximized) => updateSubjectState(summary.bindingId, (current) => ({
...current,
window: { ...current.window, maximized },
}))}
onActivate={() => activateWorkspaceWindow(`binding:${summary.bindingId}`)}
onClose={() => closeSubjectWindow(summary.bindingId)}
title={summary.displayName}
subtitle={`${summary.total} всего · ${visibleCount} на карте`}
active={activeWorkspaceWindowId === `binding:${summary.bindingId}`}
zIndex={state.window.zIndex}
minWidth={240}
minHeight={220}
autoHeight
className="catalog-map-fixture__subject-window catalog-map-fixture__map-glass-window"
>
<div className="catalog-map-fixture__target-filters">
<section aria-label={`${summary.displayName}: фильтры и счётчики`}>
<div className="catalog-map-fixture__target-filter-list">
{summary.profile.facets.filter((facet) => facet.counter || facet.filterable).flatMap((facet) => (
facet.values.map((item) => {
const active = mapPresentationFacetValueIsEnabled(state.filters, facet.field, item.value);
const rowId = `${summary.bindingId}:${facet.field}:${item.value}`;
const expanded = Boolean(expandedFacetRows[rowId]);
const matchingEntities = selectable.filter((entity) => (
entity.bindingId === summary.bindingId
&& mapFactMatchesFilters(
entity.fact,
summary.profile,
{
[summary.bindingId]: {
visible: true,
facets: { [facet.field]: [item.value] },
},
},
summary.bindingId,
)
));
return (
<div className="catalog-map-fixture__target-filter-branch" key={`${facet.field}:${item.value}`}>
<div className="catalog-map-fixture__target-filter-row" data-active={active || undefined}>
<button
type="button"
className="catalog-map-fixture__target-filter-body"
aria-pressed={active}
disabled={!facet.filterable}
onClick={() => togglePresentationFilter(
summary.bindingId,
facet.field,
item.value,
facet.values.map((value) => value.value),
)}
>
<span className="catalog-map-fixture__target-filter-label">{item.label}</span>
<span className="catalog-map-fixture__target-filter-count">{summary.counts[facet.field]?.[item.value] ?? 0}</span>
</button>
<button
type="button"
className="catalog-map-fixture__target-filter-expander"
aria-label={`${expanded ? "Свернуть" : "Развернуть"} ${item.label}`}
aria-expanded={expanded}
onClick={() => setExpandedFacetRows((current) => ({ ...current, [rowId]: !current[rowId] }))}
>
<Icon name="chevron-right" size={14} />
</button>
</div>
{expanded ? (
<div className="catalog-map-fixture__target-filter-children">
{matchingEntities.map((entity) => (
<button
type="button"
className="catalog-map-fixture__target-filter-entity"
key={entity.id}
data-selected={entity.id === selectedId || undefined}
onClick={() => handleSelectAndFocus(entity.id)}
>
<span>{entity.title}</span>
{entity.status ? <small>{entity.status}</small> : null}
</button>
))}
{!matchingEntities.length ? <small>Нет объектов в группе.</small> : null}
</div>
) : null}
</div>
);
})
))}
</div>
</section>
</div>
</WorkspaceWindow>
);
})}
{subjectCardOpen && selectedSubjectCard ? (
<WorkspaceWindow
boundsRef={workspaceRef}
rect={subjectCardRect}
onRectChange={setSubjectCardRect}
maximized={subjectCardMaximized}
onMaximizedChange={setSubjectCardMaximized}
onActivate={() => activateWorkspaceWindow("subject-card")}
onClose={() => {
setSubjectCardOpen(false);
clearActiveWorkspaceWindow("subject-card");
}}
title={selectedSubjectCard.title}
subtitle={selectedSubjectCard.sourceId}
active={activeWorkspaceWindowId === "subject-card"}
zIndex={subjectCardZIndex}
minWidth={320}
minHeight={320}
className="catalog-map-fixture__subject-card catalog-map-fixture__map-glass-window"
aria-label={`Карточка объекта: ${selectedSubjectCard.title}`}
>
<div className="catalog-map-subject-card">
<div className="catalog-map-subject-card__tabs">
<SegmentedControl
value={selectedSubjectCard.tabs.some((tab) => tab.id === subjectCardTabId) ? subjectCardTabId : selectedSubjectCard.defaultTabId}
items={selectedSubjectCard.tabs.map((tab) => ({ value: tab.id, label: tab.label }))}
label="Разделы карточки объекта"
onChange={setSubjectCardTabId}
/>
</div>
{selectedSubjectCard.tabs.filter((tab) => tab.id === (
selectedSubjectCard.tabs.some((candidate) => candidate.id === subjectCardTabId)
? subjectCardTabId
: selectedSubjectCard.defaultTabId
)).map((tab) => (
<div key={tab.id} className="catalog-map-subject-card__tab-panel" role="tabpanel">
{tab.empty ? <div className="catalog-map-subject-card__empty">{tab.emptyMessage}</div> : null}
{tab.sections.map((section) => (
<section key={section.id} className="catalog-map-subject-card__section" aria-labelledby={`subject-card-${tab.id}-${section.id}`}>
<h3 id={`subject-card-${tab.id}-${section.id}`}>{section.label}</h3>
{section.rows.length ? (
<dl>
{section.rows.map((row) => (
<div key={row.key} className="catalog-map-subject-card__row">
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
) : null}
{section.readings.length ? (
<div className="catalog-map-subject-card__readings">
{section.readings.map((reading) => (
<div className="catalog-map-subject-card__reading" key={reading.id}>
<span>{reading.label}</span>
<strong>{reading.value}</strong>
{reading.observedAt ? <time dateTime={reading.observedAt}>{new Date(reading.observedAt).toLocaleString("ru-RU")}</time> : null}
</div>
))}
</div>
) : null}
</section>
))}
</div>
))}
</div>
</WorkspaceWindow>
) : null}
{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>
);
});