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] 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]); + } + } +});