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

1794 lines
99 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 { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, SegmentedControl, Window, WorkspaceWindow } from "@nodedc/ui-react";
import type { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react";
import type {
CameraSpiralState,
CesiumMapRendererHandle,
MapCameraView,
MapGatewayHealth,
MapPresentation,
MapProviderStatus,
} from "./CesiumMapRenderer.js";
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
import {
compareMapRuntimeFacts,
mapFactMatchesFilters,
mapPresentationFacetCounts,
mapPresentationProfileForFact,
mapRuntimeDisplayLabel,
mapRuntimeFactIsRenderable,
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";
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;
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;
};
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,
gridHeightMeters: 500,
gridLod1MaxHeightKm: 10,
gridLod1StepKm: 1,
gridLod2MaxHeightKm: 50,
gridLod2StepKm: 5,
gridLod3StepKm: 25,
gridRadiusKm: 40,
gridLineWidth: 4,
gridColor: "#f5f5f5",
gridOpacity: 12,
gridDotsEnabled: true,
gridDotsSize: 7,
gridDotsColor: "#ffffff",
gridDotsOpacity: 58,
};
// 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 defaultLayersWindowRect: WorkspaceWindowRect = {
x: 1024,
y: 72,
width: 336,
height: 500,
};
const defaultSubjectCardRect: WorkspaceWindowRect = {
x: 940,
y: 52,
width: 390,
height: 560,
};
function initialSubjectState(bindings: MapDataProductBinding[], saved: MapSubjectState[] | undefined) {
const savedByBinding = new Map((saved ?? []).map((state) => [state.bindingId, state]));
return Object.fromEntries(bindings.map((binding, index) => {
const state = savedByBinding.get(binding.id);
return [binding.id, state ?? {
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;
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) {
const workspaceRef = useRef<HTMLDivElement>(null);
const [selectedId, setSelectedId] = useState<string>();
const [subjectCardOpen, setSubjectCardOpen] = useState(false);
const [subjectCardRect, setSubjectCardRect] = useState<WorkspaceWindowRect>(defaultSubjectCardRect);
const [subjectCardMaximized, setSubjectCardMaximized] = useState(false);
const [subjectCardZIndex, setSubjectCardZIndex] = useState(140);
const [subjectCardActive, setSubjectCardActive] = useState(false);
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 [layersWindowRect, setLayersWindowRect] = useState<WorkspaceWindowRect>(defaultLayersWindowRect);
const [layersWindowMaximized, setLayersWindowMaximized] = useState(false);
const [layersWindowZIndex, setLayersWindowZIndex] = useState(12);
const [layersWindowActive, setLayersWindowActive] = useState(false);
const [toolbarOpen, setToolbarOpen] = useState(Boolean(features.toolbar));
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [remoteSearchQuery, setRemoteSearchQuery] = useState("");
const [remoteSearchEpoch, setRemoteSearchEpoch] = useState(0);
const [searchActiveIndex, setSearchActiveIndex] = useState(0);
const searchInputRef = useRef<HTMLInputElement>(null);
const [assistantOpen, setAssistantOpen] = useState(false);
const [mapSettings, setMapSettings] = useState<MapPageSettings>(() => ({
...initialMapSettings,
...initialLayout?.settings,
// Layouts saved before the cache policy field existed retain the safe
// append-only default when they are opened again.
cacheNoOverwrite: initialLayout?.settings?.cacheNoOverwrite ?? true,
}));
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)
));
const [activeSubjectBindingId, setActiveSubjectBindingId] = useState<string>();
const presentationFilters = useMemo<MapPresentationFilters>(() => Object.fromEntries(
Object.entries(subjectStates).map(([bindingId, state]) => [bindingId, {
visible: state.visible,
facets: state.filters,
}]),
), [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 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 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(() => primaryRuntimeBindings.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, primaryRuntimeBindings]);
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 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);
}, []);
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);
return summary && !hasSubjectWindowControls(summary.profile)
? { ...state, window: { ...state.window, open: false } }
: state;
}),
}),
}), [dataProductBindings, inspectorOpenSections, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, referenceLayers, subjectDetailProfiles, subjectStates]);
const updateSubjectState = (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) };
});
};
const togglePresentationFilter = (bindingId: string, field: string, value: string) => {
updateSubjectState(bindingId, (state) => {
const filters = state.visible ? state.filters : {};
return {
...state,
visible: true,
filters: toggleMapPresentationFacetSelection(filters, field, value),
};
});
};
const toggleSubjectVisibility = (bindingId: string) => {
updateSubjectState(bindingId, (state) => ({ ...state, visible: !state.visible }));
};
const openSubjectWindow = (bindingId: string) => {
const nextZIndex = Math.max(20, layersWindowZIndex, subjectCardZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
updateSubjectState(bindingId, (state) => ({
...state,
window: { ...state.window, open: true, zIndex: nextZIndex },
}));
setLayersWindowActive(false);
setSubjectCardActive(false);
setActiveSubjectBindingId(bindingId);
};
const closeSubjectWindow = (bindingId: string) => {
updateSubjectState(bindingId, (state) => ({ ...state, window: { ...state.window, open: false } }));
setActiveSubjectBindingId((current) => current === bindingId ? undefined : current);
};
const activateLayersWindow = () => {
const nextZIndex = Math.max(20, layersWindowZIndex, subjectCardZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
setLayersWindowZIndex(nextZIndex);
setLayersWindowActive(true);
setSubjectCardActive(false);
setActiveSubjectBindingId(undefined);
};
const toggleLayersWindow = () => {
if (layersOpen) {
setLayersOpen(false);
setLayersWindowActive(false);
return;
}
setLayersOpen(true);
activateLayersWindow();
};
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);
setSubjectCardActive(true);
setLayersWindowActive(false);
setActiveSubjectBindingId(undefined);
setSubjectCardZIndex((current) => Math.max(current, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1);
}, [dataProductBindings, layersWindowZIndex, selectable, subjectDetailProfiles, subjectStates]);
const handleSelectAndFocus = useCallback((entityId: string) => {
handleSelect(entityId);
mapRendererRef.current?.focusRuntimeEntity(entityId);
}, [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
)));
}
if (!mapRendererRef.current?.focusRuntimeEntity(result.entityId)) {
mapRendererRef.current?.focusCoordinates(result.coordinates[0], result.coordinates[1]);
}
setSearchOpen(false);
setSearchQuery("");
setRemoteSearchQuery("");
setSearchActiveIndex(0);
}, [handleSelect, mapSearchResults]);
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: "Карта",
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: "Карта",
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: "Карта",
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" ? "Слои" : "Таргеты",
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: "Таргеты",
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: "Слои",
content: <>
<small className="catalog-map-inspector__note">Сетка размещается над поверхностью и меняет шаг по высоте камеры.</small>
<Checker checked={mapSettings.gridVisible} label="3D-сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
<Checker checked={mapSettings.gridLodEnabled} label="LOD по высоте камеры" onChange={(gridLodEnabled) => updateMapSettings({ gridLodEnabled })} />
<RangeControl label="Высота над поверхностью" value={mapSettings.gridHeightMeters} min={0} max={1000} formatValue={(value) => `${value} м`} onChange={(gridHeightMeters) => updateMapSettings({ gridHeightMeters })} />
<RangeControl label="LOD 1: до высоты" value={mapSettings.gridLod1MaxHeightKm} min={1} max={50} formatValue={(value) => `${value} км`} onChange={(gridLod1MaxHeightKm) => updateMapSettings({ gridLod1MaxHeightKm })} />
<RangeControl label="LOD 1: шаг" value={mapSettings.gridLod1StepKm} min={1} max={10} formatValue={(value) => `${value} км`} onChange={(gridLod1StepKm) => updateMapSettings({ gridLod1StepKm })} />
<RangeControl label="LOD 2: до высоты" value={mapSettings.gridLod2MaxHeightKm} min={10} max={200} formatValue={(value) => `${value} км`} onChange={(gridLod2MaxHeightKm) => updateMapSettings({ gridLod2MaxHeightKm })} />
<RangeControl label="LOD 2: шаг" value={mapSettings.gridLod2StepKm} min={1} max={25} formatValue={(value) => `${value} км`} onChange={(gridLod2StepKm) => updateMapSettings({ gridLod2StepKm })} />
<RangeControl label="LOD 3: шаг" value={mapSettings.gridLod3StepKm} min={5} max={100} formatValue={(value) => `${value} км`} onChange={(gridLod3StepKm) => updateMapSettings({ gridLod3StepKm })} />
<RangeControl label="Радиус сетки" value={mapSettings.gridRadiusKm} min={5} max={150} formatValue={(value) => `${value} км`} onChange={(gridRadiusKm) => updateMapSettings({ gridRadiusKm })} />
<ControlRow label="Цвет линий"><ColorField label="Цвет линий сетки" value={mapSettings.gridColor} onChange={(gridColor) => updateMapSettings({ gridColor })} /></ControlRow>
<RangeControl label="Толщина линий" value={mapSettings.gridLineWidth} min={1} max={8} formatValue={(value) => `${value} px`} onChange={(gridLineWidth) => updateMapSettings({ gridLineWidth })} />
<RangeControl label="Прозрачность сетки" value={mapSettings.gridOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(gridOpacity) => updateMapSettings({ gridOpacity })} />
<Checker checked={mapSettings.gridDotsEnabled} label="Точки в пересечениях" onChange={(gridDotsEnabled) => updateMapSettings({ gridDotsEnabled })} />
<RangeControl label="Размер точки" value={mapSettings.gridDotsSize} min={2} max={28} formatValue={(value) => `${value} px`} onChange={(gridDotsSize) => updateMapSettings({ gridDotsSize })} />
<ControlRow label="Цвет точек"><ColorField label="Цвет точек сетки" value={mapSettings.gridDotsColor} onChange={(gridDotsColor) => updateMapSettings({ gridDotsColor })} /></ControlRow>
<RangeControl label="Прозрачность точек" value={mapSettings.gridDotsOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(gridDotsOpacity) => updateMapSettings({ gridDotsOpacity })} />
</>,
},
{
id: "map-camera-animation",
label: "Анимация камеры",
description: "geodesic spiral survey",
group: "Камера",
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: "Хранение",
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: "Данные",
content: <>
<ControlRow label="Сущность"><strong>{selected?.title ?? "Нет выбора"}</strong></ControlRow>
<ControlRow label="Тип"><span>{selected?.kind ?? "—"}{selected?.status ? ` · ${selected.status}` : ""}</span></ControlRow>
</>,
},
];
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"
>
<Suspense fallback={<div className="catalog-map-fixture__loading">Загрузка карты…</div>}>
<CesiumMapRenderer
key={rendererRevision}
ref={mapRendererRef}
onSelect={handleSelect}
onGatewayHealth={handleRendererGatewayHealth}
onProviderStatus={setProviderStatus}
onCameraChange={handleCameraChange}
onCacheRefreshConsumed={handleCacheRefreshConsumed}
onReadyChange={setMapRendererReady}
onSpiralStateChange={handleSpiralStateChange}
initialCamera={mapCamera ?? undefined}
presentation={presentation}
runtimeBindings={[...primaryRuntimeBindings, ...referenceRuntimeBindings]}
presentationProfiles={presentationProfiles}
presentationFilters={rendererPresentationFilters}
/>
</Suspense>
<div className="catalog-map-fixture__actions">
<IconButton label="Настройки карты" aria-pressed={inspectorOpen} data-active={inspectorOpen || undefined} onClick={() => setInspectorOpen(true)}><Icon name="settings" /></IconButton>
<IconButton label="Слои карты" aria-pressed={layersOpen} data-active={layersOpen || undefined} onClick={toggleLayersWindow}><Icon name="grid" /></IconButton>
{features.toolbar ? <IconButton label="Toolbar" aria-pressed={toolbarOpen} data-active={toolbarOpen || undefined} onClick={() => setToolbarOpen((value) => !value)}><Icon name="panel" /></IconButton> : null}
{features.assistant ? <IconButton label="Assistant" aria-pressed={assistantOpen} data-active={assistantOpen || undefined} onClick={() => setAssistantOpen((value) => !value)}><Icon name="apps" /></IconButton> : null}
</div>
{layersOpen ? (
<WorkspaceWindow
boundsRef={workspaceRef}
rect={layersWindowRect}
onRectChange={setLayersWindowRect}
maximized={layersWindowMaximized}
onMaximizedChange={setLayersWindowMaximized}
onActivate={activateLayersWindow}
onClose={() => {
setLayersOpen(false);
setLayersWindowActive(false);
}}
title="Слои карты"
active={layersWindowActive}
zIndex={layersWindowZIndex}
minWidth={320}
minHeight={360}
className="catalog-map-fixture__layers catalog-map-fixture__map-glass-window"
aria-label="Настройки слоёв карты"
>
<div className="catalog-map-fixture__layers-content">
<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>
</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>
<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={() => openSubjectWindow(summary.bindingId)}
onClose={() => closeSubjectWindow(summary.bindingId)}
title={summary.displayName}
subtitle={`${summary.total} всего · ${visibleCount} на карте`}
active={activeSubjectBindingId === 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 = state.filters[facet.field]?.includes(item.value) ?? false;
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)}
>
<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={() => {
const nextZIndex = Math.max(subjectCardZIndex, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
setSubjectCardZIndex(nextZIndex);
setSubjectCardActive(true);
setLayersWindowActive(false);
setActiveSubjectBindingId(undefined);
}}
onClose={() => {
setSubjectCardOpen(false);
setSubjectCardActive(false);
}}
title={selectedSubjectCard.title}
subtitle={selectedSubjectCard.sourceId}
active={subjectCardActive}
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>
<Window
open={inspectorOpen && Boolean(features.inspector)}
title="Настройки карты"
subtitle="MAP / draggable inspector"
placement="end"
draggable
closeOnBackdrop={false}
lockBodyScroll={false}
trapFocus={false}
className="catalog-map-fixture__map-settings-window"
onClose={() => setInspectorOpen(false)}
>
<Inspector
sections={inspectorSections}
openSections={inspectorOpenSections}
singleOpen
onOpenSectionsChange={setInspectorOpenSections}
/>
</Window>
</div>
);
});