feat(map): add persisted ghost pin sandbox

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 18:29:13 +03:00
parent b91d3b913e
commit 0c3855eac5
10 changed files with 1222 additions and 13 deletions
@@ -8,6 +8,7 @@ import {
import {
CesiumMapRenderer,
initialMapRuntimeState,
type CesiumMapRendererHandle,
type MapCamera,
type MapProviderState,
type MapRuntimeState,
@@ -25,6 +26,7 @@ import {
} from "@nodedc/ui-react";
import { MapSettingsInspector } from "../../components/map/MapSettingsInspector";
import { generateGhostPins } from "../../core/map/ghostPins";
import {
cloneMapViewDocument,
defaultMapViewDocument,
@@ -51,6 +53,7 @@ export function WorldMapWorkspace({
const [layersOpen, setLayersOpen] = useState(false);
const [rendererGeneration, setRendererGeneration] = useState(0);
const operatorCameraInteraction = useRef(false);
const rendererRef = useRef<CesiumMapRendererHandle | null>(null);
const gatewayHealth = useMapGatewayHealth(settingsOpen || layersOpen);
useEffect(() => {
@@ -82,13 +85,54 @@ export function WorldMapWorkspace({
const save = useCallback(async () => {
try {
const accepted = await controller.save(draft);
const next = cloneMapViewDocument(draft);
const snapshot = rendererRef.current?.snapshotGhostPins();
if (snapshot) {
next.view.ghostPins.positions = snapshot.map((pin) => ({
id: pin.id,
label: pin.label,
longitude: pin.longitude,
latitude: pin.latitude,
headingDegrees: pin.heading_degrees,
speedMetersPerSecond: pin.speed_meters_per_second,
}));
}
const accepted = await controller.save(next);
setDraft(cloneMapViewDocument(accepted));
} catch {
// The controller exposes stable product copy and leaves the current draft intact.
}
}, [controller, draft]);
const generatePins = useCallback(() => {
const anchor = rendererRef.current?.getViewAnchor()
?? (draft.view.camera
? {
longitude: draft.view.camera.longitude,
latitude: draft.view.camera.latitude,
}
: null);
if (!anchor) return;
updateDraft((current) => {
current.view.ghostPins.anchor = anchor;
current.view.ghostPins.positions = generateGhostPins({
anchor,
count: current.view.ghostPins.count,
radiusMeters: current.view.ghostPins.radiusMeters,
});
current.view.layerVisibility.targets = true;
return current;
});
}, [draft.view.camera, updateDraft]);
const clearPins = useCallback(() => {
updateDraft((current) => {
current.view.ghostPins.positions = [];
current.view.ghostPins.anchor = null;
return current;
});
}, [updateDraft]);
const retryRenderer = useCallback(() => {
operatorCameraInteraction.current = false;
setRendererGeneration((generation) => generation + 1);
@@ -157,6 +201,7 @@ export function WorldMapWorkspace({
>
{viewInitialized ? (
<CesiumMapRenderer
ref={rendererRef}
runtimeConfigUrl="/api/v1/map/runtime-config"
camera={draft.view.camera}
layers={draft.view.layerVisibility}
@@ -218,6 +263,59 @@ export function WorldMapWorkspace({
enabled: draft.view.cacheIntent.enabled,
no_overwrite: draft.view.cacheIntent.noOverwrite,
}}
ghostPins={{
anchor: draft.view.ghostPins.anchor,
radius_meters: draft.view.ghostPins.radiusMeters,
simulation_enabled: draft.view.ghostPins.simulationEnabled,
pins: draft.view.ghostPins.positions.map((pin) => ({
id: pin.id,
label: pin.label,
longitude: pin.longitude,
latitude: pin.latitude,
heading_degrees: pin.headingDegrees,
speed_meters_per_second: pin.speedMetersPerSecond,
})),
presentation: {
color: draft.view.ghostPins.presentation.color,
opacity: draft.view.ghostPins.presentation.opacity,
stem_height_meters:
draft.view.ghostPins.presentation.stemHeightMeters,
head_size_px: draft.view.ghostPins.presentation.headSizePx,
stem_width_px: draft.view.ghostPins.presentation.stemWidthPx,
outline_color:
draft.view.ghostPins.presentation.outlineColor,
outline_opacity:
draft.view.ghostPins.presentation.outlineOpacity,
outline_width_px:
draft.view.ghostPins.presentation.outlineWidthPx,
label_mode: draft.view.ghostPins.presentation.labelMode,
label_font_weight:
draft.view.ghostPins.presentation.labelFontWeight,
label_size_px:
draft.view.ghostPins.presentation.labelSizePx,
label_color: draft.view.ghostPins.presentation.labelColor,
label_background_color:
draft.view.ghostPins.presentation.labelBackgroundColor,
label_background_opacity:
draft.view.ghostPins.presentation.labelBackgroundOpacity,
label_padding_x:
draft.view.ghostPins.presentation.labelPaddingX,
label_padding_y:
draft.view.ghostPins.presentation.labelPaddingY,
label_offset_x:
draft.view.ghostPins.presentation.labelOffsetX,
label_offset_y:
draft.view.ghostPins.presentation.labelOffsetY,
label_max_length:
draft.view.ghostPins.presentation.labelMaxLength,
label_hide_camera_height_meters:
draft.view.ghostPins.presentation
.labelHideCameraHeightMeters,
target_hide_camera_height_meters:
draft.view.ghostPins.presentation
.targetHideCameraHeightMeters,
},
}}
rendererGeneration={rendererGeneration}
className="mission-map__cesium"
onRuntimeStateChange={setRuntimeState}
@@ -293,6 +391,20 @@ export function WorldMapWorkspace({
{draft.view.layerVisibility.grid ? "Включена" : "Выключена"}
</StatusBadge>
</div>
<div className="mission-map__layer-row">
<Checker
label="Ghost Pin"
checked={draft.view.layerVisibility.targets}
onChange={(checked) => updateLayer("targets", checked)}
/>
<StatusBadge
tone={draft.view.ghostPins.positions.length ? "success" : "neutral"}
>
{draft.view.ghostPins.positions.length
? `${draft.view.ghostPins.positions.length} позиций`
: "Пусто"}
</StatusBadge>
</div>
</MapGlassSurface>
) : null}
@@ -375,6 +487,8 @@ export function WorldMapWorkspace({
draft={draft}
updateDraft={updateDraft}
gatewayHealth={gatewayHealth}
onGenerateGhostPins={generatePins}
onClearGhostPins={clearPins}
/>
</Window>
</div>