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,92 @@
import type {
MapGeoPoint,
MapGhostPin,
} from "./mapView";
const EARTH_RADIUS_METERS = 6_371_008.8;
export function generateGhostPins({
anchor,
count,
radiusMeters,
random = Math.random,
timestamp = Date.now(),
}: {
anchor: MapGeoPoint;
count: number;
radiusMeters: number;
random?: () => number;
timestamp?: number;
}): MapGhostPin[] {
const safeCount = Math.min(100, Math.max(1, Math.round(count)));
const safeRadius = Math.min(100_000, Math.max(100, radiusMeters));
const labels = new Set<string>();
return Array.from({ length: safeCount }, (_, index) => {
const distance = safeRadius * Math.sqrt(clampUnit(random()));
const bearing = clampUnit(random()) * 360;
const position = destinationPoint(anchor, bearing, distance);
const label = nextNumericLabel(labels, random);
return {
id: [
"ghost",
Math.max(0, Math.round(timestamp)).toString(36),
index.toString(36),
Math.floor(clampUnit(random()) * 0xff_ffff).toString(36),
].join("-"),
label,
...position,
headingDegrees: clampUnit(random()) * 360,
speedMetersPerSecond: 3 + clampUnit(random()) * 15,
};
});
}
function nextNumericLabel(
labels: Set<string>,
random: () => number,
): string {
let label = String(100_000 + Math.floor(clampUnit(random()) * 900_000));
for (let attempt = 0; labels.has(label) && attempt < 16; attempt += 1) {
label = String(100_000 + Math.floor(clampUnit(random()) * 900_000));
}
if (labels.has(label)) {
label = String(100_000 + (labels.size % 900_000));
}
labels.add(label);
return label;
}
function destinationPoint(
origin: MapGeoPoint,
headingDegrees: number,
distanceMeters: number,
): MapGeoPoint {
const angularDistance = distanceMeters / EARTH_RADIUS_METERS;
const bearing = toRadians(headingDegrees);
const latitude = toRadians(origin.latitude);
const longitude = toRadians(origin.longitude);
const nextLatitude = Math.asin(
Math.sin(latitude) * Math.cos(angularDistance)
+ Math.cos(latitude) * Math.sin(angularDistance) * Math.cos(bearing),
);
const nextLongitude = longitude + Math.atan2(
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latitude),
Math.cos(angularDistance) - Math.sin(latitude) * Math.sin(nextLatitude),
);
return {
longitude: ((toDegrees(nextLongitude) + 540) % 360) - 180,
latitude: Math.min(90, Math.max(-90, toDegrees(nextLatitude))),
};
}
function clampUnit(value: number): number {
return Math.min(0.999_999_999, Math.max(0, Number.isFinite(value) ? value : 0));
}
function toRadians(value: number): number {
return value * Math.PI / 180;
}
function toDegrees(value: number): number {
return value * 180 / Math.PI;
}
+386 -1
View File
@@ -3,6 +3,7 @@ export type MapInspectorSection =
| "atmosphere-light"
| "buildings"
| "targets"
| "ghost-pins"
| "grid-lod"
| "camera"
| "tile-cache"
@@ -17,6 +18,53 @@ export interface MapCamera {
roll: number;
}
export interface MapGeoPoint {
longitude: number;
latitude: number;
}
export interface MapGhostPin extends MapGeoPoint {
id: string;
label: string;
headingDegrees: number;
speedMetersPerSecond: number;
}
export type MapGhostPinLabelMode = "subject_id" | "attributes" | "none";
export interface MapGhostPinPresentation {
color: string;
opacity: number;
stemHeightMeters: number;
headSizePx: number;
stemWidthPx: number;
outlineColor: string;
outlineOpacity: number;
outlineWidthPx: number;
labelMode: MapGhostPinLabelMode;
labelFontWeight: number;
labelSizePx: number;
labelColor: string;
labelBackgroundColor: string;
labelBackgroundOpacity: number;
labelPaddingX: number;
labelPaddingY: number;
labelOffsetX: number;
labelOffsetY: number;
labelMaxLength: number;
labelHideCameraHeightMeters: number;
targetHideCameraHeightMeters: number;
}
export interface MapGhostPinConfiguration {
count: number;
radiusMeters: number;
simulationEnabled: boolean;
anchor: MapGeoPoint | null;
positions: MapGhostPin[];
presentation: MapGhostPinPresentation;
}
export interface MapVisualSettings {
atmosphereEnabled: boolean;
atmosphereHue: number;
@@ -81,6 +129,7 @@ export interface MapView {
cacheIntent: MapCacheIntent;
selectedSubjectId: string | null;
layerVisibility: MapLayerVisibility;
ghostPins: MapGhostPinConfiguration;
}
export interface MapViewDocument {
@@ -93,6 +142,7 @@ const inspectorSections = new Set<MapInspectorSection>([
"atmosphere-light",
"buildings",
"targets",
"ghost-pins",
"grid-lod",
"camera",
"tile-cache",
@@ -167,6 +217,40 @@ export function defaultMapViewDocument(): MapViewDocument {
grid: true,
targets: true,
},
ghostPins: defaultGhostPinConfiguration(),
},
};
}
export function defaultGhostPinConfiguration(): MapGhostPinConfiguration {
return {
count: 12,
radiusMeters: 5_000,
simulationEnabled: false,
anchor: null,
positions: [],
presentation: {
color: "#6fb5fb",
opacity: 0.9,
stemHeightMeters: 1_500,
headSizePx: 6,
stemWidthPx: 3,
outlineColor: "#0c0d12",
outlineOpacity: 0.6,
outlineWidthPx: 1,
labelMode: "attributes",
labelFontWeight: 600,
labelSizePx: 13,
labelColor: "#f5f5f5",
labelBackgroundColor: "#0c0d12",
labelBackgroundOpacity: 0.86,
labelPaddingX: 8,
labelPaddingY: 5,
labelOffsetX: 15,
labelOffsetY: 10,
labelMaxLength: 48,
labelHideCameraHeightMeters: 70_000,
targetHideCameraHeightMeters: 100_000,
},
};
}
@@ -174,7 +258,7 @@ export function defaultMapViewDocument(): MapViewDocument {
export function decodeMapViewDocument(value: unknown): MapViewDocument {
const document = requireRecord(value, "map view");
requireExactKeys(document, ["schema_version", "revision", "view"], "map view");
if (document.schema_version !== "missioncore.map-view/v2") {
if (document.schema_version !== "missioncore.map-view/v3") {
throw new Error("Версия сохранённого состояния карты не поддерживается.");
}
return {
@@ -254,6 +338,57 @@ export function encodeMapViewPut(document: MapViewDocument): unknown {
grid: document.view.layerVisibility.grid,
targets: document.view.layerVisibility.targets,
},
ghost_pins: {
count: document.view.ghostPins.count,
radius_meters: document.view.ghostPins.radiusMeters,
simulation_enabled: document.view.ghostPins.simulationEnabled,
anchor: document.view.ghostPins.anchor
? {
longitude: document.view.ghostPins.anchor.longitude,
latitude: document.view.ghostPins.anchor.latitude,
}
: null,
positions: document.view.ghostPins.positions.map((pin) => ({
id: pin.id,
label: pin.label,
longitude: pin.longitude,
latitude: pin.latitude,
heading_degrees: pin.headingDegrees,
speed_meters_per_second: pin.speedMetersPerSecond,
})),
presentation: {
color: document.view.ghostPins.presentation.color,
opacity: document.view.ghostPins.presentation.opacity,
stem_height_meters:
document.view.ghostPins.presentation.stemHeightMeters,
head_size_px: document.view.ghostPins.presentation.headSizePx,
stem_width_px: document.view.ghostPins.presentation.stemWidthPx,
outline_color: document.view.ghostPins.presentation.outlineColor,
outline_opacity:
document.view.ghostPins.presentation.outlineOpacity,
outline_width_px:
document.view.ghostPins.presentation.outlineWidthPx,
label_mode: document.view.ghostPins.presentation.labelMode,
label_font_weight:
document.view.ghostPins.presentation.labelFontWeight,
label_size_px: document.view.ghostPins.presentation.labelSizePx,
label_color: document.view.ghostPins.presentation.labelColor,
label_background_color:
document.view.ghostPins.presentation.labelBackgroundColor,
label_background_opacity:
document.view.ghostPins.presentation.labelBackgroundOpacity,
label_padding_x: document.view.ghostPins.presentation.labelPaddingX,
label_padding_y: document.view.ghostPins.presentation.labelPaddingY,
label_offset_x: document.view.ghostPins.presentation.labelOffsetX,
label_offset_y: document.view.ghostPins.presentation.labelOffsetY,
label_max_length:
document.view.ghostPins.presentation.labelMaxLength,
label_hide_camera_height_meters:
document.view.ghostPins.presentation.labelHideCameraHeightMeters,
target_hide_camera_height_meters:
document.view.ghostPins.presentation.targetHideCameraHeightMeters,
},
},
},
};
}
@@ -268,6 +403,14 @@ export function cloneMapViewDocument(document: MapViewDocument): MapViewDocument
inspectorOpenSections: [...document.view.inspectorOpenSections],
cacheIntent: { ...document.view.cacheIntent },
layerVisibility: { ...document.view.layerVisibility },
ghostPins: {
...document.view.ghostPins,
anchor: document.view.ghostPins.anchor
? { ...document.view.ghostPins.anchor }
: null,
positions: document.view.ghostPins.positions.map((pin) => ({ ...pin })),
presentation: { ...document.view.ghostPins.presentation },
},
},
};
}
@@ -284,6 +427,7 @@ function decodeMapView(value: unknown): MapView {
"cache_intent",
"selected_subject_id",
"layer_visibility",
"ghost_pins",
],
"map view.view",
);
@@ -311,6 +455,247 @@ function decodeMapView(value: unknown): MapView {
cacheIntent: decodeCacheIntent(view.cache_intent),
selectedSubjectId,
layerVisibility: decodeLayerVisibility(view.layer_visibility),
ghostPins: decodeGhostPinConfiguration(view.ghost_pins),
};
}
function decodeGhostPinConfiguration(value: unknown): MapGhostPinConfiguration {
const configuration = requireRecord(value, "map view.view.ghost_pins");
requireExactKeys(
configuration,
[
"count",
"radius_meters",
"simulation_enabled",
"anchor",
"positions",
"presentation",
],
"map view.view.ghost_pins",
);
const anchor = configuration.anchor === null
? null
: decodeGeoPoint(configuration.anchor, "ghost_pins.anchor");
const positions = requireArray(
configuration.positions,
"ghost_pins.positions",
);
if (positions.length > 100) {
throw new Error("ghost_pins.positions превышает лимит.");
}
return {
count: requireInteger(configuration.count, "ghost_pins.count", 1, 100),
radiusMeters: requireNumber(
configuration.radius_meters,
"ghost_pins.radius_meters",
100,
100_000,
),
simulationEnabled: requireBoolean(
configuration.simulation_enabled,
"ghost_pins.simulation_enabled",
),
anchor,
positions: positions.map((pin, index) => decodeGhostPin(
pin,
`ghost_pins.positions[${index}]`,
)),
presentation: decodeGhostPinPresentation(configuration.presentation),
};
}
function decodeGeoPoint(value: unknown, path: string): MapGeoPoint {
const point = requireRecord(value, path);
requireExactKeys(point, ["longitude", "latitude"], path);
return {
longitude: requireNumber(point.longitude, `${path}.longitude`, -180, 180),
latitude: requireNumber(point.latitude, `${path}.latitude`, -90, 90),
};
}
function decodeGhostPin(value: unknown, path: string): MapGhostPin {
const pin = requireRecord(value, path);
requireExactKeys(
pin,
[
"id",
"label",
"longitude",
"latitude",
"heading_degrees",
"speed_meters_per_second",
],
path,
);
if (typeof pin.id !== "string" || !/^ghost-[a-z0-9-]{1,80}$/i.test(pin.id)) {
throw new Error(`${path}.id некорректен.`);
}
if (typeof pin.label !== "string" || !/^\d{1,12}$/.test(pin.label)) {
throw new Error(`${path}.label должен содержать только цифры.`);
}
return {
id: pin.id,
label: pin.label,
longitude: requireNumber(pin.longitude, `${path}.longitude`, -180, 180),
latitude: requireNumber(pin.latitude, `${path}.latitude`, -90, 90),
headingDegrees: requireNumber(
pin.heading_degrees,
`${path}.heading_degrees`,
0,
360,
),
speedMetersPerSecond: requireNumber(
pin.speed_meters_per_second,
`${path}.speed_meters_per_second`,
0.1,
100,
),
};
}
function decodeGhostPinPresentation(value: unknown): MapGhostPinPresentation {
const profile = requireRecord(value, "ghost_pins.presentation");
requireExactKeys(
profile,
[
"color",
"opacity",
"stem_height_meters",
"head_size_px",
"stem_width_px",
"outline_color",
"outline_opacity",
"outline_width_px",
"label_mode",
"label_font_weight",
"label_size_px",
"label_color",
"label_background_color",
"label_background_opacity",
"label_padding_x",
"label_padding_y",
"label_offset_x",
"label_offset_y",
"label_max_length",
"label_hide_camera_height_meters",
"target_hide_camera_height_meters",
],
"ghost_pins.presentation",
);
const labelMode = profile.label_mode;
if (
labelMode !== "subject_id"
&& labelMode !== "attributes"
&& labelMode !== "none"
) {
throw new Error("ghost_pins.presentation.label_mode некорректен.");
}
return {
color: requireHexColor(profile.color, "ghost_pins.presentation.color"),
opacity: requireNumber(profile.opacity, "ghost_pins.presentation.opacity", 0, 1),
stemHeightMeters: requireNumber(
profile.stem_height_meters,
"ghost_pins.presentation.stem_height_meters",
100,
10_000,
),
headSizePx: requireNumber(
profile.head_size_px,
"ghost_pins.presentation.head_size_px",
1,
32,
),
stemWidthPx: requireNumber(
profile.stem_width_px,
"ghost_pins.presentation.stem_width_px",
0.25,
12,
),
outlineColor: requireHexColor(
profile.outline_color,
"ghost_pins.presentation.outline_color",
),
outlineOpacity: requireNumber(
profile.outline_opacity,
"ghost_pins.presentation.outline_opacity",
0,
1,
),
outlineWidthPx: requireNumber(
profile.outline_width_px,
"ghost_pins.presentation.outline_width_px",
0,
8,
),
labelMode,
labelFontWeight: requireInteger(
profile.label_font_weight,
"ghost_pins.presentation.label_font_weight",
400,
700,
),
labelSizePx: requireNumber(
profile.label_size_px,
"ghost_pins.presentation.label_size_px",
8,
32,
),
labelColor: requireHexColor(
profile.label_color,
"ghost_pins.presentation.label_color",
),
labelBackgroundColor: requireHexColor(
profile.label_background_color,
"ghost_pins.presentation.label_background_color",
),
labelBackgroundOpacity: requireNumber(
profile.label_background_opacity,
"ghost_pins.presentation.label_background_opacity",
0,
1,
),
labelPaddingX: requireNumber(
profile.label_padding_x,
"ghost_pins.presentation.label_padding_x",
0,
32,
),
labelPaddingY: requireNumber(
profile.label_padding_y,
"ghost_pins.presentation.label_padding_y",
0,
32,
),
labelOffsetX: requireNumber(
profile.label_offset_x,
"ghost_pins.presentation.label_offset_x",
-100,
100,
),
labelOffsetY: requireNumber(
profile.label_offset_y,
"ghost_pins.presentation.label_offset_y",
-100,
100,
),
labelMaxLength: requireInteger(
profile.label_max_length,
"ghost_pins.presentation.label_max_length",
1,
64,
),
labelHideCameraHeightMeters: requireNumber(
profile.label_hide_camera_height_meters,
"ghost_pins.presentation.label_hide_camera_height_meters",
1_000,
500_000,
),
targetHideCameraHeightMeters: requireNumber(
profile.target_hide_camera_height_meters,
"ghost_pins.presentation.target_hide_camera_height_meters",
1_000,
500_000,
),
};
}