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
@@ -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}`;
}