feat(map): add operational Cesium workspace

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 16:45:21 +03:00
parent 66f8d7a1fa
commit 8e3710b493
11 changed files with 1719 additions and 13 deletions
@@ -0,0 +1,359 @@
export type MapInspectorSection =
| "base-terrain"
| "atmosphere-light"
| "buildings"
| "targets"
| "grid-lod"
| "camera"
| "tile-cache"
| "selection";
export interface MapCamera {
longitude: number;
latitude: number;
height: number;
heading: number;
pitch: number;
roll: number;
}
export interface MapVisualSettings {
atmosphereEnabled: boolean;
lightingEnabled: boolean;
monochromeEnabled: boolean;
terrainExaggeration: number;
buildingsMaximumScreenSpaceError: number;
cameraAnimationEnabled: boolean;
}
export interface MapLayerVisibility {
imagery: boolean;
terrain: boolean;
buildings: boolean;
grid: boolean;
targets: boolean;
}
export interface MapCacheIntent {
enabled: boolean;
noOverwrite: boolean;
}
export interface MapView {
camera: MapCamera | null;
visualSettings: MapVisualSettings;
mapHeight: number;
inspectorOpenSections: MapInspectorSection[];
cacheIntent: MapCacheIntent;
selectedSubjectId: string | null;
layerVisibility: MapLayerVisibility;
}
export interface MapViewDocument {
revision: number;
view: MapView;
}
const inspectorSections = new Set<MapInspectorSection>([
"base-terrain",
"atmosphere-light",
"buildings",
"targets",
"grid-lod",
"camera",
"tile-cache",
"selection",
]);
export function defaultMapViewDocument(): MapViewDocument {
return {
revision: 0,
view: {
camera: null,
visualSettings: {
atmosphereEnabled: true,
lightingEnabled: true,
monochromeEnabled: false,
terrainExaggeration: 1,
buildingsMaximumScreenSpaceError: 16,
cameraAnimationEnabled: true,
},
mapHeight: 720,
inspectorOpenSections: [],
cacheIntent: {
enabled: true,
noOverwrite: true,
},
selectedSubjectId: null,
layerVisibility: {
imagery: true,
terrain: true,
buildings: true,
grid: false,
targets: true,
},
},
};
}
export function decodeMapViewDocument(value: unknown): MapViewDocument {
const document = requireRecord(value, "map view");
requireExactKeys(document, ["schema_version", "revision", "view"], "map view");
if (document.schema_version !== "missioncore.map-view/v1") {
throw new Error("Версия сохранённого состояния карты не поддерживается.");
}
return {
revision: requireInteger(document.revision, "map view.revision", 0, Number.MAX_SAFE_INTEGER),
view: decodeMapView(document.view),
};
}
export function encodeMapViewPut(document: MapViewDocument): unknown {
return {
revision: document.revision,
view: {
camera: document.view.camera
? {
longitude: document.view.camera.longitude,
latitude: document.view.camera.latitude,
height: document.view.camera.height,
heading: document.view.camera.heading,
pitch: document.view.camera.pitch,
roll: document.view.camera.roll,
}
: null,
visual_settings: {
atmosphere_enabled: document.view.visualSettings.atmosphereEnabled,
lighting_enabled: document.view.visualSettings.lightingEnabled,
monochrome_enabled: document.view.visualSettings.monochromeEnabled,
terrain_exaggeration: document.view.visualSettings.terrainExaggeration,
buildings_maximum_screen_space_error:
document.view.visualSettings.buildingsMaximumScreenSpaceError,
camera_animation_enabled: document.view.visualSettings.cameraAnimationEnabled,
},
map_height: document.view.mapHeight,
inspector_open_sections: document.view.inspectorOpenSections,
cache_intent: {
enabled: document.view.cacheIntent.enabled,
no_overwrite: document.view.cacheIntent.noOverwrite,
},
selected_subject_id: document.view.selectedSubjectId,
layer_visibility: {
imagery: document.view.layerVisibility.imagery,
terrain: document.view.layerVisibility.terrain,
buildings: document.view.layerVisibility.buildings,
grid: document.view.layerVisibility.grid,
targets: document.view.layerVisibility.targets,
},
},
};
}
export function cloneMapViewDocument(document: MapViewDocument): MapViewDocument {
return {
revision: document.revision,
view: {
...document.view,
camera: document.view.camera ? { ...document.view.camera } : null,
visualSettings: { ...document.view.visualSettings },
inspectorOpenSections: [...document.view.inspectorOpenSections],
cacheIntent: { ...document.view.cacheIntent },
layerVisibility: { ...document.view.layerVisibility },
},
};
}
function decodeMapView(value: unknown): MapView {
const view = requireRecord(value, "map view.view");
requireExactKeys(
view,
[
"camera",
"visual_settings",
"map_height",
"inspector_open_sections",
"cache_intent",
"selected_subject_id",
"layer_visibility",
],
"map view.view",
);
const sections = requireArray(view.inspector_open_sections, "inspector_open_sections");
if (
sections.length > 1 ||
sections.some((section) => typeof section !== "string" || !inspectorSections.has(
section as MapInspectorSection,
))
) {
throw new Error("Секции Inspector карты некорректны.");
}
const selectedSubjectId = requireNullableString(
view.selected_subject_id,
"map view.view.selected_subject_id",
);
if (sections[0] === "selection" && selectedSubjectId === null) {
throw new Error("Секция выбранного объекта требует стабильный subject id.");
}
return {
camera: view.camera === null ? null : decodeCamera(view.camera),
visualSettings: decodeVisualSettings(view.visual_settings),
mapHeight: requireInteger(view.map_height, "map view.view.map_height", 420, 2160),
inspectorOpenSections: sections as MapInspectorSection[],
cacheIntent: decodeCacheIntent(view.cache_intent),
selectedSubjectId,
layerVisibility: decodeLayerVisibility(view.layer_visibility),
};
}
function decodeCamera(value: unknown): MapCamera {
const camera = requireRecord(value, "map view.view.camera");
requireExactKeys(
camera,
["longitude", "latitude", "height", "heading", "pitch", "roll"],
"map view.view.camera",
);
return {
longitude: requireNumber(camera.longitude, "camera.longitude", -180, 180),
latitude: requireNumber(camera.latitude, "camera.latitude", -90, 90),
height: requireNumber(camera.height, "camera.height", 1, 100_000_000),
heading: requireNumber(camera.heading, "camera.heading", -360, 360),
pitch: requireNumber(camera.pitch, "camera.pitch", -90, 90),
roll: requireNumber(camera.roll, "camera.roll", -360, 360),
};
}
function decodeVisualSettings(value: unknown): MapVisualSettings {
const settings = requireRecord(value, "map view.view.visual_settings");
requireExactKeys(
settings,
[
"atmosphere_enabled",
"lighting_enabled",
"monochrome_enabled",
"terrain_exaggeration",
"buildings_maximum_screen_space_error",
"camera_animation_enabled",
],
"map view.view.visual_settings",
);
return {
atmosphereEnabled: requireBoolean(settings.atmosphere_enabled, "atmosphere_enabled"),
lightingEnabled: requireBoolean(settings.lighting_enabled, "lighting_enabled"),
monochromeEnabled: requireBoolean(settings.monochrome_enabled, "monochrome_enabled"),
terrainExaggeration: requireNumber(
settings.terrain_exaggeration,
"terrain_exaggeration",
0.1,
20,
),
buildingsMaximumScreenSpaceError: requireNumber(
settings.buildings_maximum_screen_space_error,
"buildings_maximum_screen_space_error",
1,
64,
),
cameraAnimationEnabled: requireBoolean(
settings.camera_animation_enabled,
"camera_animation_enabled",
),
};
}
function decodeCacheIntent(value: unknown): MapCacheIntent {
const intent = requireRecord(value, "map view.view.cache_intent");
requireExactKeys(intent, ["enabled", "no_overwrite"], "map view.view.cache_intent");
return {
enabled: requireBoolean(intent.enabled, "cache_intent.enabled"),
noOverwrite: requireBoolean(intent.no_overwrite, "cache_intent.no_overwrite"),
};
}
function decodeLayerVisibility(value: unknown): MapLayerVisibility {
const layers = requireRecord(value, "map view.view.layer_visibility");
requireExactKeys(
layers,
["imagery", "terrain", "buildings", "grid", "targets"],
"map view.view.layer_visibility",
);
return {
imagery: requireBoolean(layers.imagery, "layer_visibility.imagery"),
terrain: requireBoolean(layers.terrain, "layer_visibility.terrain"),
buildings: requireBoolean(layers.buildings, "layer_visibility.buildings"),
grid: requireBoolean(layers.grid, "layer_visibility.grid"),
targets: requireBoolean(layers.targets, "layer_visibility.targets"),
};
}
function requireRecord(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${path} должен быть объектом.`);
}
return value as Record<string, unknown>;
}
function requireArray(value: unknown, path: string): unknown[] {
if (!Array.isArray(value)) {
throw new Error(`${path} должен быть массивом.`);
}
return value;
}
function requireExactKeys(
record: Record<string, unknown>,
allowed: readonly string[],
path: string,
): void {
const allowedKeys = new Set(allowed);
const unexpected = Object.keys(record).find((key) => !allowedKeys.has(key));
if (unexpected) {
throw new Error(`${path}.${unexpected} не поддерживается.`);
}
}
function requireBoolean(value: unknown, path: string): boolean {
if (typeof value !== "boolean") {
throw new Error(`${path} должен быть boolean.`);
}
return value;
}
function requireNumber(
value: unknown,
path: string,
minimum: number,
maximum: number,
): number {
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value < minimum ||
value > maximum
) {
throw new Error(`${path} выходит за допустимый диапазон.`);
}
return value;
}
function requireInteger(
value: unknown,
path: string,
minimum: number,
maximum: number,
): number {
const result = requireNumber(value, path, minimum, maximum);
if (!Number.isSafeInteger(result)) {
throw new Error(`${path} должен быть целым числом.`);
}
return result;
}
function requireNullableString(value: unknown, path: string): string | null {
if (value === null) return null;
if (
typeof value !== "string" ||
!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/.test(value)
) {
throw new Error(`${path} не является стабильным subject id.`);
}
return value;
}
@@ -0,0 +1,153 @@
import { useEffect, useState } from "react";
export interface MapGatewayHealthSnapshot {
ionConfigured: boolean;
assetAllowlist: number[];
cache: {
mode: string;
writePolicy: string;
entries: number;
bytes: number;
maxBytes: number | null;
atCapacity: boolean;
persistent: boolean;
};
}
export interface MapGatewayHealth {
state: "idle" | "loading" | "ready" | "stale" | "error";
snapshot: MapGatewayHealthSnapshot | null;
code: string | null;
}
export function useMapGatewayHealth(active: boolean): MapGatewayHealth {
const [health, setHealth] = useState<MapGatewayHealth>({
state: "idle",
snapshot: null,
code: null,
});
useEffect(() => {
if (!active) return;
let disposed = false;
let running = false;
let lastKnownGood: MapGatewayHealthSnapshot | null = null;
let activeController: AbortController | null = null;
const poll = async () => {
if (running) return;
running = true;
const controller = new AbortController();
activeController = controller;
const timeout = window.setTimeout(() => controller.abort(), 10_000);
if (!lastKnownGood && !disposed) {
setHealth({ state: "loading", snapshot: null, code: null });
}
try {
const response = await fetch("/api/v1/map/gateway/health", {
signal: controller.signal,
cache: "no-store",
headers: { Accept: "application/json" },
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(safeCode(payload));
}
const snapshot = decodeHealth(payload);
lastKnownGood = snapshot;
if (!disposed) {
setHealth({ state: "ready", snapshot, code: null });
}
} catch (reason) {
if (!disposed) {
setHealth({
state: lastKnownGood ? "stale" : "error",
snapshot: lastKnownGood,
code: reason instanceof Error ? reason.message : "map_gateway_unavailable",
});
}
} finally {
window.clearTimeout(timeout);
if (activeController === controller) activeController = null;
running = false;
}
};
void poll();
const interval = window.setInterval(() => void poll(), 15_000);
return () => {
disposed = true;
activeController?.abort();
window.clearInterval(interval);
};
}, [active]);
return health;
}
function decodeHealth(value: unknown): MapGatewayHealthSnapshot {
const document = record(value, "gateway health");
if (document.ok !== true || document.service !== "nodedc-map-gateway") {
throw new Error("map_gateway_invalid_response");
}
const cache = record(document.cache, "gateway health.cache");
const assetAllowlist = array(document.assetAllowlist, "gateway health.assetAllowlist");
if (
typeof document.ionConfigured !== "boolean" ||
!assetAllowlist.every((asset) => typeof asset === "number" && Number.isSafeInteger(asset))
) {
throw new Error("map_gateway_invalid_response");
}
return {
ionConfigured: document.ionConfigured,
assetAllowlist: assetAllowlist as number[],
cache: {
mode: string(cache.mode),
writePolicy: string(cache.writePolicy),
entries: integer(cache.entries),
bytes: integer(cache.bytes),
maxBytes: cache.maxBytes === null ? null : integer(cache.maxBytes),
atCapacity: boolean(cache.atCapacity),
persistent: boolean(cache.persistent),
},
};
}
function safeCode(value: unknown): string {
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
const document = value as Record<string, unknown>;
if (typeof document.code === "string" && /^[a-z][a-z0-9_]{0,95}$/.test(document.code)) {
return document.code;
}
}
return "map_gateway_unavailable";
}
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${path} is invalid`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, path: string): unknown[] {
if (!Array.isArray(value)) throw new Error(`${path} is invalid`);
return value;
}
function string(value: unknown): string {
if (typeof value !== "string") throw new Error("map_gateway_invalid_response");
return value;
}
function integer(value: unknown): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new Error("map_gateway_invalid_response");
}
return value;
}
function boolean(value: unknown): boolean {
if (typeof value !== "boolean") throw new Error("map_gateway_invalid_response");
return value;
}
@@ -0,0 +1,118 @@
import { useCallback, useEffect, useState } from "react";
import {
decodeMapViewDocument,
defaultMapViewDocument,
encodeMapViewPut,
type MapViewDocument,
} from "./mapView";
export interface MapViewController {
document: MapViewDocument;
state: "loading" | "ready" | "saving" | "error";
error: string | null;
save: (draft: MapViewDocument) => Promise<MapViewDocument>;
reload: () => Promise<MapViewDocument>;
}
async function responseError(response: Response, fallback: string): Promise<string> {
try {
const body = await response.json() as { detail?: unknown; code?: unknown };
if (typeof body.detail === "string" && body.detail.trim()) return body.detail;
if (typeof body.code === "string" && body.code.trim()) return body.code;
} catch {
// Reverse-proxy HTML or an interrupted response receives stable product copy.
}
return fallback;
}
async function loadMapView(signal?: AbortSignal): Promise<MapViewDocument> {
const response = await fetch("/api/v1/map/view", {
signal,
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!response.ok) {
throw new Error(await responseError(
response,
"Не удалось загрузить состояние карты.",
));
}
return decodeMapViewDocument(await response.json());
}
export function useMapView(): MapViewController {
const [document, setDocument] = useState<MapViewDocument>(defaultMapViewDocument);
const [state, setState] = useState<MapViewController["state"]>("loading");
const [error, setError] = useState<string | null>(null);
const reload = useCallback(async () => {
setState("loading");
setError(null);
try {
const next = await loadMapView();
setDocument(next);
setState("ready");
return next;
} catch (reason) {
const message = reason instanceof Error
? reason.message
: "Не удалось загрузить состояние карты.";
setState("error");
setError(message);
throw new Error(message);
}
}, []);
useEffect(() => {
const controller = new AbortController();
void loadMapView(controller.signal).then((next) => {
setDocument(next);
setState("ready");
setError(null);
}).catch((reason: unknown) => {
if (controller.signal.aborted) return;
setState("error");
setError(reason instanceof Error
? reason.message
: "Не удалось загрузить состояние карты.");
});
return () => controller.abort();
}, []);
const save = useCallback(async (draft: MapViewDocument) => {
setState("saving");
setError(null);
try {
const response = await fetch("/api/v1/map/view", {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(encodeMapViewPut(draft)),
});
if (!response.ok) {
throw new Error(await responseError(
response,
response.status === 412
? "Состояние карты изменилось в другом окне. Обновите его перед сохранением."
: "Не удалось сохранить состояние карты.",
));
}
const accepted = decodeMapViewDocument(await response.json());
setDocument(accepted);
setState("ready");
return accepted;
} catch (reason) {
const message = reason instanceof Error
? reason.message
: "Не удалось сохранить состояние карты.";
setState("error");
setError(message);
throw new Error(message);
}
}, []);
return { document, state, error, save, reload };
}
+2
View File
@@ -1,3 +1,4 @@
@import "@nodedc/map-cesium-react/widgets.css";
@import "./styles/base.css";
@import "./styles/shell.css";
@import "./styles/workspaces.css";
@@ -13,3 +14,4 @@
@import "./styles/environment-settings.css";
@import "./styles/system-telemetry.css";
@import "./styles/artifact-health.css";
@import "./styles/map.css";
+187
View File
@@ -0,0 +1,187 @@
.mission-map {
min-width: 0;
}
.mission-map__lead {
align-items: center;
}
.mission-map__lead-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.62rem;
}
.mission-map__stage {
position: relative;
min-width: 0;
height: min(var(--mission-map-height), calc(100vh - 19rem));
min-height: 20rem;
overflow: hidden;
border-radius: var(--nodedc-radius-card);
background: var(--nodedc-canvas);
isolation: isolate;
}
.mission-map__cesium,
.mission-map__cesium .cesium-viewer,
.mission-map__cesium .cesium-viewer-cesiumWidgetContainer,
.mission-map__cesium .cesium-widget,
.mission-map__cesium canvas {
width: 100%;
height: 100%;
}
.mission-map__cesium .cesium-widget-credits {
right: 0.7rem;
bottom: 0.45rem;
max-width: min(58%, 42rem);
}
.mission-map__toolbar {
position: absolute;
z-index: 4;
right: 50%;
bottom: 1rem;
left: auto;
transform: translateX(50%);
}
.mission-map__layers {
position: absolute;
z-index: 5;
top: 1rem;
right: 1rem;
display: grid;
width: min(23rem, calc(100% - 2rem));
gap: 0.42rem;
padding: 0.72rem;
border-radius: var(--nodedc-radius-card);
}
.mission-map__overlay-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.2rem 0.2rem 0.5rem;
}
.mission-map__overlay-head > div {
display: grid;
gap: 0.34rem;
}
.mission-map__overlay-head strong {
color: var(--nodedc-map-glass-text);
font-size: 0.92rem;
}
.mission-map__layers .section-eyebrow {
color: var(--nodedc-map-glass-text-muted);
}
.mission-map__layer-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.55rem;
}
.mission-map__layer-row .nodedc-checker {
min-width: 0;
}
.mission-map__blocking {
position: absolute;
z-index: 6;
top: 50%;
left: 50%;
display: grid;
width: min(22rem, calc(100% - 2rem));
justify-items: start;
gap: 0.5rem;
padding: 1rem;
border-radius: var(--nodedc-radius-card);
transform: translate(-50%, -50%);
}
.mission-map__blocking strong {
color: var(--nodedc-map-glass-text);
font-size: 0.9rem;
}
.mission-map__blocking > span {
color: var(--nodedc-map-glass-text-muted);
font-size: 0.68rem;
line-height: 1.45;
}
.mission-map__save-error {
display: flex;
align-items: center;
gap: 0.62rem;
color: var(--nodedc-text-muted);
font-size: 0.68rem;
}
.mission-map-inspector .nodedc-window__body {
padding: 0.75rem;
}
.mission-map__inspector-controls {
display: grid;
gap: 0.48rem;
}
.mission-map__cache-health {
display: grid;
gap: 0.5rem;
margin: 0;
padding-top: 0.3rem;
}
.mission-map__cache-health > div {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.65rem;
}
.mission-map__cache-health dt,
.mission-map__cache-health dd {
margin: 0;
font-size: 0.68rem;
}
.mission-map__cache-health dt {
color: var(--nodedc-text-muted);
}
.mission-map__cache-health dd {
color: var(--nodedc-text-secondary);
text-align: right;
}
@media (max-width: 760px) {
.mission-map__lead {
align-items: flex-start;
}
.mission-map__lead-actions {
width: 100%;
justify-content: space-between;
}
.mission-map__stage {
height: min(var(--mission-map-height), calc(100vh - 21rem));
min-height: 20rem;
}
.mission-map__layers {
top: 0.65rem;
right: 0.65rem;
width: calc(100% - 1.3rem);
}
}
@@ -1,10 +1,4 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Button,
GlassSurface,
@@ -48,6 +42,7 @@ import { ContourHealthWorkspace } from "./ContourHealthWorkspace";
import { LaboratoryArchiveWorkspace } from "./laboratory/LaboratoryArchiveWorkspace";
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
import { NetworkWorkspace } from "./system/NetworkWorkspace";
import { WorldMapWorkspace } from "./map/WorldMapWorkspace";
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success";
if (status === "ready") return "accent";
@@ -1165,7 +1160,9 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
case "cameras":
return <CamerasWorkspace {...props} />;
case "map":
return <MapWorkspace {...props} />;
return props.definition.id === "world-map"
? <WorldMapWorkspace definition={props.definition} />
: <MapWorkspace {...props} />;
case "timeline":
return <TimelineWorkspace {...props} />;
case "missions":
@@ -0,0 +1,691 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import {
CesiumMapRenderer,
initialMapRuntimeState,
type MapCamera,
type MapProviderState,
type MapRuntimeState,
} from "@nodedc/map-cesium-react";
import { mapPageTemplate } from "@nodedc/page-patterns";
import {
Button,
Checker,
ControlRow,
Icon,
IconButton,
Inspector,
MapGlassSurface,
RangeControl,
StatusBadge,
Toolbar,
Window,
WindowFooterActions,
} from "@nodedc/ui-react";
import {
cloneMapViewDocument,
defaultMapViewDocument,
type MapInspectorSection,
type MapViewDocument,
} from "../../core/map/mapView";
import { useMapGatewayHealth } from "../../core/map/useMapGatewayHealth";
import { useMapView } from "../../core/map/useMapView";
import type { WorkspaceDefinition } from "../../productModel";
const editableInspectorSections = new Set<MapInspectorSection>([
"base-terrain",
"atmosphere-light",
"buildings",
"grid-lod",
"camera",
"tile-cache",
]);
type ToolbarAction = "settings" | "layers" | "reset-view";
export function WorldMapWorkspace({
definition,
}: {
definition: WorkspaceDefinition;
}) {
const controller = useMapView();
const [draft, setDraft] = useState<MapViewDocument>(
() => cloneMapViewDocument(controller.document),
);
const [viewInitialized, setViewInitialized] = useState(false);
const [runtimeState, setRuntimeState] = useState<MapRuntimeState>(
initialMapRuntimeState,
);
const [settingsOpen, setSettingsOpen] = useState(false);
const [layersOpen, setLayersOpen] = useState(false);
const [rendererGeneration, setRendererGeneration] = useState(0);
const operatorCameraInteraction = useRef(false);
const gatewayHealth = useMapGatewayHealth(settingsOpen || layersOpen);
useEffect(() => {
if (controller.state === "ready" || controller.state === "saving") {
setViewInitialized(true);
}
}, [controller.state]);
useEffect(() => {
if (!viewInitialized && controller.state !== "ready") return;
setDraft(cloneMapViewDocument(controller.document));
}, [controller.document, controller.state, viewInitialized]);
const updateDraft = useCallback((
update: (current: MapViewDocument) => MapViewDocument,
) => {
setDraft((current) => update(cloneMapViewDocument(current)));
}, []);
const updateLayer = useCallback((
layer: keyof MapViewDocument["view"]["layerVisibility"],
checked: boolean,
) => {
updateDraft((current) => {
current.view.layerVisibility[layer] = checked;
return current;
});
}, [updateDraft]);
const save = useCallback(async () => {
try {
const accepted = await controller.save(draft);
setDraft(cloneMapViewDocument(accepted));
} catch {
// The controller exposes stable product copy and leaves the current draft intact.
}
}, [controller, draft]);
const resetRenderer = useCallback(() => {
operatorCameraInteraction.current = false;
updateDraft((current) => {
current.view.camera = null;
return current;
});
setRendererGeneration((generation) => generation + 1);
}, [updateDraft]);
const retryRenderer = useCallback(() => {
operatorCameraInteraction.current = false;
setRendererGeneration((generation) => generation + 1);
}, []);
const resetDraft = useCallback(() => {
const defaults = defaultMapViewDocument();
setDraft({
revision: draft.revision,
view: defaults.view,
});
operatorCameraInteraction.current = false;
setRendererGeneration((generation) => generation + 1);
}, [draft.revision]);
const onCameraChange = useCallback((camera: MapCamera) => {
if (!operatorCameraInteraction.current) return;
updateDraft((current) => {
current.view.camera = camera;
return current;
});
}, [updateDraft]);
const toolbarItems = useMemo(() => [
{
id: "settings" as const,
label: "Настройки карты",
icon: "settings" as const,
active: settingsOpen,
onSelect: () => {
setSettingsOpen((open) => !open);
setLayersOpen(false);
},
},
{
id: "layers" as const,
label: "Слои карты",
icon: "grid" as const,
active: layersOpen,
onSelect: () => {
setLayersOpen((open) => !open);
setSettingsOpen(false);
},
},
{
id: "reset-view" as const,
label: "Сбросить камеру",
icon: "target" as const,
onSelect: resetRenderer,
},
], [layersOpen, resetRenderer, settingsOpen]);
const status = runtimePresentation(runtimeState);
const viewBlocking = !viewInitialized && controller.state !== "ready";
const runtimeBlocking = runtimeState.phase === "gateway-unavailable"
|| runtimeState.phase === "render-error";
const mapStyle = {
"--mission-map-height": `${draft.view.mapHeight}px`,
} as CSSProperties;
return (
<div
className="standard-workspace mission-map"
data-map-template={`${mapPageTemplate.id}@${mapPageTemplate.version}`}
data-runtime-phase={runtimeState.phase}
>
<section className="workspace-lead mission-map__lead">
<div>
<span className="section-eyebrow">{definition.eyebrow}</span>
<h2>{definition.title}</h2>
<p>{definition.description}</p>
</div>
<div className="mission-map__lead-actions">
<StatusBadge tone={status.tone}>{status.label}</StatusBadge>
<IconButton
label="Сохранить состояние карты"
disabled={!viewInitialized || controller.state === "saving"}
onClick={() => void save()}
>
<Icon name="save" size={16} />
</IconButton>
</div>
</section>
<section
className="mission-map__stage"
style={mapStyle}
aria-label="Карта Cesium"
onPointerDownCapture={() => {
operatorCameraInteraction.current = true;
}}
onWheelCapture={() => {
operatorCameraInteraction.current = true;
}}
>
{viewInitialized ? (
<CesiumMapRenderer
runtimeConfigUrl="/api/v1/map/runtime-config"
camera={draft.view.camera}
layers={draft.view.layerVisibility}
settings={{
atmosphere_enabled: draft.view.visualSettings.atmosphereEnabled,
lighting_enabled: draft.view.visualSettings.lightingEnabled,
monochrome_enabled: draft.view.visualSettings.monochromeEnabled,
terrain_exaggeration: draft.view.visualSettings.terrainExaggeration,
buildings_maximum_screen_space_error:
draft.view.visualSettings.buildingsMaximumScreenSpaceError,
camera_animation_enabled: draft.view.visualSettings.cameraAnimationEnabled,
}}
cacheIntent={{
enabled: draft.view.cacheIntent.enabled,
no_overwrite: draft.view.cacheIntent.noOverwrite,
}}
rendererGeneration={rendererGeneration}
className="mission-map__cesium"
onRuntimeStateChange={setRuntimeState}
onCameraChange={onCameraChange}
/>
) : null}
<Toolbar<ToolbarAction>
className="mission-map__toolbar"
placement="bottom"
label="Инструменты карты"
minSize={42}
maxSize={54}
lensCount={3}
items={toolbarItems}
/>
{layersOpen ? (
<MapGlassSurface
className="mission-map__layers"
role="dialog"
aria-label="Слои карты"
>
<header className="mission-map__overlay-head">
<div>
<span className="section-eyebrow">СЛОИ КАРТЫ</span>
<strong>Базовая композиция</strong>
</div>
<IconButton label="Закрыть слои" onClick={() => setLayersOpen(false)}>
<Icon name="close" size={15} />
</IconButton>
</header>
<LayerControl
label="Cesium World Imagery"
checked={draft.view.layerVisibility.imagery}
provider={runtimeState.providers.imagery}
onChange={(checked) => updateLayer("imagery", checked)}
/>
<LayerControl
label="Terrain"
checked={draft.view.layerVisibility.terrain}
provider={runtimeState.providers.terrain}
onChange={(checked) => updateLayer("terrain", checked)}
/>
<LayerControl
label="3D-здания"
checked={draft.view.layerVisibility.buildings}
provider={runtimeState.providers.buildings}
onChange={(checked) => updateLayer("buildings", checked)}
/>
<div className="mission-map__layer-row">
<Checker
label="Планетарная сетка"
checked={draft.view.layerVisibility.grid}
onChange={(checked) => updateLayer("grid", checked)}
/>
<StatusBadge tone={draft.view.layerVisibility.grid ? "success" : "neutral"}>
{draft.view.layerVisibility.grid ? "Включена" : "Выключена"}
</StatusBadge>
</div>
</MapGlassSurface>
) : null}
{viewBlocking ? (
<MapGlassSurface
className="mission-map__blocking"
data-error-code={controller.error ?? undefined}
>
<Icon name={controller.state === "error" ? "alert" : "globe"} size={20} />
<strong>
{controller.state === "error"
? "Состояние карты недоступно"
: "Подготавливаем карту"}
</strong>
<span>
{controller.state === "error"
? "Повторите загрузку сохранённой конфигурации."
: "Загружаем сохранённую камеру, слои и параметры отображения."}
</span>
{controller.state === "error" ? (
<Button
size="compact"
variant="secondary"
onClick={() => {
void controller.reload().catch(() => undefined);
}}
>
Повторить
</Button>
) : null}
</MapGlassSurface>
) : runtimeBlocking ? (
<MapGlassSurface
className="mission-map__blocking"
data-error-code={runtimeState.code ?? undefined}
>
<Icon name="alert" size={20} />
<strong>Источник карты недоступен</strong>
<span>
Сохранённое состояние не потеряно. Проверьте локальный контур карты и повторите
подключение.
</span>
<Button size="compact" variant="secondary" onClick={retryRenderer}>
Повторить
</Button>
</MapGlassSurface>
) : null}
</section>
{controller.error && viewInitialized ? (
<div className="mission-map__save-error" role="status">
<StatusBadge tone="danger">Состояние не сохранено</StatusBadge>
<span>{controller.error}</span>
</div>
) : null}
<Window
open={settingsOpen}
title="Настройки карты"
subtitle="Map Page · provider-neutral state"
placement="end"
size="sm"
draggable
className="mission-map-inspector"
onClose={() => setSettingsOpen(false)}
footer={(
<WindowFooterActions>
<Button variant="ghost" onClick={resetDraft}>Сбросить изменения</Button>
<Button
variant="primary"
disabled={!viewInitialized || controller.state === "saving"}
onClick={() => void save()}
>
{controller.state === "saving" ? "Сохраняем…" : "Сохранить"}
</Button>
</WindowFooterActions>
)}
>
<Inspector
singleOpen
sections={createInspectorSections(draft, updateDraft, gatewayHealth)}
openSections={draft.view.inspectorOpenSections.filter(
(section) => editableInspectorSections.has(section),
)}
activeId={draft.view.inspectorOpenSections[0]}
onOpenSectionsChange={(sections) => {
updateDraft((current) => {
current.view.inspectorOpenSections = sections.filter(
(section): section is MapInspectorSection =>
editableInspectorSections.has(section as MapInspectorSection),
).slice(0, 1);
return current;
});
}}
/>
</Window>
</div>
);
}
function LayerControl({
label,
checked,
provider,
onChange,
}: {
label: string;
checked: boolean;
provider: MapProviderState;
onChange: (checked: boolean) => void;
}) {
const presentation = providerPresentation(provider, checked);
return (
<div className="mission-map__layer-row">
<Checker label={label} checked={checked} onChange={onChange} />
<StatusBadge tone={presentation.tone}>{presentation.label}</StatusBadge>
</div>
);
}
function createInspectorSections(
draft: MapViewDocument,
updateDraft: (update: (current: MapViewDocument) => MapViewDocument) => void,
gatewayHealth: ReturnType<typeof useMapGatewayHealth>,
) {
const view = draft.view;
return [
{
id: "base-terrain",
label: "Подложка и terrain",
description: "provider-neutral surface",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Спутниковая подложка"
checked={view.layerVisibility.imagery}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.imagery = checked;
return current;
})}
/>
<Checker
label="Рельеф"
checked={view.layerVisibility.terrain}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.terrain = checked;
return current;
})}
/>
<Checker
label="Монохромная подложка"
checked={view.visualSettings.monochromeEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.monochromeEnabled = checked;
return current;
})}
/>
<RangeControl
label="Высота карты"
value={view.mapHeight}
min={420}
max={1080}
step={20}
formatValue={(value) => `${value} px`}
onChange={(value) => updateDraft((current) => {
current.view.mapHeight = value;
return current;
})}
/>
</div>
),
},
{
id: "atmosphere-light",
label: "Атмосфера и освещение",
description: "scene / color correction",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Атмосфера"
checked={view.visualSettings.atmosphereEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.atmosphereEnabled = checked;
return current;
})}
/>
<Checker
label="Освещение глобуса"
checked={view.visualSettings.lightingEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.lightingEnabled = checked;
return current;
})}
/>
</div>
),
},
{
id: "buildings",
label: "3D здания",
description: "3D Tiles / detail",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Показывать 3D-здания"
checked={view.layerVisibility.buildings}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.buildings = checked;
return current;
})}
/>
<RangeControl
label="Детализация"
value={view.visualSettings.buildingsMaximumScreenSpaceError}
min={1}
max={64}
step={1}
formatValue={(value) => `${value} SSE`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.buildingsMaximumScreenSpaceError = value;
return current;
})}
/>
</div>
),
},
{
id: "grid-lod",
label: "Сетка и LOD",
description: "first adapter control",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Планетарная сетка"
checked={view.layerVisibility.grid}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.grid = checked;
return current;
})}
/>
<RangeControl
label="Масштаб рельефа"
value={view.visualSettings.terrainExaggeration}
min={0.1}
max={8}
step={0.1}
formatValue={(value) => `${value.toFixed(1)}×`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.terrainExaggeration = value;
return current;
})}
/>
</div>
),
},
{
id: "camera",
label: "Анимация камеры",
description: "geodesic spiral survey",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Разрешить анимацию камеры"
checked={view.visualSettings.cameraAnimationEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.cameraAnimationEnabled = checked;
return current;
})}
/>
<ControlRow label="Сохранённая позиция">
<StatusBadge tone={view.camera ? "success" : "neutral"}>
{view.camera ? "Есть" : "Не задана"}
</StatusBadge>
</ControlRow>
</div>
),
},
{
id: "tile-cache",
label: "TileCache",
description: "Platform Map Gateway",
content: (
<div
className="mission-map__inspector-controls"
data-health-code={gatewayHealth.code ?? undefined}
>
<Checker
label="Использовать общий TileCache"
checked={view.cacheIntent.enabled}
onChange={(checked) => updateDraft((current) => {
current.view.cacheIntent.enabled = checked;
return current;
})}
/>
<Checker
label="Не перезаписывать существующие тайлы"
checked={view.cacheIntent.noOverwrite}
disabled={!view.cacheIntent.enabled}
onChange={(checked) => updateDraft((current) => {
current.view.cacheIntent.noOverwrite = checked;
return current;
})}
/>
<CacheHealth health={gatewayHealth} />
</div>
),
},
];
}
function CacheHealth({
health,
}: {
health: ReturnType<typeof useMapGatewayHealth>;
}) {
if (health.state === "loading" || health.state === "idle") {
return (
<ControlRow label="Состояние">
<StatusBadge tone="neutral">Проверяем</StatusBadge>
</ControlRow>
);
}
if (!health.snapshot) {
return (
<ControlRow label="Состояние">
<StatusBadge tone="danger">Недоступен</StatusBadge>
</ControlRow>
);
}
const { cache } = health.snapshot;
return (
<dl className="mission-map__cache-health">
<div>
<dt>Хранилище</dt>
<dd>
<StatusBadge tone={cache.persistent ? "success" : "warning"}>
{cache.persistent ? "Постоянное" : "Временное"}
</StatusBadge>
</dd>
</div>
<div>
<dt>Объекты</dt>
<dd>{cache.entries.toLocaleString("ru-RU")}</dd>
</div>
<div>
<dt>Объём</dt>
<dd>
{formatBytes(cache.bytes)}
{cache.maxBytes === null ? "" : ` / ${formatBytes(cache.maxBytes)}`}
</dd>
</div>
<div>
<dt>Заполнение</dt>
<dd>
<StatusBadge tone={cache.atCapacity ? "warning" : health.state === "stale" ? "warning" : "success"}>
{cache.atCapacity ? "Лимит" : health.state === "stale" ? "Устарело" : "Норма"}
</StatusBadge>
</dd>
</div>
</dl>
);
}
function runtimePresentation(state: MapRuntimeState): {
label: string;
tone: "neutral" | "success" | "warning" | "danger";
} {
if (state.phase === "ready") return { label: "Карта готова", tone: "success" };
if (state.phase === "degraded") return { label: "Частичный режим", tone: "warning" };
if (state.phase === "gateway-unavailable") {
return { label: "Контур карты недоступен", tone: "danger" };
}
if (state.phase === "render-error") return { label: "Ошибка визуализатора", tone: "danger" };
return { label: "Карта загружается", tone: "neutral" };
}
function providerPresentation(
provider: MapProviderState,
visible: boolean,
): {
label: string;
tone: "neutral" | "success" | "warning" | "danger";
} {
if (!visible || provider.phase === "disabled") {
return { label: "Выключен", tone: "neutral" };
}
if (provider.phase === "ready") return { label: "Готов", tone: "success" };
if (provider.phase === "error") return { label: "Недоступен", tone: "danger" };
return { label: "Загрузка", tone: "neutral" };
}
function formatBytes(value: number): string {
if (value < 1024) return `${value} Б`;
const units = ["КБ", "МБ", "ГБ", "ТБ"];
let amount = value / 1024;
let unit = units[0];
for (let index = 1; index < units.length && amount >= 1024; index += 1) {
amount /= 1024;
unit = units[index];
}
return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${unit}`;
}