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