feat(map): add persisted ghost pin sandbox
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user