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
@@ -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 (
<div className="mission-map__inspector-controls">
<ControlRow label="Контур">
<StatusBadge tone="warning">Временная симуляция</StatusBadge>
</ControlRow>
<RangeControl
label="Количество пинов"
value={configuration.count}
min={1}
max={100}
step={1}
formatValue={(value) => `${value}`}
onChange={(count) => updateDraft((current) => {
current.view.ghostPins.count = count;
return current;
})}
/>
<RangeControl
label="Радиус от центра view"
value={configuration.radiusMeters}
min={100}
max={100_000}
step={100}
formatValue={(value) => 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;
})}
/>
<Checker
label="Симуляция движения"
checked={configuration.simulationEnabled}
onChange={(simulationEnabled) => updateDraft((current) => {
current.view.ghostPins.simulationEnabled = simulationEnabled;
return current;
})}
/>
<ControlRow label="Позиции">
<StatusBadge tone={configuration.positions.length ? "success" : "neutral"}>
{configuration.positions.length
? `${configuration.positions.length} на карте`
: "Не созданы"}
</StatusBadge>
</ControlRow>
<Button variant="secondary" size="compact" onClick={onGenerate}>
Разместить в текущем view
</Button>
<Button
variant="ghost"
size="compact"
disabled={configuration.positions.length === 0}
onClick={onClear}
>
Очистить позиции на карте
</Button>
<Checker
label="Показывать Ghost Pin"
checked={draft.view.layerVisibility.targets}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.targets = checked;
return current;
})}
/>
<ControlRow label="Цвет пина">
<ColorField
label="Цвет Ghost Pin"
value={profile.color}
onChange={(color) => updateDraft((current) => {
current.view.ghostPins.presentation.color = color;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Прозрачность пина"
value={Math.round(profile.opacity * 100)}
min={0}
max={100}
step={1}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.ghostPins.presentation.opacity = value / 100;
return current;
})}
/>
<RangeControl
label="Высота таргета"
value={profile.stemHeightMeters}
min={100}
max={10_000}
step={50}
formatValue={(value) => `${value} м`}
onChange={(stemHeightMeters) => updateDraft((current) => {
current.view.ghostPins.presentation.stemHeightMeters = stemHeightMeters;
return current;
})}
/>
<RangeControl
label="Размер головки"
value={profile.headSizePx}
min={1}
max={32}
step={1}
formatValue={(value) => `${value} px`}
onChange={(headSizePx) => updateDraft((current) => {
current.view.ghostPins.presentation.headSizePx = headSizePx;
return current;
})}
/>
<RangeControl
label="Толщина стержня"
value={profile.stemWidthPx}
min={0.25}
max={12}
step={0.25}
formatValue={(value) => `${value} px`}
onChange={(stemWidthPx) => updateDraft((current) => {
current.view.ghostPins.presentation.stemWidthPx = stemWidthPx;
return current;
})}
/>
<ControlRow label="Цвет обводки">
<ColorField
label="Цвет обводки головки"
value={profile.outlineColor}
onChange={(outlineColor) => updateDraft((current) => {
current.view.ghostPins.presentation.outlineColor = outlineColor;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Прозрачность обводки"
value={Math.round(profile.outlineOpacity * 100)}
min={0}
max={100}
step={1}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.ghostPins.presentation.outlineOpacity = value / 100;
return current;
})}
/>
<RangeControl
label="Толщина обводки"
value={profile.outlineWidthPx}
min={0}
max={8}
step={0.5}
formatValue={(value) => `${value} px`}
onChange={(outlineWidthPx) => updateDraft((current) => {
current.view.ghostPins.presentation.outlineWidthPx = outlineWidthPx;
return current;
})}
/>
<InspectorSelectField<MapGhostPinLabelMode>
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;
})}
/>
<RangeControl
label="Насыщенность шрифта"
value={profile.labelFontWeight}
min={400}
max={700}
step={100}
formatValue={(value) => `${value}`}
onChange={(labelFontWeight) => updateDraft((current) => {
current.view.ghostPins.presentation.labelFontWeight = labelFontWeight;
return current;
})}
/>
<RangeControl
label="Размер подписи"
value={profile.labelSizePx}
min={8}
max={32}
step={1}
formatValue={(value) => `${value} px`}
onChange={(labelSizePx) => updateDraft((current) => {
current.view.ghostPins.presentation.labelSizePx = labelSizePx;
return current;
})}
/>
<ControlRow label="Цвет подписи">
<ColorField
label="Цвет текста подписи"
value={profile.labelColor}
onChange={(labelColor) => updateDraft((current) => {
current.view.ghostPins.presentation.labelColor = labelColor;
return current;
})}
/>
</ControlRow>
<ControlRow label="Фон плашки">
<ColorField
label="Цвет фона подписи"
value={profile.labelBackgroundColor}
onChange={(labelBackgroundColor) => updateDraft((current) => {
current.view.ghostPins.presentation.labelBackgroundColor =
labelBackgroundColor;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Прозрачность плашки"
value={Math.round(profile.labelBackgroundOpacity * 100)}
min={0}
max={100}
step={1}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.ghostPins.presentation.labelBackgroundOpacity =
value / 100;
return current;
})}
/>
<RangeControl
label="Отступ плашки X"
value={profile.labelPaddingX}
min={0}
max={32}
step={1}
formatValue={(value) => `${value} px`}
onChange={(labelPaddingX) => updateDraft((current) => {
current.view.ghostPins.presentation.labelPaddingX = labelPaddingX;
return current;
})}
/>
<RangeControl
label="Отступ плашки Y"
value={profile.labelPaddingY}
min={0}
max={32}
step={1}
formatValue={(value) => `${value} px`}
onChange={(labelPaddingY) => updateDraft((current) => {
current.view.ghostPins.presentation.labelPaddingY = labelPaddingY;
return current;
})}
/>
<RangeControl
label="Смещение подписи X"
value={profile.labelOffsetX}
min={-100}
max={100}
step={1}
formatValue={(value) => `${value} px`}
onChange={(labelOffsetX) => updateDraft((current) => {
current.view.ghostPins.presentation.labelOffsetX = labelOffsetX;
return current;
})}
/>
<RangeControl
label="Смещение подписи Y"
value={profile.labelOffsetY}
min={-100}
max={100}
step={1}
formatValue={(value) => `${value} px`}
onChange={(labelOffsetY) => updateDraft((current) => {
current.view.ghostPins.presentation.labelOffsetY = labelOffsetY;
return current;
})}
/>
<RangeControl
label="Максимальная длина подписи"
value={profile.labelMaxLength}
min={1}
max={64}
step={1}
formatValue={(value) => `${value}`}
onChange={(labelMaxLength) => updateDraft((current) => {
current.view.ghostPins.presentation.labelMaxLength = labelMaxLength;
return current;
})}
/>
<RangeControl
label="Скрывать подпись выше"
value={profile.labelHideCameraHeightMeters}
min={1_000}
max={500_000}
step={1_000}
formatValue={(value) => `${Math.round(value / 1_000)} км`}
onChange={(labelHideCameraHeightMeters) => updateDraft((current) => {
current.view.ghostPins.presentation.labelHideCameraHeightMeters =
labelHideCameraHeightMeters;
return current;
})}
/>
<RangeControl
label="Скрывать таргет выше"
value={profile.targetHideCameraHeightMeters}
min={1_000}
max={500_000}
step={1_000}
formatValue={(value) => `${Math.round(value / 1_000)} км`}
onChange={(targetHideCameraHeightMeters) => updateDraft((current) => {
current.view.ghostPins.presentation.targetHideCameraHeightMeters =
targetHideCameraHeightMeters;
return current;
})}
/>
</div>
);
}
@@ -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<MapInspectorSection>([
"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<typeof useMapGatewayHealth>;
onGenerateGhostPins: () => void;
onClearGhostPins: () => void;
}) {
return (
<Inspector
singleOpen
sections={createInspectorSections(draft, updateDraft, gatewayHealth)}
sections={createInspectorSections(
draft,
updateDraft,
gatewayHealth,
onGenerateGhostPins,
onClearGhostPins,
)}
openSections={draft.view.inspectorOpenSections.filter(
(section) => editableMapInspectorSections.has(section),
)}
@@ -60,6 +72,8 @@ function createInspectorSections(
update: (current: MapViewDocument) => MapViewDocument,
) => void,
gatewayHealth: ReturnType<typeof useMapGatewayHealth>,
onGenerateGhostPins: () => void,
onClearGhostPins: () => void,
) {
const view = draft.view;
const settings = view.visualSettings;
@@ -376,6 +390,19 @@ function createInspectorSections(
</div>
),
},
{
id: "ghost-pins",
label: "Гост-пин",
description: "временная симуляция",
content: (
<GhostPinSettingsPanel
draft={draft}
updateDraft={updateDraft}
onGenerate={onGenerateGhostPins}
onClear={onClearGhostPins}
/>
),
},
{
id: "grid-lod",
label: "Сетка и LOD",
@@ -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<string>();
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<string>,
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;
}
+386 -1
View File
@@ -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<MapInspectorSection>([
"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,
),
};
}
@@ -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>
@@ -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);
});
+34 -1
View File
@@ -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);
});
@@ -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;
+107 -4
View File
@@ -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
+24 -2
View File
@@ -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