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