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