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 <[email protected]>
This commit is contained in:
Tom-NeverwinterandBilawal Sidhu authored and Sameh Khamis committed 2026-09-13 17:21:42 -07:00
1 parent ba7fda74d7
commit 4374c0fa60
28 files changed
+2358 -100

No files matched your search

+7
View File
@@ -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
+27
View File
@@ -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).
+8
View File
@@ -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;
+3 -2
View File
@@ -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"
}
}
+9 -1
View File
@@ -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"
]
+290 -89
View File
@@ -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"
]
}
}
+3 -1
View File
@@ -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.',
},
+13
View File
@@ -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 },
});
+915
View File
@@ -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();
}
});
+8
View File
@@ -81,6 +81,14 @@ export const DATA_CREDITS = [
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'(ODbL 1.0)',
},
{
key: 'alpr-osm',
html:
'ALPR camera locations (automatic license plate readers): ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'(<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL 1.0</a>); ' +
'community mapping includes <a href="https://deflock.org" target="_blank" rel="noopener">DeFlock</a>',
},
{
key: 'military-installations-osm',
html:
+1
View File
@@ -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' }),
+2 -2
View File
@@ -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]]),
+4 -1
View File
@@ -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;
+12
View File
@@ -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 },
}));
+3 -2
View File
@@ -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');
+343
View File
@@ -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(
'<span class="gev-alpr-credit">ALPR: <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap</a></span>',
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';
+157
View File
@@ -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};`
);
}
+54
View File
@@ -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;
+284
View File
@@ -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,
};
}
+55
View File
@@ -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 };
}
+36
View File
@@ -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' });
});
+43 -1
View File
@@ -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 &&
+28
View File
@@ -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);
});
+2 -1
View File
@@ -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', () => {
+2
View File
@@ -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);
+19
View File
@@ -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. */
+6
View File
@@ -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([
+24
View File
@@ -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]);
}
}
});