feat(map): control ghost pin movement speed
This commit is contained in:
@@ -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),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user