feat(map): add operational Cesium workspace
This commit is contained in:
Generated
+31
-3
@@ -10,6 +10,8 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.2.0",
|
"@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/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
@@ -32,25 +34,43 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"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": {
|
"../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": {
|
||||||
"name": "@nodedc/tokens",
|
"name": "@nodedc/tokens",
|
||||||
"version": "0.6.0"
|
"version": "0.6.0"
|
||||||
},
|
},
|
||||||
"../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core": {
|
"../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core": {
|
||||||
"name": "@nodedc/ui-core",
|
"name": "@nodedc/ui-core",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nodedc/tokens": "0.6.0"
|
"@nodedc/tokens": "0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react": {
|
"../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react": {
|
||||||
"name": "@nodedc/ui-react",
|
"name": "@nodedc/ui-react",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@nodedc/ui-core": "0.6.0",
|
"@nodedc/ui-core": "0.7.0",
|
||||||
"lucide-react": "^0.468.0"
|
"lucide-react": "^0.468.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -857,6 +877,14 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/@nodedc/tokens": {
|
||||||
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||||
"link": true
|
"link": true
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.2.0",
|
"@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/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@import "@nodedc/map-cesium-react/widgets.css";
|
||||||
@import "./styles/base.css";
|
@import "./styles/base.css";
|
||||||
@import "./styles/shell.css";
|
@import "./styles/shell.css";
|
||||||
@import "./styles/workspaces.css";
|
@import "./styles/workspaces.css";
|
||||||
@@ -13,3 +14,4 @@
|
|||||||
@import "./styles/environment-settings.css";
|
@import "./styles/environment-settings.css";
|
||||||
@import "./styles/system-telemetry.css";
|
@import "./styles/system-telemetry.css";
|
||||||
@import "./styles/artifact-health.css";
|
@import "./styles/artifact-health.css";
|
||||||
|
@import "./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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
GlassSurface,
|
GlassSurface,
|
||||||
@@ -48,6 +42,7 @@ import { ContourHealthWorkspace } from "./ContourHealthWorkspace";
|
|||||||
import { LaboratoryArchiveWorkspace } from "./laboratory/LaboratoryArchiveWorkspace";
|
import { LaboratoryArchiveWorkspace } from "./laboratory/LaboratoryArchiveWorkspace";
|
||||||
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
|
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
|
||||||
import { NetworkWorkspace } from "./system/NetworkWorkspace";
|
import { NetworkWorkspace } from "./system/NetworkWorkspace";
|
||||||
|
import { WorldMapWorkspace } from "./map/WorldMapWorkspace";
|
||||||
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
||||||
if (status === "active") return "success";
|
if (status === "active") return "success";
|
||||||
if (status === "ready") return "accent";
|
if (status === "ready") return "accent";
|
||||||
@@ -1165,7 +1160,9 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
|||||||
case "cameras":
|
case "cameras":
|
||||||
return <CamerasWorkspace {...props} />;
|
return <CamerasWorkspace {...props} />;
|
||||||
case "map":
|
case "map":
|
||||||
return <MapWorkspace {...props} />;
|
return props.definition.id === "world-map"
|
||||||
|
? <WorldMapWorkspace definition={props.definition} />
|
||||||
|
: <MapWorkspace {...props} />;
|
||||||
case "timeline":
|
case "timeline":
|
||||||
return <TimelineWorkspace {...props} />;
|
return <TimelineWorkspace {...props} />;
|
||||||
case "missions":
|
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}`;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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 { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig, loadEnv, type Plugin } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import wasm from "vite-plugin-wasm";
|
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 }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, process.cwd(), "");
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000";
|
const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [react(), wasm()],
|
plugins: [react(), wasm(), cesiumRuntimeAssets()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@mission-core/plugin-sdk": fileURLToPath(
|
"@mission-core/plugin-sdk": fileURLToPath(
|
||||||
|
|||||||
Reference in New Issue
Block a user