diff --git a/apps/control-station/src/components/map/GhostPinSettingsPanel.tsx b/apps/control-station/src/components/map/GhostPinSettingsPanel.tsx index 4505200..f2a4589 100644 --- a/apps/control-station/src/components/map/GhostPinSettingsPanel.tsx +++ b/apps/control-station/src/components/map/GhostPinSettingsPanel.tsx @@ -59,6 +59,27 @@ export function GhostPinSettingsPanel({ return current; })} /> + `${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; + })} + /> 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 safeMaximumSpeed = Math.min( + 100, + Math.max(0.1, maximumSpeedMetersPerSecond), + ); const labels = new Set(); return Array.from({ length: safeCount }, (_, index) => { const distance = safeRadius * Math.sqrt(clampUnit(random())); @@ -36,7 +42,8 @@ export function generateGhostPins({ label, ...position, headingDegrees: clampUnit(random()) * 360, - speedMetersPerSecond: 3 + clampUnit(random()) * 15, + speedMetersPerSecond: + safeMaximumSpeed * (0.2 + clampUnit(random()) * 0.8), }; }); } diff --git a/apps/control-station/src/core/map/mapView.ts b/apps/control-station/src/core/map/mapView.ts index c66d52c..d9d094c 100644 --- a/apps/control-station/src/core/map/mapView.ts +++ b/apps/control-station/src/core/map/mapView.ts @@ -59,6 +59,7 @@ export interface MapGhostPinPresentation { export interface MapGhostPinConfiguration { count: number; radiusMeters: number; + maximumSpeedMetersPerSecond: number; simulationEnabled: boolean; anchor: MapGeoPoint | null; positions: MapGhostPin[]; @@ -226,6 +227,7 @@ export function defaultGhostPinConfiguration(): MapGhostPinConfiguration { return { count: 12, radiusMeters: 5_000, + maximumSpeedMetersPerSecond: 18, simulationEnabled: false, anchor: null, positions: [], @@ -341,6 +343,8 @@ export function encodeMapViewPut(document: MapViewDocument): unknown { ghost_pins: { count: document.view.ghostPins.count, radius_meters: document.view.ghostPins.radiusMeters, + maximum_speed_meters_per_second: + document.view.ghostPins.maximumSpeedMetersPerSecond, simulation_enabled: document.view.ghostPins.simulationEnabled, anchor: document.view.ghostPins.anchor ? { @@ -466,6 +470,7 @@ function decodeGhostPinConfiguration(value: unknown): MapGhostPinConfiguration { [ "count", "radius_meters", + "maximum_speed_meters_per_second", "simulation_enabled", "anchor", "positions", @@ -483,6 +488,23 @@ function decodeGhostPinConfiguration(value: unknown): MapGhostPinConfiguration { if (positions.length > 100) { 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 { count: requireInteger(configuration.count, "ghost_pins.count", 1, 100), radiusMeters: requireNumber( @@ -491,15 +513,13 @@ function decodeGhostPinConfiguration(value: unknown): MapGhostPinConfiguration { 100, 100_000, ), + maximumSpeedMetersPerSecond, simulationEnabled: requireBoolean( configuration.simulation_enabled, "ghost_pins.simulation_enabled", ), anchor, - positions: positions.map((pin, index) => decodeGhostPin( - pin, - `ghost_pins.positions[${index}]`, - )), + positions: decodedPositions, presentation: decodeGhostPinPresentation(configuration.presentation), }; } diff --git a/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx b/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx index ec81c26..5986293 100644 --- a/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx +++ b/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx @@ -119,6 +119,8 @@ export function WorldMapWorkspace({ anchor, count: current.view.ghostPins.count, radiusMeters: current.view.ghostPins.radiusMeters, + maximumSpeedMetersPerSecond: + current.view.ghostPins.maximumSpeedMetersPerSecond, }); current.view.layerVisibility.targets = true; return current; diff --git a/apps/control-station/test/ghostPins.test.mjs b/apps/control-station/test/ghostPins.test.mjs index 0da3ae5..7ecf3ee 100644 --- a/apps/control-station/test/ghostPins.test.mjs +++ b/apps/control-station/test/ghostPins.test.mjs @@ -26,6 +26,7 @@ test("ghost pins are generated inside the requested current-view radius", () => anchor: { longitude: 37.618423, latitude: 55.751244 }, count: 3, radiusMeters: 1_000, + maximumSpeedMetersPerSecond: 12, random: () => samples[index++ % samples.length], 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.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.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", () => { @@ -42,6 +44,7 @@ test("numeric pin names remain unique with a degenerate random source", () => { anchor: { longitude: 37.618423, latitude: 55.751244 }, count: 4, radiusMeters: 1_000, + maximumSpeedMetersPerSecond: 18, random: () => 0, timestamp: 42, }); diff --git a/apps/control-station/test/mapView.test.mjs b/apps/control-station/test/mapView.test.mjs index c9edc74..5b5ab51 100644 --- a/apps/control-station/test/mapView.test.mjs +++ b/apps/control-station/test/mapView.test.mjs @@ -92,6 +92,7 @@ function serverDocument(overrides = {}) { ghost_pins: { count: 12, radius_meters: 5000, + maximum_speed_meters_per_second: 18, simulation_enabled: false, anchor: null, 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.noOverwrite, true); assert.equal(document.view.ghostPins.count, 12); + assert.equal(document.view.ghostPins.maximumSpeedMetersPerSecond, 18); 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(encoded.view.camera.latitude, 55.7558); 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("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 () => { const source = await readFile( new URL("../src/workspaces/map/WorldMapWorkspace.tsx", import.meta.url), diff --git a/docs/21_MISSION_CORE_MAP_PAGE_INTEGRATION_PLAN.md b/docs/21_MISSION_CORE_MAP_PAGE_INTEGRATION_PLAN.md index 3ab0c3b..5224a94 100644 --- a/docs/21_MISSION_CORE_MAP_PAGE_INTEGRATION_PLAN.md +++ b/docs/21_MISSION_CORE_MAP_PAGE_INTEGRATION_PLAN.md @@ -219,8 +219,10 @@ revision: - selected stable subject id, or `null`; - layer visibility; - a bounded temporary Ghost Pin sandbox: generation settings, actual - coordinates, motion state and provider-neutral `elevated-spike` - presentation; + coordinates, persisted maximum movement speed, motion state and + 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. Resolution order: diff --git a/src/k1link/web/map_view_api.py b/src/k1link/web/map_view_api.py index 2530c61..ef269fe 100644 --- a/src/k1link/web/map_view_api.py +++ b/src/k1link/web/map_view_api.py @@ -151,6 +151,11 @@ class MapGhostPinPresentation(StrictMapModel): class MapGhostPinConfiguration(StrictMapModel): count: int = Field(default=12, ge=1, le=100) 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 anchor: MapGeoPoint | None = None 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] if len(ids) != len(set(ids)): 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 diff --git a/tests/test_map_view_api.py b/tests/test_map_view_api.py index 5b5551f..acabf56 100644 --- a/tests/test_map_view_api.py +++ b/tests/test_map_view_api.py @@ -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.no_overwrite is True 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.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" +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( tmp_path: Path, ) -> None: