1794 lines
99 KiB
TypeScript
1794 lines
99 KiB
TypeScript
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>
|
||
);
|
||
});
|