mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
ba7fda74d7
commit
4374c0fa60
28 files changed
+2358
-100
No files matched your search
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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]]),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
}));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
@@ -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};`
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+19
@@ -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. */
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user