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,
|
MapViewDocument,
|
||||||
} from "../../core/map/mapView";
|
} from "../../core/map/mapView";
|
||||||
import type { useMapGatewayHealth } from "../../core/map/useMapGatewayHealth";
|
import type { useMapGatewayHealth } from "../../core/map/useMapGatewayHealth";
|
||||||
|
import { GhostPinSettingsPanel } from "./GhostPinSettingsPanel";
|
||||||
|
|
||||||
export const editableMapInspectorSections = new Set<MapInspectorSection>([
|
export const editableMapInspectorSections = new Set<MapInspectorSection>([
|
||||||
"base-terrain",
|
"base-terrain",
|
||||||
"atmosphere-light",
|
"atmosphere-light",
|
||||||
"buildings",
|
"buildings",
|
||||||
|
"ghost-pins",
|
||||||
"grid-lod",
|
"grid-lod",
|
||||||
"camera",
|
"camera",
|
||||||
"tile-cache",
|
"tile-cache",
|
||||||
@@ -26,17 +28,27 @@ export function MapSettingsInspector({
|
|||||||
draft,
|
draft,
|
||||||
updateDraft,
|
updateDraft,
|
||||||
gatewayHealth,
|
gatewayHealth,
|
||||||
|
onGenerateGhostPins,
|
||||||
|
onClearGhostPins,
|
||||||
}: {
|
}: {
|
||||||
draft: MapViewDocument;
|
draft: MapViewDocument;
|
||||||
updateDraft: (
|
updateDraft: (
|
||||||
update: (current: MapViewDocument) => MapViewDocument,
|
update: (current: MapViewDocument) => MapViewDocument,
|
||||||
) => void;
|
) => void;
|
||||||
gatewayHealth: ReturnType<typeof useMapGatewayHealth>;
|
gatewayHealth: ReturnType<typeof useMapGatewayHealth>;
|
||||||
|
onGenerateGhostPins: () => void;
|
||||||
|
onClearGhostPins: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Inspector
|
<Inspector
|
||||||
singleOpen
|
singleOpen
|
||||||
sections={createInspectorSections(draft, updateDraft, gatewayHealth)}
|
sections={createInspectorSections(
|
||||||
|
draft,
|
||||||
|
updateDraft,
|
||||||
|
gatewayHealth,
|
||||||
|
onGenerateGhostPins,
|
||||||
|
onClearGhostPins,
|
||||||
|
)}
|
||||||
openSections={draft.view.inspectorOpenSections.filter(
|
openSections={draft.view.inspectorOpenSections.filter(
|
||||||
(section) => editableMapInspectorSections.has(section),
|
(section) => editableMapInspectorSections.has(section),
|
||||||
)}
|
)}
|
||||||
@@ -60,6 +72,8 @@ function createInspectorSections(
|
|||||||
update: (current: MapViewDocument) => MapViewDocument,
|
update: (current: MapViewDocument) => MapViewDocument,
|
||||||
) => void,
|
) => void,
|
||||||
gatewayHealth: ReturnType<typeof useMapGatewayHealth>,
|
gatewayHealth: ReturnType<typeof useMapGatewayHealth>,
|
||||||
|
onGenerateGhostPins: () => void,
|
||||||
|
onClearGhostPins: () => void,
|
||||||
) {
|
) {
|
||||||
const view = draft.view;
|
const view = draft.view;
|
||||||
const settings = view.visualSettings;
|
const settings = view.visualSettings;
|
||||||
@@ -376,6 +390,19 @@ function createInspectorSections(
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "ghost-pins",
|
||||||
|
label: "Гост-пин",
|
||||||
|
description: "временная симуляция",
|
||||||
|
content: (
|
||||||
|
<GhostPinSettingsPanel
|
||||||
|
draft={draft}
|
||||||
|
updateDraft={updateDraft}
|
||||||
|
onGenerate={onGenerateGhostPins}
|
||||||
|
onClear={onClearGhostPins}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "grid-lod",
|
id: "grid-lod",
|
||||||
label: "Сетка и 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"
|
| "atmosphere-light"
|
||||||
| "buildings"
|
| "buildings"
|
||||||
| "targets"
|
| "targets"
|
||||||
|
| "ghost-pins"
|
||||||
| "grid-lod"
|
| "grid-lod"
|
||||||
| "camera"
|
| "camera"
|
||||||
| "tile-cache"
|
| "tile-cache"
|
||||||
@@ -17,6 +18,53 @@ export interface MapCamera {
|
|||||||
roll: number;
|
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 {
|
export interface MapVisualSettings {
|
||||||
atmosphereEnabled: boolean;
|
atmosphereEnabled: boolean;
|
||||||
atmosphereHue: number;
|
atmosphereHue: number;
|
||||||
@@ -81,6 +129,7 @@ export interface MapView {
|
|||||||
cacheIntent: MapCacheIntent;
|
cacheIntent: MapCacheIntent;
|
||||||
selectedSubjectId: string | null;
|
selectedSubjectId: string | null;
|
||||||
layerVisibility: MapLayerVisibility;
|
layerVisibility: MapLayerVisibility;
|
||||||
|
ghostPins: MapGhostPinConfiguration;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapViewDocument {
|
export interface MapViewDocument {
|
||||||
@@ -93,6 +142,7 @@ const inspectorSections = new Set<MapInspectorSection>([
|
|||||||
"atmosphere-light",
|
"atmosphere-light",
|
||||||
"buildings",
|
"buildings",
|
||||||
"targets",
|
"targets",
|
||||||
|
"ghost-pins",
|
||||||
"grid-lod",
|
"grid-lod",
|
||||||
"camera",
|
"camera",
|
||||||
"tile-cache",
|
"tile-cache",
|
||||||
@@ -167,6 +217,40 @@ export function defaultMapViewDocument(): MapViewDocument {
|
|||||||
grid: true,
|
grid: true,
|
||||||
targets: 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 {
|
export function decodeMapViewDocument(value: unknown): MapViewDocument {
|
||||||
const document = requireRecord(value, "map view");
|
const document = requireRecord(value, "map view");
|
||||||
requireExactKeys(document, ["schema_version", "revision", "view"], "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("Версия сохранённого состояния карты не поддерживается.");
|
throw new Error("Версия сохранённого состояния карты не поддерживается.");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -254,6 +338,57 @@ export function encodeMapViewPut(document: MapViewDocument): unknown {
|
|||||||
grid: document.view.layerVisibility.grid,
|
grid: document.view.layerVisibility.grid,
|
||||||
targets: document.view.layerVisibility.targets,
|
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],
|
inspectorOpenSections: [...document.view.inspectorOpenSections],
|
||||||
cacheIntent: { ...document.view.cacheIntent },
|
cacheIntent: { ...document.view.cacheIntent },
|
||||||
layerVisibility: { ...document.view.layerVisibility },
|
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",
|
"cache_intent",
|
||||||
"selected_subject_id",
|
"selected_subject_id",
|
||||||
"layer_visibility",
|
"layer_visibility",
|
||||||
|
"ghost_pins",
|
||||||
],
|
],
|
||||||
"map view.view",
|
"map view.view",
|
||||||
);
|
);
|
||||||
@@ -311,6 +455,247 @@ function decodeMapView(value: unknown): MapView {
|
|||||||
cacheIntent: decodeCacheIntent(view.cache_intent),
|
cacheIntent: decodeCacheIntent(view.cache_intent),
|
||||||
selectedSubjectId,
|
selectedSubjectId,
|
||||||
layerVisibility: decodeLayerVisibility(view.layer_visibility),
|
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 {
|
import {
|
||||||
CesiumMapRenderer,
|
CesiumMapRenderer,
|
||||||
initialMapRuntimeState,
|
initialMapRuntimeState,
|
||||||
|
type CesiumMapRendererHandle,
|
||||||
type MapCamera,
|
type MapCamera,
|
||||||
type MapProviderState,
|
type MapProviderState,
|
||||||
type MapRuntimeState,
|
type MapRuntimeState,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import { MapSettingsInspector } from "../../components/map/MapSettingsInspector";
|
import { MapSettingsInspector } from "../../components/map/MapSettingsInspector";
|
||||||
|
import { generateGhostPins } from "../../core/map/ghostPins";
|
||||||
import {
|
import {
|
||||||
cloneMapViewDocument,
|
cloneMapViewDocument,
|
||||||
defaultMapViewDocument,
|
defaultMapViewDocument,
|
||||||
@@ -51,6 +53,7 @@ export function WorldMapWorkspace({
|
|||||||
const [layersOpen, setLayersOpen] = useState(false);
|
const [layersOpen, setLayersOpen] = useState(false);
|
||||||
const [rendererGeneration, setRendererGeneration] = useState(0);
|
const [rendererGeneration, setRendererGeneration] = useState(0);
|
||||||
const operatorCameraInteraction = useRef(false);
|
const operatorCameraInteraction = useRef(false);
|
||||||
|
const rendererRef = useRef<CesiumMapRendererHandle | null>(null);
|
||||||
const gatewayHealth = useMapGatewayHealth(settingsOpen || layersOpen);
|
const gatewayHealth = useMapGatewayHealth(settingsOpen || layersOpen);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -82,13 +85,54 @@ export function WorldMapWorkspace({
|
|||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = useCallback(async () => {
|
||||||
try {
|
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));
|
setDraft(cloneMapViewDocument(accepted));
|
||||||
} catch {
|
} catch {
|
||||||
// The controller exposes stable product copy and leaves the current draft intact.
|
// The controller exposes stable product copy and leaves the current draft intact.
|
||||||
}
|
}
|
||||||
}, [controller, draft]);
|
}, [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(() => {
|
const retryRenderer = useCallback(() => {
|
||||||
operatorCameraInteraction.current = false;
|
operatorCameraInteraction.current = false;
|
||||||
setRendererGeneration((generation) => generation + 1);
|
setRendererGeneration((generation) => generation + 1);
|
||||||
@@ -157,6 +201,7 @@ export function WorldMapWorkspace({
|
|||||||
>
|
>
|
||||||
{viewInitialized ? (
|
{viewInitialized ? (
|
||||||
<CesiumMapRenderer
|
<CesiumMapRenderer
|
||||||
|
ref={rendererRef}
|
||||||
runtimeConfigUrl="/api/v1/map/runtime-config"
|
runtimeConfigUrl="/api/v1/map/runtime-config"
|
||||||
camera={draft.view.camera}
|
camera={draft.view.camera}
|
||||||
layers={draft.view.layerVisibility}
|
layers={draft.view.layerVisibility}
|
||||||
@@ -218,6 +263,59 @@ export function WorldMapWorkspace({
|
|||||||
enabled: draft.view.cacheIntent.enabled,
|
enabled: draft.view.cacheIntent.enabled,
|
||||||
no_overwrite: draft.view.cacheIntent.noOverwrite,
|
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}
|
rendererGeneration={rendererGeneration}
|
||||||
className="mission-map__cesium"
|
className="mission-map__cesium"
|
||||||
onRuntimeStateChange={setRuntimeState}
|
onRuntimeStateChange={setRuntimeState}
|
||||||
@@ -293,6 +391,20 @@ export function WorldMapWorkspace({
|
|||||||
{draft.view.layerVisibility.grid ? "Включена" : "Выключена"}
|
{draft.view.layerVisibility.grid ? "Включена" : "Выключена"}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
</div>
|
</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>
|
</MapGlassSurface>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -375,6 +487,8 @@ export function WorldMapWorkspace({
|
|||||||
draft={draft}
|
draft={draft}
|
||||||
updateDraft={updateDraft}
|
updateDraft={updateDraft}
|
||||||
gatewayHealth={gatewayHealth}
|
gatewayHealth={gatewayHealth}
|
||||||
|
onGenerateGhostPins={generatePins}
|
||||||
|
onClearGhostPins={clearPins}
|
||||||
/>
|
/>
|
||||||
</Window>
|
</Window>
|
||||||
</div>
|
</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 = {}) {
|
function serverDocument(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
schema_version: "missioncore.map-view/v2",
|
schema_version: "missioncore.map-view/v3",
|
||||||
revision: 0,
|
revision: 0,
|
||||||
view: {
|
view: {
|
||||||
camera: {
|
camera: {
|
||||||
@@ -89,6 +89,36 @@ function serverDocument(overrides = {}) {
|
|||||||
grid: true,
|
grid: true,
|
||||||
targets: 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,
|
...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.selectedSubjectId, null);
|
||||||
assert.equal(document.view.cacheIntent.enabled, true);
|
assert.equal(document.view.cacheIntent.enabled, true);
|
||||||
assert.equal(document.view.cacheIntent.noOverwrite, 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", () => {
|
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(decoded.view.inspectorOpenSections[0], "camera");
|
||||||
assert.equal(encoded.view.camera.latitude, 55.7558);
|
assert.equal(encoded.view.camera.latitude, 55.7558);
|
||||||
assert.equal(encoded.view.cache_intent.no_overwrite, true);
|
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);
|
assert.equal("schema_version" in encoded, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ the existing Gateway is reachable and reports a configured state.
|
|||||||
## Map state contract
|
## Map state contract
|
||||||
|
|
||||||
Mission Core stores a versioned map document outside operator environment
|
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:
|
revision:
|
||||||
|
|
||||||
- its default camera and complete visual state are the canonical Foundry
|
- its default camera and complete visual state are the canonical Foundry
|
||||||
@@ -204,8 +204,8 @@ revision:
|
|||||||
imagery remains active;
|
imagery remains active;
|
||||||
- atmosphere, fog, sun, shadows, terrain, buildings and the elevated LOD grid
|
- atmosphere, fog, sun, shadows, terrain, buildings and the elevated LOD grid
|
||||||
are persisted as provider-neutral values;
|
are persisted as provider-neutral values;
|
||||||
- saved `missioncore.map-view/v1` documents are upgraded in memory and become
|
- saved `missioncore.map-view/v1` and `missioncore.map-view/v2` documents are
|
||||||
v2 on the next explicit save;
|
upgraded in memory and become v3 on the next explicit save;
|
||||||
- provider attribution remains registered in the renderer. The local
|
- provider attribution remains registered in the renderer. The local
|
||||||
experiment may hide only the visual credit overlay through
|
experiment may hide only the visual credit overlay through
|
||||||
`MISSIONCORE_MAP_SANDBOX_HIDE_CREDITS=1`; production defaults to visible
|
`MISSIONCORE_MAP_SANDBOX_HIDE_CREDITS=1`; production defaults to visible
|
||||||
@@ -218,6 +218,9 @@ revision:
|
|||||||
- cache intent (`enabled`, `noOverwrite`), never server-level cache mode;
|
- cache intent (`enabled`, `noOverwrite`), never server-level cache mode;
|
||||||
- selected stable subject id, or `null`;
|
- selected stable subject id, or `null`;
|
||||||
- layer visibility;
|
- 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.
|
- no credentials, upstream URLs, cache objects or transient Cesium ids.
|
||||||
|
|
||||||
Resolution order:
|
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
|
Until that contract exists, the basemap remains valid with an honest empty
|
||||||
subject state and the selected-entity section remains absent.
|
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
|
## State grammar
|
||||||
|
|
||||||
The UI must distinguish:
|
The UI must distinguish:
|
||||||
@@ -318,6 +329,18 @@ unbounded reconnect loops.
|
|||||||
- implement bounded renderer recreation for explicit viewport refresh;
|
- implement bounded renderer recreation for explicit viewport refresh;
|
||||||
- keep provider credits available for any non-sandbox release.
|
- 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
|
### M4 — secure API/key control plane
|
||||||
|
|
||||||
- add authenticated Mission Core admin authority;
|
- add authenticated Mission Core admin authority;
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ from typing import Literal
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
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[
|
MapInspectorSection = Literal[
|
||||||
"base-terrain",
|
"base-terrain",
|
||||||
"atmosphere-light",
|
"atmosphere-light",
|
||||||
"buildings",
|
"buildings",
|
||||||
"targets",
|
"targets",
|
||||||
|
"ghost-pins",
|
||||||
"grid-lod",
|
"grid-lod",
|
||||||
"camera",
|
"camera",
|
||||||
"tile-cache",
|
"tile-cache",
|
||||||
@@ -96,7 +97,76 @@ class MapCacheIntent(StrictMapModel):
|
|||||||
no_overwrite: bool = True
|
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(
|
camera: MapCamera | None = Field(
|
||||||
default_factory=lambda: MapCamera(
|
default_factory=lambda: MapCamera(
|
||||||
longitude=37.618423,
|
longitude=37.618423,
|
||||||
@@ -122,19 +192,35 @@ class MapViewContent(StrictMapModel):
|
|||||||
layer_visibility: MapLayerVisibility = Field(default_factory=MapLayerVisibility)
|
layer_visibility: MapLayerVisibility = Field(default_factory=MapLayerVisibility)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@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:
|
if self.selected_subject_id is None and "selection" in self.inspector_open_sections:
|
||||||
raise ValueError("selection section requires a selected stable subject")
|
raise ValueError("selection section requires a selected stable subject")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class MapViewContent(MapViewContentBase):
|
||||||
|
ghost_pins: MapGhostPinConfiguration = Field(
|
||||||
|
default_factory=MapGhostPinConfiguration,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MapViewPut(StrictMapModel):
|
class MapViewPut(StrictMapModel):
|
||||||
revision: int = Field(ge=0)
|
revision: int = Field(ge=0)
|
||||||
view: MapViewContent
|
view: MapViewContent
|
||||||
|
|
||||||
|
|
||||||
class MapViewDocument(MapViewPut):
|
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):
|
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:
|
class MapViewStore:
|
||||||
def __init__(self, root: Path) -> None:
|
def __init__(self, root: Path) -> None:
|
||||||
self.root = root.expanduser().resolve()
|
self.root = root.expanduser().resolve()
|
||||||
@@ -253,6 +349,13 @@ class MapViewStore:
|
|||||||
and payload.get("schema_version") == "missioncore.map-view/v1"
|
and payload.get("schema_version") == "missioncore.map-view/v1"
|
||||||
):
|
):
|
||||||
return upgrade_legacy_map_view(LegacyMapViewDocument.model_validate(payload))
|
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)
|
return MapViewDocument.model_validate(payload)
|
||||||
except (OSError, ValueError, TypeError) as exc:
|
except (OSError, ValueError, TypeError) as exc:
|
||||||
raise RuntimeError("map view is corrupt") from 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:
|
def test_default_map_view_matches_the_canonical_foundry_scene_and_cache_policy() -> None:
|
||||||
document = default_map_view()
|
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.revision == 0
|
||||||
assert document.view.camera is not None
|
assert document.view.camera is not None
|
||||||
assert document.view.camera.longitude == pytest.approx(37.618423)
|
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.visual_settings.background_color == "#08090d"
|
||||||
assert document.view.cache_intent.enabled is True
|
assert document.view.cache_intent.enabled is True
|
||||||
assert document.view.cache_intent.no_overwrite 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:
|
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()
|
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.revision == 7
|
||||||
assert upgraded.view.camera is None
|
assert upgraded.view.camera is None
|
||||||
assert upgraded.view.visual_settings.atmosphere_enabled is True
|
assert upgraded.view.visual_settings.atmosphere_enabled is True
|
||||||
assert upgraded.view.visual_settings.sun_enabled is False
|
assert upgraded.view.visual_settings.sun_enabled is False
|
||||||
assert upgraded.view.visual_settings.monochrome_enabled is True
|
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
|
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