feat(map): add operational Cesium workspace
This commit is contained in:
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
@@ -48,6 +42,7 @@ import { ContourHealthWorkspace } from "./ContourHealthWorkspace";
|
||||
import { LaboratoryArchiveWorkspace } from "./laboratory/LaboratoryArchiveWorkspace";
|
||||
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
|
||||
import { NetworkWorkspace } from "./system/NetworkWorkspace";
|
||||
import { WorldMapWorkspace } from "./map/WorldMapWorkspace";
|
||||
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
||||
if (status === "active") return "success";
|
||||
if (status === "ready") return "accent";
|
||||
@@ -1165,7 +1160,9 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
case "cameras":
|
||||
return <CamerasWorkspace {...props} />;
|
||||
case "map":
|
||||
return <MapWorkspace {...props} />;
|
||||
return props.definition.id === "world-map"
|
||||
? <WorldMapWorkspace definition={props.definition} />
|
||||
: <MapWorkspace {...props} />;
|
||||
case "timeline":
|
||||
return <TimelineWorkspace {...props} />;
|
||||
case "missions":
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import {
|
||||
CesiumMapRenderer,
|
||||
initialMapRuntimeState,
|
||||
type MapCamera,
|
||||
type MapProviderState,
|
||||
type MapRuntimeState,
|
||||
} from "@nodedc/map-cesium-react";
|
||||
import { mapPageTemplate } from "@nodedc/page-patterns";
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
ControlRow,
|
||||
Icon,
|
||||
IconButton,
|
||||
Inspector,
|
||||
MapGlassSurface,
|
||||
RangeControl,
|
||||
StatusBadge,
|
||||
Toolbar,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
cloneMapViewDocument,
|
||||
defaultMapViewDocument,
|
||||
type MapInspectorSection,
|
||||
type MapViewDocument,
|
||||
} from "../../core/map/mapView";
|
||||
import { useMapGatewayHealth } from "../../core/map/useMapGatewayHealth";
|
||||
import { useMapView } from "../../core/map/useMapView";
|
||||
import type { WorkspaceDefinition } from "../../productModel";
|
||||
|
||||
const editableInspectorSections = new Set<MapInspectorSection>([
|
||||
"base-terrain",
|
||||
"atmosphere-light",
|
||||
"buildings",
|
||||
"grid-lod",
|
||||
"camera",
|
||||
"tile-cache",
|
||||
]);
|
||||
|
||||
type ToolbarAction = "settings" | "layers" | "reset-view";
|
||||
|
||||
export function WorldMapWorkspace({
|
||||
definition,
|
||||
}: {
|
||||
definition: WorkspaceDefinition;
|
||||
}) {
|
||||
const controller = useMapView();
|
||||
const [draft, setDraft] = useState<MapViewDocument>(
|
||||
() => cloneMapViewDocument(controller.document),
|
||||
);
|
||||
const [viewInitialized, setViewInitialized] = useState(false);
|
||||
const [runtimeState, setRuntimeState] = useState<MapRuntimeState>(
|
||||
initialMapRuntimeState,
|
||||
);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [layersOpen, setLayersOpen] = useState(false);
|
||||
const [rendererGeneration, setRendererGeneration] = useState(0);
|
||||
const operatorCameraInteraction = useRef(false);
|
||||
const gatewayHealth = useMapGatewayHealth(settingsOpen || layersOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (controller.state === "ready" || controller.state === "saving") {
|
||||
setViewInitialized(true);
|
||||
}
|
||||
}, [controller.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewInitialized && controller.state !== "ready") return;
|
||||
setDraft(cloneMapViewDocument(controller.document));
|
||||
}, [controller.document, controller.state, viewInitialized]);
|
||||
|
||||
const updateDraft = useCallback((
|
||||
update: (current: MapViewDocument) => MapViewDocument,
|
||||
) => {
|
||||
setDraft((current) => update(cloneMapViewDocument(current)));
|
||||
}, []);
|
||||
|
||||
const updateLayer = useCallback((
|
||||
layer: keyof MapViewDocument["view"]["layerVisibility"],
|
||||
checked: boolean,
|
||||
) => {
|
||||
updateDraft((current) => {
|
||||
current.view.layerVisibility[layer] = checked;
|
||||
return current;
|
||||
});
|
||||
}, [updateDraft]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
try {
|
||||
const accepted = await controller.save(draft);
|
||||
setDraft(cloneMapViewDocument(accepted));
|
||||
} catch {
|
||||
// The controller exposes stable product copy and leaves the current draft intact.
|
||||
}
|
||||
}, [controller, draft]);
|
||||
|
||||
const resetRenderer = useCallback(() => {
|
||||
operatorCameraInteraction.current = false;
|
||||
updateDraft((current) => {
|
||||
current.view.camera = null;
|
||||
return current;
|
||||
});
|
||||
setRendererGeneration((generation) => generation + 1);
|
||||
}, [updateDraft]);
|
||||
|
||||
const retryRenderer = useCallback(() => {
|
||||
operatorCameraInteraction.current = false;
|
||||
setRendererGeneration((generation) => generation + 1);
|
||||
}, []);
|
||||
|
||||
const resetDraft = useCallback(() => {
|
||||
const defaults = defaultMapViewDocument();
|
||||
setDraft({
|
||||
revision: draft.revision,
|
||||
view: defaults.view,
|
||||
});
|
||||
operatorCameraInteraction.current = false;
|
||||
setRendererGeneration((generation) => generation + 1);
|
||||
}, [draft.revision]);
|
||||
|
||||
const onCameraChange = useCallback((camera: MapCamera) => {
|
||||
if (!operatorCameraInteraction.current) return;
|
||||
updateDraft((current) => {
|
||||
current.view.camera = camera;
|
||||
return current;
|
||||
});
|
||||
}, [updateDraft]);
|
||||
|
||||
const toolbarItems = useMemo(() => [
|
||||
{
|
||||
id: "settings" as const,
|
||||
label: "Настройки карты",
|
||||
icon: "settings" as const,
|
||||
active: settingsOpen,
|
||||
onSelect: () => {
|
||||
setSettingsOpen((open) => !open);
|
||||
setLayersOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "layers" as const,
|
||||
label: "Слои карты",
|
||||
icon: "grid" as const,
|
||||
active: layersOpen,
|
||||
onSelect: () => {
|
||||
setLayersOpen((open) => !open);
|
||||
setSettingsOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "reset-view" as const,
|
||||
label: "Сбросить камеру",
|
||||
icon: "target" as const,
|
||||
onSelect: resetRenderer,
|
||||
},
|
||||
], [layersOpen, resetRenderer, settingsOpen]);
|
||||
|
||||
const status = runtimePresentation(runtimeState);
|
||||
const viewBlocking = !viewInitialized && controller.state !== "ready";
|
||||
const runtimeBlocking = runtimeState.phase === "gateway-unavailable"
|
||||
|| runtimeState.phase === "render-error";
|
||||
const mapStyle = {
|
||||
"--mission-map-height": `${draft.view.mapHeight}px`,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="standard-workspace mission-map"
|
||||
data-map-template={`${mapPageTemplate.id}@${mapPageTemplate.version}`}
|
||||
data-runtime-phase={runtimeState.phase}
|
||||
>
|
||||
<section className="workspace-lead mission-map__lead">
|
||||
<div>
|
||||
<span className="section-eyebrow">{definition.eyebrow}</span>
|
||||
<h2>{definition.title}</h2>
|
||||
<p>{definition.description}</p>
|
||||
</div>
|
||||
<div className="mission-map__lead-actions">
|
||||
<StatusBadge tone={status.tone}>{status.label}</StatusBadge>
|
||||
<IconButton
|
||||
label="Сохранить состояние карты"
|
||||
disabled={!viewInitialized || controller.state === "saving"}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
<Icon name="save" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="mission-map__stage"
|
||||
style={mapStyle}
|
||||
aria-label="Карта Cesium"
|
||||
onPointerDownCapture={() => {
|
||||
operatorCameraInteraction.current = true;
|
||||
}}
|
||||
onWheelCapture={() => {
|
||||
operatorCameraInteraction.current = true;
|
||||
}}
|
||||
>
|
||||
{viewInitialized ? (
|
||||
<CesiumMapRenderer
|
||||
runtimeConfigUrl="/api/v1/map/runtime-config"
|
||||
camera={draft.view.camera}
|
||||
layers={draft.view.layerVisibility}
|
||||
settings={{
|
||||
atmosphere_enabled: draft.view.visualSettings.atmosphereEnabled,
|
||||
lighting_enabled: draft.view.visualSettings.lightingEnabled,
|
||||
monochrome_enabled: draft.view.visualSettings.monochromeEnabled,
|
||||
terrain_exaggeration: draft.view.visualSettings.terrainExaggeration,
|
||||
buildings_maximum_screen_space_error:
|
||||
draft.view.visualSettings.buildingsMaximumScreenSpaceError,
|
||||
camera_animation_enabled: draft.view.visualSettings.cameraAnimationEnabled,
|
||||
}}
|
||||
cacheIntent={{
|
||||
enabled: draft.view.cacheIntent.enabled,
|
||||
no_overwrite: draft.view.cacheIntent.noOverwrite,
|
||||
}}
|
||||
rendererGeneration={rendererGeneration}
|
||||
className="mission-map__cesium"
|
||||
onRuntimeStateChange={setRuntimeState}
|
||||
onCameraChange={onCameraChange}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Toolbar<ToolbarAction>
|
||||
className="mission-map__toolbar"
|
||||
placement="bottom"
|
||||
label="Инструменты карты"
|
||||
minSize={42}
|
||||
maxSize={54}
|
||||
lensCount={3}
|
||||
items={toolbarItems}
|
||||
/>
|
||||
|
||||
{layersOpen ? (
|
||||
<MapGlassSurface
|
||||
className="mission-map__layers"
|
||||
role="dialog"
|
||||
aria-label="Слои карты"
|
||||
>
|
||||
<header className="mission-map__overlay-head">
|
||||
<div>
|
||||
<span className="section-eyebrow">СЛОИ КАРТЫ</span>
|
||||
<strong>Базовая композиция</strong>
|
||||
</div>
|
||||
<IconButton label="Закрыть слои" onClick={() => setLayersOpen(false)}>
|
||||
<Icon name="close" size={15} />
|
||||
</IconButton>
|
||||
</header>
|
||||
<LayerControl
|
||||
label="Cesium World Imagery"
|
||||
checked={draft.view.layerVisibility.imagery}
|
||||
provider={runtimeState.providers.imagery}
|
||||
onChange={(checked) => updateLayer("imagery", checked)}
|
||||
/>
|
||||
<LayerControl
|
||||
label="Terrain"
|
||||
checked={draft.view.layerVisibility.terrain}
|
||||
provider={runtimeState.providers.terrain}
|
||||
onChange={(checked) => updateLayer("terrain", checked)}
|
||||
/>
|
||||
<LayerControl
|
||||
label="3D-здания"
|
||||
checked={draft.view.layerVisibility.buildings}
|
||||
provider={runtimeState.providers.buildings}
|
||||
onChange={(checked) => updateLayer("buildings", checked)}
|
||||
/>
|
||||
<div className="mission-map__layer-row">
|
||||
<Checker
|
||||
label="Планетарная сетка"
|
||||
checked={draft.view.layerVisibility.grid}
|
||||
onChange={(checked) => updateLayer("grid", checked)}
|
||||
/>
|
||||
<StatusBadge tone={draft.view.layerVisibility.grid ? "success" : "neutral"}>
|
||||
{draft.view.layerVisibility.grid ? "Включена" : "Выключена"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</MapGlassSurface>
|
||||
) : null}
|
||||
|
||||
{viewBlocking ? (
|
||||
<MapGlassSurface
|
||||
className="mission-map__blocking"
|
||||
data-error-code={controller.error ?? undefined}
|
||||
>
|
||||
<Icon name={controller.state === "error" ? "alert" : "globe"} size={20} />
|
||||
<strong>
|
||||
{controller.state === "error"
|
||||
? "Состояние карты недоступно"
|
||||
: "Подготавливаем карту"}
|
||||
</strong>
|
||||
<span>
|
||||
{controller.state === "error"
|
||||
? "Повторите загрузку сохранённой конфигурации."
|
||||
: "Загружаем сохранённую камеру, слои и параметры отображения."}
|
||||
</span>
|
||||
{controller.state === "error" ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void controller.reload().catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
) : null}
|
||||
</MapGlassSurface>
|
||||
) : runtimeBlocking ? (
|
||||
<MapGlassSurface
|
||||
className="mission-map__blocking"
|
||||
data-error-code={runtimeState.code ?? undefined}
|
||||
>
|
||||
<Icon name="alert" size={20} />
|
||||
<strong>Источник карты недоступен</strong>
|
||||
<span>
|
||||
Сохранённое состояние не потеряно. Проверьте локальный контур карты и повторите
|
||||
подключение.
|
||||
</span>
|
||||
<Button size="compact" variant="secondary" onClick={retryRenderer}>
|
||||
Повторить
|
||||
</Button>
|
||||
</MapGlassSurface>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{controller.error && viewInitialized ? (
|
||||
<div className="mission-map__save-error" role="status">
|
||||
<StatusBadge tone="danger">Состояние не сохранено</StatusBadge>
|
||||
<span>{controller.error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Window
|
||||
open={settingsOpen}
|
||||
title="Настройки карты"
|
||||
subtitle="Map Page · provider-neutral state"
|
||||
placement="end"
|
||||
size="sm"
|
||||
draggable
|
||||
className="mission-map-inspector"
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button variant="ghost" onClick={resetDraft}>Сбросить изменения</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!viewInitialized || controller.state === "saving"}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{controller.state === "saving" ? "Сохраняем…" : "Сохранить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<Inspector
|
||||
singleOpen
|
||||
sections={createInspectorSections(draft, updateDraft, gatewayHealth)}
|
||||
openSections={draft.view.inspectorOpenSections.filter(
|
||||
(section) => editableInspectorSections.has(section),
|
||||
)}
|
||||
activeId={draft.view.inspectorOpenSections[0]}
|
||||
onOpenSectionsChange={(sections) => {
|
||||
updateDraft((current) => {
|
||||
current.view.inspectorOpenSections = sections.filter(
|
||||
(section): section is MapInspectorSection =>
|
||||
editableInspectorSections.has(section as MapInspectorSection),
|
||||
).slice(0, 1);
|
||||
return current;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Window>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayerControl({
|
||||
label,
|
||||
checked,
|
||||
provider,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
provider: MapProviderState;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
const presentation = providerPresentation(provider, checked);
|
||||
return (
|
||||
<div className="mission-map__layer-row">
|
||||
<Checker label={label} checked={checked} onChange={onChange} />
|
||||
<StatusBadge tone={presentation.tone}>{presentation.label}</StatusBadge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function createInspectorSections(
|
||||
draft: MapViewDocument,
|
||||
updateDraft: (update: (current: MapViewDocument) => MapViewDocument) => void,
|
||||
gatewayHealth: ReturnType<typeof useMapGatewayHealth>,
|
||||
) {
|
||||
const view = draft.view;
|
||||
return [
|
||||
{
|
||||
id: "base-terrain",
|
||||
label: "Подложка и terrain",
|
||||
description: "provider-neutral surface",
|
||||
content: (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<Checker
|
||||
label="Спутниковая подложка"
|
||||
checked={view.layerVisibility.imagery}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.layerVisibility.imagery = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Рельеф"
|
||||
checked={view.layerVisibility.terrain}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.layerVisibility.terrain = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Монохромная подложка"
|
||||
checked={view.visualSettings.monochromeEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.monochromeEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Высота карты"
|
||||
value={view.mapHeight}
|
||||
min={420}
|
||||
max={1080}
|
||||
step={20}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.mapHeight = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "atmosphere-light",
|
||||
label: "Атмосфера и освещение",
|
||||
description: "scene / color correction",
|
||||
content: (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<Checker
|
||||
label="Атмосфера"
|
||||
checked={view.visualSettings.atmosphereEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.atmosphereEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Освещение глобуса"
|
||||
checked={view.visualSettings.lightingEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.lightingEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "buildings",
|
||||
label: "3D здания",
|
||||
description: "3D Tiles / detail",
|
||||
content: (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<Checker
|
||||
label="Показывать 3D-здания"
|
||||
checked={view.layerVisibility.buildings}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.layerVisibility.buildings = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Детализация"
|
||||
value={view.visualSettings.buildingsMaximumScreenSpaceError}
|
||||
min={1}
|
||||
max={64}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} SSE`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.buildingsMaximumScreenSpaceError = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "grid-lod",
|
||||
label: "Сетка и LOD",
|
||||
description: "first adapter control",
|
||||
content: (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<Checker
|
||||
label="Планетарная сетка"
|
||||
checked={view.layerVisibility.grid}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.layerVisibility.grid = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Масштаб рельефа"
|
||||
value={view.visualSettings.terrainExaggeration}
|
||||
min={0.1}
|
||||
max={8}
|
||||
step={0.1}
|
||||
formatValue={(value) => `${value.toFixed(1)}×`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.terrainExaggeration = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "camera",
|
||||
label: "Анимация камеры",
|
||||
description: "geodesic spiral survey",
|
||||
content: (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<Checker
|
||||
label="Разрешить анимацию камеры"
|
||||
checked={view.visualSettings.cameraAnimationEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.cameraAnimationEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Сохранённая позиция">
|
||||
<StatusBadge tone={view.camera ? "success" : "neutral"}>
|
||||
{view.camera ? "Есть" : "Не задана"}
|
||||
</StatusBadge>
|
||||
</ControlRow>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tile-cache",
|
||||
label: "TileCache",
|
||||
description: "Platform Map Gateway",
|
||||
content: (
|
||||
<div
|
||||
className="mission-map__inspector-controls"
|
||||
data-health-code={gatewayHealth.code ?? undefined}
|
||||
>
|
||||
<Checker
|
||||
label="Использовать общий TileCache"
|
||||
checked={view.cacheIntent.enabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.cacheIntent.enabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Не перезаписывать существующие тайлы"
|
||||
checked={view.cacheIntent.noOverwrite}
|
||||
disabled={!view.cacheIntent.enabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.cacheIntent.noOverwrite = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<CacheHealth health={gatewayHealth} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function CacheHealth({
|
||||
health,
|
||||
}: {
|
||||
health: ReturnType<typeof useMapGatewayHealth>;
|
||||
}) {
|
||||
if (health.state === "loading" || health.state === "idle") {
|
||||
return (
|
||||
<ControlRow label="Состояние">
|
||||
<StatusBadge tone="neutral">Проверяем</StatusBadge>
|
||||
</ControlRow>
|
||||
);
|
||||
}
|
||||
if (!health.snapshot) {
|
||||
return (
|
||||
<ControlRow label="Состояние">
|
||||
<StatusBadge tone="danger">Недоступен</StatusBadge>
|
||||
</ControlRow>
|
||||
);
|
||||
}
|
||||
const { cache } = health.snapshot;
|
||||
return (
|
||||
<dl className="mission-map__cache-health">
|
||||
<div>
|
||||
<dt>Хранилище</dt>
|
||||
<dd>
|
||||
<StatusBadge tone={cache.persistent ? "success" : "warning"}>
|
||||
{cache.persistent ? "Постоянное" : "Временное"}
|
||||
</StatusBadge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Объекты</dt>
|
||||
<dd>{cache.entries.toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Объём</dt>
|
||||
<dd>
|
||||
{formatBytes(cache.bytes)}
|
||||
{cache.maxBytes === null ? "" : ` / ${formatBytes(cache.maxBytes)}`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Заполнение</dt>
|
||||
<dd>
|
||||
<StatusBadge tone={cache.atCapacity ? "warning" : health.state === "stale" ? "warning" : "success"}>
|
||||
{cache.atCapacity ? "Лимит" : health.state === "stale" ? "Устарело" : "Норма"}
|
||||
</StatusBadge>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function runtimePresentation(state: MapRuntimeState): {
|
||||
label: string;
|
||||
tone: "neutral" | "success" | "warning" | "danger";
|
||||
} {
|
||||
if (state.phase === "ready") return { label: "Карта готова", tone: "success" };
|
||||
if (state.phase === "degraded") return { label: "Частичный режим", tone: "warning" };
|
||||
if (state.phase === "gateway-unavailable") {
|
||||
return { label: "Контур карты недоступен", tone: "danger" };
|
||||
}
|
||||
if (state.phase === "render-error") return { label: "Ошибка визуализатора", tone: "danger" };
|
||||
return { label: "Карта загружается", tone: "neutral" };
|
||||
}
|
||||
|
||||
function providerPresentation(
|
||||
provider: MapProviderState,
|
||||
visible: boolean,
|
||||
): {
|
||||
label: string;
|
||||
tone: "neutral" | "success" | "warning" | "danger";
|
||||
} {
|
||||
if (!visible || provider.phase === "disabled") {
|
||||
return { label: "Выключен", tone: "neutral" };
|
||||
}
|
||||
if (provider.phase === "ready") return { label: "Готов", tone: "success" };
|
||||
if (provider.phase === "error") return { label: "Недоступен", tone: "danger" };
|
||||
return { label: "Загрузка", tone: "neutral" };
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} Б`;
|
||||
const units = ["КБ", "МБ", "ГБ", "ТБ"];
|
||||
let amount = value / 1024;
|
||||
let unit = units[0];
|
||||
for (let index = 1; index < units.length && amount >= 1024; index += 1) {
|
||||
amount /= 1024;
|
||||
unit = units[index];
|
||||
}
|
||||
return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${unit}`;
|
||||
}
|
||||
Reference in New Issue
Block a user