From 4374c0fa60058ce865ff7147ae3a1c9ce77ca39d Mon Sep 17 00:00:00 2001 From: Tom-Neverwinter <3943634+Tom-Neverwinter@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:21:08 -0700 Subject: [PATCH 1/6] feat: add mapped ALPR cameras with bounded source loading Adapt the ALPR camera contribution to reusable layer components, retaining source attribution, bounded queries, selection and voice controls. Co-authored-by: Bilawal Sidhu --- CHANGELOG.md | 7 + DATA_SOURCES.md | 27 + docs/CURRENT-STATE.md | 8 + package.json | 5 +- scripts/format-scope.json | 10 +- scripts/package-boundaries.json | 379 ++++++++++--- server/providers/openai/tools.js | 4 +- src/data/alprCameras.js | 13 + src/data/alprCameras.test.mjs | 915 +++++++++++++++++++++++++++++++ src/data/dataCredits.js | 8 + src/data/layerState.js | 1 + src/data/layerState.test.mjs | 4 +- src/data/trackedReadout.js | 5 +- src/data/trackedReadout.test.mjs | 12 + src/firstRunExperience.test.mjs | 5 +- src/layers/alpr/index.js | 343 ++++++++++++ src/layers/alpr/model.js | 157 ++++++ src/layers/alpr/policy.js | 54 ++ src/layers/alpr/presentation.js | 284 ++++++++++ src/layers/alpr/source.js | 55 ++ src/layers/alpr/source.test.mjs | 36 ++ src/loadingFeedback.js | 44 +- src/loadingFeedback.test.mjs | 28 + src/radioMarkup.test.mjs | 3 +- src/standalone/data.js | 2 + src/ui/styles/foundation.css | 19 + src/voice/gevActions.js | 6 + src/voice/gevActions.test.mjs | 24 + 28 files changed, 2358 insertions(+), 100 deletions(-) create mode 100644 src/data/alprCameras.js create mode 100644 src/data/alprCameras.test.mjs create mode 100644 src/layers/alpr/index.js create mode 100644 src/layers/alpr/model.js create mode 100644 src/layers/alpr/policy.js create mode 100644 src/layers/alpr/presentation.js create mode 100644 src/layers/alpr/source.js create mode 100644 src/layers/alpr/source.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 49e15a285..b9988b044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## ALPR camera locations + +- Add optional, source-labeled OpenStreetMap ALPR camera locations, bounded city queries, + cached-response and coverage notices, selection cards, share links, and voice toggles. +- Separate the request adapter, camera model, presentation, and instance lifecycle. + Source cancellation also guards late response bodies and rejects invalid query bounds. + ## Release disabled infrastructure rendering - Remove built Data Center, Dam and Submarine Cable entities when their layers diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 650d3fe6b..0bc164162 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -15,6 +15,7 @@ How to read this: | Source | Used for | License / terms | Attribution | |--------|----------|-----------------|-------------| +| **OpenStreetMap ALPR camera locations** (including DeFlock community mapping) | Optional mapped automatic license-plate-reader camera layer | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/); commercial use permitted with applicable attribution and database share-alike obligations | [© OpenStreetMap contributors](https://www.openstreetmap.org/copyright); [DeFlock](https://deflock.org) community mapping | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., *"Bringing Up OpenSky"*, IPSN 2014 + opensky-network.org | | **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` | @@ -75,6 +76,32 @@ The submarine-cable GeoJSON is **CC BY-NC-SA 3.0** (Attribution-**NonCommercial* The richer structured dataset is licensed separately/commercially by TeleGeography. +### ALPR camera mapping + +The optional ALPR layer queries OpenStreetMap nodes tagged `man_made=surveillance` +and `surveillance:type=ALPR` (including case-insensitive semicolon multi-values) +through the shared Overpass proxy. Source/tag reference: +[OSM ALPR tagging](https://wiki.openstreetmap.org/wiki/Tag:surveillance:type%3DALPR). +No DeFlock software, branded assets or separate database is bundled. The layer +shows contributor-supplied locations and tags, not plate records, camera video, +current operating status, or exhaustive coverage. All manufacturers share the +ALPR category and marker color. Manufacturer, operator and camera type are shown +only when tagged, not inferred or independently verified. Every selected card +names OpenStreetMap in a smaller, muted source line beneath those details. + +Queries are limited to a city-scale viewport (at most 3° per axis), at most +1,500 returned nodes, with outward-snapped query reuse and explicit stale/limited +coverage notices. A linked © OpenStreetMap credit appears for five +seconds when camera locations first display after enabling, then collapses; +Data attribution retains the full source/license entry, following the +[OSMF interactive-map guidance](https://osmfoundation.org/wiki/Licence/Attribution_Guidelines). +OSM data remains separately attributed under ODbL; querying +at runtime does not replace the license obligations. ODbL permits commercial +use and does not license the application code. If publicly using a derivative +database, follow its share-alike and access requirements; keep independent +datasets separate rather than treating the whole collection as MIT data. +Overpass hosting capacity and usage policies are separate from the data license. + ### ODbL share-alike (datacenters, dams) The OSM-derived datasets are under the **Open Database License**. ODbL's share-alike applies to the **data / derived database, not this MIT-licensed code** — the two coexist (exactly how Open Infrastructure Map ships: MIT software + ODbL data). If you publicly distribute a *modified* version of these databases, you must offer it under ODbL. Keep the "© OpenStreetMap contributors" notice (link: https://www.openstreetmap.org/copyright). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0f4c03167..373f01e8d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,13 @@ # God's Eye View Current State +The optional **ALPR Cameras** layer shows community-mapped OpenStreetMap locations, +not camera footage or plate records. City-scale queries use the existing Overpass +provider, with capped results, retry, cached-data and incomplete-coverage notices. +Its `./layers/alpr` entry exposes a per-instance layer factory and bounded source +adapter. The standalone registration supplies selection, picking, terrain and +render services. Selected cards and Data attribution identify OpenStreetMap. +Layer state and the existing voice layer tools include `alpr-cameras`. + Data Centers, Dams and Submarine Cables release their built Cesium data sources and record references when disabled. Parsed datasets remain cached for the layer lifetime, so re-enable rebuilds entities without downloading or parsing again; diff --git a/package.json b/package.json index da56ce8e5..471285140 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gods-eye-view", "private": true, "version": "0.1.1", - "description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", + "description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", "type": "module", "license": "MIT", "author": "Bilawal Sidhu", @@ -155,6 +155,7 @@ "./layers/awareness": "./src/layers/awareness/index.js", "./layers/traffic": "./src/layers/traffic/index.js", "./layers/bikeshare": "./src/layers/bikeshare/index.js", - "./layers/cctv": "./src/layers/cctv/index.js" + "./layers/cctv": "./src/layers/cctv/index.js", + "./layers/alpr": "./src/layers/alpr/index.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 4af247603..ccac80a9b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -455,5 +455,13 @@ "src/layers/cctv/ground.js", "src/layers/cctv/health.js", "src/data/cctv.js", - "src/layers/cctv/source.test.mjs" + "src/layers/cctv/source.test.mjs", + "src/data/alprCameras.js", + "src/data/alprCameras.test.mjs", + "src/layers/alpr/index.js", + "src/layers/alpr/model.js", + "src/layers/alpr/policy.js", + "src/layers/alpr/presentation.js", + "src/layers/alpr/source.js", + "src/layers/alpr/source.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a32644391..66bf3adf2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,27 +10,47 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "application": { - "exports": ["./application"], - "modules": ["src/app/application.js"], + "exports": [ + "./application" + ], + "modules": [ + "src/app/application.js" + ], "external": [] }, "viewer": { - "exports": ["./application/viewer"], - "modules": ["src/app/viewer.js"], - "external": ["cesium"] + "exports": [ + "./application/viewer" + ], + "modules": [ + "src/app/viewer.js" + ], + "external": [ + "cesium" + ] }, "vite-build": { "runtime": "node", - "exports": ["./build/vite"], - "modules": ["build/vite.js"], - "external": ["vite-plugin-cesium"] + "exports": [ + "./build/vite" + ], + "modules": [ + "build/vite.js" + ], + "external": [ + "vite-plugin-cesium" + ] }, "live-providers": { "runtime": "node", - "exports": ["./server/providers/live"], + "exports": [ + "./server/providers/live" + ], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -45,16 +65,24 @@ "src/data/aisStreamAdapter.js", "src/data/aisWatchdog.js" ], - "external": ["ws"] + "external": [ + "ws" + ] }, "adsb-lol-source": { - "exports": ["./sources/adsb-lol"], - "modules": ["src/data/adsbLolFallback.js"], + "exports": [ + "./sources/adsb-lol" + ], + "modules": [ + "src/data/adsbLolFallback.js" + ], "external": [] }, "place-providers": { "runtime": "node", - "exports": ["./server/providers/places"], + "exports": [ + "./server/providers/places" + ], "modules": [ "server/providers/places.js", "server/providers/places/google.js", @@ -69,13 +97,19 @@ "external": [] }, "place-payloads": { - "exports": ["./sources/places"], - "modules": ["src/data/placeProviderPayloads.js"], + "exports": [ + "./sources/places" + ], + "modules": [ + "src/data/placeProviderPayloads.js" + ], "external": [] }, "space-providers": { "runtime": "node", - "exports": ["./server/providers/space"], + "exports": [ + "./server/providers/space" + ], "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", @@ -86,13 +120,19 @@ "external": [] }, "space-requests": { - "exports": ["./sources/space"], - "modules": ["src/data/spaceProviderRequests.js"], + "exports": [ + "./sources/space" + ], + "modules": [ + "src/data/spaceProviderRequests.js" + ], "external": [] }, "terrain-provider": { "runtime": "node", - "exports": ["./server/providers/terrain"], + "exports": [ + "./server/providers/terrain" + ], "modules": [ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js" @@ -100,30 +140,50 @@ "external": [] }, "terrain-source": { - "exports": ["./sources/terrain"], - "modules": ["src/data/terrainHeightsProxy.js"], + "exports": [ + "./sources/terrain" + ], + "modules": [ + "src/data/terrainHeightsProxy.js" + ], "external": [] }, "traffic-provider": { "runtime": "node", - "exports": ["./server/providers/traffic"], - "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], + "exports": [ + "./server/providers/traffic" + ], + "modules": [ + "server/providers/traffic.js", + "src/data/tomtomTiles.js" + ], "external": [] }, "traffic-source": { - "exports": ["./sources/traffic"], - "modules": ["src/data/tomtomTiles.js"], + "exports": [ + "./sources/traffic" + ], + "modules": [ + "src/data/tomtomTiles.js" + ], "external": [] }, "firms-provider": { "runtime": "node", - "exports": ["./server/providers/firms"], - "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], + "exports": [ + "./server/providers/firms" + ], + "modules": [ + "server/providers/firms.js", + "src/data/firmsCsv.js" + ], "external": [] }, "gbfs-provider": { "runtime": "node", - "exports": ["./server/providers/gbfs"], + "exports": [ + "./server/providers/gbfs" + ], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -132,13 +192,19 @@ "external": [] }, "gbfs-source": { - "exports": ["./sources/gbfs"], - "modules": ["src/data/gbfsSource.js"], + "exports": [ + "./sources/gbfs" + ], + "modules": [ + "src/data/gbfsSource.js" + ], "external": [] }, "cctv-provider": { "runtime": "node", - "exports": ["./server/providers/cctv"], + "exports": [ + "./server/providers/cctv" + ], "modules": [ "server/providers/cctv.js", "server/providers/cctv/catalog.js", @@ -155,7 +221,9 @@ }, "radio-provider": { "runtime": "node", - "exports": ["./server/providers/radio"], + "exports": [ + "./server/providers/radio" + ], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -169,7 +237,9 @@ }, "overpass-provider": { "runtime": "node", - "exports": ["./server/providers/overpass"], + "exports": [ + "./server/providers/overpass" + ], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -188,7 +258,9 @@ }, "military-installations-provider": { "runtime": "node", - "exports": ["./server/providers/military-installations"], + "exports": [ + "./server/providers/military-installations" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -205,7 +277,9 @@ }, "regional-provider": { "runtime": "node", - "exports": ["./server/providers/regional"], + "exports": [ + "./server/providers/regional" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -224,7 +298,9 @@ }, "openai-provider": { "runtime": "node", - "exports": ["./server/providers/openai"], + "exports": [ + "./server/providers/openai" + ], "modules": [ "server/providers/common/rate-limit.js", "server/providers/common/request.js", @@ -244,7 +320,9 @@ }, "standalone-key-setup": { "runtime": "node", - "exports": ["./server/standalone/key-setup"], + "exports": [ + "./server/standalone/key-setup" + ], "modules": [ "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", @@ -255,7 +333,9 @@ "external": [] }, "place-search": { - "exports": ["./search"], + "exports": [ + "./search" + ], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -265,17 +345,27 @@ "external": [] }, "panel-disclosure": { - "exports": ["./ui/panels"], - "modules": ["src/ui/panelDisclosure.js"], + "exports": [ + "./ui/panels" + ], + "modules": [ + "src/ui/panelDisclosure.js" + ], "external": [] }, "surface-keyboard": { - "exports": ["./ui/surfaces"], - "modules": ["src/ui/surfaceKeyboard.js"], + "exports": [ + "./ui/surfaces" + ], + "modules": [ + "src/ui/surfaceKeyboard.js" + ], "external": [] }, "panel-rail-layout": { - "exports": ["./ui/layout"], + "exports": [ + "./ui/layout" + ], "modules": [ "src/ui/panelRails.js", "src/ui/leftPanelRail.js", @@ -287,7 +377,9 @@ "external": [] }, "visual-input": { - "exports": ["./ui/input"], + "exports": [ + "./ui/input" + ], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -296,12 +388,19 @@ "external": [] }, "display-controls": { - "exports": ["./ui/display"], - "modules": ["src/ui/displayControls.js"], + "exports": [ + "./ui/display" + ], + "modules": [ + "src/ui/displayControls.js" + ], "external": [] }, "visual-effects": { - "exports": ["./ui/effects", "./ui/effects/bloom"], + "exports": [ + "./ui/effects", + "./ui/effects/bloom" + ], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -314,10 +413,14 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "map-source-controls": { - "exports": ["./ui/maps"], + "exports": [ + "./ui/maps" + ], "modules": [ "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -327,7 +430,10 @@ "external": [] }, "layer-panel": { - "exports": ["./ui/layers", "./ui/layers/feedback"], + "exports": [ + "./ui/layers", + "./ui/layers/feedback" + ], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -338,7 +444,9 @@ "external": [] }, "location-controls": { - "exports": ["./ui/location"], + "exports": [ + "./ui/location" + ], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -349,7 +457,9 @@ "external": [] }, "radio-controls": { - "exports": ["./ui/radio"], + "exports": [ + "./ui/radio" + ], "modules": [ "src/ui/radio.js", "src/ui/radioControls.js", @@ -360,7 +470,9 @@ "external": [] }, "cctv-controls": { - "exports": ["./ui/cctv"], + "exports": [ + "./ui/cctv" + ], "modules": [ "src/ui/cctv.js", "src/ui/cctvBindings.js", @@ -372,7 +484,10 @@ "external": [] }, "context-controls": { - "exports": ["./ui/context", "./ui/context/policy"], + "exports": [ + "./ui/context", + "./ui/context/policy" + ], "modules": [ "src/ui/context.js", "src/ui/contextActions.js", @@ -414,10 +529,14 @@ "src/cockpitVisionPolicy.js", "src/ui/panelRailGeometry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "scene-controls": { - "exports": ["./ui/scenes"], + "exports": [ + "./ui/scenes" + ], "modules": [ "src/ui/scenes.js", "src/ui/sceneControls.js", @@ -426,7 +545,9 @@ "external": [] }, "ui-shell": { - "exports": ["./ui/shell"], + "exports": [ + "./ui/shell" + ], "modules": [ "src/bloom.js", "src/cctvFocusPolicy.js", @@ -523,10 +644,14 @@ "src/app/stateChannel.js", "src/ui/frameRateMonitor.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-styles": { - "exports": ["./ui/styles"], + "exports": [ + "./ui/styles" + ], "modules": [ "src/ui/styles.js", "style.css", @@ -553,7 +678,9 @@ "external": [] }, "scenes": { - "exports": ["./scenes"], + "exports": [ + "./scenes" + ], "modules": [ "src/scenes/director.js", "src/scenes/recipes.js", @@ -565,10 +692,14 @@ "src/ui/scenePresentation.js", "src/contextModePolicy.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "live-observations": { - "exports": ["./sources/live"], + "exports": [ + "./sources/live" + ], "modules": [ "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -579,7 +710,9 @@ "external": [] }, "civil-flights": { - "exports": ["./layers/flights"], + "exports": [ + "./layers/flights" + ], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -610,10 +743,14 @@ "src/layers/flights/testing.js", "src/layers/flights/tracking.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "military-flights": { - "exports": ["./layers/military"], + "exports": [ + "./layers/military" + ], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -641,10 +778,14 @@ "src/layers/military/testing.js", "src/layers/military/tracking.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "aircraft-mechanics": { - "exports": ["./aircraft"], + "exports": [ + "./aircraft" + ], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -664,10 +805,14 @@ "src/layers/aircraft/classification.js", "src/layers/aircraft/index.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "vessel-layer": { - "exports": ["./layers/vessels"], + "exports": [ + "./layers/vessels" + ], "modules": [ "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -685,19 +830,27 @@ "src/data/iconOrientation.js", "src/data/vesselLabels.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "earthquakes": { - "exports": ["./layers/earthquakes"], + "exports": [ + "./layers/earthquakes" + ], "modules": [ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "fire-layer": { - "exports": ["./layers/firms"], + "exports": [ + "./layers/firms" + ], "modules": [ "src/data/firmsAdapt.js", "src/data/firmsLabels.js", @@ -716,10 +869,14 @@ "src/layers/firms/state.js", "src/layers/firms/viewport.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "satellites-layer": { - "exports": ["./layers/satellites"], + "exports": [ + "./layers/satellites" + ], "modules": [ "src/data/issPass.js", "src/data/satelliteClass.js", @@ -738,10 +895,15 @@ "src/layers/satellites/testing.js", "src/layers/satellites/tracking.js" ], - "external": ["cesium", "satellite.js"] + "external": [ + "cesium", + "satellite.js" + ] }, "launches-layer": { - "exports": ["./layers/launches"], + "exports": [ + "./layers/launches" + ], "modules": [ "src/layers/launches/camera.js", "src/layers/launches/controls.js", @@ -763,10 +925,14 @@ "src/layers/launches/state.js", "src/layers/launches/testing.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "installations-layer": { - "exports": ["./layers/installations"], + "exports": [ + "./layers/installations" + ], "modules": [ "src/data/installationFeedback.js", "src/data/militaryInstallationData.js", @@ -782,10 +948,14 @@ "src/layers/installations/state.js", "src/layers/installations/viewport.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "awareness-layer": { - "exports": ["./layers/awareness"], + "exports": [ + "./layers/awareness" + ], "modules": [ "src/cockpitMath.js", "src/data/iconOrientation.js", @@ -808,10 +978,14 @@ "src/layers/awareness/testing.js", "src/ui/panelRailGeometry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "traffic-layer": { - "exports": ["./layers/traffic"], + "exports": [ + "./layers/traffic" + ], "modules": [ "src/data/flowMatch.js", "src/data/tomtomTiles.js", @@ -836,10 +1010,16 @@ "src/layers/traffic/timing.js", "src/layers/traffic/viewport.js" ], - "external": ["cesium", "pbf", "@mapbox/vector-tile"] + "external": [ + "cesium", + "pbf", + "@mapbox/vector-tile" + ] }, "bikeshare-layer": { - "exports": ["./layers/bikeshare"], + "exports": [ + "./layers/bikeshare" + ], "modules": [ "src/layers/bikeshare/controls.js", "src/layers/bikeshare/index.js", @@ -856,10 +1036,14 @@ "src/layers/bikeshare/testing.js", "src/layers/bikeshare/viewport.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "cctv-layer": { - "exports": ["./layers/cctv"], + "exports": [ + "./layers/cctv" + ], "modules": [ "src/data/cctvCards.js", "src/data/cctvGizmo.js", @@ -892,6 +1076,23 @@ "src/layers/cctv/testing.js", "src/overlays/worldOverlayTokens.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] + }, + "alpr-cameras": { + "exports": [ + "./layers/alpr" + ], + "modules": [ + "src/layers/alpr/index.js", + "src/layers/alpr/model.js", + "src/layers/alpr/policy.js", + "src/layers/alpr/presentation.js", + "src/layers/alpr/source.js" + ], + "external": [ + "cesium" + ] } } diff --git a/server/providers/openai/tools.js b/server/providers/openai/tools.js index 66e2bfd2d..612bf3174 100644 --- a/server/providers/openai/tools.js +++ b/server/providers/openai/tools.js @@ -137,7 +137,7 @@ const GEV_REALTIME_TOOLS = [ layerId: { type: 'string', description: - 'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio.', + 'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras.', enum: [ 'flights', 'military', @@ -153,6 +153,7 @@ const GEV_REALTIME_TOOLS = [ 'local-dams', 'telegeography-submarine-cables', 'local-firms', + 'alpr-cameras', ], }, enabled: { type: 'boolean' }, @@ -185,6 +186,7 @@ const GEV_REALTIME_TOOLS = [ 'local-dams', 'telegeography-submarine-cables', 'local-firms', + 'alpr-cameras', ], description: 'Optional layer row to scroll into view and highlight.', }, diff --git a/src/data/alprCameras.js b/src/data/alprCameras.js new file mode 100644 index 000000000..40802d945 --- /dev/null +++ b/src/data/alprCameras.js @@ -0,0 +1,13 @@ +import { + createAlprCamerasLayer, + createOverpassAlprSource, +} from '../layers/alpr/index.js'; +import * as render from '../renderGovernor.js'; +import * as context from './contextStore.js'; +import * as picking from './pickRegistry.js'; +import * as groundFloor from './groundFloor.js'; +export * from '../layers/alpr/index.js'; +export default createAlprCamerasLayer({ + source: createOverpassAlprSource(), + services: { render, context, picking, groundFloor }, +}); diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs new file mode 100644 index 000000000..bc5d63583 --- /dev/null +++ b/src/data/alprCameras.test.mjs @@ -0,0 +1,915 @@ +// src/data/alprCameras.test.mjs +// Pure helpers and actual layer lifecycle with a minimal viewer/input harness. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import alprCamerasLayer, { createAlprCamerasLayer } from './alprCameras.js'; +import { DATA_CREDITS } from './dataCredits.js'; +import { + clearSelectedEntityContextForLayer, + getContextStore, + getSelectedEntityContext, + registerEntityContext, + selectEntityContext, +} from './contextStore.js'; +import { + MAX_RENDERED, + QUERY_LIMIT, + QUERY_SNAP_DEGREES, + QUERY_REUSE_MS, + alprRetryDelayMs, + boxContains, + buildOverpassQuery, + snapAlprBox, + destinationPointDeg, + isAlprSurveillanceType, + normalizeAlprNode, +} from './alprCameras.js'; + +test('normalizeAlprNode: maps a full OSM node to a plain record', () => { + const record = normalizeAlprNode({ + type: 'node', + id: 12345, + lat: 30.2672, + lon: -97.7431, + tags: { + man_made: 'surveillance', + 'surveillance:type': 'ALPR', + operator: 'Flock Safety', + 'camera:type': 'fixed', + 'surveillance:zone': 'traffic', + 'camera:direction': '270', + ref: 'ATX-001', + check_date: '2026-06-01', + }, + }); + assert.deepEqual(record, { + id: 'alpr:12345', + osmId: 12345, + latitude: 30.2672, + longitude: -97.7431, + operator: 'Flock Safety', + manufacturer: null, + cameraType: 'fixed', + zone: 'traffic', + directionDeg: 270, + ref: 'ATX-001', + lastVerified: '2026-06-01', + source: null, + }); +}); + +test('normalizeAlprNode: rejects non-node elements and missing coordinates', () => { + assert.equal(normalizeAlprNode(null), null); + assert.equal(normalizeAlprNode({ type: 'way', id: 1, tags: {} }), null); + assert.equal( + normalizeAlprNode({ type: 'node', id: 1, lat: NaN, lon: 1, tags: {} }), + null, + ); +}); + +test('normalizeAlprNode: a present-but-blank direction tag reads as missing, not zero', () => { + const record = normalizeAlprNode({ + type: 'node', + id: 1, + lat: 30, + lon: -97, + tags: { + man_made: 'surveillance', + 'surveillance:type': 'ALPR', + 'camera:direction': '', + }, + }); + assert.equal(record.directionDeg, null); +}); + +test('destinationPointDeg: due-north offset increases latitude, keeps longitude', () => { + const dest = destinationPointDeg(30, -97, 0, 100); + assert.ok(dest.latitude > 30); + assert.ok(Math.abs(dest.longitude - -97) < 1e-6); +}); + +test('alprRetryDelayMs: doubles from a 30s floor to a 240s ceiling', () => { + assert.equal(alprRetryDelayMs(0), 30000); + assert.equal(alprRetryDelayMs(30000), 60000); + assert.equal(alprRetryDelayMs(200000), 240000); + assert.equal(alprRetryDelayMs(240000), 240000); +}); + +test('isAlprSurveillanceType: accepts semicolon multi-values and mixed case, rejects other cameras', () => { + assert.equal(isAlprSurveillanceType('ALPR'), true); + assert.equal(isAlprSurveillanceType('alpr'), true); + assert.equal(isAlprSurveillanceType('camera;ALPR'), true); + assert.equal(isAlprSurveillanceType('ALPR; camera'), true); + assert.equal(isAlprSurveillanceType('camera'), false); + assert.equal(isAlprSurveillanceType('ALPRX'), false); + assert.equal(isAlprSurveillanceType(undefined), false); +}); + +test('normalizeAlprNode: keeps a camera;ALPR multi-value node and drops a plain camera', () => { + const multi = normalizeAlprNode({ + type: 'node', + id: 2, + lat: 30, + lon: -97, + tags: { man_made: 'surveillance', 'surveillance:type': 'camera;ALPR' }, + }); + assert.equal(multi?.id, 'alpr:2'); + const plain = normalizeAlprNode({ + type: 'node', + id: 3, + lat: 30, + lon: -97, + tags: { man_made: 'surveillance', 'surveillance:type': 'camera' }, + }); + assert.equal(plain, null); +}); + +test('buildOverpassQuery: matches multi-value tags case-insensitively and asks for the full cap', () => { + const query = buildOverpassQuery(30, -98, 31, -97); + assert.match( + query, + /\["surveillance:type"~"[^"]*ALPR[^"]*",i\]/, + 'regex, case-insensitive match', + ); + assert.doesNotMatch( + query, + /"surveillance:type"="ALPR"/, + 'no exact-only match', + ); + assert.ok(query.endsWith(`out body ${QUERY_LIMIT};`)); + assert.ok( + QUERY_LIMIT > 0 && QUERY_LIMIT <= 1500, + 'bounded query, not a global camera catalog', + ); + assert.ok( + MAX_RENDERED >= QUERY_LIMIT, + 'a render cap below the query cap silently hides cameras while count reports them', + ); +}); + +test('normalizeAlprNode: a whitespace-only direction tag reads as missing, not due north', () => { + const record = normalizeAlprNode({ + type: 'node', + id: 9, + lat: 30, + lon: -97, + tags: { + man_made: 'surveillance', + 'surveillance:type': 'ALPR', + 'camera:direction': ' ', + }, + }); + assert.equal(record.directionDeg, null); +}); + +test('snapAlprBox: snaps outward to the query grid and clamps to valid ranges', () => { + const snapped = snapAlprBox({ + south: 30.2671, + west: -97.7431, + north: 30.3012, + east: -97.7002, + }); + assert.deepEqual(snapped, { + south: 30.25, + west: -97.75, + north: 30.35, + east: -97.7, + }); + assert.ok( + snapped.south <= 30.2671 && + snapped.north >= 30.3012 && + snapped.west <= -97.7431 && + snapped.east >= -97.7002, + ); + assert.equal( + snapAlprBox({ south: 89.99, west: 179.99, north: 90, east: 180 }).north, + 90, + ); + assert.equal( + snapAlprBox({ south: -90, west: -180, north: -89.99, east: -179.99 }).west, + -180, + ); + assert.ok( + QUERY_SNAP_DEGREES > 0 && QUERY_SNAP_DEGREES <= 0.1, + 'grid stays a fraction of a metro so reuse cannot over-fetch', + ); +}); + +test('boxContains: a nudged view stays inside its snapped box; a real pan leaves it', () => { + const outer = snapAlprBox({ + south: 30.2671, + west: -97.7431, + north: 30.3012, + east: -97.7002, + }); + assert.equal( + boxContains(outer, { + south: 30.27, + west: -97.74, + north: 30.3, + east: -97.71, + }), + true, + ); + assert.equal( + boxContains(outer, { + south: 30.36, + west: -97.74, + north: 30.4, + east: -97.71, + }), + false, + ); + assert.equal(boxContains(null, outer), false); +}); + +const cameraNode = (id = 42, extra = {}) => ({ + type: 'node', + id, + lat: 30.2672, + lon: -97.7431, + tags: { + man_made: 'surveillance', + 'surveillance:type': 'ALPR', + manufacturer: 'Flock Safety', + }, + ...extra, +}); +const cameraResponse = (elements = [cameraNode()], stale = false) => ({ + ok: true, + status: 200, + headers: { + get: (name) => (name === 'x-overpass-cache' && stale ? 'STALE' : null), + }, + json: async () => ({ elements }), +}); + +// Real layer, Cesium entities and context store. Only the DOM, upstream fetch, +// camera rectangle and input event delivery are substituted (no GL in Node). +function cameraHarness(layer = alprCamerasLayer) { + const originals = { + document: globalThis.document, + window: globalThis.window, + fetch: globalThis.fetch, + now: Date.now, + }; + const events = new EventTarget(); + globalThis.document = { addEventListener() {}, removeEventListener() {} }; + globalThis.window = events; + let now = originals.now(); + Date.now = () => now; + let response = async () => cameraResponse(); + const requests = []; + globalThis.fetch = (...args) => { + requests.push(args); + return response(...args); + }; + let box = { south: 30.26, west: -97.75, north: 30.28, east: -97.73 }; + let source, click, picked; + const credits = new Set(); + const viewer = { + creditDisplay: { + addStaticCredit: (credit) => credits.add(credit), + removeStaticCredit: (credit) => credits.delete(credit), + }, + camera: { + moveEnd: new Cesium.Event(), + computeViewRectangle: () => + box + ? Cesium.Rectangle.fromDegrees( + box.west, + box.south, + box.east, + box.north, + ) + : null, + }, + scene: { + canvas: { addEventListener() {}, removeEventListener() {} }, + globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, + postRender: new Cesium.Event(), + pick: () => picked, + }, + dataSources: { + add: (value) => { + source = value; + }, + remove() {}, + }, + }; + const setInput = Cesium.ScreenSpaceEventHandler.prototype.setInputAction; + Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function ( + fn, + type, + modifier, + ) { + if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK) click = fn; + return setInput.call(this, fn, type, modifier); + }; + try { + layer.init(viewer); + } finally { + Cesium.ScreenSpaceEventHandler.prototype.setInputAction = setInput; + } + layer.enable(); + return { + viewer, + requests, + credits, + get source() { + return source; + }, + setFetch(fn) { + response = fn; + }, + setBox(value) { + box = value; + }, + expire() { + now += QUERY_REUSE_MS + 1; + }, + advance(ms) { + now += ms; + }, + click(id) { + const entity = typeof id === 'string' ? source.entities.getById(id) : id; + picked = entity ? { id: entity } : undefined; + click({ position: new Cesium.Cartesian2() }); + }, + restore() { + layer.destroy(viewer); + Date.now = originals.now; + for (const name of ['window', 'document', 'fetch']) { + if (originals[name] === undefined) delete globalThis[name]; + else globalThis[name] = originals[name]; + } + }, + }; +} + +test('OSM attribution introduces displayed data for five seconds, then stays discoverable in the overflow', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = cameraHarness(); + try { + assert.equal( + h.credits.size, + 0, + 'do not use up the introduction while data is still loading', + ); + await alprCamerasLayer.update(); + assert.equal(h.credits.size, 1); + const [credit] = h.credits; + assert.equal(credit.showOnScreen, true); + assert.match(credit.html, /class="gev-alpr-credit">ALPR:/); + assert.match( + credit.html, + /href="https:\/\/www.openstreetmap.org\/copyright"/, + ); + assert.match(credit.html, /© OpenStreetMap<\/a>/); + t.mock.timers.tick(4999); + assert.equal(h.credits.size, 1); + t.mock.timers.tick(1); + assert.equal(h.credits.size, 0); + const overflow = DATA_CREDITS.find((entry) => entry.key === 'alpr-osm'); + assert.match( + overflow.html, + /ALPR camera locations \(automatic license plate readers\)/, + ); + assert.match( + overflow.html, + /href="https:\/\/www.openstreetmap.org\/copyright"/, + ); + assert.match(overflow.html, /ODbL 1\.0/); + await alprCamerasLayer.update(); + assert.equal( + h.credits.size, + 0, + 'refresh must not keep reintroducing the credit', + ); + alprCamerasLayer.disable(); + assert.equal(h.credits.size, 0); + alprCamerasLayer.enable(); + await alprCamerasLayer.update(); + t.mock.timers.tick(4000); + alprCamerasLayer.enable(); + assert.equal(h.credits.size, 1); + t.mock.timers.tick(1000); + assert.equal( + h.credits.size, + 0, + 'duplicate enable must not restart the timer', + ); + alprCamerasLayer.disable(); + alprCamerasLayer.enable(); + await alprCamerasLayer.update(); + assert.equal(h.credits.size, 1); + } finally { + h.restore(); + } + assert.equal(h.credits.size, 0); + t.mock.timers.tick(5000); + assert.equal(h.credits.size, 0, 'destroy cancels the presentation timer'); +}); + +test('disabling midway through the introduction cancels its timer before a new enable', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = cameraHarness(); + try { + await alprCamerasLayer.update(); + t.mock.timers.tick(3000); + alprCamerasLayer.disable(); + assert.equal(h.credits.size, 0); + alprCamerasLayer.enable(); + await alprCamerasLayer.update(); + t.mock.timers.tick(2000); + assert.equal( + h.credits.size, + 1, + 'old deadline must not hide the new presentation', + ); + t.mock.timers.tick(3000); + assert.equal(h.credits.size, 0); + } finally { + h.restore(); + } +}); + +test('all manufacturers share one ALPR title and color; only supplied metadata appears underneath', async () => { + const h = cameraHarness(); + try { + h.setFetch(async () => + cameraResponse([ + cameraNode(42, { + tags: { + 'surveillance:type': 'ALPR', + manufacturer: 'Flock Safety', + operator: 'City Police', + 'camera:type': 'fixed', + }, + }), + cameraNode(43, { + tags: { + 'surveillance:type': 'ALPR', + manufacturer: 'Motorola Solutions', + }, + }), + cameraNode(44, { tags: { 'surveillance:type': 'ALPR' } }), + ]), + ); + await alprCamerasLayer.update(); + const entities = h.source.entities.values; + assert.equal(alprCamerasLayer.name, 'ALPR Cameras'); + assert.deepEqual( + entities.map((entity) => entity.gevLabelModel.title), + Array(3).fill('ALPR CAMERA'), + ); + assert.equal( + new Set( + entities.map((entity) => + entity.point.color.getValue().toCssColorString(), + ), + ).size, + 1, + ); + assert.deepEqual(entities[0].gevLabelModel.details, [ + 'Manufacturer: Flock Safety', + 'Operator: City Police', + 'Type: fixed', + 'Source: OpenStreetMap', + ]); + assert.deepEqual(entities[1].gevLabelModel.details, [ + 'Manufacturer: Motorola Solutions', + 'Source: OpenStreetMap', + ]); + assert.deepEqual( + entities[2].gevLabelModel.details, + ['Source: OpenStreetMap'], + 'unknown metadata is not guessed, but the source is always named', + ); + for (const entity of entities) { + h.click(entity.id); + assert.equal(getSelectedEntityContext().label, 'ALPR camera'); + assert.equal(getSelectedEntityContext().layerName, 'ALPR Cameras'); + } + } finally { + h.restore(); + } +}); + +test('invalid OSM ids and out-of-range coordinates never become camera entities', () => { + for (const id of [undefined, null, 0, -1, 1.5, '42', NaN]) + assert.equal(normalizeAlprNode(cameraNode(id, { id })), null); + for (const coords of [ + { lat: 95 }, + { lat: -95 }, + { lon: 181 }, + { lon: -181 }, + ]) { + assert.equal(normalizeAlprNode(cameraNode(42, coords)), null); + } +}); + +test('clicking the selected camera or empty map deselects, including after refresh', async () => { + const h = cameraHarness(); + try { + await alprCamerasLayer.update(); + for (const next of ['alpr:42', null]) { + h.click('alpr:42'); + assert.equal(getSelectedEntityContext()?.id, 'alpr:42'); + h.click(next); + assert.equal(getSelectedEntityContext(), null); + h.expire(); + await alprCamerasLayer.update(); + assert.equal(getSelectedEntityContext(), null); + assert.equal( + h.source.entities.getById('alpr:42').point.pixelSize.getValue(), + 8, + ); + } + } finally { + h.restore(); + } +}); + +test('a refresh cannot reclaim selection from another layer or an explicit clear', async () => { + const h = cameraHarness(); + try { + await alprCamerasLayer.update(); + h.click('alpr:42'); + const aircraft = { id: 'flight:TEST' }; + registerEntityContext(aircraft, { id: aircraft.id, layerId: 'flights' }); + selectEntityContext(aircraft); + h.expire(); + await alprCamerasLayer.update(); + assert.equal(getSelectedEntityContext()?.id, aircraft.id); + h.click('alpr:42'); + clearSelectedEntityContextForLayer('alpr-cameras'); + h.expire(); + await alprCamerasLayer.update(); + assert.equal(getSelectedEntityContext(), null); + } finally { + h.restore(); + } +}); + +test('only a surviving selected camera keeps selection through a dataset refresh', async () => { + const h = cameraHarness(); + try { + await alprCamerasLayer.update(); + h.click('alpr:42'); + h.expire(); + await alprCamerasLayer.update(); + assert.equal(getSelectedEntityContext()?.id, 'alpr:42'); + h.setFetch(async () => cameraResponse([cameraNode(43)])); + h.expire(); + await alprCamerasLayer.update(); + assert.equal(getSelectedEntityContext(), null); + } finally { + h.restore(); + } +}); + +test('disable removes contexts and highlight; cache reuse after re-enable does not reselect', async () => { + const h = cameraHarness(); + try { + await alprCamerasLayer.update(); + h.click('alpr:42'); + alprCamerasLayer.disable(); + assert.equal(getSelectedEntityContext(), null); + assert.equal(getContextStore().entities.size, 0); + alprCamerasLayer.enable(); + await alprCamerasLayer.update(); + assert.equal(getSelectedEntityContext(), null); + assert.equal( + h.source.entities.getById('alpr:42').point.pixelSize.getValue(), + 8, + ); + } finally { + h.restore(); + } +}); + +test('retry clears the old terminal error while the new request is loading', async () => { + const h = cameraHarness(); + try { + h.setFetch(async () => ({ ...cameraResponse(), ok: false, status: 429 })); + await alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().status, 'unavailable'); + let resolve; + h.setFetch( + () => + new Promise((r) => { + resolve = r; + }), + ); + const pending = alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().loading, true); + assert.equal(alprCamerasLayer.getStats().error, null); + assert.equal(alprCamerasLayer.getStats().status, 'loading'); + resolve(cameraResponse()); + await pending; + assert.equal(alprCamerasLayer.getStats().status, 'ready'); + } finally { + h.restore(); + } +}); + +test('a cancelled older request cannot overwrite a newer request with a late network error', async () => { + const h = cameraHarness(); + try { + let rejectOld, resolveNew; + h.setFetch( + () => + new Promise((resolve, reject) => { + rejectOld = reject; + }), + ); + const old = alprCamerasLayer.update(); + h.setBox({ south: 31, west: -98, north: 31.02, east: -97.98 }); + h.setFetch( + () => + new Promise((resolve) => { + resolveNew = resolve; + }), + ); + const fresh = alprCamerasLayer.update(); + rejectOld(new TypeError('Network error on a cancelled request')); + await old; + assert.equal(alprCamerasLayer.getStats().status, 'loading'); + assert.equal(alprCamerasLayer.getStats().error, null); + resolveNew(cameraResponse([])); + await fresh; + assert.equal(alprCamerasLayer.getStats().status, 'empty'); + } finally { + h.restore(); + } +}); + +test('invalid or partial successful payloads are failures, not an empty all-clear', async () => { + const h = cameraHarness(); + try { + for (const payload of [ + {}, + { elements: null }, + { elements: [], remark: 'runtime error: Query timed out' }, + ]) { + h.setFetch(async () => ({ + ...cameraResponse(), + json: async () => payload, + })); + await alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().status, 'unavailable'); + } + } finally { + h.restore(); + } +}); + +test('duplicate nodes render once with a count that agrees with the entity set', async () => { + const h = cameraHarness(); + try { + h.setFetch(async () => cameraResponse([cameraNode(), cameraNode()])); + await alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().status, 'ready'); + assert.equal(alprCamerasLayer.getStats().count, 1); + assert.equal(h.source.entities.values.length, 1); + } finally { + h.restore(); + } +}); + +test('zooming out hides local markers, releases selection and makes no unbounded request', async () => { + const h = cameraHarness(); + try { + await alprCamerasLayer.update(); + h.click('alpr:42'); + h.setBox(null); + await alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().status, 'zoom-in'); + assert.equal(alprCamerasLayer.getStats().count, 0); + assert.equal(getSelectedEntityContext(), null); + assert.equal(h.source.entities.values.length, 0); + assert.equal(h.requests.length, 1); + } finally { + h.restore(); + } +}); + +test('state, country and dateline views never request data; returning to the city reuses its cache', async () => { + const h = cameraHarness(); + const city = { south: 30.26, west: -97.75, north: 30.28, east: -97.73 }; + const wideViews = [ + { south: 26, west: -107, north: 36, east: -93 }, + { south: 24, west: -125, north: 50, east: -66 }, + { south: 30, west: -99, north: 31, east: -95 }, + { south: -1, west: 179, north: 1, east: -179 }, + ]; + try { + for (const box of wideViews) { + h.setBox(box); + await alprCamerasLayer.update(); + assert.equal(h.requests.length, 0, 'fresh wide view must not query'); + assert.equal(alprCamerasLayer.getStats().status, 'zoom-in'); + assert.equal(h.credits.size, 0, 'no data presentation has occurred'); + } + h.setBox(city); + await alprCamerasLayer.update(); + h.click('alpr:42'); + for (const box of wideViews) { + h.setBox(box); + await alprCamerasLayer.update(); + const stats = alprCamerasLayer.getStats(); + assert.equal(stats.status, 'zoom-in'); + assert.equal(stats.loading, false); + assert.equal(stats.error, null); + assert.equal(stats.retryAt, 0); + assert.equal(stats.count, 0); + assert.equal(getSelectedEntityContext(), null); + assert.equal(h.requests.length, 1, 'zoom out must not query'); + } + h.setBox(city); + await alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().count, 1); + assert.equal(h.requests.length, 1, 'return uses fresh cached city data'); + } finally { + h.restore(); + } +}); + +test('nearby camera moves reuse the snapped query but count and register only in-view nodes', async () => { + const h = cameraHarness(); + try { + h.setFetch(async () => + cameraResponse([cameraNode(), cameraNode(43, { lon: -97.71 })]), + ); + await alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().count, 1); + h.setBox({ south: 30.26, west: -97.72, north: 30.28, east: -97.7 }); + await alprCamerasLayer.update(); + assert.equal(h.requests.length, 1); + assert.deepEqual( + h.source.entities.values.map((e) => e.id), + ['alpr:43'], + ); + assert.deepEqual([...getContextStore().entities.keys()], ['alpr:43']); + h.expire(); + await alprCamerasLayer.update(); + assert.equal(h.requests.length, 2); + } finally { + h.restore(); + } +}); + +test('empty mapped coverage, stale data and a saturated response remain distinct', async () => { + const h = cameraHarness(); + try { + h.setFetch(async () => cameraResponse([])); + await alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().status, 'empty'); + assert.match( + alprCamerasLayer.getStats().loadingLabel, + /coverage is incomplete/, + ); + h.expire(); + h.setFetch(async () => + cameraResponse( + Array.from({ length: QUERY_LIMIT }, (_, i) => cameraNode(i + 1)), + true, + ), + ); + await alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().status, 'stale'); + assert.equal(alprCamerasLayer.getStats().error, null); + assert.equal(alprCamerasLayer.getStats().saturated, true); + assert.match( + alprCamerasLayer.getStats().loadingLabel, + /cached.*Coverage limited/, + ); + } finally { + h.restore(); + } +}); + +test('selected readout samples the rendered ground, not zero elevation or the direction line midpoint', async () => { + const h = cameraHarness(); + try { + let samples = 0; + h.viewer.scene.sampleHeightSupported = true; + h.viewer.scene.sampleHeight = (point, excluded) => { + samples += 1; + assert.equal(excluded[0].id, 'alpr:42'); + return 187; + }; + h.setFetch(async () => + cameraResponse([ + cameraNode(42, { + tags: { 'surveillance:type': 'ALPR', 'camera:direction': '90' }, + }), + ]), + ); + await alprCamerasLayer.update(); + h.click('alpr:42'); + const entity = h.source.entities.getById('alpr:42'); + const anchor = Cesium.Cartographic.fromCartesian( + entity.gevDisplayPosition(), + ); + assert.ok(Math.abs(anchor.height - 187) < 0.001); + assert.ok( + Math.abs(Cesium.Math.toDegrees(anchor.longitude) + 97.7431) < 1e-7, + ); + for (let i = 0; i < 60; i++) { + h.viewer.scene.postRender.raiseEvent(); + entity.gevDisplayPosition(); + } + assert.equal(samples, 1, 'no per-frame raycasts'); + h.advance(1000); + h.viewer.scene.postRender.raiseEvent(); + assert.equal(samples, 2); + h.click(null); + h.advance(1000); + h.viewer.scene.postRender.raiseEvent(); + assert.equal(samples, 2, 'no terrain sampling without a selection'); + } finally { + h.restore(); + } +}); + +test('a late network failure after disable cannot resurrect retries or an error state', async () => { + const h = cameraHarness(); + try { + let reject; + h.setFetch( + () => + new Promise((resolve, rejectRequest) => { + reject = rejectRequest; + }), + ); + const pending = alprCamerasLayer.update(); + alprCamerasLayer.disable(); + reject(new TypeError('cancelled connection')); + await pending; + assert.equal(alprCamerasLayer.getStats().status, 'idle'); + assert.equal(alprCamerasLayer.getStats().error, null); + assert.equal(alprCamerasLayer.getStats().retryAt, 0); + } finally { + h.restore(); + } +}); + +test('two factories keep their requests, records and destruction independent', async () => { + const services = { + render: { governorRequestRender() {} }, + picking: { registerPickOwner() {}, unregisterPickOwner() {} }, + groundFloor: { + cachedGroundFloor() { + return null; + }, + }, + context: { + clearSelectedEntityContextForLayer() {}, + getSelectedEntityContext() { + return null; + }, + registerEntityContext() {}, + removeEntityContextsForLayer() {}, + selectEntityContext() {}, + }, + }; + let resolveFirst, firstSignal; + const first = createAlprCamerasLayer({ + services, + source: { + fetch: (_box, signal) => { + firstSignal = signal; + return new Promise((resolve) => { + resolveFirst = resolve; + }); + }, + }, + }); + const second = createAlprCamerasLayer({ + services, + source: { + fetch: async () => ({ elements: [cameraNode(99)], stale: false }), + }, + }); + const a = cameraHarness(first), + b = cameraHarness(second); + try { + const pending = first.update(); + await second.update(); + first.destroy(); + assert.equal(firstSignal.aborted, true); + resolveFirst({ elements: [cameraNode(42)], stale: false }); + await pending; + assert.equal(first.getStats().count, 0); + assert.equal(second.getStats().count, 1); + assert.equal(b.source.entities.values[0].id, 'alpr:99'); + assert.equal(a.viewer.camera.moveEnd.numberOfListeners, 0); + assert.equal(a.viewer.scene.postRender.numberOfListeners, 0); + } finally { + b.restore(); + a.restore(); + } +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 009ff6d01..0f5885fa6 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -81,6 +81,14 @@ export const DATA_CREDITS = [ '© OpenStreetMap contributors ' + '(ODbL 1.0)', }, + { + key: 'alpr-osm', + html: + 'ALPR camera locations (automatic license plate readers): ' + + '© OpenStreetMap contributors ' + + '(ODbL 1.0); ' + + 'community mapping includes DeFlock', + }, { key: 'military-installations-osm', html: diff --git a/src/data/layerState.js b/src/data/layerState.js index eb68c14e7..ed73b98cc 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -276,6 +276,7 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({ */ export const LAYER_STATE_REGISTRY = Object.freeze([ Object.freeze({ id: 'ais-live-vessels', token: 'a', disposition: 'enabled-only' }), + Object.freeze({ id: 'alpr-cameras', token: 'p', disposition: 'enabled-only' }), Object.freeze({ id: 'bikeshare', token: 'b', disposition: 'enabled-only' }), Object.freeze({ id: 'cctv', token: 'c', disposition: 'enabled+options', optionOwner: 'cctv' }), Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }), diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 9e50a3efe..a2682fce0 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -156,8 +156,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 16); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 16); + assert.equal(REGISTERED_LAYER_IDS.length, 17); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 17); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.throws( () => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]), diff --git a/src/data/trackedReadout.js b/src/data/trackedReadout.js index f8203184e..133e9fed4 100644 --- a/src/data/trackedReadout.js +++ b/src/data/trackedReadout.js @@ -194,6 +194,9 @@ export function getActiveTrackedReadoutId() { return _activeEntryId; } +/** Static-context layers whose click selection publishes the readout card. */ +const READOUT_CONTEXT_LAYERS = new Set(['military-installations', 'alpr-cameras']); + /** * Initialize the model bridge and selection listeners. No render listener is * installed; the already-initialized world-overlay host owns the frame lane. @@ -210,7 +213,7 @@ export function initTrackedReadout(viewer) { }) || null; _contextSelectedHandler = (event) => { const record = event.detail; - if (record?.layerId === 'military-installations') { + if (READOUT_CONTEXT_LAYERS.has(record?.layerId)) { _selectedContext = record; publishEntity(record.entity); return; diff --git a/src/data/trackedReadout.test.mjs b/src/data/trackedReadout.test.mjs index 48e484aca..cbdaa3b7d 100644 --- a/src/data/trackedReadout.test.mjs +++ b/src/data/trackedReadout.test.mjs @@ -198,6 +198,18 @@ test('selection lifecycle ignores vessels, accepts installations, and clears wit assert.equal(getActiveTrackedReadoutId(), 'installations:fort-test'); assert.equal(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].title, 'FORT TEST'); + // ALPR cameras are static context too: a click publishes the same card. + const camera = { + gevTrackedId: 'alpr:42', + gevDisplayPosition: () => ({ x: 4, y: 5, z: 6 }), + gevLabelModel: { title: 'FLOCK SAFETY ALPR', details: ['CITY PD'], accent: '#ff66c4' }, + }; + fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', { + detail: { layerId: 'alpr-cameras', entity: camera }, + })); + assert.equal(getActiveTrackedReadoutId(), 'alpr:42'); + assert.equal(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].title, 'FLOCK SAFETY ALPR'); + fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: { layerId: 'ais-live-vessels', entity: installation }, })); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 002959ba9..41bd894f2 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -656,14 +656,15 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins // ── Voice: instruction-only, tool schema unchanged ───────────────────── test('the voice TOOL SCHEMA matches the pinned release — the mission mapping is instructions only', () => { + // ALPR deliberately adds its ID to the two layer menus and visibility aliases. // Canonical serialization pins every tool name, description, property and // ordering while allowing source formatting. Derived from the unchanged // release schema before formatting (the previous source-byte pin passed). const block = JSON.stringify(GEV_REALTIME_TOOLS); - assert.equal(block.length, 26121, 'serialized tool schema length drifted'); + assert.equal(block.length, 26208, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '11680affb4a7aebf142642c8185b28f2ee7d523407054c5b0c3962d933b045eb', + '135d4ec66239777da34a8476cdf8348574421d7afd2e981cc3490909a5bc8686', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8'); diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js new file mode 100644 index 000000000..a3842c868 --- /dev/null +++ b/src/layers/alpr/index.js @@ -0,0 +1,343 @@ +import * as Cesium from 'cesium'; +import { + LAYER_ID, + REQUEST_DEBOUNCE_MS, + QUERY_LIMIT, + MAX_RENDERED, + QUERY_SNAP_DEGREES, + QUERY_REUSE_MS, +} from './policy.js'; +import { + snapAlprBox, + boxContains, + destinationPointDeg, + alprRetryDelayMs, + isAlprSurveillanceType, + normalizeAlprNode, + buildOverpassQuery, +} from './model.js'; +import { createAlprPresentation } from './presentation.js'; + +/** Own one layer's requests, timers, records, and viewer subscriptions. */ +export function createAlprCamerasLayer({ source, services } = {}) { + if (typeof source?.fetch !== 'function') + throw new TypeError('ALPR requires a camera source'); + const { governorRequestRender } = services.render; + + const { registerPickOwner, unregisterPickOwner } = services.picking; + + const state = { + viewer: null, + dataSource: null, + credit: null, + creditTimer: null, + creditPresented: false, + enabled: false, + records: [], + recordById: new Map(), + selectedId: null, + lastUpdate: null, + error: null, + status: 'idle', + stale: false, + /** Whether the query hit QUERY_LIMIT — the view likely holds more cameras than shown. */ + saturated: false, + loading: false, + abort: null, + retryTimer: null, + retryDelayMs: 0, + retryAt: 0, + retrying: false, + moveEndRemove: null, + postRenderRemove: null, + lastAnchorSampleAt: 0, + clickHandler: null, + debounceTimer: null, + /** Snapped box of the last successful query; a view still inside it reuses its records. */ + lastQueryBox: null, + }; + const { + markerColor, + viewportBox, + clearRendered, + hideOnMapCredit, + presentOnMapCredit, + renderRecords, + selectRecord, + clearSelection, + updateSelectedAnchor, + installInteraction, + } = createAlprPresentation({ state, services }); + + function setAlprStatus(status, error = null) { + if (state.status === status && state.error === error) return; + state.status = status; + state.error = error; + governorRequestRender('alpr-status'); + } + + function scheduleUnavailableRetry() { + if (!state.enabled) return; + clearTimeout(state.retryTimer); + state.retryDelayMs = alprRetryDelayMs(state.retryDelayMs); + state.retryAt = Date.now() + state.retryDelayMs; + state.retryTimer = setTimeout(() => { + state.retryTimer = null; + state.retryAt = 0; + if (state.enabled && !state.loading) loadCameras(); + }, state.retryDelayMs); + } + + function clearUnavailableRetry({ resetBackoff = true } = {}) { + clearTimeout(state.retryTimer); + state.retryTimer = null; + state.retryAt = 0; + if (resetBackoff) state.retryDelayMs = 0; + } + + function scheduleLoad() { + if (!state.enabled) return; + clearUnavailableRetry({ resetBackoff: false }); + clearTimeout(state.debounceTimer); + state.debounceTimer = setTimeout(() => { + state.debounceTimer = null; + loadCameras(); + }, REQUEST_DEBOUNCE_MS); + } + + async function loadCameras() { + if (!state.enabled || !state.viewer) return; + clearTimeout(state.debounceTimer); + state.debounceTimer = null; + clearUnavailableRetry({ resetBackoff: false }); + const box = viewportBox(state.viewer); + if (!box) { + state.abort?.abort(); + state.abort = null; + state.loading = false; + state.retrying = false; + clearUnavailableRetry(); + renderRecords(); + setAlprStatus('zoom-in'); + return; + } + // A settled view still inside the last snapped query box, with fresh + // records, needs no new request: tiny pans and orbits + // otherwise became distinct upstream fetches against the shared proxy. Any + // request still in flight was for a viewport the user has since left, so it + // is aborted rather than allowed to replace the records that already cover + // this view. Failed queries never replace the last successful box. + if ( + state.lastQueryBox && + boxContains(state.lastQueryBox, box) && + state.lastUpdate && + Date.now() - state.lastUpdate < QUERY_REUSE_MS && + !state.stale && + state.status !== 'unavailable' + ) { + if (state.abort) { + state.abort.abort(); + state.abort = null; + state.loading = false; + } + renderRecords(); + state.retrying = false; + setAlprStatus( + state.dataSource.entities.values.length ? 'ready' : 'empty', + ); + return; + } + const queryBox = snapAlprBox(box); + state.abort?.abort(); + const requestAbort = new AbortController(); + state.abort = requestAbort; + state.loading = true; + state.retrying = state.status === 'unavailable' || state.retryDelayMs > 0; + setAlprStatus('loading'); + renderRecords(); + try { + const { elements, stale } = await source.fetch( + queryBox, + requestAbort.signal, + ); + if ( + requestAbort.signal.aborted || + state.abort !== requestAbort || + !state.enabled + ) + return; + const records = [ + ...new Map( + elements + .map(normalizeAlprNode) + .filter(Boolean) + .map((record) => [record.id, record]), + ).values(), + ]; + state.records = records; + state.recordById = new Map(records.map((r) => [r.id, r])); + state.lastUpdate = Date.now(); + state.stale = stale; + // Saturated when the QUERY truncated OR the render cap would hide cameras: + // either way the view holds more than the screen shows, and the user must + // be told rather than left with a count that disagrees with the map. + state.saturated = + elements.length >= QUERY_LIMIT || records.length > MAX_RENDERED; + state.lastQueryBox = queryBox; + clearUnavailableRetry(); + // Stale and saturated are independent facts; a cached response that also + // hit the cap must still warn about coverage, not just about freshness. + renderRecords(); + setAlprStatus( + state.dataSource.entities.values.length + ? stale + ? 'stale' + : 'ready' + : 'empty', + ); + } catch (error) { + if ( + error?.name === 'AbortError' || + requestAbort.signal.aborted || + state.abort !== requestAbort || + !state.enabled + ) + return; + state.stale = Boolean(state.lastUpdate); + setAlprStatus( + 'unavailable', + error?.message || 'Overpass temporarily unavailable', + ); + scheduleUnavailableRetry(); + } finally { + if (state.abort === requestAbort) { + state.abort = null; + state.loading = false; + state.retrying = false; + governorRequestRender('alpr-status'); + } + } + } + + const alprCamerasLayer = { + id: LAYER_ID, + name: 'ALPR Cameras', + icon: '📷', + source: 'OpenStreetMap · community mapped', + updateInterval: 0, + statsRefreshInterval: 1000, + init(viewer) { + if (state.viewer) throw new Error('ALPR layer is already initialized'); + state.viewer = viewer; + state.dataSource = new Cesium.CustomDataSource('alpr-cameras'); + state.credit = new Cesium.Credit( + 'ALPR: © OpenStreetMap', + true, + ); + viewer.dataSources.add(state.dataSource); + state.moveEndRemove = + viewer.camera.moveEnd.addEventListener(scheduleLoad); + state.postRenderRemove = + viewer.scene.postRender?.addEventListener(updateSelectedAnchor); + installInteraction(viewer); + }, + enable() { + if (state.enabled) return; + state.enabled = true; + state.creditPresented = false; + registerPickOwner(LAYER_ID, (id) => state.recordById.has(id)); + state.dataSource.show = true; + // DataLayerManager calls update() right after enable(); it owns the first fetch. + }, + disable() { + state.enabled = false; + hideOnMapCredit(); + unregisterPickOwner(LAYER_ID); + clearUnavailableRetry(); + clearTimeout(state.debounceTimer); + state.abort?.abort(); + state.abort = null; + state.loading = false; + state.retrying = false; + if (state.dataSource) state.dataSource.show = false; + clearSelection(); + clearRendered(); + setAlprStatus('idle'); + }, + update() { + return loadCameras(); + }, + destroy(viewer = state.viewer) { + this.disable(); + state.moveEndRemove?.(); + state.moveEndRemove = null; + state.postRenderRemove?.(); + state.postRenderRemove = null; + state.clickHandler?.destroy(); + state.clickHandler = null; + clearRendered(); + if (state.dataSource && viewer) + viewer.dataSources.remove(state.dataSource, true); + state.dataSource = null; + state.credit = null; + state.creditPresented = false; + state.records = []; + state.recordById = new Map(); + state.lastQueryBox = null; + state.lastUpdate = null; + state.error = null; + state.status = 'idle'; + state.stale = false; + state.saturated = false; + state.viewer = null; + }, + getStats() { + return { + count: state.dataSource?.entities.values.length || 0, + lastUpdate: state.lastUpdate, + stale: state.stale, + saturated: state.saturated, + error: state.error, + status: state.status, + loading: state.loading, + retryAt: state.retryAt, + retrying: state.retrying, + retryInSec: state.retryAt + ? Math.max(0, Math.ceil((state.retryAt - Date.now()) / 1000)) + : 0, + loadingLabel: state.loading + ? state.retrying + ? 'retrying mapped ALPR cameras' + : 'loading mapped ALPR cameras' + : state.status === 'zoom-in' + ? 'Zoom in to load mapped cameras' + : [ + state.stale ? 'Showing cached locations' : '', + state.saturated ? 'Coverage limited — zoom in' : '', + state.status === 'empty' + ? 'No mapped cameras returned — coverage is incomplete' + : '', + ] + .filter(Boolean) + .join(' · '), + }; + }, + }; + return alprCamerasLayer; +} +export { + snapAlprBox, + boxContains, + destinationPointDeg, + alprRetryDelayMs, + isAlprSurveillanceType, + normalizeAlprNode, + buildOverpassQuery, +} from './model.js'; +export { + QUERY_LIMIT, + MAX_RENDERED, + QUERY_SNAP_DEGREES, + QUERY_REUSE_MS, +} from './policy.js'; +export { createOverpassAlprSource } from './source.js'; diff --git a/src/layers/alpr/model.js b/src/layers/alpr/model.js new file mode 100644 index 000000000..05f6c73bf --- /dev/null +++ b/src/layers/alpr/model.js @@ -0,0 +1,157 @@ +import * as Cesium from 'cesium'; +import { + QUERY_LIMIT, + EARTH_MEAN_RADIUS_M, + QUERY_SNAP_DEGREES, +} from './policy.js'; + +export function textTag(value) { + const t = String(value ?? '').trim(); + return t || null; +} + +export function numTag(value) { + // `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only + // tag value must read as missing, never as a due-north bearing. + if (value == null) return null; + const trimmed = String(value).trim(); + if (!trimmed) return null; + const n = Number(trimmed); + return Number.isFinite(n) ? n : null; +} + +/** + * Snap a viewport box OUTWARD to the query grid, clamped to valid ranges. + * Pure so the reuse contract is pinnable. + * @param {{south:number,west:number,north:number,east:number}} box + * @param {number} [step=QUERY_SNAP_DEGREES] + */ +export function snapAlprBox(box, step = QUERY_SNAP_DEGREES) { + const down = (v) => Math.floor(v / step) * step; + const up = (v) => Math.ceil(v / step) * step; + return { + south: Math.max(-90, down(box.south)), + west: Math.max(-180, down(box.west)), + north: Math.min(90, up(box.north)), + east: Math.min(180, up(box.east)), + }; +} + +/** Whether `inner` lies entirely within `outer` (no dateline handling; the viewport gate already rejects east <= west). */ +export function boxContains(outer, inner) { + if (!outer || !inner) return false; + return ( + inner.south >= outer.south && + inner.north <= outer.north && + inner.west >= outer.west && + inner.east <= outer.east + ); +} + +/** + * Great-circle destination point — used only to draw the short + * facing-direction line when a node carries `camera:direction`/`direction`. + * @param {number} latDeg @param {number} lonDeg + * @param {number} bearingDeg Compass bearing, degrees clockwise from north. + * @param {number} distanceM + * @returns {{latitude:number, longitude:number}} + */ +export function destinationPointDeg(latDeg, lonDeg, bearingDeg, distanceM) { + const angularDistance = distanceM / EARTH_MEAN_RADIUS_M; + const bearing = Cesium.Math.toRadians(bearingDeg); + const lat1 = Cesium.Math.toRadians(latDeg); + const lon1 = Cesium.Math.toRadians(lonDeg); + const lat2 = Math.asin( + Math.sin(lat1) * Math.cos(angularDistance) + + Math.cos(lat1) * Math.sin(angularDistance) * Math.cos(bearing), + ); + const lon2 = + lon1 + + Math.atan2( + Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(lat1), + Math.cos(angularDistance) - Math.sin(lat1) * Math.sin(lat2), + ); + return { + latitude: Cesium.Math.toDegrees(lat2), + longitude: Cesium.Math.toDegrees(lon2), + }; +} + +/** + * Backoff progression for the unavailable-state retry: 30 s, doubling to a + * 240 s ceiling. Pure so the progression is pinnable without booting the layer. + */ +export function alprRetryDelayMs(prevDelayMs) { + const RETRY_MIN_MS = 30000; + const RETRY_CEIL_MS = 240000; + if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS; + return Math.min(prevDelayMs * 2, RETRY_CEIL_MS); +} + +/** + * Whether a `surveillance:type` value denotes an ALPR camera. OSM allows + * semicolon multi-values (`camera;ALPR`) and mixed case, so an exact match + * would silently miss tagged cameras. + * @param {*} value - raw `surveillance:type` tag value + * @returns {boolean} + */ +export function isAlprSurveillanceType(value) { + return String(value ?? '') + .split(';') + .some((part) => part.trim().toUpperCase() === 'ALPR'); +} + +/** Map one raw Overpass node element to a plain camera record. Null for anything unusable. */ +export function normalizeAlprNode(el) { + if ( + !el || + el.type !== 'node' || + !Number.isSafeInteger(el.id) || + el.id <= 0 || + !Number.isFinite(el.lat) || + Math.abs(el.lat) > 90 || + !Number.isFinite(el.lon) || + Math.abs(el.lon) > 180 + ) + return null; + const tags = el.tags || {}; + if (!isAlprSurveillanceType(tags['surveillance:type'])) return null; + return { + id: `alpr:${el.id}`, + osmId: el.id, + latitude: el.lat, + longitude: el.lon, + operator: textTag(tags.operator), + manufacturer: textTag(tags.manufacturer), + cameraType: textTag(tags['camera:type']), + zone: textTag(tags['surveillance:zone']), + // Only numeric bearings are parsed; compass-word directions ("N"/"NE") + // are rare on this tag and simply render with no facing line. + directionDeg: normalizeDirection( + tags['camera:direction'] ?? tags.direction, + ), + ref: textTag(tags.ref), + lastVerified: textTag(tags.check_date) || textTag(tags['survey:date']), + source: textTag(tags.source), + }; +} + +export function normalizeDirection(value) { + const degrees = numTag(value); + return degrees != null && degrees >= 0 && degrees <= 360 + ? degrees % 360 + : null; +} + +/** + * Overpass QL for the viewport. The tag match is a case-insensitive regex, not + * an exact `="ALPR"`, because OSM semicolon multi-values such as + * `surveillance:type=camera;ALPR` are common and an exact match drops them. + * Exported so the query shape is pinnable. + */ +export function buildOverpassQuery(south, west, north, east) { + return ( + `[out:json][timeout:20];node["man_made"="surveillance"]["surveillance:type"~"(^|;)\\s*ALPR\\s*(;|$)",i]` + + `(${south},${west},${north},${east});out body ${QUERY_LIMIT};` + ); +} diff --git a/src/layers/alpr/policy.js b/src/layers/alpr/policy.js new file mode 100644 index 000000000..b945b99a2 --- /dev/null +++ b/src/layers/alpr/policy.js @@ -0,0 +1,54 @@ +/** + * @file Community-mapped automatic license plate reader (ALPR) camera layer. + * + * Data source: OpenStreetMap contributors, including the DeFlock community + * (`man_made=surveillance` + `surveillance:type=ALPR`) — see + * https://wiki.openstreetmap.org/wiki/Tag:surveillance:type=ALPR and + * https://deflock.org. OSM data remains ODbL 1.0, separate from the MIT code. + * Fetched viewport-bounded through the existing generic + * `/api/overpass` proxy (same one `traffic.js` and `militaryInstallations.js` + * use) — no new server route needed for a single narrow tag pair. + * + * This is mapped surveillance infrastructure, not a live camera feed: no + * plate records, no vendor accounts, nothing beyond what a contributor chose + * to publish to OSM. Coverage and tag accuracy are not guaranteed. + * + * @module data/alprCameras + */ + +export const LAYER_ID = 'alpr-cameras'; + +export const OVERPASS_URL = '/api/overpass'; + +export const REQUEST_DEBOUNCE_MS = 500; + +/** Keep public Overpass queries city-scale, never globe-wide. */ +export const MAX_VIEWPORT_DEGREES = 3; + +/** Overpass `out body N;` cap — also detects possibly truncated coverage. */ +export const QUERY_LIMIT = 1500; + +/** Render cap. Kept at or above QUERY_LIMIT on purpose: if it ever sat below + * it, cameras between the two would be silently dropped while `saturated` + * stayed false and `count` still reported them — a lie about coverage. */ +export const MAX_RENDERED = 1500; + +/** Meters — length of the facing-direction indicator line, when a camera reports one. */ +export const DIRECTION_CONE_M = 25; + +export const EARTH_MEAN_RADIUS_M = 6371008.8; + +/** Query boxes are snapped outward to this grid so nearby camera moves reuse + * one Overpass request body (the shared proxy keys its cache and in-flight + * dedupe by the exact body) instead of each becoming a distinct upstream fetch. */ +export const QUERY_SNAP_DEGREES = 0.05; + +/** A view still fully inside the last snapped query box reuses those records + * for this long before asking Overpass again. */ +export const QUERY_REUSE_MS = 10 * 60 * 1000; + +/** One vendor-neutral color, distinct from CCTV's cyan markers. */ +export const ALPR_COLOR = '#c084fc'; + +/** OSM attribution may collapse after five seconds; full credit stays in Data attribution. */ +export const CREDIT_DISPLAY_MS = 5000; diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js new file mode 100644 index 000000000..64c098d61 --- /dev/null +++ b/src/layers/alpr/presentation.js @@ -0,0 +1,284 @@ +import * as Cesium from 'cesium'; +import { + LAYER_ID, + MAX_VIEWPORT_DEGREES, + MAX_RENDERED, + DIRECTION_CONE_M, + ALPR_COLOR, + CREDIT_DISPLAY_MS, +} from './policy.js'; +import { boxContains, destinationPointDeg } from './model.js'; + +export function createAlprPresentation({ state, services }) { + const { governorRequestRender } = services.render; + const { + clearSelectedEntityContextForLayer, + getSelectedEntityContext, + registerEntityContext, + removeEntityContextsForLayer, + selectEntityContext, + } = services.context; + + const { cachedGroundFloor } = services.groundFloor; + + function markerColor() { + return Cesium.Color.fromCssColorString(ALPR_COLOR); + } + + function viewportBox(viewer) { + const rectangle = viewer?.camera?.computeViewRectangle( + viewer.scene.globe.ellipsoid, + ); + if (!rectangle) return null; + const south = Cesium.Math.toDegrees(rectangle.south); + const north = Cesium.Math.toDegrees(rectangle.north); + const west = Cesium.Math.toDegrees(rectangle.west); + const east = Cesium.Math.toDegrees(rectangle.east); + if ( + !Number.isFinite(south + north + west + east) || + east <= west || + north - south > MAX_VIEWPORT_DEGREES || + east - west > MAX_VIEWPORT_DEGREES + ) + return null; + return { south, west, north, east }; + } + + function clearRendered() { + if (state.dataSource?.entities) state.dataSource.entities.removeAll(); + removeEntityContextsForLayer(LAYER_ID); + } + + function hideOnMapCredit() { + clearTimeout(state.creditTimer); + state.creditTimer = null; + if (state.credit) + state.viewer?.creditDisplay?.removeStaticCredit(state.credit); + governorRequestRender('alpr-credit'); + } + + function presentOnMapCredit() { + if (!state.enabled || state.creditPresented || !state.credit) return; + state.creditPresented = true; + state.viewer.creditDisplay?.addStaticCredit(state.credit); + state.creditTimer = setTimeout(hideOnMapCredit, CREDIT_DISPLAY_MS); + governorRequestRender('alpr-credit'); + } + + function renderRecords() { + const selectedContext = getSelectedEntityContext(); + const box = viewportBox(state.viewer); + const visible = box + ? state.records + .filter((record) => + boxContains(box, { + south: record.latitude, + north: record.latitude, + west: record.longitude, + east: record.longitude, + }), + ) + .slice(0, MAX_RENDERED) + : []; + // A refresh may retain its own selection, never reclaim one cleared or + // replaced by an aircraft, another layer, or a voice action. + if ( + selectedContext?.layerId !== LAYER_ID || + selectedContext.id !== state.selectedId || + !visible.some((record) => record.id === state.selectedId) + ) { + state.selectedId = null; + clearSelectedEntityContextForLayer(LAYER_ID); + } + governorRequestRender('alpr-render'); + clearRendered(); + for (const record of visible) { + const color = markerColor(); + const selected = record.id === state.selectedId; + const position = Cesium.Cartesian3.fromDegrees( + record.longitude, + record.latitude, + ); + const entityDef = { + id: record.id, + position, + point: { + pixelSize: selected ? 12 : 8, + color: selected ? Cesium.Color.WHITE : color, + outlineColor: Cesium.Color.BLACK.withAlpha(0.8), + outlineWidth: 1, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }; + if (Number.isFinite(record.directionDeg)) { + const tip = destinationPointDeg( + record.latitude, + record.longitude, + record.directionDeg, + DIRECTION_CONE_M, + ); + entityDef.polyline = { + positions: [ + position, + Cesium.Cartesian3.fromDegrees(tip.longitude, tip.latitude), + ], + width: 2, + material: color.withAlpha(0.85), + clampToGround: true, + }; + } + const entity = state.dataSource.entities.add(entityDef); + entity.gevTrackedId = record.id; + // The raw OSM datum has no elevation; it is not the clamped marker's + // visual anchor. Only the selected marker samples the rendered surface, + // at most once per second, through Cesium's public height APIs. + entity.gevAlprDisplayPosition = null; + entity.gevDisplayPosition = () => entity.gevAlprDisplayPosition; + entity.gevLabelModel = { + title: 'ALPR CAMERA', + details: [ + record.manufacturer ? `Manufacturer: ${record.manufacturer}` : null, + record.operator ? `Operator: ${record.operator}` : null, + record.cameraType ? `Type: ${record.cameraType}` : null, + 'Source: OpenStreetMap', + ].filter(Boolean), + accent: color.toCssColorString(), + }; + registerEntityContext(entity, { + id: record.id, + layerId: LAYER_ID, + dataSource: state.dataSource, + layerName: 'ALPR Cameras', + source: 'OpenStreetMap contributors (ODbL 1.0; community mapped)', + label: 'ALPR camera', + latitude: record.latitude, + longitude: record.longitude, + properties: { + operator: record.operator, + manufacturer: record.manufacturer, + cameraType: record.cameraType, + zone: record.zone, + directionDeg: record.directionDeg, + ref: record.ref, + lastVerified: record.lastVerified, + osmId: record.osmId, + sourceTag: record.source, + }, + }); + } + const selectedEntity = state.selectedId + ? state.dataSource.entities.getById(state.selectedId) + : null; + if (selectedEntity) selectEntityContext(selectedEntity); + else state.selectedId = null; + state.lastAnchorSampleAt = 0; + updateSelectedAnchor(); + // Start only when mapped data is actually displayed, not while an upstream + // request or a zoom-in prompt could consume the five-second introduction. + if (visible.length) presentOnMapCredit(); + } + + function selectRecord(id) { + const entity = state.dataSource?.entities.getById(id); + if (!entity || !state.recordById.has(id)) return false; + clearSelection(); + state.selectedId = id; + entity.point.pixelSize = 12; + entity.point.color = Cesium.Color.WHITE; + state.lastAnchorSampleAt = 0; + selectEntityContext(entity); + updateSelectedAnchor(); + governorRequestRender('alpr-selection'); + return true; + } + + function clearSelection() { + const entity = state.selectedId + ? state.dataSource?.entities.getById(state.selectedId) + : null; + const record = state.recordById.get(state.selectedId); + if (entity && record) { + entity.point.pixelSize = 8; + entity.point.color = markerColor(); + } + state.selectedId = null; + clearSelectedEntityContextForLayer(LAYER_ID); + governorRequestRender('alpr-selection'); + } + + function updateSelectedAnchor() { + if (!state.enabled || !state.selectedId) return; + const selected = getSelectedEntityContext(); + if (selected?.id !== state.selectedId) { + clearSelection(); + return; + } + const entity = state.dataSource?.entities.getById(state.selectedId); + const record = state.recordById.get(state.selectedId); + if (!entity || !record || Date.now() - state.lastAnchorSampleAt < 1000) + return; + state.lastAnchorSampleAt = Date.now(); + const scene = state.viewer.scene; + const location = Cesium.Cartographic.fromDegrees( + record.longitude, + record.latitude, + ); + let height; + if (scene.sampleHeightSupported) { + try { + height = scene.sampleHeight(location, [entity]); + } catch { + /* tiles may still be streaming */ + } + } + if (!Number.isFinite(height) && scene.globe.show) + height = scene.globe.getHeight?.(location); + if (!Number.isFinite(height)) + height = cachedGroundFloor(record.latitude, record.longitude); + if (!Number.isFinite(height)) return; + const next = Cesium.Cartesian3.fromDegrees( + record.longitude, + record.latitude, + height, + ); + if ( + !entity.gevAlprDisplayPosition || + Cesium.Cartesian3.distance(next, entity.gevAlprDisplayPosition) > 0.1 + ) { + entity.gevAlprDisplayPosition = next; + governorRequestRender('alpr-anchor'); + } + } + + function installInteraction(viewer) { + if (state.clickHandler) return; + state.clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + state.clickHandler.setInputAction((click) => { + if (!state.enabled) return; + const picked = viewer.scene.pick(click.position); + const id = typeof picked?.id?.id === 'string' ? picked.id.id : null; + if ( + id && + state.recordById.has(id) && + (id !== state.selectedId || getSelectedEntityContext()?.id !== id) + ) + selectRecord(id); + else if (state.selectedId) clearSelection(); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + return { + markerColor, + viewportBox, + clearRendered, + hideOnMapCredit, + presentOnMapCredit, + renderRecords, + selectRecord, + clearSelection, + updateSelectedAnchor, + installInteraction, + }; +} diff --git a/src/layers/alpr/source.js b/src/layers/alpr/source.js new file mode 100644 index 000000000..0b95cc04b --- /dev/null +++ b/src/layers/alpr/source.js @@ -0,0 +1,55 @@ +import { + OVERPASS_URL, + MAX_VIEWPORT_DEGREES, + QUERY_SNAP_DEGREES, +} from './policy.js'; +import { buildOverpassQuery } from './model.js'; +/** Construct the bounded OSM request adapter without starting a request. */ +export function createOverpassAlprSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + async function fetchAlprNodes(box, signal) { + signal?.throwIfAborted(); + if ( + !box || + ![box.south, box.west, box.north, box.east].every(Number.isFinite) || + box.south < -90 || + box.north > 90 || + box.west < -180 || + box.east > 180 || + box.north <= box.south || + box.east <= box.west || + box.north - box.south > + MAX_VIEWPORT_DEGREES + 2 * QUERY_SNAP_DEGREES + 1e-9 || + box.east - box.west > MAX_VIEWPORT_DEGREES + 2 * QUERY_SNAP_DEGREES + 1e-9 + ) { + throw new TypeError('ALPR requires a bounded city viewport'); + } + const query = buildOverpassQuery(box.south, box.west, box.north, box.east); + const response = await fetchImpl(OVERPASS_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `data=${encodeURIComponent(query)}`, + signal, + }); + if (!response.ok) { + const message = + response.status === 429 + ? 'Overpass rate-limited' + : response.status === 504 + ? 'Overpass timed out' + : 'Overpass temporarily unavailable'; + throw new Error(message); + } + const stale = response.headers.get('x-overpass-cache') === 'STALE'; + const payload = await response.json(); + signal?.throwIfAborted(); + // The shared proxy already rejects query errors. Validate here too so a + // malformed or partial response never becomes an authoritative empty map. + if (!Array.isArray(payload?.elements) || payload.remark) { + throw new Error('Overpass returned an incomplete camera response'); + } + return { elements: payload.elements, stale }; + } + return { fetch: fetchAlprNodes }; +} diff --git a/src/layers/alpr/source.test.mjs b/src/layers/alpr/source.test.mjs new file mode 100644 index 000000000..9624f7c62 --- /dev/null +++ b/src/layers/alpr/source.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createOverpassAlprSource } from './source.js'; +const box = { south: 30, west: -98, north: 30.1, east: -97.9 }; +test('the source rejects invalid and unbounded queries before fetching', async () => { + let calls = 0; + const source = createOverpassAlprSource({ + fetchImpl() { + calls++; + }, + }); + for (const bad of [ + null, + { ...box, west: '0);out;' }, + { ...box, north: 90 }, + { ...box, east: -99 }, + { ...box, south: NaN }, + ]) { + await assert.rejects(source.fetch(bad), /bounded city viewport/); + } + assert.equal(calls, 0); +}); +test('cancellation during body parsing rejects even when the transport ignores it', async () => { + const abort = new AbortController(); + const source = createOverpassAlprSource({ + fetchImpl: async () => ({ + ok: true, + headers: new Headers(), + json: async () => { + abort.abort(); + return { elements: [] }; + }, + }), + }); + await assert.rejects(source.fetch(box, abort.signal), { name: 'AbortError' }); +}); diff --git a/src/loadingFeedback.js b/src/loadingFeedback.js index 85eb38606..df7766019 100644 --- a/src/loadingFeedback.js +++ b/src/loadingFeedback.js @@ -28,7 +28,7 @@ export function normalizeLayerLoading(layer = {}) { stats.refreshing === true; const count = finiteCount(stats.count); const stoppingInstallations = - layer.id === 'military-installations' && disabling; + ['military-installations', 'alpr-cameras'].includes(layer.id) && disabling; // Guidance statuses ask the user to act (zoom in, run a search). They are // normal operation, never a batch failure — mirrors layerFeedState's carve-out // so a prompt stored alongside the status cannot turn the chip red. @@ -61,6 +61,14 @@ export function normalizeLayerLoading(layer = {}) { unavailable, keyRequired, degraded, + cameraRetry: + layer.id === 'alpr-cameras' && layer.enabled && !disabling + ? { + retryAt: Number(stats.retryAt) || 0, + retrying: stats.retrying === true, + error, + } + : null, installationRetry: layer.id === 'military-installations' && layer.enabled && !disabling ? { @@ -367,6 +375,30 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) { /** Build the user-facing status copy for the current loading state. */ export function presentLoadingFeedback(state, summary, nowMs) { + const camera = summary.records.find( + (record) => record.cameraRetry?.retryAt > 0, + ); + const otherCameraFailure = + summary.records.some( + (record) => + record.id !== 'alpr-cameras' && + (state?.activeIds || []).includes(record.id) && + (record.error || record.unavailable || record.keyRequired), + ) || (state?.failedEventIds || []).some((id) => id !== 'alpr-cameras'); + if (camera && !summary.active.length && !otherCameraFailure) { + const seconds = Math.max( + 0, + Math.ceil((camera.cameraRetry.retryAt - Date.now()) / 1000), + ); + return { + state: 'retry', + label: ( + camera.cameraRetry.error || 'Overpass temporarily unavailable' + ).toUpperCase(), + detail: `ALPR cameras · ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`, + }; + } + const site = summary.records.find( (record) => record.installationRetry?.retryAt > 0, ); @@ -403,6 +435,16 @@ export function presentLoadingFeedback(state, summary, nowMs) { return { state: state.terminal, label, detail: '' }; } const active = summary.active; + if (active.length === 1 && active[0].cameraRetry && !summary.disabling) { + return { + state: 'loading', + label: active[0].cameraRetry.retrying + ? 'RETRYING ALPR CAMERAS' + : 'FETCHING ALPR CAMERAS', + detail: 'OpenStreetMap · Overpass', + }; + } + if ( active.length === 1 && active[0].installationRetry && diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index 78e6b6bbd..09e81d43c 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -711,3 +711,31 @@ test('guidance does not suppress independent manager and feed failures', () => { assert.equal(record.error, 'Network unavailable'); } }); + +test('ALPR retries show a countdown, preserve other failures, and clear when disabled', () => { + const camera = { ...retrySite({ error: 'Overpass rate-limited' }), id: 'alpr-cameras', name: 'ALPR cameras' }; + const summary = aggregateLayerLoading([camera]); + const view = presentLoadingFeedback(createLoadingFeedbackState(), summary, 0); + assert.equal(view.state, 'retry'); + assert.equal(view.label, 'OVERPASS RATE-LIMITED'); + assert.match(view.detail, /ALPR cameras · retrying in 30s/); + const failed = { visible: true, phase: 'terminal', terminal: 'error', activeIds: ['alpr-cameras', 'flights'] }; + const otherFailure = aggregateLayerLoading([camera, { id: 'flights', enabled: true, stats: { error: 'Failed' } }]); + assert.equal(presentLoadingFeedback(failed, otherFailure, 0).label, 'LOAD FAILED'); + assert.equal(presentLoadingFeedback({ ...failed, failedEventIds: ['flights'] }, summary, 0).label, 'LOAD FAILED'); + assert.equal(presentLoadingFeedback(createLoadingFeedbackState(), aggregateLayerLoading([{ ...camera, enabled: false }]), 0), null); +}); + +test('ALPR retry success does not inherit its prior error, including turning the layer off', () => { + const camera = stats => ({ id: 'alpr-cameras', enabled: true, stats }); + const loading = aggregateLayerLoading([camera({ status: 'loading', loading: true, retrying: true })]); + let state = reduceLoadingFeedback(createLoadingFeedbackState(), loading, 0); + state = reduceLoadingFeedback(state, loading, 200); + assert.equal(presentLoadingFeedback(state, loading, 200).label, 'RETRYING ALPR CAMERAS'); + const done = aggregateLayerLoading([camera({ status: 'ready', count: 3 })]); + state = reduceLoadingFeedback(state, done, 300); + assert.equal(presentLoadingFeedback(state, done, 300).label, 'LOAD COMPLETE'); + const stopping = normalizeLayerLoading({ ...camera({ status: 'unavailable', error: 'Old failure' }), lifecycleState: 'disabling' }); + assert.equal(stopping.error, null); + assert.equal(stopping.unavailable, false); +}); diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index c43afd80b..4047d7104 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -189,7 +189,8 @@ test('no unchanged Realtime tool definition drifts silently', () => { .update(JSON.stringify(unchanged)) .digest('hex') .slice(0, 16); - assert.equal(digest, '802ed694b8887b88', 'an unchanged Realtime tool definition drifted'); + // ALPR intentionally extends the two layer enums; retain the complete pin. + assert.equal(digest, '6963175a0c9a76de', 'an unchanged Realtime tool definition drifted'); }); test('Radio volume and mission speed share the Sharpen slider visual language', () => { diff --git a/src/standalone/data.js b/src/standalone/data.js index 1c3e20366..717a2ae18 100644 --- a/src/standalone/data.js +++ b/src/standalone/data.js @@ -1,6 +1,7 @@ import { DataLayerManager } from '../data/manager.js'; import flightsLayer from '../data/flights.js'; import militaryFlightsLayer from '../data/militaryFlights.js'; +import alprCamerasLayer from '../data/alprCameras.js'; import earthquakesLayer from '../data/earthquakes.js'; import satellitesLayer from '../data/satellites.js'; import rocketLaunchesLayer from '../data/rocketLaunches.js'; @@ -35,6 +36,7 @@ export function createStandaloneData({ dataManager.register(flightsLayer); dataManager.register(militaryFlightsLayer); dataManager.register(earthquakesLayer); + dataManager.register(alprCamerasLayer); dataManager.register(satellitesLayer); dataManager.register(rocketLaunchesLayer); rocketLaunchesLayer.attachDataManager(dataManager); diff --git a/src/ui/styles/foundation.css b/src/ui/styles/foundation.css index 73582b962..828eb9922 100644 --- a/src/ui/styles/foundation.css +++ b/src/ui/styles/foundation.css @@ -299,6 +299,25 @@ body.cockpit-mode #cockpit-cloud-effects.active { color: rgba(232, 234, 237, 0.7); } +/* Keep the ALPR source overflow reachable even after its brief on-map credit + collapses. Wrap whole entries only while the optional layer is on. */ +body:has(.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-btn.active) + #cesium-credits { + white-space: normal; +} +body:has(.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-btn.active) + #cesium-credits + .cesium-credit-wrapper { + display: inline-block !important; + white-space: nowrap; +} +body:has(.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-btn.active) + #cesium-credits + .cesium-credit-expand-link { + /* Keep the complete link at the left edge, clear of the Power Up button. */ + display: block !important; +} + /* Cesium's stock attribution lightbox grows with every registered data source. Keep the title and close affordance fixed while the complete, unabridged credit list wraps and scrolls inside a compact panel. */ diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 53c74ec79..d380edd96 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -177,6 +177,12 @@ const LAYER_ALIASES = new Map([ ['firms', 'local-firms'], ['fires', 'local-firms'], ['active fires', 'local-firms'], + ['alpr', 'alpr-cameras'], + ['alpr cameras', 'alpr-cameras'], + ['flock cameras', 'alpr-cameras'], + ['license plate readers', 'alpr-cameras'], + ['license plate cameras', 'alpr-cameras'], + ['plate readers', 'alpr-cameras'], ]); const CITY_ALIASES = new Map([ diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 5fa93514e..c7a0677bc 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3140,3 +3140,27 @@ test('voice Radio: the keyless path applies no country filter the keyed path wou const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ }); function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options }); } function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); } + +test('ALPR common names toggle only the registered camera layer through the normal voice action', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const viewer = { clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, + camera: { moveEnd: { addEventListener() {} } } }; + const calls = []; + let enabled = false; + const dataManager = { + layers: new Map([['alpr-cameras', { module: {} }]]), + getAll: () => [{ id: 'alpr-cameras', name: 'ALPR Cameras' }], + isEnabled: () => enabled, + setEnabled: async (id, value) => { calls.push([id, value]); enabled = value; return true; }, + }; + const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager }); + for (const alias of ['alpr-cameras', 'alpr', 'alpr cameras', 'flock cameras', 'license plate readers', 'license plate cameras', 'plate readers']) { + for (const value of [true, false]) { + const result = await runner('set_layer_visibility', { layerId: alias, enabled: value }); + assert.equal(result.ok, true); + assert.equal(result.layerId, 'alpr-cameras'); + assert.deepEqual(calls.at(-1), ['alpr-cameras', value]); + } + } +}); From 0feaab0a72463f3a36b1e6275b934608c0af3e4e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 17:33:48 -0700 Subject: [PATCH 2/6] refactor(alpr): accept normalized camera snapshots and source attribution --- docs/CURRENT-STATE.md | 4 +- package.json | 2 +- scripts/package-boundaries.json | 372 ++++++++------------------------ src/data/alprCameras.test.mjs | 24 ++- src/layers/alpr/index.js | 46 ++-- src/layers/alpr/model.js | 54 +++++ src/layers/alpr/presentation.js | 9 +- src/layers/alpr/source.js | 33 ++- src/layers/alpr/source.test.mjs | 81 +++++++ 9 files changed, 307 insertions(+), 318 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 373f01e8d..90cbe96dc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4,7 +4,9 @@ The optional **ALPR Cameras** layer shows community-mapped OpenStreetMap locatio not camera footage or plate records. City-scale queries use the existing Overpass provider, with capped results, retry, cached-data and incomplete-coverage notices. Its `./layers/alpr` entry exposes a per-instance layer factory and bounded source -adapter. The standalone registration supplies selection, picking, terrain and +adapter. Sources return normalized records, stale/coverage flags and their own +attribution; rendering does not parse Overpass payloads. The standalone +registration supplies selection, picking, terrain and render services. Selected cards and Data attribution identify OpenStreetMap. Layer state and the existing voice layer tools include `alpr-cameras`. diff --git a/package.json b/package.json index 471285140..084a2614f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gods-eye-view", "private": true, "version": "0.1.1", - "description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", + "description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", "type": "module", "license": "MIT", "author": "Bilawal Sidhu", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 66bf3adf2..fcb93195d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,47 +10,27 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "application": { - "exports": [ - "./application" - ], - "modules": [ - "src/app/application.js" - ], + "exports": ["./application"], + "modules": ["src/app/application.js"], "external": [] }, "viewer": { - "exports": [ - "./application/viewer" - ], - "modules": [ - "src/app/viewer.js" - ], - "external": [ - "cesium" - ] + "exports": ["./application/viewer"], + "modules": ["src/app/viewer.js"], + "external": ["cesium"] }, "vite-build": { "runtime": "node", - "exports": [ - "./build/vite" - ], - "modules": [ - "build/vite.js" - ], - "external": [ - "vite-plugin-cesium" - ] + "exports": ["./build/vite"], + "modules": ["build/vite.js"], + "external": ["vite-plugin-cesium"] }, "live-providers": { "runtime": "node", - "exports": [ - "./server/providers/live" - ], + "exports": ["./server/providers/live"], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -65,24 +45,16 @@ "src/data/aisStreamAdapter.js", "src/data/aisWatchdog.js" ], - "external": [ - "ws" - ] + "external": ["ws"] }, "adsb-lol-source": { - "exports": [ - "./sources/adsb-lol" - ], - "modules": [ - "src/data/adsbLolFallback.js" - ], + "exports": ["./sources/adsb-lol"], + "modules": ["src/data/adsbLolFallback.js"], "external": [] }, "place-providers": { "runtime": "node", - "exports": [ - "./server/providers/places" - ], + "exports": ["./server/providers/places"], "modules": [ "server/providers/places.js", "server/providers/places/google.js", @@ -97,19 +69,13 @@ "external": [] }, "place-payloads": { - "exports": [ - "./sources/places" - ], - "modules": [ - "src/data/placeProviderPayloads.js" - ], + "exports": ["./sources/places"], + "modules": ["src/data/placeProviderPayloads.js"], "external": [] }, "space-providers": { "runtime": "node", - "exports": [ - "./server/providers/space" - ], + "exports": ["./server/providers/space"], "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", @@ -120,19 +86,13 @@ "external": [] }, "space-requests": { - "exports": [ - "./sources/space" - ], - "modules": [ - "src/data/spaceProviderRequests.js" - ], + "exports": ["./sources/space"], + "modules": ["src/data/spaceProviderRequests.js"], "external": [] }, "terrain-provider": { "runtime": "node", - "exports": [ - "./server/providers/terrain" - ], + "exports": ["./server/providers/terrain"], "modules": [ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js" @@ -140,50 +100,30 @@ "external": [] }, "terrain-source": { - "exports": [ - "./sources/terrain" - ], - "modules": [ - "src/data/terrainHeightsProxy.js" - ], + "exports": ["./sources/terrain"], + "modules": ["src/data/terrainHeightsProxy.js"], "external": [] }, "traffic-provider": { "runtime": "node", - "exports": [ - "./server/providers/traffic" - ], - "modules": [ - "server/providers/traffic.js", - "src/data/tomtomTiles.js" - ], + "exports": ["./server/providers/traffic"], + "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], "external": [] }, "traffic-source": { - "exports": [ - "./sources/traffic" - ], - "modules": [ - "src/data/tomtomTiles.js" - ], + "exports": ["./sources/traffic"], + "modules": ["src/data/tomtomTiles.js"], "external": [] }, "firms-provider": { "runtime": "node", - "exports": [ - "./server/providers/firms" - ], - "modules": [ - "server/providers/firms.js", - "src/data/firmsCsv.js" - ], + "exports": ["./server/providers/firms"], + "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], "external": [] }, "gbfs-provider": { "runtime": "node", - "exports": [ - "./server/providers/gbfs" - ], + "exports": ["./server/providers/gbfs"], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -192,19 +132,13 @@ "external": [] }, "gbfs-source": { - "exports": [ - "./sources/gbfs" - ], - "modules": [ - "src/data/gbfsSource.js" - ], + "exports": ["./sources/gbfs"], + "modules": ["src/data/gbfsSource.js"], "external": [] }, "cctv-provider": { "runtime": "node", - "exports": [ - "./server/providers/cctv" - ], + "exports": ["./server/providers/cctv"], "modules": [ "server/providers/cctv.js", "server/providers/cctv/catalog.js", @@ -221,9 +155,7 @@ }, "radio-provider": { "runtime": "node", - "exports": [ - "./server/providers/radio" - ], + "exports": ["./server/providers/radio"], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -237,9 +169,7 @@ }, "overpass-provider": { "runtime": "node", - "exports": [ - "./server/providers/overpass" - ], + "exports": ["./server/providers/overpass"], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -258,9 +188,7 @@ }, "military-installations-provider": { "runtime": "node", - "exports": [ - "./server/providers/military-installations" - ], + "exports": ["./server/providers/military-installations"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -277,9 +205,7 @@ }, "regional-provider": { "runtime": "node", - "exports": [ - "./server/providers/regional" - ], + "exports": ["./server/providers/regional"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -298,9 +224,7 @@ }, "openai-provider": { "runtime": "node", - "exports": [ - "./server/providers/openai" - ], + "exports": ["./server/providers/openai"], "modules": [ "server/providers/common/rate-limit.js", "server/providers/common/request.js", @@ -320,9 +244,7 @@ }, "standalone-key-setup": { "runtime": "node", - "exports": [ - "./server/standalone/key-setup" - ], + "exports": ["./server/standalone/key-setup"], "modules": [ "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", @@ -333,9 +255,7 @@ "external": [] }, "place-search": { - "exports": [ - "./search" - ], + "exports": ["./search"], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -345,27 +265,17 @@ "external": [] }, "panel-disclosure": { - "exports": [ - "./ui/panels" - ], - "modules": [ - "src/ui/panelDisclosure.js" - ], + "exports": ["./ui/panels"], + "modules": ["src/ui/panelDisclosure.js"], "external": [] }, "surface-keyboard": { - "exports": [ - "./ui/surfaces" - ], - "modules": [ - "src/ui/surfaceKeyboard.js" - ], + "exports": ["./ui/surfaces"], + "modules": ["src/ui/surfaceKeyboard.js"], "external": [] }, "panel-rail-layout": { - "exports": [ - "./ui/layout" - ], + "exports": ["./ui/layout"], "modules": [ "src/ui/panelRails.js", "src/ui/leftPanelRail.js", @@ -377,9 +287,7 @@ "external": [] }, "visual-input": { - "exports": [ - "./ui/input" - ], + "exports": ["./ui/input"], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -388,19 +296,12 @@ "external": [] }, "display-controls": { - "exports": [ - "./ui/display" - ], - "modules": [ - "src/ui/displayControls.js" - ], + "exports": ["./ui/display"], + "modules": ["src/ui/displayControls.js"], "external": [] }, "visual-effects": { - "exports": [ - "./ui/effects", - "./ui/effects/bloom" - ], + "exports": ["./ui/effects", "./ui/effects/bloom"], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -413,14 +314,10 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "map-source-controls": { - "exports": [ - "./ui/maps" - ], + "exports": ["./ui/maps"], "modules": [ "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -430,10 +327,7 @@ "external": [] }, "layer-panel": { - "exports": [ - "./ui/layers", - "./ui/layers/feedback" - ], + "exports": ["./ui/layers", "./ui/layers/feedback"], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -444,9 +338,7 @@ "external": [] }, "location-controls": { - "exports": [ - "./ui/location" - ], + "exports": ["./ui/location"], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -457,9 +349,7 @@ "external": [] }, "radio-controls": { - "exports": [ - "./ui/radio" - ], + "exports": ["./ui/radio"], "modules": [ "src/ui/radio.js", "src/ui/radioControls.js", @@ -470,9 +360,7 @@ "external": [] }, "cctv-controls": { - "exports": [ - "./ui/cctv" - ], + "exports": ["./ui/cctv"], "modules": [ "src/ui/cctv.js", "src/ui/cctvBindings.js", @@ -484,10 +372,7 @@ "external": [] }, "context-controls": { - "exports": [ - "./ui/context", - "./ui/context/policy" - ], + "exports": ["./ui/context", "./ui/context/policy"], "modules": [ "src/ui/context.js", "src/ui/contextActions.js", @@ -529,14 +414,10 @@ "src/cockpitVisionPolicy.js", "src/ui/panelRailGeometry.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "scene-controls": { - "exports": [ - "./ui/scenes" - ], + "exports": ["./ui/scenes"], "modules": [ "src/ui/scenes.js", "src/ui/sceneControls.js", @@ -545,9 +426,7 @@ "external": [] }, "ui-shell": { - "exports": [ - "./ui/shell" - ], + "exports": ["./ui/shell"], "modules": [ "src/bloom.js", "src/cctvFocusPolicy.js", @@ -644,14 +523,10 @@ "src/app/stateChannel.js", "src/ui/frameRateMonitor.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "ui-styles": { - "exports": [ - "./ui/styles" - ], + "exports": ["./ui/styles"], "modules": [ "src/ui/styles.js", "style.css", @@ -678,9 +553,7 @@ "external": [] }, "scenes": { - "exports": [ - "./scenes" - ], + "exports": ["./scenes"], "modules": [ "src/scenes/director.js", "src/scenes/recipes.js", @@ -692,14 +565,10 @@ "src/ui/scenePresentation.js", "src/contextModePolicy.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "live-observations": { - "exports": [ - "./sources/live" - ], + "exports": ["./sources/live"], "modules": [ "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -710,9 +579,7 @@ "external": [] }, "civil-flights": { - "exports": [ - "./layers/flights" - ], + "exports": ["./layers/flights"], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -743,14 +610,10 @@ "src/layers/flights/testing.js", "src/layers/flights/tracking.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "military-flights": { - "exports": [ - "./layers/military" - ], + "exports": ["./layers/military"], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -778,14 +641,10 @@ "src/layers/military/testing.js", "src/layers/military/tracking.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "aircraft-mechanics": { - "exports": [ - "./aircraft" - ], + "exports": ["./aircraft"], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -805,14 +664,10 @@ "src/layers/aircraft/classification.js", "src/layers/aircraft/index.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "vessel-layer": { - "exports": [ - "./layers/vessels" - ], + "exports": ["./layers/vessels"], "modules": [ "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -830,27 +685,19 @@ "src/data/iconOrientation.js", "src/data/vesselLabels.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "earthquakes": { - "exports": [ - "./layers/earthquakes" - ], + "exports": ["./layers/earthquakes"], "modules": [ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "fire-layer": { - "exports": [ - "./layers/firms" - ], + "exports": ["./layers/firms"], "modules": [ "src/data/firmsAdapt.js", "src/data/firmsLabels.js", @@ -869,14 +716,10 @@ "src/layers/firms/state.js", "src/layers/firms/viewport.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "satellites-layer": { - "exports": [ - "./layers/satellites" - ], + "exports": ["./layers/satellites"], "modules": [ "src/data/issPass.js", "src/data/satelliteClass.js", @@ -895,15 +738,10 @@ "src/layers/satellites/testing.js", "src/layers/satellites/tracking.js" ], - "external": [ - "cesium", - "satellite.js" - ] + "external": ["cesium", "satellite.js"] }, "launches-layer": { - "exports": [ - "./layers/launches" - ], + "exports": ["./layers/launches"], "modules": [ "src/layers/launches/camera.js", "src/layers/launches/controls.js", @@ -925,14 +763,10 @@ "src/layers/launches/state.js", "src/layers/launches/testing.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "installations-layer": { - "exports": [ - "./layers/installations" - ], + "exports": ["./layers/installations"], "modules": [ "src/data/installationFeedback.js", "src/data/militaryInstallationData.js", @@ -948,14 +782,10 @@ "src/layers/installations/state.js", "src/layers/installations/viewport.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "awareness-layer": { - "exports": [ - "./layers/awareness" - ], + "exports": ["./layers/awareness"], "modules": [ "src/cockpitMath.js", "src/data/iconOrientation.js", @@ -978,14 +808,10 @@ "src/layers/awareness/testing.js", "src/ui/panelRailGeometry.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "traffic-layer": { - "exports": [ - "./layers/traffic" - ], + "exports": ["./layers/traffic"], "modules": [ "src/data/flowMatch.js", "src/data/tomtomTiles.js", @@ -1010,16 +836,10 @@ "src/layers/traffic/timing.js", "src/layers/traffic/viewport.js" ], - "external": [ - "cesium", - "pbf", - "@mapbox/vector-tile" - ] + "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, "bikeshare-layer": { - "exports": [ - "./layers/bikeshare" - ], + "exports": ["./layers/bikeshare"], "modules": [ "src/layers/bikeshare/controls.js", "src/layers/bikeshare/index.js", @@ -1036,14 +856,10 @@ "src/layers/bikeshare/testing.js", "src/layers/bikeshare/viewport.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "cctv-layer": { - "exports": [ - "./layers/cctv" - ], + "exports": ["./layers/cctv"], "modules": [ "src/data/cctvCards.js", "src/data/cctvGizmo.js", @@ -1076,14 +892,10 @@ "src/layers/cctv/testing.js", "src/overlays/worldOverlayTokens.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "alpr-cameras": { - "exports": [ - "./layers/alpr" - ], + "exports": ["./layers/alpr"], "modules": [ "src/layers/alpr/index.js", "src/layers/alpr/model.js", @@ -1091,8 +903,6 @@ "src/layers/alpr/presentation.js", "src/layers/alpr/source.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] } } diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index bc5d63583..89a77426c 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -891,7 +891,15 @@ test('two factories keep their requests, records and destruction independent', a const second = createAlprCamerasLayer({ services, source: { - fetch: async () => ({ elements: [cameraNode(99)], stale: false }), + label: 'Test camera directory', + attribution: { name: 'Test directory' }, + fetch: async () => ({ + records: [ + { id: 'directory:99', latitude: 30.2672, longitude: -97.7431 }, + ], + stale: false, + saturated: false, + }), }, }); const a = cameraHarness(first), @@ -901,11 +909,21 @@ test('two factories keep their requests, records and destruction independent', a await second.update(); first.destroy(); assert.equal(firstSignal.aborted, true); - resolveFirst({ elements: [cameraNode(42)], stale: false }); + resolveFirst({ + records: [normalizeAlprNode(cameraNode(42))], + stale: false, + saturated: false, + }); await pending; assert.equal(first.getStats().count, 0); assert.equal(second.getStats().count, 1); - assert.equal(b.source.entities.values[0].id, 'alpr:99'); + assert.equal(b.source.entities.values[0].id, 'directory:99'); + assert.equal(second.source, 'Test camera directory'); + assert.ok( + b.source.entities.values[0].gevLabelModel.details.includes( + 'Source: Test directory', + ), + ); assert.equal(a.viewer.camera.moveEnd.numberOfListeners, 0); assert.equal(a.viewer.scene.postRender.numberOfListeners, 0); } finally { diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index a3842c868..d89913499 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -12,16 +12,24 @@ import { boxContains, destinationPointDeg, alprRetryDelayMs, - isAlprSurveillanceType, - normalizeAlprNode, - buildOverpassQuery, + validateAlprSnapshot, + alprCreditMarkup, } from './model.js'; import { createAlprPresentation } from './presentation.js'; -/** Own one layer's requests, timers, records, and viewer subscriptions. */ +/** + * Own one layer's requests, records, display and viewer subscriptions. + * A source fetches a bounded box with an AbortSignal and resolves + * { records, stale, saturated }. Records use stable string ids and latitude / + * longitude in degrees; optional directionDeg is a compass bearing [0, 360). + * Manufacturer, operator and cameraType are optional display fields. + * Source label and attribution { name, description, text, href } identify the + * actual provider; href must be HTTPS. Provider payload parsing stays in source. + */ export function createAlprCamerasLayer({ source, services } = {}) { if (typeof source?.fetch !== 'function') throw new TypeError('ALPR requires a camera source'); + const creditMarkup = alprCreditMarkup(source.attribution); const { governorRequestRender } = services.render; const { registerPickOwner, unregisterPickOwner } = services.picking; @@ -67,7 +75,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { clearSelection, updateSelectedAnchor, installInteraction, - } = createAlprPresentation({ state, services }); + } = createAlprPresentation({ state, services, source }); function setAlprStatus(status, error = null) { if (state.status === status && state.error === error) return; @@ -156,24 +164,14 @@ export function createAlprCamerasLayer({ source, services } = {}) { setAlprStatus('loading'); renderRecords(); try { - const { elements, stale } = await source.fetch( - queryBox, - requestAbort.signal, - ); + const snapshot = await source.fetch(queryBox, requestAbort.signal); if ( requestAbort.signal.aborted || state.abort !== requestAbort || !state.enabled ) return; - const records = [ - ...new Map( - elements - .map(normalizeAlprNode) - .filter(Boolean) - .map((record) => [record.id, record]), - ).values(), - ]; + const { records, stale, saturated } = validateAlprSnapshot(snapshot); state.records = records; state.recordById = new Map(records.map((r) => [r.id, r])); state.lastUpdate = Date.now(); @@ -181,8 +179,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { // Saturated when the QUERY truncated OR the render cap would hide cameras: // either way the view holds more than the screen shows, and the user must // be told rather than left with a count that disagrees with the map. - state.saturated = - elements.length >= QUERY_LIMIT || records.length > MAX_RENDERED; + state.saturated = saturated || records.length > MAX_RENDERED; state.lastQueryBox = queryBox; clearUnavailableRetry(); // Stale and saturated are independent facts; a cached response that also @@ -206,7 +203,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { state.stale = Boolean(state.lastUpdate); setAlprStatus( 'unavailable', - error?.message || 'Overpass temporarily unavailable', + error?.message || 'Camera source temporarily unavailable', ); scheduleUnavailableRetry(); } finally { @@ -223,17 +220,16 @@ export function createAlprCamerasLayer({ source, services } = {}) { id: LAYER_ID, name: 'ALPR Cameras', icon: '📷', - source: 'OpenStreetMap · community mapped', + source: source.label || 'Mapped camera locations', updateInterval: 0, statsRefreshInterval: 1000, init(viewer) { if (state.viewer) throw new Error('ALPR layer is already initialized'); state.viewer = viewer; state.dataSource = new Cesium.CustomDataSource('alpr-cameras'); - state.credit = new Cesium.Credit( - 'ALPR: © OpenStreetMap', - true, - ); + state.credit = creditMarkup + ? new Cesium.Credit(creditMarkup, true) + : null; viewer.dataSources.add(state.dataSource); state.moveEndRemove = viewer.camera.moveEnd.addEventListener(scheduleLoad); diff --git a/src/layers/alpr/model.js b/src/layers/alpr/model.js index 05f6c73bf..9abc9e3ca 100644 --- a/src/layers/alpr/model.js +++ b/src/layers/alpr/model.js @@ -155,3 +155,57 @@ export function buildOverpassQuery(south, west, north, east) { `(${south},${west},${north},${east});out body ${QUERY_LIMIT};` ); } + +/** Validate a source-independent snapshot before replacing the accepted display. */ +export function validateAlprSnapshot(snapshot) { + if ( + !Array.isArray(snapshot?.records) || + typeof snapshot.stale !== 'boolean' || + typeof snapshot.saturated !== 'boolean' + ) { + throw new TypeError('Camera source returned an invalid snapshot'); + } + const ids = new Set(); + for (const record of snapshot.records) { + if ( + !record || + typeof record.id !== 'string' || + !record.id.trim() || + ids.has(record.id) || + !Number.isFinite(record.latitude) || + Math.abs(record.latitude) > 90 || + !Number.isFinite(record.longitude) || + Math.abs(record.longitude) > 180 || + (record.directionDeg != null && + (!Number.isFinite(record.directionDeg) || + record.directionDeg < 0 || + record.directionDeg >= 360)) + ) { + throw new TypeError('Camera source returned an invalid record'); + } + ids.add(record.id); + } + return snapshot; +} + +/** Build a linked attribution from plain text and an HTTPS URL only. */ +export function alprCreditMarkup(attribution) { + if (!attribution?.text || !attribution?.href) return null; + const href = new URL(attribution.href); + if (href.protocol !== 'https:' || href.username || href.password) { + throw new TypeError('Camera attribution requires a public HTTPS link'); + } + const escape = (value) => + String(value).replace( + /[&<>"']/g, + (char) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[char], + ); + return `ALPR: ${escape(attribution.text)}`; +} diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index 64c098d61..9226e74a5 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -9,7 +9,7 @@ import { } from './policy.js'; import { boxContains, destinationPointDeg } from './model.js'; -export function createAlprPresentation({ state, services }) { +export function createAlprPresentation({ state, services, source }) { const { governorRequestRender } = services.render; const { clearSelectedEntityContextForLayer, @@ -130,7 +130,7 @@ export function createAlprPresentation({ state, services }) { } const entity = state.dataSource.entities.add(entityDef); entity.gevTrackedId = record.id; - // The raw OSM datum has no elevation; it is not the clamped marker's + // The mapped camera datum has no elevation; it is not the clamped marker's // visual anchor. Only the selected marker samples the rendered surface, // at most once per second, through Cesium's public height APIs. entity.gevAlprDisplayPosition = null; @@ -141,7 +141,7 @@ export function createAlprPresentation({ state, services }) { record.manufacturer ? `Manufacturer: ${record.manufacturer}` : null, record.operator ? `Operator: ${record.operator}` : null, record.cameraType ? `Type: ${record.cameraType}` : null, - 'Source: OpenStreetMap', + `Source: ${source.attribution?.name || source.label || 'Camera source'}`, ].filter(Boolean), accent: color.toCssColorString(), }; @@ -150,7 +150,8 @@ export function createAlprPresentation({ state, services }) { layerId: LAYER_ID, dataSource: state.dataSource, layerName: 'ALPR Cameras', - source: 'OpenStreetMap contributors (ODbL 1.0; community mapped)', + source: + source.attribution?.description || source.label || 'Camera source', label: 'ALPR camera', latitude: record.latitude, longitude: record.longitude, diff --git a/src/layers/alpr/source.js b/src/layers/alpr/source.js index 0b95cc04b..bf9288269 100644 --- a/src/layers/alpr/source.js +++ b/src/layers/alpr/source.js @@ -2,8 +2,9 @@ import { OVERPASS_URL, MAX_VIEWPORT_DEGREES, QUERY_SNAP_DEGREES, + QUERY_LIMIT, } from './policy.js'; -import { buildOverpassQuery } from './model.js'; +import { buildOverpassQuery, normalizeAlprNode } from './model.js'; /** Construct the bounded OSM request adapter without starting a request. */ export function createOverpassAlprSource({ fetchImpl = (...args) => globalThis.fetch(...args), @@ -33,6 +34,11 @@ export function createOverpassAlprSource({ signal, }); if (!response.ok) { + try { + await response.body?.cancel(); + } catch { + /* already closed */ + } const message = response.status === 429 ? 'Overpass rate-limited' @@ -49,7 +55,28 @@ export function createOverpassAlprSource({ if (!Array.isArray(payload?.elements) || payload.remark) { throw new Error('Overpass returned an incomplete camera response'); } - return { elements: payload.elements, stale }; + return { + records: [ + ...new Map( + payload.elements + .slice(0, QUERY_LIMIT) + .map(normalizeAlprNode) + .filter(Boolean) + .map((record) => [record.id, record]), + ).values(), + ], + stale, + saturated: payload.elements.length >= QUERY_LIMIT, + }; } - return { fetch: fetchAlprNodes }; + return { + fetch: fetchAlprNodes, + label: 'OpenStreetMap · community mapped', + attribution: { + name: 'OpenStreetMap', + description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)', + text: '© OpenStreetMap', + href: 'https://www.openstreetmap.org/copyright', + }, + }; } diff --git a/src/layers/alpr/source.test.mjs b/src/layers/alpr/source.test.mjs index 9624f7c62..797007a88 100644 --- a/src/layers/alpr/source.test.mjs +++ b/src/layers/alpr/source.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createOverpassAlprSource } from './source.js'; +import { validateAlprSnapshot, alprCreditMarkup } from './model.js'; const box = { south: 30, west: -98, north: 30.1, east: -97.9 }; test('the source rejects invalid and unbounded queries before fetching', async () => { let calls = 0; @@ -34,3 +35,83 @@ test('cancellation during body parsing rejects even when the transport ignores i }); await assert.rejects(source.fetch(box, abort.signal), { name: 'AbortError' }); }); + +test('Overpass normalization returns camera records and preserves limited/stale coverage', async () => { + const source = createOverpassAlprSource({ + fetchImpl: async () => ({ + ok: true, + headers: new Headers({ 'x-overpass-cache': 'STALE' }), + json: async () => ({ + elements: [ + { + type: 'node', + id: 12, + lat: 30, + lon: -98, + tags: { + 'surveillance:type': 'camera;ALPR', + 'camera:direction': '90', + }, + }, + ], + }), + }), + }); + const snapshot = await source.fetch(box); + assert.equal(snapshot.records[0].id, 'alpr:12'); + assert.equal(snapshot.records[0].latitude, 30); + assert.equal(snapshot.records[0].directionDeg, 90); + assert.equal(snapshot.stale, true); + assert.equal(snapshot.saturated, false); + assert.equal('elements' in snapshot, false); +}); + +test('an unsuccessful source response releases its body before reporting an error', async () => { + let cancelled = 0; + const source = createOverpassAlprSource({ + fetchImpl: async () => ({ + ok: false, + status: 429, + body: { + async cancel() { + cancelled++; + }, + }, + }), + }); + await assert.rejects(source.fetch(box), /rate-limited/); + assert.equal(cancelled, 1); +}); + +test('source snapshots reject malformed coordinates and duplicate identities', () => { + const record = { id: 'camera:1', latitude: 30, longitude: -98 }; + for (const records of [ + [{ ...record, latitude: NaN }], + [record, record], + [{ ...record, id: '' }], + ]) { + assert.throws( + () => validateAlprSnapshot({ records, stale: false, saturated: false }), + /invalid record/, + ); + } + assert.throws( + () => validateAlprSnapshot({ elements: [] }), + /invalid snapshot/, + ); +}); + +test('provider attribution escapes markup and rejects executable links', () => { + assert.equal(alprCreditMarkup(null), null); + assert.throws( + () => alprCreditMarkup({ text: 'test', href: 'javascript:alert(1)' }), + /HTTPS/, + ); + const html = alprCreditMarkup({ + text: '', + href: 'https://example.org/?a=1&b=2', + }); + assert.ok(html.includes('<img onerror=alert(1)>')); + assert.ok(html.includes('?a=1&b=2')); + assert.ok(!html.includes(' Date: Sun, 13 Sep 2026 19:39:00 -0700 Subject: [PATCH 3/6] test(tracking): keep the synthetic floor above refreshed contact altitude --- scripts/track-regression.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index c5832ecd4..7f521edce 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -3287,9 +3287,11 @@ async function main() { const bbBefore = window.__dfFindBB('aaa097'); if (!bbBefore) return { error: 'aaa097 billboard missing' }; const d0 = window.__dfCarto(bbBefore.position); - // Plant a floor well ABOVE where it currently renders, across the block - // it can move within, so an UNFLOORED tracked entity is unmistakable. - const seeded = d0.h + 40; + // Plant above both the current billboard and this group's 400 m + // identity-probe floor. A poll can refresh the raw render altitude + // after d0 was read; a lower seed makes the negative model-ownership + // assertion impossible even when the clamp correctly stands aside. + const seeded = Math.max(d0.h, 400) + 40; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { gf.reportMeshFloorCell(cell(d0.lat) + dy * 0.001, cell(d0.lon) + dx * 0.001, seeded); From 9547658dc7017ff02def45137a22f2e1c25887f3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 20:04:38 -0700 Subject: [PATCH 4/6] fix: keep ALPR camera markers stable during rotation --- CHANGELOG.md | 3 + docs/CURRENT-STATE.md | 6 ++ src/data/alprCameras.test.mjs | 151 ++++++++++++++++++++++++++++++++ src/data/contextStore.js | 9 +- src/layers/alpr/index.js | 14 +++ src/layers/alpr/presentation.js | 77 ++++++++++++++-- 6 files changed, 251 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffaeed332..5ba7cb4c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## ALPR camera locations +- Keep nearby camera markers and selection stable during rotation, use bounded + ground-centered coverage instead of the horizon rectangle, and reuse in-flight queries. + - Add optional, source-labeled OpenStreetMap ALPR camera locations, bounded city queries, cached-response and coverage notices, selection cards, share links, and voice toggles. - Separate the request adapter, camera model, presentation, and instance lifecycle. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d3cd03af6..9b3b51821 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,6 +9,12 @@ attribution; rendering does not parse Overpass payloads. The standalone registration supplies selection, picking, terrain and render services. Selected cards and Data attribution identify OpenStreetMap. Layer state and the existing voice layer tools include `alpr-cameras`. +Coverage follows a bounded neighborhood around the screen-center ground point, +with a radius of twice the camera-to-ground range (at least 1 km), rather than +extending to the horizon. Sky, dateline-crossing and wider-than-city views do not +query. Movement within an accepted or pending query reuses it. Markers retain +their Cesium identity and ground clamping while their geometry is unchanged; +refreshes preserve selection without replaying a click event. Data Centers, Dams and Submarine Cables release their built Cesium data sources and record references when disabled. Parsed datasets remain cached for the layer diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 89a77426c..0970f1920 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -931,3 +931,154 @@ test('two factories keep their requests, records and destruction independent', a a.restore(); } }); + +test('orbit cache hits and metadata refreshes preserve marker geometry and selection events', async () => { + const h = cameraHarness(); + try { + h.setFetch(async () => + cameraResponse([ + cameraNode(42, { + tags: { 'surveillance:type': 'ALPR', 'camera:direction': '90' }, + }), + ]), + ); + await alprCamerasLayer.update(); + h.click('alpr:42'); + const entity = h.source.entities.getById('alpr:42'); + const position = entity.position, + line = entity.polyline; + let selections = 0, + clears = 0, + collectionChanges = 0; + window.addEventListener('gev:entity-selected', () => selections++); + window.addEventListener('gev:entity-selection-cleared', () => clears++); + h.source.entities.collectionChanged.addEventListener( + (_collection, added, removed) => { + collectionChanges += added.length + removed.length; + }, + ); + h.setBox({ south: 30.261, west: -97.749, north: 30.279, east: -97.731 }); + await alprCamerasLayer.update(); + h.expire(); + h.setFetch(async () => + cameraResponse([ + cameraNode(42, { + tags: { + 'surveillance:type': 'ALPR', + 'camera:direction': '90', + operator: 'Updated directory', + }, + }), + ]), + ); + await alprCamerasLayer.update(); + assert.equal(h.source.entities.getById('alpr:42'), entity); + assert.equal(entity.position, position); + assert.equal(entity.polyline, line); + assert.equal( + getSelectedEntityContext().properties.operator, + 'Updated directory', + ); + assert.equal(selections, 0); + assert.equal(clears, 0); + assert.equal(collectionChanges, 0); + assert.equal(h.requests.length, 2, 'only expiry needs another request'); + h.expire(); + h.setFetch(async () => cameraResponse([cameraNode(42, { lon: -97.742 })])); + await alprCamerasLayer.update(); + assert.equal(h.source.entities.getById('alpr:42'), entity); + assert.equal( + entity.polyline, + undefined, + 'removed direction must not persist', + ); + assert.ok( + Cesium.Cartesian3.equals( + entity.position.getValue(), + Cesium.Cartesian3.fromDegrees(-97.742, 30.2672), + ), + ); + } finally { + h.restore(); + } +}); + +test('moves inside a pending query do not restart it; moving to another city cancels it', async () => { + const h = cameraHarness(); + try { + let resolve; + h.setFetch( + () => + new Promise((r) => { + resolve = r; + }), + ); + const first = alprCamerasLayer.update(); + const signal = h.requests[0][1].signal; + h.setBox({ south: 30.261, west: -97.749, north: 30.279, east: -97.731 }); + await alprCamerasLayer.update(); + assert.equal(h.requests.length, 1); + assert.equal(signal.aborted, false); + assert.equal(alprCamerasLayer.getStats().loading, true); + const finishFirst = resolve; + h.setBox({ south: 51.49, west: -0.14, north: 51.5, east: -0.12 }); + const second = alprCamerasLayer.update(); + assert.equal(signal.aborted, true); + assert.equal(h.requests.length, 2); + finishFirst(cameraResponse()); + await first; + assert.equal( + alprCamerasLayer.getStats().loading, + true, + 'late old response cannot finish current request', + ); + resolve(cameraResponse([cameraNode(43, { lat: 51.495, lon: -0.13 })])); + await second; + assert.deepEqual( + h.source.entities.values.map((e) => e.id), + ['alpr:43'], + ); + } finally { + h.restore(); + } +}); + +test('ground-centered orbits ignore horizon rectangles while sky, distant and dateline views stay bounded', async () => { + const h = cameraHarness(); + const camera = h.viewer.camera; + const focus = Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672); + h.viewer.scene.canvas.clientWidth = 1440; + h.viewer.scene.canvas.clientHeight = 900; + camera.pickEllipsoid = () => focus; + camera.positionWC = Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 1200); + try { + await alprCamerasLayer.update(); + const entity = h.source.entities.getById('alpr:42'); + h.click('alpr:42'); + for (const box of [null, { south: 31, west: -99, north: 36, east: -95 }]) { + h.setBox(box); + await alprCamerasLayer.update(); + assert.equal(h.source.entities.getById('alpr:42'), entity); + assert.equal(getSelectedEntityContext().id, 'alpr:42'); + assert.equal(h.requests.length, 1); + } + camera.positionWC = Cesium.Cartesian3.fromDegrees( + -97.7431, + 30.2672, + 800000, + ); + await alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().status, 'zoom-in'); + assert.equal(h.source.entities.values.length, 0); + camera.pickEllipsoid = () => undefined; + await alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().status, 'zoom-in'); + camera.pickEllipsoid = () => Cesium.Cartesian3.fromDegrees(179.999, 0); + camera.positionWC = Cesium.Cartesian3.fromDegrees(179.999, 0, 1200); + await alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().status, 'zoom-in'); + assert.equal(h.requests.length, 1); + } finally { + h.restore(); + } +}); diff --git a/src/data/contextStore.js b/src/data/contextStore.js index 6217c010c..81ca311d0 100644 --- a/src/data/contextStore.js +++ b/src/data/contextStore.js @@ -159,11 +159,14 @@ export function clearSelectedEntityContextForLayer(layerId, { evicted = false } } } -/** Remove obsolete context records when a viewport-scoped layer refreshes. */ -export function removeEntityContextsForLayer(layerId) { +/** + * Remove obsolete context records when a viewport-scoped layer refreshes. + * Optional retainIds keeps surviving records and their current selection intact. + */ +export function removeEntityContextsForLayer(layerId, { retainIds } = {}) { const store = getContextStore(); for (const [id, record] of store.entities) { - if (record?.layerId === layerId) store.entities.delete(id); + if (record?.layerId === layerId && !retainIds?.has(id)) store.entities.delete(id); } if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) { store.selectedEntityId = null; diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index d89913499..005ecc6cc 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -25,6 +25,9 @@ import { createAlprPresentation } from './presentation.js'; * Manufacturer, operator and cameraType are optional display fields. * Source label and attribution { name, description, text, href } identify the * actual provider; href must be HTTPS. Provider payload parsing stays in source. + * Context services remove obsolete records with + * removeEntityContextsForLayer(layerId, { retainIds: Set }), preserving + * retained selection without dispatching a new selection event. */ export function createAlprCamerasLayer({ source, services } = {}) { if (typeof source?.fetch !== 'function') @@ -52,6 +55,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { saturated: false, loading: false, abort: null, + pendingQueryBox: null, retryTimer: null, retryDelayMs: 0, retryAt: 0, @@ -122,6 +126,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { if (!box) { state.abort?.abort(); state.abort = null; + state.pendingQueryBox = null; state.loading = false; state.retrying = false; clearUnavailableRetry(); @@ -146,6 +151,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { if (state.abort) { state.abort.abort(); state.abort = null; + state.pendingQueryBox = null; state.loading = false; } renderRecords(); @@ -155,10 +161,16 @@ export function createAlprCamerasLayer({ source, services } = {}) { ); return; } + // A move inside an in-flight query must not abort and restart that query. + if (state.abort && boxContains(state.pendingQueryBox, box)) { + renderRecords(); + return; + } const queryBox = snapAlprBox(box); state.abort?.abort(); const requestAbort = new AbortController(); state.abort = requestAbort; + state.pendingQueryBox = queryBox; state.loading = true; state.retrying = state.status === 'unavailable' || state.retryDelayMs > 0; setAlprStatus('loading'); @@ -209,6 +221,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { } finally { if (state.abort === requestAbort) { state.abort = null; + state.pendingQueryBox = null; state.loading = false; state.retrying = false; governorRequestRender('alpr-status'); @@ -253,6 +266,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { clearTimeout(state.debounceTimer); state.abort?.abort(); state.abort = null; + state.pendingQueryBox = null; state.loading = false; state.retrying = false; if (state.dataSource) state.dataSource.show = false; diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index 9226e74a5..8e335e1ae 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -25,7 +25,43 @@ export function createAlprPresentation({ state, services, source }) { return Cesium.Color.fromCssColorString(ALPR_COLOR); } + // The horizon rectangle is unstable during low-angle orbits and can exclude + // the ground point in the center of the screen. Bound nearby coverage around + // that point instead; the camera-to-ground range keeps zoom-out queries capped. function viewportBox(viewer) { + const camera = viewer?.camera; + const canvas = viewer?.scene.canvas; + if (typeof camera?.pickEllipsoid === 'function' && canvas) { + const width = canvas.clientWidth || canvas.width; + const height = canvas.clientHeight || canvas.height; + if (!width || !height) return null; + const focus = camera.pickEllipsoid( + new Cesium.Cartesian2(width / 2, height / 2), + viewer.scene.globe.ellipsoid, + ); + if (!focus) return null; + const location = Cesium.Cartographic.fromCartesian(focus); + const range = Cesium.Cartesian3.distance(camera.positionWC, focus); + const radius = Math.max(1000, 2 * range); + const latitude = Cesium.Math.toDegrees(location.latitude); + const longitude = Cesium.Math.toDegrees(location.longitude); + const latSpan = radius / 111000; + const lonSpan = latSpan / Math.cos(location.latitude); + if ( + !Number.isFinite(latSpan + lonSpan) || + 2 * Math.max(latSpan, lonSpan) > MAX_VIEWPORT_DEGREES || + Math.abs(latitude) + latSpan > 90 || + Math.abs(longitude) + lonSpan > 180 + ) + return null; + return { + south: latitude - latSpan, + west: longitude - lonSpan, + north: latitude + latSpan, + east: longitude + lonSpan, + }; + } + // Rectangle-only viewers retain the same bounded-area contract. const rectangle = viewer?.camera?.computeViewRectangle( viewer.scene.globe.ellipsoid, ); @@ -91,8 +127,19 @@ export function createAlprPresentation({ state, services, source }) { clearSelectedEntityContextForLayer(LAYER_ID); } governorRequestRender('alpr-render'); - clearRendered(); + const visibleIds = new Set(visible.map((record) => record.id)); + for (const entity of [...state.dataSource.entities.values]) { + if (!visibleIds.has(entity.id)) state.dataSource.entities.remove(entity); + } + removeEntityContextsForLayer(LAYER_ID, { retainIds: visibleIds }); for (const record of visible) { + const existing = state.dataSource.entities.getById(record.id); + if (existing?.gevAlprRecord === record) { + existing.point.pixelSize = record.id === state.selectedId ? 12 : 8; + existing.point.color = + record.id === state.selectedId ? Cesium.Color.WHITE : markerColor(); + continue; + } const color = markerColor(); const selected = record.id === state.selectedId; const position = Cesium.Cartesian3.fromDegrees( @@ -128,12 +175,32 @@ export function createAlprPresentation({ state, services, source }) { clampToGround: true, }; } - const entity = state.dataSource.entities.add(entityDef); + let entity = existing; + // Keep the Cesium entity and its ground-clamping subscription while its + // geometry is unchanged. Updating metadata must not rebuild the marker. + const previous = entity?.gevAlprRecord; + if (!entity) entity = state.dataSource.entities.add(entityDef); + else { + if ( + previous.latitude !== record.latitude || + previous.longitude !== record.longitude + ) + entity.position = position; + if ( + previous.latitude !== record.latitude || + previous.longitude !== record.longitude || + previous.directionDeg !== record.directionDeg + ) + entity.polyline = entityDef.polyline; + entity.point.pixelSize = selected ? 12 : 8; + entity.point.color = selected ? Cesium.Color.WHITE : color; + } + entity.gevAlprRecord = record; entity.gevTrackedId = record.id; // The mapped camera datum has no elevation; it is not the clamped marker's // visual anchor. Only the selected marker samples the rendered surface, // at most once per second, through Cesium's public height APIs. - entity.gevAlprDisplayPosition = null; + entity.gevAlprDisplayPosition ??= null; entity.gevDisplayPosition = () => entity.gevAlprDisplayPosition; entity.gevLabelModel = { title: 'ALPR CAMERA', @@ -171,9 +238,7 @@ export function createAlprPresentation({ state, services, source }) { const selectedEntity = state.selectedId ? state.dataSource.entities.getById(state.selectedId) : null; - if (selectedEntity) selectEntityContext(selectedEntity); - else state.selectedId = null; - state.lastAnchorSampleAt = 0; + if (!selectedEntity) state.selectedId = null; updateSelectedAnchor(); // Start only when mapped data is actually displayed, not while an upstream // request or a zoom-in prompt could consume the five-second introduction. From fe1aa3b6f64efedb66fb6143ec2b479d58fb02c0 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 20:35:38 -0700 Subject: [PATCH 5/6] fix: clarify nearby ALPR counts and help locate cameras --- CHANGELOG.md | 3 ++ docs/CURRENT-STATE.md | 5 ++++ src/data/alprCameras.test.mjs | 42 ++++++++++++++++++++++++++ src/data/manager.test.mjs | 34 +++++++++++++++++++++ src/layers/alpr/index.js | 31 ++++++++++++++++++++ src/layers/alpr/presentation.js | 52 +++++++++++++++++++++++++++++++++ src/ui/layerPanel.js | 25 +++++++--------- src/ui/styles/layers.css | 8 +++++ 8 files changed, 186 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 502fbd351..83f90ce1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## ALPR camera locations +- Label the loaded camera count as nearby, show a purple-dot legend, and add + SHOW NEAREST to frame and select a loaded camera when none are on screen. + - Keep nearby camera markers and selection stable during rotation, use bounded ground-centered coverage instead of the horizon rectangle, and reuse in-flight queries. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 10ae0e1cc..d6b753e17 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,6 +9,11 @@ attribution; rendering does not parse Overpass payloads. The standalone registration supplies selection, picking, terrain and render services. Selected cards and Data attribution identify OpenStreetMap. Layer state and the existing voice layer tools include `alpr-cameras`. +The row count explicitly says **nearby**: loaded records can be outside the +screen. A purple-dot legend identifies the fixed 8-pixel markers (12 pixels when +selected). **SHOW NEAREST** frames and selects the nearest loaded camera; it is +disabled with no loaded cameras or while following a tracked entity. This action +chooses from the current source snapshot. Coverage follows a bounded neighborhood around the screen-center ground point, with a radius of twice the camera-to-ground range (at least 1 km), rather than extending to the horizon. Sky, dateline-crossing and wider-than-city views do not diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 0970f1920..d758f7060 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -1082,3 +1082,45 @@ test('ground-centered orbits ignore horizon rectangles while sky, distant and da h.restore(); } }); + +test('nearby count and discovery control frame a real loaded camera without fetching or stealing tracking', async () => { + const h = cameraHarness(); + const flights = []; + h.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + -97.7431, + 30.2672, + 600, + ); + h.viewer.camera.flyToBoundingSphere = (sphere, options) => + flights.push({ sphere, options }); + try { + assert.equal(alprCamerasLayer.getRowControls().chips[0].disabled, true); + await alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().countLabel, '1 nearby'); + const controls = alprCamerasLayer.getRowControls(); + assert.equal(controls.legend[0].label, 'Purple dots'); + assert.equal(controls.chips[0].onClick(), true); + assert.equal(flights.length, 1); + assert.equal(getSelectedEntityContext().id, 'alpr:42'); + const center = Cesium.Cartographic.fromCartesian(flights[0].sphere.center); + assert.ok( + Math.abs(Cesium.Math.toDegrees(center.latitude) - 30.2672) < 1e-9, + ); + assert.equal(flights[0].options.offset.range, 800); + assert.equal(h.requests.length, 1); + h.viewer.trackedEntity = {}; + assert.equal(alprCamerasLayer.getRowControls().chips[0].disabled, true); + assert.equal( + controls.chips[0].onClick(), + false, + 'a stale button cannot steal the follow camera', + ); + h.viewer.trackedEntity = undefined; + alprCamerasLayer.disable(); + assert.equal(alprCamerasLayer.getStats().countLabel, ''); + assert.equal(controls.chips[0].onClick(), false); + assert.equal(flights.length, 1); + } finally { + h.restore(); + } +}); diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index e2f084d62..80a1a8459 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -3302,3 +3302,37 @@ test('panel remount releases old listeners and destruction revokes retained cont else globalThis.document = originalDocument; } }); + +test('row action chips use live disabled state and descriptive counts without writing parameters', async () => { + const originalDocument = globalThis.document; + globalThis.document = { createElement: makeControlElement }; + const mgr = new DataLayerManager({}); + const layer = makeRowControlLayer(); + let calls = 0, blocked = false; + layer.module.getStats = () => ({ count: 3, countLabel: '3 nearby' }); + layer.module.getRowControls = () => ({ chips: [{ id: 'find', label: 'SHOW NEAREST', disabled: blocked, onClick: () => calls++ }] }); + layer.module.setParams = () => { throw new Error('navigation must not write persistent params'); }; + mgr.register(layer.module); + const container = makeControlElement(); + try { + mgr.buildTogglePanel(container); + await mgr.setEnabled('satellites', true); + const row = container.querySelector('[data-layer-id="satellites"]'); + assert.equal(row.querySelector('.data-count').textContent, '3 nearby'); + const controls = row.querySelector('.data-toggle-controls'); + const chip = collectByClass(controls, 'data-toggle-chip')[0]; + controls.listeners.click({ target: chip }); + assert.equal(calls, 1); + blocked = true; + controls.listeners.click({ target: chip }); + assert.equal(calls, 1, 'live disabled descriptor wins before row repaint'); + blocked = false; + await mgr.setEnabled('satellites', false); + controls.listeners.click({ target: chip }); + assert.equal(calls, 1, 'disabled layer cannot navigate'); + } finally { + await mgr.destroyAll(); + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + } +}); diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index 005ecc6cc..74a20ffcf 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -6,6 +6,7 @@ import { MAX_RENDERED, QUERY_SNAP_DEGREES, QUERY_REUSE_MS, + ALPR_COLOR, } from './policy.js'; import { snapAlprBox, @@ -76,6 +77,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { presentOnMapCredit, renderRecords, selectRecord, + focusNearest, clearSelection, updateSelectedAnchor, installInteraction, @@ -301,9 +303,38 @@ export function createAlprCamerasLayer({ source, services } = {}) { state.saturated = false; state.viewer = null; }, + getRowControls() { + const count = state.dataSource?.entities.values.length || 0; + return { + chips: [ + { + id: 'find-camera', + label: 'SHOW NEAREST', + title: state.viewer?.trackedEntity + ? 'Stop following the current object before navigating to a camera' + : 'Move to the nearest loaded camera and show its details', + disabled: + !state.enabled || !count || Boolean(state.viewer?.trackedEntity), + onClick: focusNearest, + }, + ], + legend: [ + { + label: 'Purple dots', + color: ALPR_COLOR, + count, + blurb: + 'Nearby mapped cameras may be outside the screen. Dots stay 8 pixels wide as you zoom; click one for details.', + }, + ], + }; + }, getStats() { return { count: state.dataSource?.entities.values.length || 0, + countLabel: state.enabled + ? `${state.dataSource?.entities.values.length || 0} nearby` + : '', lastUpdate: state.lastUpdate, stale: state.stale, saturated: state.saturated, diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index 8e335e1ae..89b6c6735 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -245,6 +245,57 @@ export function createAlprPresentation({ state, services, source }) { if (visible.length) presentOnMapCredit(); } + function focusNearest() { + const camera = state.viewer?.camera; + if (!state.enabled || !camera?.positionWC || state.viewer.trackedEntity) + return false; + let nearest = null, + distance = Infinity; + for (const entity of state.dataSource.entities.values) { + const position = entity.position.getValue(Cesium.JulianDate.now()); + const candidate = Cesium.Cartesian3.distanceSquared( + camera.positionWC, + position, + ); + if (candidate < distance) { + nearest = entity; + distance = candidate; + } + } + if (!nearest) return false; + const record = state.recordById.get(nearest.id); + const location = Cesium.Cartographic.fromDegrees( + record.longitude, + record.latitude, + ); + let height; + if (state.viewer.scene.sampleHeightSupported) { + try { + height = state.viewer.scene.sampleHeight(location, [nearest]); + } catch { + /* tiles may still be streaming */ + } + } + if (!Number.isFinite(height) || height < -500 || height > 10000) + height = cachedGroundFloor(record.latitude, record.longitude); + const center = Cesium.Cartesian3.fromDegrees( + record.longitude, + record.latitude, + Number.isFinite(height) ? height : 0, + ); + if (!selectRecord(nearest.id)) return false; + camera.flyToBoundingSphere(new Cesium.BoundingSphere(center, 30), { + duration: 1.2, + offset: new Cesium.HeadingPitchRange( + camera.heading || 0, + -Math.PI / 4, + 800, + ), + }); + governorRequestRender('alpr-focus'); + return true; + } + function selectRecord(id) { const entity = state.dataSource?.entities.getById(id); if (!entity || !state.recordById.has(id)) return false; @@ -343,6 +394,7 @@ export function createAlprPresentation({ state, services, source }) { presentOnMapCredit, renderRecords, selectRecord, + focusNearest, clearSelection, updateSelectedAnchor, installInteraction, diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 6bd94bf9f..fafcb5d67 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -136,9 +136,7 @@ export class LayerPanel { const count = document.createElement('span'); count.className = 'data-count'; - count.textContent = layer.stats.count - ? this._formatCount(layer.stats.count) - : '—'; + count.textContent = this._layerCountText(layer.stats); const toggle = document.createElement('button'); toggle.type = 'button'; @@ -202,7 +200,9 @@ export class LayerPanel { const chip = this._rowControlsFor(layer.id)?.chips?.find( (entry) => entry.id === button.dataset.chipId, ); - if (chip?.params) + if (!chip || chip.disabled || !this.isEnabled(layer.id)) return; + if (typeof chip.onClick === 'function') chip.onClick(); + else if (chip.params) this.setLayerParams(layer.id, chip.params, { origin: 'user' }); }); row.appendChild(controls); @@ -213,13 +213,12 @@ export class LayerPanel { } } - /** - * Read a layer's optional row-control descriptor, tolerating a throw so one - * misbehaving layer cannot blank the whole panel. Resolved from the registry - * rather than the `getAll()` projection, which deliberately omits `module`. - * @param {string} layerId Registered layer id. - * @returns {{ chips?: Array, legend?: Array }|null} Descriptor. - */ + /** Qualify a loaded count when it does not mean items currently on screen. */ + _layerCountText(stats) { + if (typeof stats.countLabel === 'string' && stats.countLabel.trim()) + return stats.countLabel; + return stats.count ? this._formatCount(stats.count) : '—'; + } /** * Render a layer's row chips and color legend, and keep the whole block @@ -307,9 +306,7 @@ export class LayerPanel { const count = row.querySelector('.data-count'); if (count) { - count.textContent = layer.stats.count - ? this._formatCount(layer.stats.count) - : '—'; + count.textContent = this._layerCountText(layer.stats); } const meta = row.querySelector('.data-toggle-meta'); diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 87153ebd5..4127334e5 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -1273,3 +1273,11 @@ #global-context-panel #cockpit-entry kbd { padding: 2px 4px; } + +/* Qualified nearby counts must also fit beside the wider loading chip. */ +.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-top { + flex-wrap: wrap; +} +.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-right { + margin-left: auto; +} From bcba7c5c01e03afaaf879ed529ca71e9a1558a40 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 20:42:48 -0700 Subject: [PATCH 6/6] fix: use globe terrain when framing an ALPR camera --- CHANGELOG.md | 3 ++- docs/CURRENT-STATE.md | 3 ++- src/data/alprCameras.test.mjs | 6 ++++++ src/layers/alpr/presentation.js | 2 ++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f90ce1f..16986c795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ ## ALPR camera locations - Label the loaded camera count as nearby, show a purple-dot legend, and add - SHOW NEAREST to frame and select a loaded camera when none are on screen. + SHOW NEAREST to frame and select a loaded camera when none are on screen, + using the available 3D-tile or globe terrain height. - Keep nearby camera markers and selection stable during rotation, use bounded ground-centered coverage instead of the horizon rectangle, and reuse in-flight queries. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d6b753e17..3e4455ab3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -13,7 +13,8 @@ The row count explicitly says **nearby**: loaded records can be outside the screen. A purple-dot legend identifies the fixed 8-pixel markers (12 pixels when selected). **SHOW NEAREST** frames and selects the nearest loaded camera; it is disabled with no loaded cameras or while following a tracked entity. This action -chooses from the current source snapshot. +chooses from the current source snapshot and uses rendered surface height from +3D tiles or globe terrain when available. Coverage follows a bounded neighborhood around the screen-center ground point, with a radius of twice the camera-to-ground range (at least 1 km), rather than extending to the horizon. Sky, dateline-crossing and wider-than-city views do not diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index d758f7060..ff24e3159 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -1093,6 +1093,8 @@ test('nearby count and discovery control frame a real loaded camera without fetc ); h.viewer.camera.flyToBoundingSphere = (sphere, options) => flights.push({ sphere, options }); + h.viewer.scene.globe.show = true; + h.viewer.scene.globe.getHeight = () => 312; try { assert.equal(alprCamerasLayer.getRowControls().chips[0].disabled, true); await alprCamerasLayer.update(); @@ -1106,6 +1108,10 @@ test('nearby count and discovery control frame a real loaded camera without fetc assert.ok( Math.abs(Cesium.Math.toDegrees(center.latitude) - 30.2672) < 1e-9, ); + assert.ok( + Math.abs(center.height - 312) < 1e-6, + 'use the rendered globe floor on keyless basemaps', + ); assert.equal(flights[0].options.offset.range, 800); assert.equal(h.requests.length, 1); h.viewer.trackedEntity = {}; diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index 89b6c6735..f56787c79 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -276,6 +276,8 @@ export function createAlprPresentation({ state, services, source }) { /* tiles may still be streaming */ } } + if (!Number.isFinite(height) && state.viewer.scene.globe.show) + height = state.viewer.scene.globe.getHeight?.(location); if (!Number.isFinite(height) || height < -500 || height > 10000) height = cachedGroundFloor(record.latitude, record.longitude); const center = Cesium.Cartesian3.fromDegrees(