feat(map): add persisted ghost pin sandbox

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 18:29:13 +03:00
parent b91d3b913e
commit 0c3855eac5
10 changed files with 1222 additions and 13 deletions
@@ -0,0 +1,360 @@
import {
Button,
Checker,
ColorField,
ControlRow,
InspectorSelectField,
RangeControl,
StatusBadge,
} from "@nodedc/ui-react";
import type {
MapGhostPinLabelMode,
MapViewDocument,
} from "../../core/map/mapView";
export function GhostPinSettingsPanel({
draft,
updateDraft,
onGenerate,
onClear,
}: {
draft: MapViewDocument;
updateDraft: (
update: (current: MapViewDocument) => MapViewDocument,
) => void;
onGenerate: () => void;
onClear: () => void;
}) {
const configuration = draft.view.ghostPins;
const profile = configuration.presentation;
return (
<div className="mission-map__inspector-controls">
<ControlRow label="Контур">
<StatusBadge tone="warning">Временная симуляция</StatusBadge>
</ControlRow>
<RangeControl
label="Количество пинов"
value={configuration.count}
min={1}
max={100}
step={1}
formatValue={(value) => `${value}`}
onChange={(count) => updateDraft((current) => {
current.view.ghostPins.count = count;
return current;
})}
/>
<RangeControl
label="Радиус от центра view"
value={configuration.radiusMeters}
min={100}
max={100_000}
step={100}
formatValue={(value) => value >= 1_000
? `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)} км`
: `${value} м`}
onChange={(radiusMeters) => updateDraft((current) => {
current.view.ghostPins.radiusMeters = radiusMeters;
return current;
})}
/>
<Checker
label="Симуляция движения"
checked={configuration.simulationEnabled}
onChange={(simulationEnabled) => updateDraft((current) => {
current.view.ghostPins.simulationEnabled = simulationEnabled;
return current;
})}
/>
<ControlRow label="Позиции">
<StatusBadge tone={configuration.positions.length ? "success" : "neutral"}>
{configuration.positions.length
? `${configuration.positions.length} на карте`
: "Не созданы"}
</StatusBadge>
</ControlRow>
<Button variant="secondary" size="compact" onClick={onGenerate}>
Разместить в текущем view
</Button>
<Button
variant="ghost"
size="compact"
disabled={configuration.positions.length === 0}
onClick={onClear}
>
Очистить позиции на карте
</Button>
<Checker
label="Показывать Ghost Pin"
checked={draft.view.layerVisibility.targets}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.targets = checked;
return current;
})}
/>
<ControlRow label="Цвет пина">
<ColorField
label="Цвет Ghost Pin"
value={profile.color}
onChange={(color) => updateDraft((current) => {
current.view.ghostPins.presentation.color = color;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Прозрачность пина"
value={Math.round(profile.opacity * 100)}
min={0}
max={100}
step={1}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.ghostPins.presentation.opacity = value / 100;
return current;
})}
/>
<RangeControl
label="Высота таргета"
value={profile.stemHeightMeters}
min={100}
max={10_000}
step={50}
formatValue={(value) => `${value} м`}
onChange={(stemHeightMeters) => updateDraft((current) => {
current.view.ghostPins.presentation.stemHeightMeters = stemHeightMeters;
return current;
})}
/>
<RangeControl
label="Размер головки"
value={profile.headSizePx}
min={1}
max={32}
step={1}
formatValue={(value) => `${value} px`}
onChange={(headSizePx) => updateDraft((current) => {
current.view.ghostPins.presentation.headSizePx = headSizePx;
return current;
})}
/>
<RangeControl
label="Толщина стержня"
value={profile.stemWidthPx}
min={0.25}
max={12}
step={0.25}
formatValue={(value) => `${value} px`}
onChange={(stemWidthPx) => updateDraft((current) => {
current.view.ghostPins.presentation.stemWidthPx = stemWidthPx;
return current;
})}
/>
<ControlRow label="Цвет обводки">
<ColorField
label="Цвет обводки головки"
value={profile.outlineColor}
onChange={(outlineColor) => updateDraft((current) => {
current.view.ghostPins.presentation.outlineColor = outlineColor;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Прозрачность обводки"
value={Math.round(profile.outlineOpacity * 100)}
min={0}
max={100}
step={1}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.ghostPins.presentation.outlineOpacity = value / 100;
return current;
})}
/>
<RangeControl
label="Толщина обводки"
value={profile.outlineWidthPx}
min={0}
max={8}
step={0.5}
formatValue={(value) => `${value} px`}
onChange={(outlineWidthPx) => updateDraft((current) => {
current.view.ghostPins.presentation.outlineWidthPx = outlineWidthPx;
return current;
})}
/>
<InspectorSelectField<MapGhostPinLabelMode>
label="Подпись"
value={profile.labelMode}
options={[
{
value: "attributes",
label: "Имя",
description: "Случайное числовое имя Ghost Pin",
},
{
value: "subject_id",
label: "ID",
description: "Стабильный синтетический идентификатор",
},
{
value: "none",
label: "Нет",
description: "Не показывать плашку",
},
]}
onChange={(labelMode) => updateDraft((current) => {
current.view.ghostPins.presentation.labelMode = labelMode;
return current;
})}
/>
<RangeControl
label="Насыщенность шрифта"
value={profile.labelFontWeight}
min={400}
max={700}
step={100}
formatValue={(value) => `${value}`}
onChange={(labelFontWeight) => updateDraft((current) => {
current.view.ghostPins.presentation.labelFontWeight = labelFontWeight;
return current;
})}
/>
<RangeControl
label="Размер подписи"
value={profile.labelSizePx}
min={8}
max={32}
step={1}
formatValue={(value) => `${value} px`}
onChange={(labelSizePx) => updateDraft((current) => {
current.view.ghostPins.presentation.labelSizePx = labelSizePx;
return current;
})}
/>
<ControlRow label="Цвет подписи">
<ColorField
label="Цвет текста подписи"
value={profile.labelColor}
onChange={(labelColor) => updateDraft((current) => {
current.view.ghostPins.presentation.labelColor = labelColor;
return current;
})}
/>
</ControlRow>
<ControlRow label="Фон плашки">
<ColorField
label="Цвет фона подписи"
value={profile.labelBackgroundColor}
onChange={(labelBackgroundColor) => updateDraft((current) => {
current.view.ghostPins.presentation.labelBackgroundColor =
labelBackgroundColor;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Прозрачность плашки"
value={Math.round(profile.labelBackgroundOpacity * 100)}
min={0}
max={100}
step={1}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.ghostPins.presentation.labelBackgroundOpacity =
value / 100;
return current;
})}
/>
<RangeControl
label="Отступ плашки X"
value={profile.labelPaddingX}
min={0}
max={32}
step={1}
formatValue={(value) => `${value} px`}
onChange={(labelPaddingX) => updateDraft((current) => {
current.view.ghostPins.presentation.labelPaddingX = labelPaddingX;
return current;
})}
/>
<RangeControl
label="Отступ плашки Y"
value={profile.labelPaddingY}
min={0}
max={32}
step={1}
formatValue={(value) => `${value} px`}
onChange={(labelPaddingY) => updateDraft((current) => {
current.view.ghostPins.presentation.labelPaddingY = labelPaddingY;
return current;
})}
/>
<RangeControl
label="Смещение подписи X"
value={profile.labelOffsetX}
min={-100}
max={100}
step={1}
formatValue={(value) => `${value} px`}
onChange={(labelOffsetX) => updateDraft((current) => {
current.view.ghostPins.presentation.labelOffsetX = labelOffsetX;
return current;
})}
/>
<RangeControl
label="Смещение подписи Y"
value={profile.labelOffsetY}
min={-100}
max={100}
step={1}
formatValue={(value) => `${value} px`}
onChange={(labelOffsetY) => updateDraft((current) => {
current.view.ghostPins.presentation.labelOffsetY = labelOffsetY;
return current;
})}
/>
<RangeControl
label="Максимальная длина подписи"
value={profile.labelMaxLength}
min={1}
max={64}
step={1}
formatValue={(value) => `${value}`}
onChange={(labelMaxLength) => updateDraft((current) => {
current.view.ghostPins.presentation.labelMaxLength = labelMaxLength;
return current;
})}
/>
<RangeControl
label="Скрывать подпись выше"
value={profile.labelHideCameraHeightMeters}
min={1_000}
max={500_000}
step={1_000}
formatValue={(value) => `${Math.round(value / 1_000)} км`}
onChange={(labelHideCameraHeightMeters) => updateDraft((current) => {
current.view.ghostPins.presentation.labelHideCameraHeightMeters =
labelHideCameraHeightMeters;
return current;
})}
/>
<RangeControl
label="Скрывать таргет выше"
value={profile.targetHideCameraHeightMeters}
min={1_000}
max={500_000}
step={1_000}
formatValue={(value) => `${Math.round(value / 1_000)} км`}
onChange={(targetHideCameraHeightMeters) => updateDraft((current) => {
current.view.ghostPins.presentation.targetHideCameraHeightMeters =
targetHideCameraHeightMeters;
return current;
})}
/>
</div>
);
}
@@ -12,11 +12,13 @@ import type {
MapViewDocument,
} from "../../core/map/mapView";
import type { useMapGatewayHealth } from "../../core/map/useMapGatewayHealth";
import { GhostPinSettingsPanel } from "./GhostPinSettingsPanel";
export const editableMapInspectorSections = new Set<MapInspectorSection>([
"base-terrain",
"atmosphere-light",
"buildings",
"ghost-pins",
"grid-lod",
"camera",
"tile-cache",
@@ -26,17 +28,27 @@ export function MapSettingsInspector({
draft,
updateDraft,
gatewayHealth,
onGenerateGhostPins,
onClearGhostPins,
}: {
draft: MapViewDocument;
updateDraft: (
update: (current: MapViewDocument) => MapViewDocument,
) => void;
gatewayHealth: ReturnType<typeof useMapGatewayHealth>;
onGenerateGhostPins: () => void;
onClearGhostPins: () => void;
}) {
return (
<Inspector
singleOpen
sections={createInspectorSections(draft, updateDraft, gatewayHealth)}
sections={createInspectorSections(
draft,
updateDraft,
gatewayHealth,
onGenerateGhostPins,
onClearGhostPins,
)}
openSections={draft.view.inspectorOpenSections.filter(
(section) => editableMapInspectorSections.has(section),
)}
@@ -60,6 +72,8 @@ function createInspectorSections(
update: (current: MapViewDocument) => MapViewDocument,
) => void,
gatewayHealth: ReturnType<typeof useMapGatewayHealth>,
onGenerateGhostPins: () => void,
onClearGhostPins: () => void,
) {
const view = draft.view;
const settings = view.visualSettings;
@@ -376,6 +390,19 @@ function createInspectorSections(
</div>
),
},
{
id: "ghost-pins",
label: "Гост-пин",
description: "временная симуляция",
content: (
<GhostPinSettingsPanel
draft={draft}
updateDraft={updateDraft}
onGenerate={onGenerateGhostPins}
onClear={onClearGhostPins}
/>
),
},
{
id: "grid-lod",
label: "Сетка и LOD",