feat(map): control ghost pin movement speed

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 18:53:00 +03:00
parent 0c3855eac5
commit 2678295ca7
9 changed files with 121 additions and 8 deletions
@@ -59,6 +59,27 @@ export function GhostPinSettingsPanel({
return current; return current;
})} })}
/> />
<RangeControl
label="Максимальная скорость"
value={configuration.maximumSpeedMetersPerSecond}
min={1}
max={100}
step={1}
formatValue={(value) => `${value} м/с`}
onChange={(maximumSpeedMetersPerSecond) => updateDraft((current) => {
current.view.ghostPins.maximumSpeedMetersPerSecond =
maximumSpeedMetersPerSecond;
current.view.ghostPins.positions =
current.view.ghostPins.positions.map((pin) => ({
...pin,
speedMetersPerSecond: Math.min(
pin.speedMetersPerSecond,
maximumSpeedMetersPerSecond,
),
}));
return current;
})}
/>
<Checker <Checker
label="Симуляция движения" label="Симуляция движения"
checked={configuration.simulationEnabled} checked={configuration.simulationEnabled}
@@ -9,17 +9,23 @@ export function generateGhostPins({
anchor, anchor,
count, count,
radiusMeters, radiusMeters,
maximumSpeedMetersPerSecond,
random = Math.random, random = Math.random,
timestamp = Date.now(), timestamp = Date.now(),
}: { }: {
anchor: MapGeoPoint; anchor: MapGeoPoint;
count: number; count: number;
radiusMeters: number; radiusMeters: number;
maximumSpeedMetersPerSecond: number;
random?: () => number; random?: () => number;
timestamp?: number; timestamp?: number;
}): MapGhostPin[] { }): MapGhostPin[] {
const safeCount = Math.min(100, Math.max(1, Math.round(count))); const safeCount = Math.min(100, Math.max(1, Math.round(count)));
const safeRadius = Math.min(100_000, Math.max(100, radiusMeters)); const safeRadius = Math.min(100_000, Math.max(100, radiusMeters));
const safeMaximumSpeed = Math.min(
100,
Math.max(0.1, maximumSpeedMetersPerSecond),
);
const labels = new Set<string>(); const labels = new Set<string>();
return Array.from({ length: safeCount }, (_, index) => { return Array.from({ length: safeCount }, (_, index) => {
const distance = safeRadius * Math.sqrt(clampUnit(random())); const distance = safeRadius * Math.sqrt(clampUnit(random()));
@@ -36,7 +42,8 @@ export function generateGhostPins({
label, label,
...position, ...position,
headingDegrees: clampUnit(random()) * 360, headingDegrees: clampUnit(random()) * 360,
speedMetersPerSecond: 3 + clampUnit(random()) * 15, speedMetersPerSecond:
safeMaximumSpeed * (0.2 + clampUnit(random()) * 0.8),
}; };
}); });
} }
+24 -4
View File
@@ -59,6 +59,7 @@ export interface MapGhostPinPresentation {
export interface MapGhostPinConfiguration { export interface MapGhostPinConfiguration {
count: number; count: number;
radiusMeters: number; radiusMeters: number;
maximumSpeedMetersPerSecond: number;
simulationEnabled: boolean; simulationEnabled: boolean;
anchor: MapGeoPoint | null; anchor: MapGeoPoint | null;
positions: MapGhostPin[]; positions: MapGhostPin[];
@@ -226,6 +227,7 @@ export function defaultGhostPinConfiguration(): MapGhostPinConfiguration {
return { return {
count: 12, count: 12,
radiusMeters: 5_000, radiusMeters: 5_000,
maximumSpeedMetersPerSecond: 18,
simulationEnabled: false, simulationEnabled: false,
anchor: null, anchor: null,
positions: [], positions: [],
@@ -341,6 +343,8 @@ export function encodeMapViewPut(document: MapViewDocument): unknown {
ghost_pins: { ghost_pins: {
count: document.view.ghostPins.count, count: document.view.ghostPins.count,
radius_meters: document.view.ghostPins.radiusMeters, radius_meters: document.view.ghostPins.radiusMeters,
maximum_speed_meters_per_second:
document.view.ghostPins.maximumSpeedMetersPerSecond,
simulation_enabled: document.view.ghostPins.simulationEnabled, simulation_enabled: document.view.ghostPins.simulationEnabled,
anchor: document.view.ghostPins.anchor anchor: document.view.ghostPins.anchor
? { ? {
@@ -466,6 +470,7 @@ function decodeGhostPinConfiguration(value: unknown): MapGhostPinConfiguration {
[ [
"count", "count",
"radius_meters", "radius_meters",
"maximum_speed_meters_per_second",
"simulation_enabled", "simulation_enabled",
"anchor", "anchor",
"positions", "positions",
@@ -483,6 +488,23 @@ function decodeGhostPinConfiguration(value: unknown): MapGhostPinConfiguration {
if (positions.length > 100) { if (positions.length > 100) {
throw new Error("ghost_pins.positions превышает лимит."); throw new Error("ghost_pins.positions превышает лимит.");
} }
const maximumSpeedMetersPerSecond = requireNumber(
configuration.maximum_speed_meters_per_second,
"ghost_pins.maximum_speed_meters_per_second",
0.1,
100,
);
const decodedPositions = positions.map((pin, index) => decodeGhostPin(
pin,
`ghost_pins.positions[${index}]`,
));
if (
decodedPositions.some(
(pin) => pin.speedMetersPerSecond > maximumSpeedMetersPerSecond,
)
) {
throw new Error("Скорость Ghost Pin превышает настроенный максимум.");
}
return { return {
count: requireInteger(configuration.count, "ghost_pins.count", 1, 100), count: requireInteger(configuration.count, "ghost_pins.count", 1, 100),
radiusMeters: requireNumber( radiusMeters: requireNumber(
@@ -491,15 +513,13 @@ function decodeGhostPinConfiguration(value: unknown): MapGhostPinConfiguration {
100, 100,
100_000, 100_000,
), ),
maximumSpeedMetersPerSecond,
simulationEnabled: requireBoolean( simulationEnabled: requireBoolean(
configuration.simulation_enabled, configuration.simulation_enabled,
"ghost_pins.simulation_enabled", "ghost_pins.simulation_enabled",
), ),
anchor, anchor,
positions: positions.map((pin, index) => decodeGhostPin( positions: decodedPositions,
pin,
`ghost_pins.positions[${index}]`,
)),
presentation: decodeGhostPinPresentation(configuration.presentation), presentation: decodeGhostPinPresentation(configuration.presentation),
}; };
} }
@@ -119,6 +119,8 @@ export function WorldMapWorkspace({
anchor, anchor,
count: current.view.ghostPins.count, count: current.view.ghostPins.count,
radiusMeters: current.view.ghostPins.radiusMeters, radiusMeters: current.view.ghostPins.radiusMeters,
maximumSpeedMetersPerSecond:
current.view.ghostPins.maximumSpeedMetersPerSecond,
}); });
current.view.layerVisibility.targets = true; current.view.layerVisibility.targets = true;
return current; return current;
+4 -1
View File
@@ -26,6 +26,7 @@ test("ghost pins are generated inside the requested current-view radius", () =>
anchor: { longitude: 37.618423, latitude: 55.751244 }, anchor: { longitude: 37.618423, latitude: 55.751244 },
count: 3, count: 3,
radiusMeters: 1_000, radiusMeters: 1_000,
maximumSpeedMetersPerSecond: 12,
random: () => samples[index++ % samples.length], random: () => samples[index++ % samples.length],
timestamp: 42, timestamp: 42,
}); });
@@ -34,7 +35,8 @@ test("ghost pins are generated inside the requested current-view radius", () =>
assert.equal(new Set(pins.map((pin) => pin.label)).size, 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) => /^\d{6}$/.test(pin.label)));
assert.ok(pins.every((pin) => pin.id.startsWith("ghost-16-"))); assert.ok(pins.every((pin) => pin.id.startsWith("ghost-16-")));
assert.ok(pins.every((pin) => pin.speedMetersPerSecond >= 3)); assert.ok(pins.every((pin) => pin.speedMetersPerSecond >= 2.4));
assert.ok(pins.every((pin) => pin.speedMetersPerSecond <= 12));
}); });
test("numeric pin names remain unique with a degenerate random source", () => { test("numeric pin names remain unique with a degenerate random source", () => {
@@ -42,6 +44,7 @@ test("numeric pin names remain unique with a degenerate random source", () => {
anchor: { longitude: 37.618423, latitude: 55.751244 }, anchor: { longitude: 37.618423, latitude: 55.751244 },
count: 4, count: 4,
radiusMeters: 1_000, radiusMeters: 1_000,
maximumSpeedMetersPerSecond: 18,
random: () => 0, random: () => 0,
timestamp: 42, timestamp: 42,
}); });
@@ -92,6 +92,7 @@ function serverDocument(overrides = {}) {
ghost_pins: { ghost_pins: {
count: 12, count: 12,
radius_meters: 5000, radius_meters: 5000,
maximum_speed_meters_per_second: 18,
simulation_enabled: false, simulation_enabled: false,
anchor: null, anchor: null,
positions: [], positions: [],
@@ -134,6 +135,7 @@ test("map view starts from the canonical Foundry camera without a fabricated sub
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.count, 12);
assert.equal(document.view.ghostPins.maximumSpeedMetersPerSecond, 18);
assert.equal(document.view.ghostPins.positions.length, 0); assert.equal(document.view.ghostPins.positions.length, 0);
}); });
@@ -160,6 +162,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.maximum_speed_meters_per_second, 18);
assert.equal(encoded.view.ghost_pins.presentation.stem_height_meters, 1500); 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);
}); });
@@ -184,6 +187,29 @@ test("map view fails closed on credentials, runtime URLs and unbound selection",
); );
}); });
test("map view rejects a ghost pin faster than its configured maximum", () => {
assert.throws(
() => mapView.decodeMapViewDocument(serverDocument({
view: {
...serverDocument().view,
ghost_pins: {
...serverDocument().view.ghost_pins,
maximum_speed_meters_per_second: 5,
positions: [{
id: "ghost-fast-1",
label: "123456",
longitude: 37.618423,
latitude: 55.751244,
heading_degrees: 90,
speed_meters_per_second: 6,
}],
},
},
})),
/превышает настроенный максимум/i,
);
});
test("map workspace keeps the canonical top actions without a bottom toolbar", async () => { test("map workspace keeps the canonical top actions without a bottom toolbar", async () => {
const source = await readFile( const source = await readFile(
new URL("../src/workspaces/map/WorldMapWorkspace.tsx", import.meta.url), new URL("../src/workspaces/map/WorldMapWorkspace.tsx", import.meta.url),
@@ -219,8 +219,10 @@ revision:
- 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 - a bounded temporary Ghost Pin sandbox: generation settings, actual
coordinates, motion state and provider-neutral `elevated-spike` coordinates, persisted maximum movement speed, motion state and
presentation; provider-neutral `elevated-spike` presentation. Heads and stems resolve
terrain height through live Cesium properties so asynchronous tile loading
cannot leave only a depth-test-independent head visible;
- no credentials, upstream URLs, cache objects or transient Cesium ids. - no credentials, upstream URLs, cache objects or transient Cesium ids.
Resolution order: Resolution order:
+10
View File
@@ -151,6 +151,11 @@ class MapGhostPinPresentation(StrictMapModel):
class MapGhostPinConfiguration(StrictMapModel): class MapGhostPinConfiguration(StrictMapModel):
count: int = Field(default=12, ge=1, le=100) count: int = Field(default=12, ge=1, le=100)
radius_meters: float = Field(default=5_000.0, ge=100.0, le=100_000.0) radius_meters: float = Field(default=5_000.0, ge=100.0, le=100_000.0)
maximum_speed_meters_per_second: float = Field(
default=18.0,
ge=0.1,
le=100.0,
)
simulation_enabled: bool = False simulation_enabled: bool = False
anchor: MapGeoPoint | None = None anchor: MapGeoPoint | None = None
positions: list[MapGhostPin] = Field(default_factory=list, max_length=100) positions: list[MapGhostPin] = Field(default_factory=list, max_length=100)
@@ -163,6 +168,11 @@ class MapGhostPinConfiguration(StrictMapModel):
ids = [pin.id for pin in self.positions] ids = [pin.id for pin in self.positions]
if len(ids) != len(set(ids)): if len(ids) != len(set(ids)):
raise ValueError("ghost pin ids must be unique") raise ValueError("ghost pin ids must be unique")
if any(
pin.speed_meters_per_second > self.maximum_speed_meters_per_second
for pin in self.positions
):
raise ValueError("ghost pin speed exceeds configured maximum")
return self return self
+22
View File
@@ -35,6 +35,7 @@ def test_default_map_view_matches_the_canonical_foundry_scene_and_cache_policy()
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.count == 12
assert document.view.ghost_pins.maximum_speed_meters_per_second == 18
assert document.view.ghost_pins.positions == [] assert document.view.ghost_pins.positions == []
assert document.view.ghost_pins.presentation.stem_height_meters == 1_500 assert document.view.ghost_pins.presentation.stem_height_meters == 1_500
@@ -96,6 +97,27 @@ def test_selection_inspector_requires_real_stable_subject() -> None:
assert selected.selected_subject_id == "map.moving_object/device-006" assert selected.selected_subject_id == "map.moving_object/device-006"
def test_ghost_pin_speed_cannot_exceed_configured_maximum() -> None:
with pytest.raises(ValidationError, match="speed exceeds configured maximum"):
MapViewContent.model_validate(
{
"ghost_pins": {
"maximum_speed_meters_per_second": 5,
"positions": [
{
"id": "ghost-fast-1",
"label": "123456",
"longitude": 37.618423,
"latitude": 55.751244,
"heading_degrees": 90,
"speed_meters_per_second": 6,
}
],
}
}
)
def test_persisted_document_contains_no_runtime_or_credential_material( def test_persisted_document_contains_no_runtime_or_credential_material(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None: