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