diff --git a/apps/control-station/src/components/map/GhostPinSettingsPanel.tsx b/apps/control-station/src/components/map/GhostPinSettingsPanel.tsx
new file mode 100644
index 0000000..4505200
--- /dev/null
+++ b/apps/control-station/src/components/map/GhostPinSettingsPanel.tsx
@@ -0,0 +1,360 @@
+import {
+ Button,
+ Checker,
+ ColorField,
+ ControlRow,
+ InspectorSelectField,
+ RangeControl,
+ StatusBadge,
+} from "@nodedc/ui-react";
+
+import type {
+ MapGhostPinLabelMode,
+ MapViewDocument,
+} from "../../core/map/mapView";
+
+export function GhostPinSettingsPanel({
+ draft,
+ updateDraft,
+ onGenerate,
+ onClear,
+}: {
+ draft: MapViewDocument;
+ updateDraft: (
+ update: (current: MapViewDocument) => MapViewDocument,
+ ) => void;
+ onGenerate: () => void;
+ onClear: () => void;
+}) {
+ const configuration = draft.view.ghostPins;
+ const profile = configuration.presentation;
+ return (
+
+
+ Временная симуляция
+
+ `${value}`}
+ onChange={(count) => updateDraft((current) => {
+ current.view.ghostPins.count = count;
+ return current;
+ })}
+ />
+ value >= 1_000
+ ? `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)} км`
+ : `${value} м`}
+ onChange={(radiusMeters) => updateDraft((current) => {
+ current.view.ghostPins.radiusMeters = radiusMeters;
+ return current;
+ })}
+ />
+ updateDraft((current) => {
+ current.view.ghostPins.simulationEnabled = simulationEnabled;
+ return current;
+ })}
+ />
+
+
+ {configuration.positions.length
+ ? `${configuration.positions.length} на карте`
+ : "Не созданы"}
+
+
+
+
+
+ updateDraft((current) => {
+ current.view.layerVisibility.targets = checked;
+ return current;
+ })}
+ />
+
+ updateDraft((current) => {
+ current.view.ghostPins.presentation.color = color;
+ return current;
+ })}
+ />
+
+ `${value}%`}
+ onChange={(value) => updateDraft((current) => {
+ current.view.ghostPins.presentation.opacity = value / 100;
+ return current;
+ })}
+ />
+ `${value} м`}
+ onChange={(stemHeightMeters) => updateDraft((current) => {
+ current.view.ghostPins.presentation.stemHeightMeters = stemHeightMeters;
+ return current;
+ })}
+ />
+ `${value} px`}
+ onChange={(headSizePx) => updateDraft((current) => {
+ current.view.ghostPins.presentation.headSizePx = headSizePx;
+ return current;
+ })}
+ />
+ `${value} px`}
+ onChange={(stemWidthPx) => updateDraft((current) => {
+ current.view.ghostPins.presentation.stemWidthPx = stemWidthPx;
+ return current;
+ })}
+ />
+
+ updateDraft((current) => {
+ current.view.ghostPins.presentation.outlineColor = outlineColor;
+ return current;
+ })}
+ />
+
+ `${value}%`}
+ onChange={(value) => updateDraft((current) => {
+ current.view.ghostPins.presentation.outlineOpacity = value / 100;
+ return current;
+ })}
+ />
+ `${value} px`}
+ onChange={(outlineWidthPx) => updateDraft((current) => {
+ current.view.ghostPins.presentation.outlineWidthPx = outlineWidthPx;
+ return current;
+ })}
+ />
+
+ label="Подпись"
+ value={profile.labelMode}
+ options={[
+ {
+ value: "attributes",
+ label: "Имя",
+ description: "Случайное числовое имя Ghost Pin",
+ },
+ {
+ value: "subject_id",
+ label: "ID",
+ description: "Стабильный синтетический идентификатор",
+ },
+ {
+ value: "none",
+ label: "Нет",
+ description: "Не показывать плашку",
+ },
+ ]}
+ onChange={(labelMode) => updateDraft((current) => {
+ current.view.ghostPins.presentation.labelMode = labelMode;
+ return current;
+ })}
+ />
+ `${value}`}
+ onChange={(labelFontWeight) => updateDraft((current) => {
+ current.view.ghostPins.presentation.labelFontWeight = labelFontWeight;
+ return current;
+ })}
+ />
+ `${value} px`}
+ onChange={(labelSizePx) => updateDraft((current) => {
+ current.view.ghostPins.presentation.labelSizePx = labelSizePx;
+ return current;
+ })}
+ />
+
+ updateDraft((current) => {
+ current.view.ghostPins.presentation.labelColor = labelColor;
+ return current;
+ })}
+ />
+
+
+ updateDraft((current) => {
+ current.view.ghostPins.presentation.labelBackgroundColor =
+ labelBackgroundColor;
+ return current;
+ })}
+ />
+
+ `${value}%`}
+ onChange={(value) => updateDraft((current) => {
+ current.view.ghostPins.presentation.labelBackgroundOpacity =
+ value / 100;
+ return current;
+ })}
+ />
+ `${value} px`}
+ onChange={(labelPaddingX) => updateDraft((current) => {
+ current.view.ghostPins.presentation.labelPaddingX = labelPaddingX;
+ return current;
+ })}
+ />
+ `${value} px`}
+ onChange={(labelPaddingY) => updateDraft((current) => {
+ current.view.ghostPins.presentation.labelPaddingY = labelPaddingY;
+ return current;
+ })}
+ />
+ `${value} px`}
+ onChange={(labelOffsetX) => updateDraft((current) => {
+ current.view.ghostPins.presentation.labelOffsetX = labelOffsetX;
+ return current;
+ })}
+ />
+ `${value} px`}
+ onChange={(labelOffsetY) => updateDraft((current) => {
+ current.view.ghostPins.presentation.labelOffsetY = labelOffsetY;
+ return current;
+ })}
+ />
+ `${value}`}
+ onChange={(labelMaxLength) => updateDraft((current) => {
+ current.view.ghostPins.presentation.labelMaxLength = labelMaxLength;
+ return current;
+ })}
+ />
+ `${Math.round(value / 1_000)} км`}
+ onChange={(labelHideCameraHeightMeters) => updateDraft((current) => {
+ current.view.ghostPins.presentation.labelHideCameraHeightMeters =
+ labelHideCameraHeightMeters;
+ return current;
+ })}
+ />
+ `${Math.round(value / 1_000)} км`}
+ onChange={(targetHideCameraHeightMeters) => updateDraft((current) => {
+ current.view.ghostPins.presentation.targetHideCameraHeightMeters =
+ targetHideCameraHeightMeters;
+ return current;
+ })}
+ />
+
+ );
+}
diff --git a/apps/control-station/src/components/map/MapSettingsInspector.tsx b/apps/control-station/src/components/map/MapSettingsInspector.tsx
index f3cf5f0..65b92ff 100644
--- a/apps/control-station/src/components/map/MapSettingsInspector.tsx
+++ b/apps/control-station/src/components/map/MapSettingsInspector.tsx
@@ -12,11 +12,13 @@ import type {
MapViewDocument,
} from "../../core/map/mapView";
import type { useMapGatewayHealth } from "../../core/map/useMapGatewayHealth";
+import { GhostPinSettingsPanel } from "./GhostPinSettingsPanel";
export const editableMapInspectorSections = new Set([
"base-terrain",
"atmosphere-light",
"buildings",
+ "ghost-pins",
"grid-lod",
"camera",
"tile-cache",
@@ -26,17 +28,27 @@ export function MapSettingsInspector({
draft,
updateDraft,
gatewayHealth,
+ onGenerateGhostPins,
+ onClearGhostPins,
}: {
draft: MapViewDocument;
updateDraft: (
update: (current: MapViewDocument) => MapViewDocument,
) => void;
gatewayHealth: ReturnType;
+ onGenerateGhostPins: () => void;
+ onClearGhostPins: () => void;
}) {
return (
editableMapInspectorSections.has(section),
)}
@@ -60,6 +72,8 @@ function createInspectorSections(
update: (current: MapViewDocument) => MapViewDocument,
) => void,
gatewayHealth: ReturnType,
+ onGenerateGhostPins: () => void,
+ onClearGhostPins: () => void,
) {
const view = draft.view;
const settings = view.visualSettings;
@@ -376,6 +390,19 @@ function createInspectorSections(
),
},
+ {
+ id: "ghost-pins",
+ label: "Гост-пин",
+ description: "временная симуляция",
+ content: (
+
+ ),
+ },
{
id: "grid-lod",
label: "Сетка и LOD",
diff --git a/apps/control-station/src/core/map/ghostPins.ts b/apps/control-station/src/core/map/ghostPins.ts
new file mode 100644
index 0000000..c3d6d08
--- /dev/null
+++ b/apps/control-station/src/core/map/ghostPins.ts
@@ -0,0 +1,92 @@
+import type {
+ MapGeoPoint,
+ MapGhostPin,
+} from "./mapView";
+
+const EARTH_RADIUS_METERS = 6_371_008.8;
+
+export function generateGhostPins({
+ anchor,
+ count,
+ radiusMeters,
+ random = Math.random,
+ timestamp = Date.now(),
+}: {
+ anchor: MapGeoPoint;
+ count: number;
+ radiusMeters: number;
+ random?: () => number;
+ timestamp?: number;
+}): MapGhostPin[] {
+ const safeCount = Math.min(100, Math.max(1, Math.round(count)));
+ const safeRadius = Math.min(100_000, Math.max(100, radiusMeters));
+ const labels = new Set();
+ return Array.from({ length: safeCount }, (_, index) => {
+ const distance = safeRadius * Math.sqrt(clampUnit(random()));
+ const bearing = clampUnit(random()) * 360;
+ const position = destinationPoint(anchor, bearing, distance);
+ const label = nextNumericLabel(labels, random);
+ return {
+ id: [
+ "ghost",
+ Math.max(0, Math.round(timestamp)).toString(36),
+ index.toString(36),
+ Math.floor(clampUnit(random()) * 0xff_ffff).toString(36),
+ ].join("-"),
+ label,
+ ...position,
+ headingDegrees: clampUnit(random()) * 360,
+ speedMetersPerSecond: 3 + clampUnit(random()) * 15,
+ };
+ });
+}
+
+function nextNumericLabel(
+ labels: Set,
+ random: () => number,
+): string {
+ let label = String(100_000 + Math.floor(clampUnit(random()) * 900_000));
+ for (let attempt = 0; labels.has(label) && attempt < 16; attempt += 1) {
+ label = String(100_000 + Math.floor(clampUnit(random()) * 900_000));
+ }
+ if (labels.has(label)) {
+ label = String(100_000 + (labels.size % 900_000));
+ }
+ labels.add(label);
+ return label;
+}
+
+function destinationPoint(
+ origin: MapGeoPoint,
+ headingDegrees: number,
+ distanceMeters: number,
+): MapGeoPoint {
+ const angularDistance = distanceMeters / EARTH_RADIUS_METERS;
+ const bearing = toRadians(headingDegrees);
+ const latitude = toRadians(origin.latitude);
+ const longitude = toRadians(origin.longitude);
+ const nextLatitude = Math.asin(
+ Math.sin(latitude) * Math.cos(angularDistance)
+ + Math.cos(latitude) * Math.sin(angularDistance) * Math.cos(bearing),
+ );
+ const nextLongitude = longitude + Math.atan2(
+ Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latitude),
+ Math.cos(angularDistance) - Math.sin(latitude) * Math.sin(nextLatitude),
+ );
+ return {
+ longitude: ((toDegrees(nextLongitude) + 540) % 360) - 180,
+ latitude: Math.min(90, Math.max(-90, toDegrees(nextLatitude))),
+ };
+}
+
+function clampUnit(value: number): number {
+ return Math.min(0.999_999_999, Math.max(0, Number.isFinite(value) ? value : 0));
+}
+
+function toRadians(value: number): number {
+ return value * Math.PI / 180;
+}
+
+function toDegrees(value: number): number {
+ return value * 180 / Math.PI;
+}
diff --git a/apps/control-station/src/core/map/mapView.ts b/apps/control-station/src/core/map/mapView.ts
index 82ebedc..c66d52c 100644
--- a/apps/control-station/src/core/map/mapView.ts
+++ b/apps/control-station/src/core/map/mapView.ts
@@ -3,6 +3,7 @@ export type MapInspectorSection =
| "atmosphere-light"
| "buildings"
| "targets"
+ | "ghost-pins"
| "grid-lod"
| "camera"
| "tile-cache"
@@ -17,6 +18,53 @@ export interface MapCamera {
roll: number;
}
+export interface MapGeoPoint {
+ longitude: number;
+ latitude: number;
+}
+
+export interface MapGhostPin extends MapGeoPoint {
+ id: string;
+ label: string;
+ headingDegrees: number;
+ speedMetersPerSecond: number;
+}
+
+export type MapGhostPinLabelMode = "subject_id" | "attributes" | "none";
+
+export interface MapGhostPinPresentation {
+ color: string;
+ opacity: number;
+ stemHeightMeters: number;
+ headSizePx: number;
+ stemWidthPx: number;
+ outlineColor: string;
+ outlineOpacity: number;
+ outlineWidthPx: number;
+ labelMode: MapGhostPinLabelMode;
+ labelFontWeight: number;
+ labelSizePx: number;
+ labelColor: string;
+ labelBackgroundColor: string;
+ labelBackgroundOpacity: number;
+ labelPaddingX: number;
+ labelPaddingY: number;
+ labelOffsetX: number;
+ labelOffsetY: number;
+ labelMaxLength: number;
+ labelHideCameraHeightMeters: number;
+ targetHideCameraHeightMeters: number;
+}
+
+export interface MapGhostPinConfiguration {
+ count: number;
+ radiusMeters: number;
+ simulationEnabled: boolean;
+ anchor: MapGeoPoint | null;
+ positions: MapGhostPin[];
+ presentation: MapGhostPinPresentation;
+}
+
export interface MapVisualSettings {
atmosphereEnabled: boolean;
atmosphereHue: number;
@@ -81,6 +129,7 @@ export interface MapView {
cacheIntent: MapCacheIntent;
selectedSubjectId: string | null;
layerVisibility: MapLayerVisibility;
+ ghostPins: MapGhostPinConfiguration;
}
export interface MapViewDocument {
@@ -93,6 +142,7 @@ const inspectorSections = new Set([
"atmosphere-light",
"buildings",
"targets",
+ "ghost-pins",
"grid-lod",
"camera",
"tile-cache",
@@ -167,6 +217,40 @@ export function defaultMapViewDocument(): MapViewDocument {
grid: true,
targets: true,
},
+ ghostPins: defaultGhostPinConfiguration(),
+ },
+ };
+}
+
+export function defaultGhostPinConfiguration(): MapGhostPinConfiguration {
+ return {
+ count: 12,
+ radiusMeters: 5_000,
+ simulationEnabled: false,
+ anchor: null,
+ positions: [],
+ presentation: {
+ color: "#6fb5fb",
+ opacity: 0.9,
+ stemHeightMeters: 1_500,
+ headSizePx: 6,
+ stemWidthPx: 3,
+ outlineColor: "#0c0d12",
+ outlineOpacity: 0.6,
+ outlineWidthPx: 1,
+ labelMode: "attributes",
+ labelFontWeight: 600,
+ labelSizePx: 13,
+ labelColor: "#f5f5f5",
+ labelBackgroundColor: "#0c0d12",
+ labelBackgroundOpacity: 0.86,
+ labelPaddingX: 8,
+ labelPaddingY: 5,
+ labelOffsetX: 15,
+ labelOffsetY: 10,
+ labelMaxLength: 48,
+ labelHideCameraHeightMeters: 70_000,
+ targetHideCameraHeightMeters: 100_000,
},
};
}
@@ -174,7 +258,7 @@ export function defaultMapViewDocument(): MapViewDocument {
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/v2") {
+ if (document.schema_version !== "missioncore.map-view/v3") {
throw new Error("Версия сохранённого состояния карты не поддерживается.");
}
return {
@@ -254,6 +338,57 @@ export function encodeMapViewPut(document: MapViewDocument): unknown {
grid: document.view.layerVisibility.grid,
targets: document.view.layerVisibility.targets,
},
+ ghost_pins: {
+ count: document.view.ghostPins.count,
+ radius_meters: document.view.ghostPins.radiusMeters,
+ simulation_enabled: document.view.ghostPins.simulationEnabled,
+ anchor: document.view.ghostPins.anchor
+ ? {
+ longitude: document.view.ghostPins.anchor.longitude,
+ latitude: document.view.ghostPins.anchor.latitude,
+ }
+ : null,
+ positions: document.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: document.view.ghostPins.presentation.color,
+ opacity: document.view.ghostPins.presentation.opacity,
+ stem_height_meters:
+ document.view.ghostPins.presentation.stemHeightMeters,
+ head_size_px: document.view.ghostPins.presentation.headSizePx,
+ stem_width_px: document.view.ghostPins.presentation.stemWidthPx,
+ outline_color: document.view.ghostPins.presentation.outlineColor,
+ outline_opacity:
+ document.view.ghostPins.presentation.outlineOpacity,
+ outline_width_px:
+ document.view.ghostPins.presentation.outlineWidthPx,
+ label_mode: document.view.ghostPins.presentation.labelMode,
+ label_font_weight:
+ document.view.ghostPins.presentation.labelFontWeight,
+ label_size_px: document.view.ghostPins.presentation.labelSizePx,
+ label_color: document.view.ghostPins.presentation.labelColor,
+ label_background_color:
+ document.view.ghostPins.presentation.labelBackgroundColor,
+ label_background_opacity:
+ document.view.ghostPins.presentation.labelBackgroundOpacity,
+ label_padding_x: document.view.ghostPins.presentation.labelPaddingX,
+ label_padding_y: document.view.ghostPins.presentation.labelPaddingY,
+ label_offset_x: document.view.ghostPins.presentation.labelOffsetX,
+ label_offset_y: document.view.ghostPins.presentation.labelOffsetY,
+ label_max_length:
+ document.view.ghostPins.presentation.labelMaxLength,
+ label_hide_camera_height_meters:
+ document.view.ghostPins.presentation.labelHideCameraHeightMeters,
+ target_hide_camera_height_meters:
+ document.view.ghostPins.presentation.targetHideCameraHeightMeters,
+ },
+ },
},
};
}
@@ -268,6 +403,14 @@ export function cloneMapViewDocument(document: MapViewDocument): MapViewDocument
inspectorOpenSections: [...document.view.inspectorOpenSections],
cacheIntent: { ...document.view.cacheIntent },
layerVisibility: { ...document.view.layerVisibility },
+ ghostPins: {
+ ...document.view.ghostPins,
+ anchor: document.view.ghostPins.anchor
+ ? { ...document.view.ghostPins.anchor }
+ : null,
+ positions: document.view.ghostPins.positions.map((pin) => ({ ...pin })),
+ presentation: { ...document.view.ghostPins.presentation },
+ },
},
};
}
@@ -284,6 +427,7 @@ function decodeMapView(value: unknown): MapView {
"cache_intent",
"selected_subject_id",
"layer_visibility",
+ "ghost_pins",
],
"map view.view",
);
@@ -311,6 +455,247 @@ function decodeMapView(value: unknown): MapView {
cacheIntent: decodeCacheIntent(view.cache_intent),
selectedSubjectId,
layerVisibility: decodeLayerVisibility(view.layer_visibility),
+ ghostPins: decodeGhostPinConfiguration(view.ghost_pins),
+ };
+}
+
+function decodeGhostPinConfiguration(value: unknown): MapGhostPinConfiguration {
+ const configuration = requireRecord(value, "map view.view.ghost_pins");
+ requireExactKeys(
+ configuration,
+ [
+ "count",
+ "radius_meters",
+ "simulation_enabled",
+ "anchor",
+ "positions",
+ "presentation",
+ ],
+ "map view.view.ghost_pins",
+ );
+ const anchor = configuration.anchor === null
+ ? null
+ : decodeGeoPoint(configuration.anchor, "ghost_pins.anchor");
+ const positions = requireArray(
+ configuration.positions,
+ "ghost_pins.positions",
+ );
+ if (positions.length > 100) {
+ throw new Error("ghost_pins.positions превышает лимит.");
+ }
+ return {
+ count: requireInteger(configuration.count, "ghost_pins.count", 1, 100),
+ radiusMeters: requireNumber(
+ configuration.radius_meters,
+ "ghost_pins.radius_meters",
+ 100,
+ 100_000,
+ ),
+ simulationEnabled: requireBoolean(
+ configuration.simulation_enabled,
+ "ghost_pins.simulation_enabled",
+ ),
+ anchor,
+ positions: positions.map((pin, index) => decodeGhostPin(
+ pin,
+ `ghost_pins.positions[${index}]`,
+ )),
+ presentation: decodeGhostPinPresentation(configuration.presentation),
+ };
+}
+
+function decodeGeoPoint(value: unknown, path: string): MapGeoPoint {
+ const point = requireRecord(value, path);
+ requireExactKeys(point, ["longitude", "latitude"], path);
+ return {
+ longitude: requireNumber(point.longitude, `${path}.longitude`, -180, 180),
+ latitude: requireNumber(point.latitude, `${path}.latitude`, -90, 90),
+ };
+}
+
+function decodeGhostPin(value: unknown, path: string): MapGhostPin {
+ const pin = requireRecord(value, path);
+ requireExactKeys(
+ pin,
+ [
+ "id",
+ "label",
+ "longitude",
+ "latitude",
+ "heading_degrees",
+ "speed_meters_per_second",
+ ],
+ path,
+ );
+ if (typeof pin.id !== "string" || !/^ghost-[a-z0-9-]{1,80}$/i.test(pin.id)) {
+ throw new Error(`${path}.id некорректен.`);
+ }
+ if (typeof pin.label !== "string" || !/^\d{1,12}$/.test(pin.label)) {
+ throw new Error(`${path}.label должен содержать только цифры.`);
+ }
+ return {
+ id: pin.id,
+ label: pin.label,
+ longitude: requireNumber(pin.longitude, `${path}.longitude`, -180, 180),
+ latitude: requireNumber(pin.latitude, `${path}.latitude`, -90, 90),
+ headingDegrees: requireNumber(
+ pin.heading_degrees,
+ `${path}.heading_degrees`,
+ 0,
+ 360,
+ ),
+ speedMetersPerSecond: requireNumber(
+ pin.speed_meters_per_second,
+ `${path}.speed_meters_per_second`,
+ 0.1,
+ 100,
+ ),
+ };
+}
+
+function decodeGhostPinPresentation(value: unknown): MapGhostPinPresentation {
+ const profile = requireRecord(value, "ghost_pins.presentation");
+ requireExactKeys(
+ profile,
+ [
+ "color",
+ "opacity",
+ "stem_height_meters",
+ "head_size_px",
+ "stem_width_px",
+ "outline_color",
+ "outline_opacity",
+ "outline_width_px",
+ "label_mode",
+ "label_font_weight",
+ "label_size_px",
+ "label_color",
+ "label_background_color",
+ "label_background_opacity",
+ "label_padding_x",
+ "label_padding_y",
+ "label_offset_x",
+ "label_offset_y",
+ "label_max_length",
+ "label_hide_camera_height_meters",
+ "target_hide_camera_height_meters",
+ ],
+ "ghost_pins.presentation",
+ );
+ const labelMode = profile.label_mode;
+ if (
+ labelMode !== "subject_id"
+ && labelMode !== "attributes"
+ && labelMode !== "none"
+ ) {
+ throw new Error("ghost_pins.presentation.label_mode некорректен.");
+ }
+ return {
+ color: requireHexColor(profile.color, "ghost_pins.presentation.color"),
+ opacity: requireNumber(profile.opacity, "ghost_pins.presentation.opacity", 0, 1),
+ stemHeightMeters: requireNumber(
+ profile.stem_height_meters,
+ "ghost_pins.presentation.stem_height_meters",
+ 100,
+ 10_000,
+ ),
+ headSizePx: requireNumber(
+ profile.head_size_px,
+ "ghost_pins.presentation.head_size_px",
+ 1,
+ 32,
+ ),
+ stemWidthPx: requireNumber(
+ profile.stem_width_px,
+ "ghost_pins.presentation.stem_width_px",
+ 0.25,
+ 12,
+ ),
+ outlineColor: requireHexColor(
+ profile.outline_color,
+ "ghost_pins.presentation.outline_color",
+ ),
+ outlineOpacity: requireNumber(
+ profile.outline_opacity,
+ "ghost_pins.presentation.outline_opacity",
+ 0,
+ 1,
+ ),
+ outlineWidthPx: requireNumber(
+ profile.outline_width_px,
+ "ghost_pins.presentation.outline_width_px",
+ 0,
+ 8,
+ ),
+ labelMode,
+ labelFontWeight: requireInteger(
+ profile.label_font_weight,
+ "ghost_pins.presentation.label_font_weight",
+ 400,
+ 700,
+ ),
+ labelSizePx: requireNumber(
+ profile.label_size_px,
+ "ghost_pins.presentation.label_size_px",
+ 8,
+ 32,
+ ),
+ labelColor: requireHexColor(
+ profile.label_color,
+ "ghost_pins.presentation.label_color",
+ ),
+ labelBackgroundColor: requireHexColor(
+ profile.label_background_color,
+ "ghost_pins.presentation.label_background_color",
+ ),
+ labelBackgroundOpacity: requireNumber(
+ profile.label_background_opacity,
+ "ghost_pins.presentation.label_background_opacity",
+ 0,
+ 1,
+ ),
+ labelPaddingX: requireNumber(
+ profile.label_padding_x,
+ "ghost_pins.presentation.label_padding_x",
+ 0,
+ 32,
+ ),
+ labelPaddingY: requireNumber(
+ profile.label_padding_y,
+ "ghost_pins.presentation.label_padding_y",
+ 0,
+ 32,
+ ),
+ labelOffsetX: requireNumber(
+ profile.label_offset_x,
+ "ghost_pins.presentation.label_offset_x",
+ -100,
+ 100,
+ ),
+ labelOffsetY: requireNumber(
+ profile.label_offset_y,
+ "ghost_pins.presentation.label_offset_y",
+ -100,
+ 100,
+ ),
+ labelMaxLength: requireInteger(
+ profile.label_max_length,
+ "ghost_pins.presentation.label_max_length",
+ 1,
+ 64,
+ ),
+ labelHideCameraHeightMeters: requireNumber(
+ profile.label_hide_camera_height_meters,
+ "ghost_pins.presentation.label_hide_camera_height_meters",
+ 1_000,
+ 500_000,
+ ),
+ targetHideCameraHeightMeters: requireNumber(
+ profile.target_hide_camera_height_meters,
+ "ghost_pins.presentation.target_hide_camera_height_meters",
+ 1_000,
+ 500_000,
+ ),
};
}
diff --git a/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx b/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx
index 39191e5..ec81c26 100644
--- a/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx
+++ b/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx
@@ -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(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 ? (
({
+ 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 ? "Включена" : "Выключена"}
+
+ updateLayer("targets", checked)}
+ />
+
+ {draft.view.ghostPins.positions.length
+ ? `${draft.view.ghostPins.positions.length} позиций`
+ : "Пусто"}
+
+
) : null}
@@ -375,6 +487,8 @@ export function WorldMapWorkspace({
draft={draft}
updateDraft={updateDraft}
gatewayHealth={gatewayHealth}
+ onGenerateGhostPins={generatePins}
+ onClearGhostPins={clearPins}
/>
diff --git a/apps/control-station/test/ghostPins.test.mjs b/apps/control-station/test/ghostPins.test.mjs
new file mode 100644
index 0000000..0da3ae5
--- /dev/null
+++ b/apps/control-station/test/ghostPins.test.mjs
@@ -0,0 +1,50 @@
+import assert from "node:assert/strict";
+import { after, before, test } from "node:test";
+
+import { createServer } from "vite";
+
+let server;
+let ghostPins;
+
+before(async () => {
+ server = await createServer({
+ appType: "custom",
+ logLevel: "silent",
+ server: { middlewareMode: true },
+ });
+ ghostPins = await server.ssrLoadModule("/src/core/map/ghostPins.ts");
+});
+
+after(async () => {
+ await server?.close();
+});
+
+test("ghost pins are generated inside the requested current-view radius", () => {
+ const samples = [0, 0.25, 0.5, 0.75, 0.1, 0.9];
+ let index = 0;
+ const pins = ghostPins.generateGhostPins({
+ anchor: { longitude: 37.618423, latitude: 55.751244 },
+ count: 3,
+ radiusMeters: 1_000,
+ random: () => samples[index++ % samples.length],
+ timestamp: 42,
+ });
+
+ assert.equal(pins.length, 3);
+ assert.equal(new Set(pins.map((pin) => pin.label)).size, 3);
+ assert.ok(pins.every((pin) => /^\d{6}$/.test(pin.label)));
+ assert.ok(pins.every((pin) => pin.id.startsWith("ghost-16-")));
+ assert.ok(pins.every((pin) => pin.speedMetersPerSecond >= 3));
+});
+
+test("numeric pin names remain unique with a degenerate random source", () => {
+ const pins = ghostPins.generateGhostPins({
+ anchor: { longitude: 37.618423, latitude: 55.751244 },
+ count: 4,
+ radiusMeters: 1_000,
+ random: () => 0,
+ timestamp: 42,
+ });
+
+ assert.equal(new Set(pins.map((pin) => pin.label)).size, 4);
+});
diff --git a/apps/control-station/test/mapView.test.mjs b/apps/control-station/test/mapView.test.mjs
index 3660782..c9edc74 100644
--- a/apps/control-station/test/mapView.test.mjs
+++ b/apps/control-station/test/mapView.test.mjs
@@ -22,7 +22,7 @@ after(async () => {
function serverDocument(overrides = {}) {
return {
- schema_version: "missioncore.map-view/v2",
+ schema_version: "missioncore.map-view/v3",
revision: 0,
view: {
camera: {
@@ -89,6 +89,36 @@ function serverDocument(overrides = {}) {
grid: true,
targets: true,
},
+ ghost_pins: {
+ count: 12,
+ radius_meters: 5000,
+ simulation_enabled: false,
+ anchor: null,
+ positions: [],
+ presentation: {
+ color: "#6fb5fb",
+ opacity: 0.9,
+ stem_height_meters: 1500,
+ head_size_px: 6,
+ stem_width_px: 3,
+ outline_color: "#0c0d12",
+ outline_opacity: 0.6,
+ outline_width_px: 1,
+ label_mode: "attributes",
+ label_font_weight: 600,
+ label_size_px: 13,
+ label_color: "#f5f5f5",
+ label_background_color: "#0c0d12",
+ label_background_opacity: 0.86,
+ label_padding_x: 8,
+ label_padding_y: 5,
+ label_offset_x: 15,
+ label_offset_y: 10,
+ label_max_length: 48,
+ label_hide_camera_height_meters: 70000,
+ target_hide_camera_height_meters: 100000,
+ },
+ },
},
...overrides,
};
@@ -103,6 +133,8 @@ test("map view starts from the canonical Foundry camera without a fabricated sub
assert.equal(document.view.selectedSubjectId, null);
assert.equal(document.view.cacheIntent.enabled, true);
assert.equal(document.view.cacheIntent.noOverwrite, true);
+ assert.equal(document.view.ghostPins.count, 12);
+ assert.equal(document.view.ghostPins.positions.length, 0);
});
test("map view decodes and re-encodes the complete versioned contract", () => {
@@ -128,6 +160,7 @@ test("map view decodes and re-encodes the complete versioned contract", () => {
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(encoded.view.ghost_pins.presentation.stem_height_meters, 1500);
assert.equal("schema_version" in encoded, false);
});
diff --git a/docs/21_MISSION_CORE_MAP_PAGE_INTEGRATION_PLAN.md b/docs/21_MISSION_CORE_MAP_PAGE_INTEGRATION_PLAN.md
index d8d426a..3ab0c3b 100644
--- a/docs/21_MISSION_CORE_MAP_PAGE_INTEGRATION_PLAN.md
+++ b/docs/21_MISSION_CORE_MAP_PAGE_INTEGRATION_PLAN.md
@@ -195,7 +195,7 @@ the existing Gateway is reachable and reports a configured state.
## Map state contract
Mission Core stores a versioned map document outside operator environment
-media. The implemented contract is `missioncore.map-view/v2`, with optimistic
+media. The implemented contract is `missioncore.map-view/v3`, with optimistic
revision:
- its default camera and complete visual state are the canonical Foundry
@@ -204,8 +204,8 @@ revision:
imagery remains active;
- atmosphere, fog, sun, shadows, terrain, buildings and the elevated LOD grid
are persisted as provider-neutral values;
-- saved `missioncore.map-view/v1` documents are upgraded in memory and become
- v2 on the next explicit save;
+- saved `missioncore.map-view/v1` and `missioncore.map-view/v2` documents are
+ upgraded in memory and become v3 on the next explicit save;
- provider attribution remains registered in the renderer. The local
experiment may hide only the visual credit overlay through
`MISSIONCORE_MAP_SANDBOX_HIDE_CREDITS=1`; production defaults to visible
@@ -218,6 +218,9 @@ revision:
- cache intent (`enabled`, `noOverwrite`), never server-level cache mode;
- selected stable subject id, or `null`;
- layer visibility;
+- a bounded temporary Ghost Pin sandbox: generation settings, actual
+ coordinates, motion state and provider-neutral `elevated-spike`
+ presentation;
- no credentials, upstream URLs, cache objects or transient Cesium ids.
Resolution order:
@@ -256,6 +259,14 @@ A real Mission Core map subject requires:
Until that contract exists, the basemap remains valid with an honest empty
subject state and the selected-entity section remains absent.
+The temporary `Гост-пин` Inspector section is an explicit visual sandbox and
+does not relax this boundary. It creates numeric synthetic labels around the
+centre of the current Cesium view, can move them inside a bounded radius and
+persists only its declared positions on explicit map save. It never claims
+scanner evidence, `map.moving_object` authority, Data Product provenance or an
+Engine command. `Очистить позиции на карте` removes the complete synthetic
+layer from the draft; the next explicit save makes that removal durable.
+
## State grammar
The UI must distinguish:
@@ -318,6 +329,18 @@ unbounded reconnect loops.
- implement bounded renderer recreation for explicit viewport refresh;
- keep provider credits available for any non-sandbox release.
+### M3.1 — temporary Ghost Pin visual sandbox
+
+- reuse the canonical Foundry moving-object `elevated-spike` profile without
+ reading or mutating Foundry source;
+- generate at most 100 numeric synthetic pins around the actual current view
+ centre;
+- keep one local animation loop and bounce motion back inside its saved radius;
+- save a snapshot of current positions together with camera and map state;
+- expose explicit clear and the complete pin/label presentation controls in the
+ existing map Inspector;
+- keep the feature visibly marked as temporary and non-authoritative.
+
### M4 — secure API/key control plane
- add authenticated Mission Core admin authority;
diff --git a/src/k1link/web/map_view_api.py b/src/k1link/web/map_view_api.py
index de50e32..2530c61 100644
--- a/src/k1link/web/map_view_api.py
+++ b/src/k1link/web/map_view_api.py
@@ -10,12 +10,13 @@ from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, ConfigDict, Field, model_validator
-MAP_VIEW_SCHEMA_VERSION: Literal["missioncore.map-view/v2"] = "missioncore.map-view/v2"
+MAP_VIEW_SCHEMA_VERSION: Literal["missioncore.map-view/v3"] = "missioncore.map-view/v3"
MapInspectorSection = Literal[
"base-terrain",
"atmosphere-light",
"buildings",
"targets",
+ "ghost-pins",
"grid-lod",
"camera",
"tile-cache",
@@ -96,7 +97,76 @@ class MapCacheIntent(StrictMapModel):
no_overwrite: bool = True
-class MapViewContent(StrictMapModel):
+class MapGeoPoint(StrictMapModel):
+ longitude: float = Field(ge=-180.0, le=180.0)
+ latitude: float = Field(ge=-90.0, le=90.0)
+
+
+class MapGhostPin(MapGeoPoint):
+ id: str = Field(
+ min_length=7,
+ max_length=86,
+ pattern=r"^ghost-[A-Za-z0-9-]{1,80}$",
+ )
+ label: str = Field(min_length=1, max_length=12, pattern=r"^\d{1,12}$")
+ heading_degrees: float = Field(ge=0.0, le=360.0)
+ speed_meters_per_second: float = Field(ge=0.1, le=100.0)
+
+
+class MapGhostPinPresentation(StrictMapModel):
+ color: str = Field(default="#6fb5fb", pattern=r"^#[0-9A-Fa-f]{6}$")
+ opacity: float = Field(default=0.9, ge=0.0, le=1.0)
+ stem_height_meters: float = Field(default=1_500.0, ge=100.0, le=10_000.0)
+ head_size_px: float = Field(default=6.0, ge=1.0, le=32.0)
+ stem_width_px: float = Field(default=3.0, ge=0.25, le=12.0)
+ outline_color: str = Field(default="#0c0d12", pattern=r"^#[0-9A-Fa-f]{6}$")
+ outline_opacity: float = Field(default=0.6, ge=0.0, le=1.0)
+ outline_width_px: float = Field(default=1.0, ge=0.0, le=8.0)
+ label_mode: Literal["subject_id", "attributes", "none"] = "attributes"
+ label_font_weight: int = Field(default=600, ge=400, le=700, multiple_of=100)
+ label_size_px: float = Field(default=13.0, ge=8.0, le=32.0)
+ label_color: str = Field(default="#f5f5f5", pattern=r"^#[0-9A-Fa-f]{6}$")
+ label_background_color: str = Field(
+ default="#0c0d12",
+ pattern=r"^#[0-9A-Fa-f]{6}$",
+ )
+ label_background_opacity: float = Field(default=0.86, ge=0.0, le=1.0)
+ label_padding_x: float = Field(default=8.0, ge=0.0, le=32.0)
+ label_padding_y: float = Field(default=5.0, ge=0.0, le=32.0)
+ label_offset_x: float = Field(default=15.0, ge=-100.0, le=100.0)
+ label_offset_y: float = Field(default=10.0, ge=-100.0, le=100.0)
+ label_max_length: int = Field(default=48, ge=1, le=64)
+ label_hide_camera_height_meters: float = Field(
+ default=70_000.0,
+ ge=1_000.0,
+ le=500_000.0,
+ )
+ target_hide_camera_height_meters: float = Field(
+ default=100_000.0,
+ ge=1_000.0,
+ le=500_000.0,
+ )
+
+
+class MapGhostPinConfiguration(StrictMapModel):
+ count: int = Field(default=12, ge=1, le=100)
+ radius_meters: float = Field(default=5_000.0, ge=100.0, le=100_000.0)
+ simulation_enabled: bool = False
+ anchor: MapGeoPoint | None = None
+ positions: list[MapGhostPin] = Field(default_factory=list, max_length=100)
+ presentation: MapGhostPinPresentation = Field(
+ default_factory=MapGhostPinPresentation,
+ )
+
+ @model_validator(mode="after")
+ def validate_unique_positions(self) -> MapGhostPinConfiguration:
+ ids = [pin.id for pin in self.positions]
+ if len(ids) != len(set(ids)):
+ raise ValueError("ghost pin ids must be unique")
+ return self
+
+
+class MapViewContentBase(StrictMapModel):
camera: MapCamera | None = Field(
default_factory=lambda: MapCamera(
longitude=37.618423,
@@ -122,19 +192,35 @@ class MapViewContent(StrictMapModel):
layer_visibility: MapLayerVisibility = Field(default_factory=MapLayerVisibility)
@model_validator(mode="after")
- def validate_selection_section(self) -> MapViewContent:
+ def validate_selection_section(self) -> MapViewContentBase:
if self.selected_subject_id is None and "selection" in self.inspector_open_sections:
raise ValueError("selection section requires a selected stable subject")
return self
+class MapViewContent(MapViewContentBase):
+ ghost_pins: MapGhostPinConfiguration = Field(
+ default_factory=MapGhostPinConfiguration,
+ )
+
+
class MapViewPut(StrictMapModel):
revision: int = Field(ge=0)
view: MapViewContent
class MapViewDocument(MapViewPut):
- schema_version: Literal["missioncore.map-view/v2"] = MAP_VIEW_SCHEMA_VERSION
+ schema_version: Literal["missioncore.map-view/v3"] = MAP_VIEW_SCHEMA_VERSION
+
+
+class LegacyV2MapViewContent(MapViewContentBase):
+ pass
+
+
+class LegacyV2MapViewDocument(StrictMapModel):
+ schema_version: Literal["missioncore.map-view/v2"]
+ revision: int = Field(ge=0)
+ view: LegacyV2MapViewContent
class LegacyMapVisualSettings(StrictMapModel):
@@ -210,6 +296,16 @@ def upgrade_legacy_map_view(document: LegacyMapViewDocument) -> MapViewDocument:
)
+def upgrade_v2_map_view(document: LegacyV2MapViewDocument) -> MapViewDocument:
+ return MapViewDocument(
+ revision=document.revision,
+ view=MapViewContent(
+ **document.view.model_dump(mode="python"),
+ ghost_pins=MapGhostPinConfiguration(),
+ ),
+ )
+
+
class MapViewStore:
def __init__(self, root: Path) -> None:
self.root = root.expanduser().resolve()
@@ -253,6 +349,13 @@ class MapViewStore:
and payload.get("schema_version") == "missioncore.map-view/v1"
):
return upgrade_legacy_map_view(LegacyMapViewDocument.model_validate(payload))
+ if (
+ isinstance(payload, dict)
+ and payload.get("schema_version") == "missioncore.map-view/v2"
+ ):
+ return upgrade_v2_map_view(
+ LegacyV2MapViewDocument.model_validate(payload)
+ )
return MapViewDocument.model_validate(payload)
except (OSError, ValueError, TypeError) as exc:
raise RuntimeError("map view is corrupt") from exc
diff --git a/tests/test_map_view_api.py b/tests/test_map_view_api.py
index ef456a4..5b5551f 100644
--- a/tests/test_map_view_api.py
+++ b/tests/test_map_view_api.py
@@ -18,7 +18,7 @@ from k1link.web.map_view_api import (
def test_default_map_view_matches_the_canonical_foundry_scene_and_cache_policy() -> None:
document = default_map_view()
- assert document.schema_version == "missioncore.map-view/v2"
+ assert document.schema_version == "missioncore.map-view/v3"
assert document.revision == 0
assert document.view.camera is not None
assert document.view.camera.longitude == pytest.approx(37.618423)
@@ -34,6 +34,9 @@ def test_default_map_view_matches_the_canonical_foundry_scene_and_cache_policy()
assert document.view.visual_settings.background_color == "#08090d"
assert document.view.cache_intent.enabled is True
assert document.view.cache_intent.no_overwrite is True
+ assert document.view.ghost_pins.count == 12
+ assert document.view.ghost_pins.positions == []
+ assert document.view.ghost_pins.presentation.stem_height_meters == 1_500
def test_map_view_round_trip_uses_optimistic_revision(tmp_path: Path) -> None:
@@ -143,10 +146,29 @@ def test_v1_map_view_is_upgraded_without_rewriting_the_source_file(
upgraded = store.read()
- assert upgraded.schema_version == "missioncore.map-view/v2"
+ assert upgraded.schema_version == "missioncore.map-view/v3"
assert upgraded.revision == 7
assert upgraded.view.camera is None
assert upgraded.view.visual_settings.atmosphere_enabled is True
assert upgraded.view.visual_settings.sun_enabled is False
assert upgraded.view.visual_settings.monochrome_enabled is True
+ assert upgraded.view.ghost_pins.positions == []
assert json.loads(store.document_path.read_text(encoding="utf-8")) == legacy
+
+
+def test_v2_map_view_receives_the_default_ghost_pin_layer_without_rewrite(
+ tmp_path: Path,
+) -> None:
+ store = MapViewStore(tmp_path / "map-view")
+ store.root.mkdir(parents=True)
+ current = default_map_view().model_dump(mode="json")
+ current["schema_version"] = "missioncore.map-view/v2"
+ current["view"].pop("ghost_pins")
+ store.document_path.write_text(json.dumps(current), encoding="utf-8")
+
+ upgraded = store.read()
+
+ assert upgraded.schema_version == "missioncore.map-view/v3"
+ assert upgraded.view.ghost_pins.count == 12
+ assert upgraded.view.ghost_pins.presentation.label_mode == "attributes"
+ assert json.loads(store.document_path.read_text(encoding="utf-8")) == current