From 8e3710b49379d303d3bc7db0fde9d57cebfeec91 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 29 Jul 2026 16:45:21 +0300 Subject: [PATCH] feat(map): add operational Cesium workspace --- apps/control-station/package-lock.json | 34 +- apps/control-station/package.json | 2 + apps/control-station/src/core/map/mapView.ts | 359 +++++++++ .../src/core/map/useMapGatewayHealth.ts | 153 ++++ .../src/core/map/useMapView.ts | 118 +++ apps/control-station/src/styles.css | 2 + apps/control-station/src/styles/map.css | 187 +++++ .../src/workspaces/Workspaces.tsx | 13 +- .../src/workspaces/map/WorldMapWorkspace.tsx | 691 ++++++++++++++++++ apps/control-station/test/mapView.test.mjs | 107 +++ apps/control-station/vite.config.ts | 66 +- 11 files changed, 1719 insertions(+), 13 deletions(-) create mode 100644 apps/control-station/src/core/map/mapView.ts create mode 100644 apps/control-station/src/core/map/useMapGatewayHealth.ts create mode 100644 apps/control-station/src/core/map/useMapView.ts create mode 100644 apps/control-station/src/styles/map.css create mode 100644 apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx create mode 100644 apps/control-station/test/mapView.test.mjs diff --git a/apps/control-station/package-lock.json b/apps/control-station/package-lock.json index 7e0587f..1671467 100644 --- a/apps/control-station/package-lock.json +++ b/apps/control-station/package-lock.json @@ -10,6 +10,8 @@ "hasInstallScript": true, "dependencies": { "@noble/hashes": "^2.2.0", + "@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react", + "@nodedc/page-patterns": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/page-patterns", "@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", @@ -32,25 +34,43 @@ "node": "^20.19.0 || >=22.12.0" } }, + "../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react": { + "name": "@nodedc/map-cesium-react", + "version": "0.1.0", + "dependencies": { + "cesium": "1.143.0" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "react": "^19.1.0" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "../../../NODEDC_DESIGN_GUIDELINE/packages/page-patterns": { + "name": "@nodedc/page-patterns", + "version": "0.1.0" + }, "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": { "name": "@nodedc/tokens", "version": "0.6.0" }, "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core": { "name": "@nodedc/ui-core", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@nodedc/tokens": "0.6.0" } }, "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react": { "name": "@nodedc/ui-react", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@nodedc/ui-core": "0.6.0", + "@nodedc/ui-core": "0.7.0", "lucide-react": "^0.468.0" }, "devDependencies": { @@ -857,6 +877,14 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodedc/map-cesium-react": { + "resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react", + "link": true + }, + "node_modules/@nodedc/page-patterns": { + "resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/page-patterns", + "link": true + }, "node_modules/@nodedc/tokens": { "resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", "link": true diff --git a/apps/control-station/package.json b/apps/control-station/package.json index a834759..e394f3e 100644 --- a/apps/control-station/package.json +++ b/apps/control-station/package.json @@ -13,6 +13,8 @@ }, "dependencies": { "@noble/hashes": "^2.2.0", + "@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react", + "@nodedc/page-patterns": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/page-patterns", "@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", diff --git a/apps/control-station/src/core/map/mapView.ts b/apps/control-station/src/core/map/mapView.ts new file mode 100644 index 0000000..26b3c0c --- /dev/null +++ b/apps/control-station/src/core/map/mapView.ts @@ -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([ + "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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${path} должен быть объектом.`); + } + return value as Record; +} + +function requireArray(value: unknown, path: string): unknown[] { + if (!Array.isArray(value)) { + throw new Error(`${path} должен быть массивом.`); + } + return value; +} + +function requireExactKeys( + record: Record, + 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; +} diff --git a/apps/control-station/src/core/map/useMapGatewayHealth.ts b/apps/control-station/src/core/map/useMapGatewayHealth.ts new file mode 100644 index 0000000..80bb87f --- /dev/null +++ b/apps/control-station/src/core/map/useMapGatewayHealth.ts @@ -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({ + 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; + 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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${path} is invalid`); + } + return value as Record; +} + +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; +} diff --git a/apps/control-station/src/core/map/useMapView.ts b/apps/control-station/src/core/map/useMapView.ts new file mode 100644 index 0000000..c1ac555 --- /dev/null +++ b/apps/control-station/src/core/map/useMapView.ts @@ -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; + reload: () => Promise; +} + +async function responseError(response: Response, fallback: string): Promise { + 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 { + 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(defaultMapViewDocument); + const [state, setState] = useState("loading"); + const [error, setError] = useState(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 }; +} diff --git a/apps/control-station/src/styles.css b/apps/control-station/src/styles.css index 46d2bee..e218f4a 100644 --- a/apps/control-station/src/styles.css +++ b/apps/control-station/src/styles.css @@ -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"; diff --git a/apps/control-station/src/styles/map.css b/apps/control-station/src/styles/map.css new file mode 100644 index 0000000..2b1b9d5 --- /dev/null +++ b/apps/control-station/src/styles/map.css @@ -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); + } +} diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index 41a98e9..642606a 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -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 ; case "map": - return ; + return props.definition.id === "world-map" + ? + : ; case "timeline": return ; case "missions": diff --git a/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx b/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx new file mode 100644 index 0000000..6f628f1 --- /dev/null +++ b/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx @@ -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([ + "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( + () => cloneMapViewDocument(controller.document), + ); + const [viewInitialized, setViewInitialized] = useState(false); + const [runtimeState, setRuntimeState] = useState( + 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 ( +
+
+
+ {definition.eyebrow} +

{definition.title}

+

{definition.description}

+
+
+ {status.label} + void save()} + > + + +
+
+ +
{ + operatorCameraInteraction.current = true; + }} + onWheelCapture={() => { + operatorCameraInteraction.current = true; + }} + > + {viewInitialized ? ( + + ) : null} + + + className="mission-map__toolbar" + placement="bottom" + label="Инструменты карты" + minSize={42} + maxSize={54} + lensCount={3} + items={toolbarItems} + /> + + {layersOpen ? ( + +
+
+ СЛОИ КАРТЫ + Базовая композиция +
+ setLayersOpen(false)}> + + +
+ updateLayer("imagery", checked)} + /> + updateLayer("terrain", checked)} + /> + updateLayer("buildings", checked)} + /> +
+ updateLayer("grid", checked)} + /> + + {draft.view.layerVisibility.grid ? "Включена" : "Выключена"} + +
+
+ ) : null} + + {viewBlocking ? ( + + + + {controller.state === "error" + ? "Состояние карты недоступно" + : "Подготавливаем карту"} + + + {controller.state === "error" + ? "Повторите загрузку сохранённой конфигурации." + : "Загружаем сохранённую камеру, слои и параметры отображения."} + + {controller.state === "error" ? ( + + ) : null} + + ) : runtimeBlocking ? ( + + + Источник карты недоступен + + Сохранённое состояние не потеряно. Проверьте локальный контур карты и повторите + подключение. + + + + ) : null} +
+ + {controller.error && viewInitialized ? ( +
+ Состояние не сохранено + {controller.error} +
+ ) : null} + + setSettingsOpen(false)} + footer={( + + + + + )} + > + 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; + }); + }} + /> + +
+ ); +} + +function LayerControl({ + label, + checked, + provider, + onChange, +}: { + label: string; + checked: boolean; + provider: MapProviderState; + onChange: (checked: boolean) => void; +}) { + const presentation = providerPresentation(provider, checked); + return ( +
+ + {presentation.label} +
+ ); +} + +function createInspectorSections( + draft: MapViewDocument, + updateDraft: (update: (current: MapViewDocument) => MapViewDocument) => void, + gatewayHealth: ReturnType, +) { + const view = draft.view; + return [ + { + id: "base-terrain", + label: "Подложка и terrain", + description: "provider-neutral surface", + content: ( +
+ updateDraft((current) => { + current.view.layerVisibility.imagery = checked; + return current; + })} + /> + updateDraft((current) => { + current.view.layerVisibility.terrain = checked; + return current; + })} + /> + updateDraft((current) => { + current.view.visualSettings.monochromeEnabled = checked; + return current; + })} + /> + `${value} px`} + onChange={(value) => updateDraft((current) => { + current.view.mapHeight = value; + return current; + })} + /> +
+ ), + }, + { + id: "atmosphere-light", + label: "Атмосфера и освещение", + description: "scene / color correction", + content: ( +
+ updateDraft((current) => { + current.view.visualSettings.atmosphereEnabled = checked; + return current; + })} + /> + updateDraft((current) => { + current.view.visualSettings.lightingEnabled = checked; + return current; + })} + /> +
+ ), + }, + { + id: "buildings", + label: "3D здания", + description: "3D Tiles / detail", + content: ( +
+ updateDraft((current) => { + current.view.layerVisibility.buildings = checked; + return current; + })} + /> + `${value} SSE`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.buildingsMaximumScreenSpaceError = value; + return current; + })} + /> +
+ ), + }, + { + id: "grid-lod", + label: "Сетка и LOD", + description: "first adapter control", + content: ( +
+ updateDraft((current) => { + current.view.layerVisibility.grid = checked; + return current; + })} + /> + `${value.toFixed(1)}×`} + onChange={(value) => updateDraft((current) => { + current.view.visualSettings.terrainExaggeration = value; + return current; + })} + /> +
+ ), + }, + { + id: "camera", + label: "Анимация камеры", + description: "geodesic spiral survey", + content: ( +
+ updateDraft((current) => { + current.view.visualSettings.cameraAnimationEnabled = checked; + return current; + })} + /> + + + {view.camera ? "Есть" : "Не задана"} + + +
+ ), + }, + { + id: "tile-cache", + label: "TileCache", + description: "Platform Map Gateway", + content: ( +
+ updateDraft((current) => { + current.view.cacheIntent.enabled = checked; + return current; + })} + /> + updateDraft((current) => { + current.view.cacheIntent.noOverwrite = checked; + return current; + })} + /> + +
+ ), + }, + ]; +} + +function CacheHealth({ + health, +}: { + health: ReturnType; +}) { + if (health.state === "loading" || health.state === "idle") { + return ( + + Проверяем + + ); + } + if (!health.snapshot) { + return ( + + Недоступен + + ); + } + const { cache } = health.snapshot; + return ( +
+
+
Хранилище
+
+ + {cache.persistent ? "Постоянное" : "Временное"} + +
+
+
+
Объекты
+
{cache.entries.toLocaleString("ru-RU")}
+
+
+
Объём
+
+ {formatBytes(cache.bytes)} + {cache.maxBytes === null ? "" : ` / ${formatBytes(cache.maxBytes)}`} +
+
+
+
Заполнение
+
+ + {cache.atCapacity ? "Лимит" : health.state === "stale" ? "Устарело" : "Норма"} + +
+
+
+ ); +} + +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}`; +} diff --git a/apps/control-station/test/mapView.test.mjs b/apps/control-station/test/mapView.test.mjs new file mode 100644 index 0000000..cf0f70c --- /dev/null +++ b/apps/control-station/test/mapView.test.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { createServer } from "vite"; + +let server; +let mapView; + +before(async () => { + server = await createServer({ + appType: "custom", + logLevel: "silent", + server: { middlewareMode: true }, + }); + mapView = await server.ssrLoadModule("/src/core/map/mapView.ts"); +}); + +after(async () => { + await server?.close(); +}); + +function serverDocument(overrides = {}) { + return { + schema_version: "missioncore.map-view/v1", + revision: 0, + view: { + camera: null, + visual_settings: { + atmosphere_enabled: true, + lighting_enabled: true, + monochrome_enabled: false, + terrain_exaggeration: 1, + buildings_maximum_screen_space_error: 16, + camera_animation_enabled: true, + }, + map_height: 720, + inspector_open_sections: [], + cache_intent: { + enabled: true, + no_overwrite: true, + }, + selected_subject_id: null, + layer_visibility: { + imagery: true, + terrain: true, + buildings: true, + grid: false, + targets: true, + }, + }, + ...overrides, + }; +} + +test("map view starts without a fabricated global camera or subject", () => { + const document = mapView.defaultMapViewDocument(); + assert.equal(document.view.camera, null); + assert.equal(document.view.selectedSubjectId, null); + assert.equal(document.view.cacheIntent.enabled, true); + assert.equal(document.view.cacheIntent.noOverwrite, true); +}); + +test("map view decodes and re-encodes the complete versioned contract", () => { + const decoded = mapView.decodeMapViewDocument(serverDocument({ + revision: 3, + view: { + ...serverDocument().view, + camera: { + longitude: 37.6176, + latitude: 55.7558, + height: 2500, + heading: 12, + pitch: -48, + roll: 0, + }, + inspector_open_sections: ["camera"], + }, + })); + const encoded = mapView.encodeMapViewPut(decoded); + + assert.equal(decoded.revision, 3); + assert.equal(decoded.view.camera.longitude, 37.6176); + assert.equal(decoded.view.inspectorOpenSections[0], "camera"); + assert.equal(encoded.view.camera.latitude, 55.7558); + assert.equal(encoded.view.cache_intent.no_overwrite, true); + assert.equal("schema_version" in encoded, false); +}); + +test("map view fails closed on credentials, runtime URLs and unbound selection", () => { + assert.throws( + () => mapView.decodeMapViewDocument(serverDocument({ + view: { + ...serverDocument().view, + token: "forbidden", + }, + })), + ); + assert.throws( + () => mapView.decodeMapViewDocument(serverDocument({ + view: { + ...serverDocument().view, + inspector_open_sections: ["selection"], + }, + })), + /stable subject id|стабильный subject id/i, + ); +}); diff --git a/apps/control-station/vite.config.ts b/apps/control-station/vite.config.ts index 6bc1df8..f3f911b 100644 --- a/apps/control-station/vite.config.ts +++ b/apps/control-station/vite.config.ts @@ -1,15 +1,77 @@ +import { createReadStream, cpSync, existsSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; -import { defineConfig, loadEnv } from "vite"; +import { defineConfig, loadEnv, type Plugin } from "vite"; import react from "@vitejs/plugin-react"; import wasm from "vite-plugin-wasm"; +const mapAdapterEntry = fileURLToPath(import.meta.resolve("@nodedc/map-cesium-react")); +const cesiumPackageJson = createRequire(mapAdapterEntry).resolve("cesium/package.json"); +const cesiumBuildRoot = resolve( + dirname(cesiumPackageJson), + "Build", + "Cesium", +); +const cesiumRuntimeDirectories = ["Assets", "ThirdParty", "Widgets", "Workers"] as const; + +function cesiumRuntimeAssets(): Plugin { + return { + name: "mission-core-cesium-runtime-assets", + config: () => ({ + define: { + CESIUM_BASE_URL: JSON.stringify("/cesium"), + }, + }), + configureServer(server) { + server.middlewares.use("/cesium", (request, response, next) => { + const pathname = new URL(request.url ?? "/", "http://localhost").pathname; + const target = resolve(cesiumBuildRoot, `.${decodeURIComponent(pathname)}`); + if (!target.startsWith(`${cesiumBuildRoot}${sep}`) || !existsSync(target)) { + next(); + return; + } + const stats = statSync(target); + if (!stats.isFile()) { + next(); + return; + } + if (target.endsWith(".js")) response.setHeader("Content-Type", "text/javascript"); + else if (target.endsWith(".json")) response.setHeader("Content-Type", "application/json"); + else if (target.endsWith(".css")) response.setHeader("Content-Type", "text/css"); + else if (target.endsWith(".wasm")) response.setHeader("Content-Type", "application/wasm"); + else if (target.endsWith(".png")) response.setHeader("Content-Type", "image/png"); + else if (target.endsWith(".svg")) response.setHeader("Content-Type", "image/svg+xml"); + else if (target.endsWith(".jpg") || target.endsWith(".jpeg")) { + response.setHeader("Content-Type", "image/jpeg"); + } + createReadStream(target).pipe(response); + }); + }, + writeBundle(options) { + const outputDirectory = resolve( + process.cwd(), + typeof options.dir === "string" ? options.dir : "dist", + "cesium", + ); + cesiumRuntimeDirectories.forEach((directory) => { + cpSync( + resolve(cesiumBuildRoot, directory), + resolve(outputDirectory, directory), + { recursive: true }, + ); + }); + }, + }; +} + export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000"; return { - plugins: [react(), wasm()], + plugins: [react(), wasm(), cesiumRuntimeAssets()], resolve: { alias: { "@mission-core/plugin-sdk": fileURLToPath(