diff --git a/CHANGELOG.md b/CHANGELOG.md
index daa9f99c6..16986c795 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
# Changelog
+## ALPR camera locations
+
+- Label the loaded camera count as nearby, show a purple-dot legend, and add
+ SHOW NEAREST to frame and select a loaded camera when none are on screen,
+ using the available 3D-tile or globe terrain height.
+
+- Keep nearby camera markers and selection stable during rotation, use bounded
+ ground-centered coverage instead of the horizon rectangle, and reuse in-flight queries.
+
+- Add optional, source-labeled OpenStreetMap ALPR camera locations, bounded city queries,
+ cached-response and coverage notices, selection cards, share links, and voice toggles.
+- Separate the request adapter, camera model, presentation, and instance lifecycle.
+ Source cancellation also guards late response bodies and rejects invalid query bounds.
+
## Release disabled infrastructure rendering
- Remove built Data Center, Dam and Submarine Cable entities when their layers
diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md
index 7e6cf5afa..08a3d662e 100644
--- a/DATA_SOURCES.md
+++ b/DATA_SOURCES.md
@@ -15,6 +15,7 @@ How to read this:
| Source | Used for | License / terms | Attribution |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
+| **OpenStreetMap ALPR camera locations** (including DeFlock community mapping) | Optional mapped automatic license-plate-reader camera layer | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/); commercial use permitted with applicable attribution and database share-alike obligations | [© OpenStreetMap contributors](https://www.openstreetmap.org/copyright); [DeFlock](https://deflock.org) community mapping |
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
@@ -92,6 +93,32 @@ The submarine-cable GeoJSON is **CC BY-NC-SA 3.0** (Attribution-**NonCommercial*
The richer structured dataset is licensed separately/commercially by TeleGeography.
+### ALPR camera mapping
+
+The optional ALPR layer queries OpenStreetMap nodes tagged `man_made=surveillance`
+and `surveillance:type=ALPR` (including case-insensitive semicolon multi-values)
+through the shared Overpass proxy. Source/tag reference:
+[OSM ALPR tagging](https://wiki.openstreetmap.org/wiki/Tag:surveillance:type%3DALPR).
+No DeFlock software, branded assets or separate database is bundled. The layer
+shows contributor-supplied locations and tags, not plate records, camera video,
+current operating status, or exhaustive coverage. All manufacturers share the
+ALPR category and marker color. Manufacturer, operator and camera type are shown
+only when tagged, not inferred or independently verified. Every selected card
+names OpenStreetMap in a smaller, muted source line beneath those details.
+
+Queries are limited to a city-scale viewport (at most 3° per axis), at most
+1,500 returned nodes, with outward-snapped query reuse and explicit stale/limited
+coverage notices. A linked © OpenStreetMap credit appears for five
+seconds when camera locations first display after enabling, then collapses;
+Data attribution retains the full source/license entry, following the
+[OSMF interactive-map guidance](https://osmfoundation.org/wiki/Licence/Attribution_Guidelines).
+OSM data remains separately attributed under ODbL; querying
+at runtime does not replace the license obligations. ODbL permits commercial
+use and does not license the application code. If publicly using a derivative
+database, follow its share-alike and access requirements; keep independent
+datasets separate rather than treating the whole collection as MIT data.
+Overpass hosting capacity and usage policies are separate from the data license.
+
### ODbL share-alike (datacenters, dams)
The OSM-derived datasets are under the **Open Database License**. ODbL's share-alike applies to the **data / derived database, not this MIT-licensed code** — the two coexist (exactly how Open Infrastructure Map ships: MIT software + ODbL data). If you publicly distribute a _modified_ version of these databases, you must offer it under ODbL. Keep the "© OpenStreetMap contributors" notice (link: https://www.openstreetmap.org/copyright).
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 9422910e9..3e4455ab3 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1,5 +1,27 @@
# 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. Sources return normalized records, stale/coverage flags and their own
+attribution; rendering does not parse Overpass payloads. The standalone
+registration supplies selection, picking, terrain and
+render services. Selected cards and Data attribution identify OpenStreetMap.
+Layer state and the existing voice layer tools include `alpr-cameras`.
+The row count explicitly says **nearby**: loaded records can be outside the
+screen. A purple-dot legend identifies the fixed 8-pixel markers (12 pixels when
+selected). **SHOW NEAREST** frames and selects the nearest loaded camera; it is
+disabled with no loaded cameras or while following a tracked entity. This action
+chooses from the current source snapshot and uses rendered surface height from
+3D tiles or globe terrain when available.
+Coverage follows a bounded neighborhood around the screen-center ground point,
+with a radius of twice the camera-to-ground range (at least 1 km), rather than
+extending to the horizon. Sky, dateline-crossing and wider-than-city views do not
+query. Movement within an accepted or pending query reuses it. Markers retain
+their Cesium identity and ground clamping while their geometry is unchanged;
+refreshes preserve selection without replaying a click event.
+
Data Centers, Dams and Submarine Cables release their built Cesium data sources
and record references when disabled. Parsed datasets remain cached for the layer
lifetime, so re-enable rebuilds entities without downloading or parsing again;
diff --git a/package.json b/package.json
index c861e10b3..07297242a 100644
--- a/package.json
+++ b/package.json
@@ -156,6 +156,7 @@
"./layers/traffic": "./src/layers/traffic/index.js",
"./layers/bikeshare": "./src/layers/bikeshare/index.js",
"./layers/cctv": "./src/layers/cctv/index.js",
+ "./layers/alpr": "./src/layers/alpr/index.js",
"./layers/radio": "./src/layers/radio/index.js",
"./layers/submarine-cables": "./src/layers/submarineCables/index.js",
"./sources/bundled-cables": "./src/layers/submarineCables/bundledSource.js",
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 1e7a561da..f2acaea16 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -469,6 +469,14 @@
"src/data/cctvNswSource.test.mjs",
"src/data/cctvTxdotSource.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",
"src/layers/radio/catalogModel.js",
"src/layers/radio/categories.js",
"src/layers/radio/clustering.js",
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index fb27672fa..f704a7a38 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -898,6 +898,17 @@
],
"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"]
+ },
"radio-layer": {
"exports": ["./layers/radio"],
"modules": [
diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs
index c5832ecd4..7f521edce 100644
--- a/scripts/track-regression.mjs
+++ b/scripts/track-regression.mjs
@@ -3287,9 +3287,11 @@ async function main() {
const bbBefore = window.__dfFindBB('aaa097');
if (!bbBefore) return { error: 'aaa097 billboard missing' };
const d0 = window.__dfCarto(bbBefore.position);
- // Plant a floor well ABOVE where it currently renders, across the block
- // it can move within, so an UNFLOORED tracked entity is unmistakable.
- const seeded = d0.h + 40;
+ // Plant above both the current billboard and this group's 400 m
+ // identity-probe floor. A poll can refresh the raw render altitude
+ // after d0 was read; a lower seed makes the negative model-ownership
+ // assertion impossible even when the clamp correctly stands aside.
+ const seeded = Math.max(d0.h, 400) + 40;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
gf.reportMeshFloorCell(cell(d0.lat) + dy * 0.001, cell(d0.lon) + dx * 0.001, seeded);
diff --git a/server/providers/openai/tools.js b/server/providers/openai/tools.js
index 66e2bfd2d..612bf3174 100644
--- a/server/providers/openai/tools.js
+++ b/server/providers/openai/tools.js
@@ -137,7 +137,7 @@ const GEV_REALTIME_TOOLS = [
layerId: {
type: 'string',
description:
- 'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio.',
+ 'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras.',
enum: [
'flights',
'military',
@@ -153,6 +153,7 @@ const GEV_REALTIME_TOOLS = [
'local-dams',
'telegeography-submarine-cables',
'local-firms',
+ 'alpr-cameras',
],
},
enabled: { type: 'boolean' },
@@ -185,6 +186,7 @@ const GEV_REALTIME_TOOLS = [
'local-dams',
'telegeography-submarine-cables',
'local-firms',
+ 'alpr-cameras',
],
description: 'Optional layer row to scroll into view and highlight.',
},
diff --git a/src/data/alprCameras.js b/src/data/alprCameras.js
new file mode 100644
index 000000000..40802d945
--- /dev/null
+++ b/src/data/alprCameras.js
@@ -0,0 +1,13 @@
+import {
+ createAlprCamerasLayer,
+ createOverpassAlprSource,
+} from '../layers/alpr/index.js';
+import * as render from '../renderGovernor.js';
+import * as context from './contextStore.js';
+import * as picking from './pickRegistry.js';
+import * as groundFloor from './groundFloor.js';
+export * from '../layers/alpr/index.js';
+export default createAlprCamerasLayer({
+ source: createOverpassAlprSource(),
+ services: { render, context, picking, groundFloor },
+});
diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs
new file mode 100644
index 000000000..ff24e3159
--- /dev/null
+++ b/src/data/alprCameras.test.mjs
@@ -0,0 +1,1132 @@
+// 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: {
+ label: 'Test camera directory',
+ attribution: { name: 'Test directory' },
+ fetch: async () => ({
+ records: [
+ { id: 'directory:99', latitude: 30.2672, longitude: -97.7431 },
+ ],
+ stale: false,
+ saturated: false,
+ }),
+ },
+ });
+ const a = cameraHarness(first),
+ b = cameraHarness(second);
+ try {
+ const pending = first.update();
+ await second.update();
+ first.destroy();
+ assert.equal(firstSignal.aborted, true);
+ resolveFirst({
+ records: [normalizeAlprNode(cameraNode(42))],
+ stale: false,
+ saturated: false,
+ });
+ await pending;
+ assert.equal(first.getStats().count, 0);
+ assert.equal(second.getStats().count, 1);
+ assert.equal(b.source.entities.values[0].id, 'directory:99');
+ assert.equal(second.source, 'Test camera directory');
+ assert.ok(
+ b.source.entities.values[0].gevLabelModel.details.includes(
+ 'Source: Test directory',
+ ),
+ );
+ assert.equal(a.viewer.camera.moveEnd.numberOfListeners, 0);
+ assert.equal(a.viewer.scene.postRender.numberOfListeners, 0);
+ } finally {
+ b.restore();
+ a.restore();
+ }
+});
+
+test('orbit cache hits and metadata refreshes preserve marker geometry and selection events', async () => {
+ const h = cameraHarness();
+ try {
+ h.setFetch(async () =>
+ cameraResponse([
+ cameraNode(42, {
+ tags: { 'surveillance:type': 'ALPR', 'camera:direction': '90' },
+ }),
+ ]),
+ );
+ await alprCamerasLayer.update();
+ h.click('alpr:42');
+ const entity = h.source.entities.getById('alpr:42');
+ const position = entity.position,
+ line = entity.polyline;
+ let selections = 0,
+ clears = 0,
+ collectionChanges = 0;
+ window.addEventListener('gev:entity-selected', () => selections++);
+ window.addEventListener('gev:entity-selection-cleared', () => clears++);
+ h.source.entities.collectionChanged.addEventListener(
+ (_collection, added, removed) => {
+ collectionChanges += added.length + removed.length;
+ },
+ );
+ h.setBox({ south: 30.261, west: -97.749, north: 30.279, east: -97.731 });
+ await alprCamerasLayer.update();
+ h.expire();
+ h.setFetch(async () =>
+ cameraResponse([
+ cameraNode(42, {
+ tags: {
+ 'surveillance:type': 'ALPR',
+ 'camera:direction': '90',
+ operator: 'Updated directory',
+ },
+ }),
+ ]),
+ );
+ await alprCamerasLayer.update();
+ assert.equal(h.source.entities.getById('alpr:42'), entity);
+ assert.equal(entity.position, position);
+ assert.equal(entity.polyline, line);
+ assert.equal(
+ getSelectedEntityContext().properties.operator,
+ 'Updated directory',
+ );
+ assert.equal(selections, 0);
+ assert.equal(clears, 0);
+ assert.equal(collectionChanges, 0);
+ assert.equal(h.requests.length, 2, 'only expiry needs another request');
+ h.expire();
+ h.setFetch(async () => cameraResponse([cameraNode(42, { lon: -97.742 })]));
+ await alprCamerasLayer.update();
+ assert.equal(h.source.entities.getById('alpr:42'), entity);
+ assert.equal(
+ entity.polyline,
+ undefined,
+ 'removed direction must not persist',
+ );
+ assert.ok(
+ Cesium.Cartesian3.equals(
+ entity.position.getValue(),
+ Cesium.Cartesian3.fromDegrees(-97.742, 30.2672),
+ ),
+ );
+ } finally {
+ h.restore();
+ }
+});
+
+test('moves inside a pending query do not restart it; moving to another city cancels it', async () => {
+ const h = cameraHarness();
+ try {
+ let resolve;
+ h.setFetch(
+ () =>
+ new Promise((r) => {
+ resolve = r;
+ }),
+ );
+ const first = alprCamerasLayer.update();
+ const signal = h.requests[0][1].signal;
+ h.setBox({ south: 30.261, west: -97.749, north: 30.279, east: -97.731 });
+ await alprCamerasLayer.update();
+ assert.equal(h.requests.length, 1);
+ assert.equal(signal.aborted, false);
+ assert.equal(alprCamerasLayer.getStats().loading, true);
+ const finishFirst = resolve;
+ h.setBox({ south: 51.49, west: -0.14, north: 51.5, east: -0.12 });
+ const second = alprCamerasLayer.update();
+ assert.equal(signal.aborted, true);
+ assert.equal(h.requests.length, 2);
+ finishFirst(cameraResponse());
+ await first;
+ assert.equal(
+ alprCamerasLayer.getStats().loading,
+ true,
+ 'late old response cannot finish current request',
+ );
+ resolve(cameraResponse([cameraNode(43, { lat: 51.495, lon: -0.13 })]));
+ await second;
+ assert.deepEqual(
+ h.source.entities.values.map((e) => e.id),
+ ['alpr:43'],
+ );
+ } finally {
+ h.restore();
+ }
+});
+
+test('ground-centered orbits ignore horizon rectangles while sky, distant and dateline views stay bounded', async () => {
+ const h = cameraHarness();
+ const camera = h.viewer.camera;
+ const focus = Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672);
+ h.viewer.scene.canvas.clientWidth = 1440;
+ h.viewer.scene.canvas.clientHeight = 900;
+ camera.pickEllipsoid = () => focus;
+ camera.positionWC = Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 1200);
+ try {
+ await alprCamerasLayer.update();
+ const entity = h.source.entities.getById('alpr:42');
+ h.click('alpr:42');
+ for (const box of [null, { south: 31, west: -99, north: 36, east: -95 }]) {
+ h.setBox(box);
+ await alprCamerasLayer.update();
+ assert.equal(h.source.entities.getById('alpr:42'), entity);
+ assert.equal(getSelectedEntityContext().id, 'alpr:42');
+ assert.equal(h.requests.length, 1);
+ }
+ camera.positionWC = Cesium.Cartesian3.fromDegrees(
+ -97.7431,
+ 30.2672,
+ 800000,
+ );
+ await alprCamerasLayer.update();
+ assert.equal(alprCamerasLayer.getStats().status, 'zoom-in');
+ assert.equal(h.source.entities.values.length, 0);
+ camera.pickEllipsoid = () => undefined;
+ await alprCamerasLayer.update();
+ assert.equal(alprCamerasLayer.getStats().status, 'zoom-in');
+ camera.pickEllipsoid = () => Cesium.Cartesian3.fromDegrees(179.999, 0);
+ camera.positionWC = Cesium.Cartesian3.fromDegrees(179.999, 0, 1200);
+ await alprCamerasLayer.update();
+ assert.equal(alprCamerasLayer.getStats().status, 'zoom-in');
+ assert.equal(h.requests.length, 1);
+ } finally {
+ h.restore();
+ }
+});
+
+test('nearby count and discovery control frame a real loaded camera without fetching or stealing tracking', async () => {
+ const h = cameraHarness();
+ const flights = [];
+ h.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
+ -97.7431,
+ 30.2672,
+ 600,
+ );
+ h.viewer.camera.flyToBoundingSphere = (sphere, options) =>
+ flights.push({ sphere, options });
+ h.viewer.scene.globe.show = true;
+ h.viewer.scene.globe.getHeight = () => 312;
+ try {
+ assert.equal(alprCamerasLayer.getRowControls().chips[0].disabled, true);
+ await alprCamerasLayer.update();
+ assert.equal(alprCamerasLayer.getStats().countLabel, '1 nearby');
+ const controls = alprCamerasLayer.getRowControls();
+ assert.equal(controls.legend[0].label, 'Purple dots');
+ assert.equal(controls.chips[0].onClick(), true);
+ assert.equal(flights.length, 1);
+ assert.equal(getSelectedEntityContext().id, 'alpr:42');
+ const center = Cesium.Cartographic.fromCartesian(flights[0].sphere.center);
+ assert.ok(
+ Math.abs(Cesium.Math.toDegrees(center.latitude) - 30.2672) < 1e-9,
+ );
+ assert.ok(
+ Math.abs(center.height - 312) < 1e-6,
+ 'use the rendered globe floor on keyless basemaps',
+ );
+ assert.equal(flights[0].options.offset.range, 800);
+ assert.equal(h.requests.length, 1);
+ h.viewer.trackedEntity = {};
+ assert.equal(alprCamerasLayer.getRowControls().chips[0].disabled, true);
+ assert.equal(
+ controls.chips[0].onClick(),
+ false,
+ 'a stale button cannot steal the follow camera',
+ );
+ h.viewer.trackedEntity = undefined;
+ alprCamerasLayer.disable();
+ assert.equal(alprCamerasLayer.getStats().countLabel, '');
+ assert.equal(controls.chips[0].onClick(), false);
+ assert.equal(flights.length, 1);
+ } finally {
+ h.restore();
+ }
+});
diff --git a/src/data/contextStore.js b/src/data/contextStore.js
index 6217c010c..81ca311d0 100644
--- a/src/data/contextStore.js
+++ b/src/data/contextStore.js
@@ -159,11 +159,14 @@ export function clearSelectedEntityContextForLayer(layerId, { evicted = false }
}
}
-/** Remove obsolete context records when a viewport-scoped layer refreshes. */
-export function removeEntityContextsForLayer(layerId) {
+/**
+ * Remove obsolete context records when a viewport-scoped layer refreshes.
+ * Optional retainIds keeps surviving records and their current selection intact.
+ */
+export function removeEntityContextsForLayer(layerId, { retainIds } = {}) {
const store = getContextStore();
for (const [id, record] of store.entities) {
- if (record?.layerId === layerId) store.entities.delete(id);
+ if (record?.layerId === layerId && !retainIds?.has(id)) store.entities.delete(id);
}
if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
store.selectedEntityId = null;
diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js
index 93051206d..aaadbce9c 100644
--- a/src/data/dataCredits.js
+++ b/src/data/dataCredits.js
@@ -81,6 +81,14 @@ export const DATA_CREDITS = [
'© OpenStreetMap contributors ' +
'(ODbL 1.0)',
},
+ {
+ key: 'alpr-osm',
+ html:
+ 'ALPR camera locations (automatic license plate readers): ' +
+ '© OpenStreetMap contributors ' +
+ '(ODbL 1.0); ' +
+ 'community mapping includes DeFlock',
+ },
{
key: 'military-installations-osm',
html:
diff --git a/src/data/layerState.js b/src/data/layerState.js
index eb68c14e7..ed73b98cc 100644
--- a/src/data/layerState.js
+++ b/src/data/layerState.js
@@ -276,6 +276,7 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
*/
export const LAYER_STATE_REGISTRY = Object.freeze([
Object.freeze({ id: 'ais-live-vessels', token: 'a', disposition: 'enabled-only' }),
+ Object.freeze({ id: 'alpr-cameras', token: 'p', disposition: 'enabled-only' }),
Object.freeze({ id: 'bikeshare', token: 'b', disposition: 'enabled-only' }),
Object.freeze({ id: 'cctv', token: 'c', disposition: 'enabled+options', optionOwner: 'cctv' }),
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs
index 9e50a3efe..a2682fce0 100644
--- a/src/data/layerState.test.mjs
+++ b/src/data/layerState.test.mjs
@@ -156,8 +156,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
- assert.equal(REGISTERED_LAYER_IDS.length, 16);
- assert.equal(new Set(REGISTERED_LAYER_IDS).size, 16);
+ assert.equal(REGISTERED_LAYER_IDS.length, 17);
+ assert.equal(new Set(REGISTERED_LAYER_IDS).size, 17);
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.throws(
() => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs
index e2f084d62..80a1a8459 100644
--- a/src/data/manager.test.mjs
+++ b/src/data/manager.test.mjs
@@ -3302,3 +3302,37 @@ test('panel remount releases old listeners and destruction revokes retained cont
else globalThis.document = originalDocument;
}
});
+
+test('row action chips use live disabled state and descriptive counts without writing parameters', async () => {
+ const originalDocument = globalThis.document;
+ globalThis.document = { createElement: makeControlElement };
+ const mgr = new DataLayerManager({});
+ const layer = makeRowControlLayer();
+ let calls = 0, blocked = false;
+ layer.module.getStats = () => ({ count: 3, countLabel: '3 nearby' });
+ layer.module.getRowControls = () => ({ chips: [{ id: 'find', label: 'SHOW NEAREST', disabled: blocked, onClick: () => calls++ }] });
+ layer.module.setParams = () => { throw new Error('navigation must not write persistent params'); };
+ mgr.register(layer.module);
+ const container = makeControlElement();
+ try {
+ mgr.buildTogglePanel(container);
+ await mgr.setEnabled('satellites', true);
+ const row = container.querySelector('[data-layer-id="satellites"]');
+ assert.equal(row.querySelector('.data-count').textContent, '3 nearby');
+ const controls = row.querySelector('.data-toggle-controls');
+ const chip = collectByClass(controls, 'data-toggle-chip')[0];
+ controls.listeners.click({ target: chip });
+ assert.equal(calls, 1);
+ blocked = true;
+ controls.listeners.click({ target: chip });
+ assert.equal(calls, 1, 'live disabled descriptor wins before row repaint');
+ blocked = false;
+ await mgr.setEnabled('satellites', false);
+ controls.listeners.click({ target: chip });
+ assert.equal(calls, 1, 'disabled layer cannot navigate');
+ } finally {
+ await mgr.destroyAll();
+ if (originalDocument === undefined) delete globalThis.document;
+ else globalThis.document = originalDocument;
+ }
+});
diff --git a/src/data/trackedReadout.js b/src/data/trackedReadout.js
index f8203184e..133e9fed4 100644
--- a/src/data/trackedReadout.js
+++ b/src/data/trackedReadout.js
@@ -194,6 +194,9 @@ export function getActiveTrackedReadoutId() {
return _activeEntryId;
}
+/** Static-context layers whose click selection publishes the readout card. */
+const READOUT_CONTEXT_LAYERS = new Set(['military-installations', 'alpr-cameras']);
+
/**
* Initialize the model bridge and selection listeners. No render listener is
* installed; the already-initialized world-overlay host owns the frame lane.
@@ -210,7 +213,7 @@ export function initTrackedReadout(viewer) {
}) || null;
_contextSelectedHandler = (event) => {
const record = event.detail;
- if (record?.layerId === 'military-installations') {
+ if (READOUT_CONTEXT_LAYERS.has(record?.layerId)) {
_selectedContext = record;
publishEntity(record.entity);
return;
diff --git a/src/data/trackedReadout.test.mjs b/src/data/trackedReadout.test.mjs
index 48e484aca..cbdaa3b7d 100644
--- a/src/data/trackedReadout.test.mjs
+++ b/src/data/trackedReadout.test.mjs
@@ -198,6 +198,18 @@ test('selection lifecycle ignores vessels, accepts installations, and clears wit
assert.equal(getActiveTrackedReadoutId(), 'installations:fort-test');
assert.equal(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].title, 'FORT TEST');
+ // ALPR cameras are static context too: a click publishes the same card.
+ const camera = {
+ gevTrackedId: 'alpr:42',
+ gevDisplayPosition: () => ({ x: 4, y: 5, z: 6 }),
+ gevLabelModel: { title: 'FLOCK SAFETY ALPR', details: ['CITY PD'], accent: '#ff66c4' },
+ };
+ fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
+ detail: { layerId: 'alpr-cameras', entity: camera },
+ }));
+ assert.equal(getActiveTrackedReadoutId(), 'alpr:42');
+ assert.equal(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].title, 'FLOCK SAFETY ALPR');
+
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
detail: { layerId: 'ais-live-vessels', entity: installation },
}));
diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs
index 002959ba9..41bd894f2 100644
--- a/src/firstRunExperience.test.mjs
+++ b/src/firstRunExperience.test.mjs
@@ -656,14 +656,15 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins
// ── Voice: instruction-only, tool schema unchanged ─────────────────────
test('the voice TOOL SCHEMA matches the pinned release — the mission mapping is instructions only', () => {
+ // ALPR deliberately adds its ID to the two layer menus and visibility aliases.
// Canonical serialization pins every tool name, description, property and
// ordering while allowing source formatting. Derived from the unchanged
// release schema before formatting (the previous source-byte pin passed).
const block = JSON.stringify(GEV_REALTIME_TOOLS);
- assert.equal(block.length, 26121, 'serialized tool schema length drifted');
+ assert.equal(block.length, 26208, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
- '11680affb4a7aebf142642c8185b28f2ee7d523407054c5b0c3962d933b045eb',
+ '135d4ec66239777da34a8476cdf8348574421d7afd2e981cc3490909a5bc8686',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js
new file mode 100644
index 000000000..74a20ffcf
--- /dev/null
+++ b/src/layers/alpr/index.js
@@ -0,0 +1,384 @@
+import * as Cesium from 'cesium';
+import {
+ LAYER_ID,
+ REQUEST_DEBOUNCE_MS,
+ QUERY_LIMIT,
+ MAX_RENDERED,
+ QUERY_SNAP_DEGREES,
+ QUERY_REUSE_MS,
+ ALPR_COLOR,
+} from './policy.js';
+import {
+ snapAlprBox,
+ boxContains,
+ destinationPointDeg,
+ alprRetryDelayMs,
+ validateAlprSnapshot,
+ alprCreditMarkup,
+} from './model.js';
+import { createAlprPresentation } from './presentation.js';
+
+/**
+ * Own one layer's requests, records, display and viewer subscriptions.
+ * A source fetches a bounded box with an AbortSignal and resolves
+ * { records, stale, saturated }. Records use stable string ids and latitude /
+ * longitude in degrees; optional directionDeg is a compass bearing [0, 360).
+ * Manufacturer, operator and cameraType are optional display fields.
+ * Source label and attribution { name, description, text, href } identify the
+ * actual provider; href must be HTTPS. Provider payload parsing stays in source.
+ * Context services remove obsolete records with
+ * removeEntityContextsForLayer(layerId, { retainIds: Set }), preserving
+ * retained selection without dispatching a new selection event.
+ */
+export function createAlprCamerasLayer({ source, services } = {}) {
+ if (typeof source?.fetch !== 'function')
+ throw new TypeError('ALPR requires a camera source');
+ const creditMarkup = alprCreditMarkup(source.attribution);
+ 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,
+ pendingQueryBox: 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,
+ focusNearest,
+ clearSelection,
+ updateSelectedAnchor,
+ installInteraction,
+ } = createAlprPresentation({ state, services, source });
+
+ 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.pendingQueryBox = 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.pendingQueryBox = null;
+ state.loading = false;
+ }
+ renderRecords();
+ state.retrying = false;
+ setAlprStatus(
+ state.dataSource.entities.values.length ? 'ready' : 'empty',
+ );
+ return;
+ }
+ // A move inside an in-flight query must not abort and restart that query.
+ if (state.abort && boxContains(state.pendingQueryBox, box)) {
+ renderRecords();
+ return;
+ }
+ const queryBox = snapAlprBox(box);
+ state.abort?.abort();
+ const requestAbort = new AbortController();
+ state.abort = requestAbort;
+ state.pendingQueryBox = queryBox;
+ state.loading = true;
+ state.retrying = state.status === 'unavailable' || state.retryDelayMs > 0;
+ setAlprStatus('loading');
+ renderRecords();
+ try {
+ const snapshot = await source.fetch(queryBox, requestAbort.signal);
+ if (
+ requestAbort.signal.aborted ||
+ state.abort !== requestAbort ||
+ !state.enabled
+ )
+ return;
+ const { records, stale, saturated } = validateAlprSnapshot(snapshot);
+ 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 = saturated || 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 || 'Camera source temporarily unavailable',
+ );
+ scheduleUnavailableRetry();
+ } finally {
+ if (state.abort === requestAbort) {
+ state.abort = null;
+ state.pendingQueryBox = null;
+ state.loading = false;
+ state.retrying = false;
+ governorRequestRender('alpr-status');
+ }
+ }
+ }
+
+ const alprCamerasLayer = {
+ id: LAYER_ID,
+ name: 'ALPR Cameras',
+ icon: '📷',
+ source: source.label || 'Mapped camera locations',
+ updateInterval: 0,
+ statsRefreshInterval: 1000,
+ init(viewer) {
+ if (state.viewer) throw new Error('ALPR layer is already initialized');
+ state.viewer = viewer;
+ state.dataSource = new Cesium.CustomDataSource('alpr-cameras');
+ state.credit = creditMarkup
+ ? new Cesium.Credit(creditMarkup, true)
+ : null;
+ 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.pendingQueryBox = 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;
+ },
+ getRowControls() {
+ const count = state.dataSource?.entities.values.length || 0;
+ return {
+ chips: [
+ {
+ id: 'find-camera',
+ label: 'SHOW NEAREST',
+ title: state.viewer?.trackedEntity
+ ? 'Stop following the current object before navigating to a camera'
+ : 'Move to the nearest loaded camera and show its details',
+ disabled:
+ !state.enabled || !count || Boolean(state.viewer?.trackedEntity),
+ onClick: focusNearest,
+ },
+ ],
+ legend: [
+ {
+ label: 'Purple dots',
+ color: ALPR_COLOR,
+ count,
+ blurb:
+ 'Nearby mapped cameras may be outside the screen. Dots stay 8 pixels wide as you zoom; click one for details.',
+ },
+ ],
+ };
+ },
+ getStats() {
+ return {
+ count: state.dataSource?.entities.values.length || 0,
+ countLabel: state.enabled
+ ? `${state.dataSource?.entities.values.length || 0} nearby`
+ : '',
+ lastUpdate: state.lastUpdate,
+ stale: state.stale,
+ saturated: state.saturated,
+ error: state.error,
+ status: state.status,
+ loading: state.loading,
+ retryAt: state.retryAt,
+ retrying: state.retrying,
+ retryInSec: state.retryAt
+ ? Math.max(0, Math.ceil((state.retryAt - Date.now()) / 1000))
+ : 0,
+ loadingLabel: state.loading
+ ? state.retrying
+ ? 'retrying mapped ALPR cameras'
+ : 'loading mapped ALPR cameras'
+ : state.status === 'zoom-in'
+ ? 'Zoom in to load mapped cameras'
+ : [
+ state.stale ? 'Showing cached locations' : '',
+ state.saturated ? 'Coverage limited — zoom in' : '',
+ state.status === 'empty'
+ ? 'No mapped cameras returned — coverage is incomplete'
+ : '',
+ ]
+ .filter(Boolean)
+ .join(' · '),
+ };
+ },
+ };
+ return alprCamerasLayer;
+}
+export {
+ snapAlprBox,
+ boxContains,
+ destinationPointDeg,
+ alprRetryDelayMs,
+ isAlprSurveillanceType,
+ normalizeAlprNode,
+ buildOverpassQuery,
+} from './model.js';
+export {
+ QUERY_LIMIT,
+ MAX_RENDERED,
+ QUERY_SNAP_DEGREES,
+ QUERY_REUSE_MS,
+} from './policy.js';
+export { createOverpassAlprSource } from './source.js';
diff --git a/src/layers/alpr/model.js b/src/layers/alpr/model.js
new file mode 100644
index 000000000..9abc9e3ca
--- /dev/null
+++ b/src/layers/alpr/model.js
@@ -0,0 +1,211 @@
+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};`
+ );
+}
+
+/** Validate a source-independent snapshot before replacing the accepted display. */
+export function validateAlprSnapshot(snapshot) {
+ if (
+ !Array.isArray(snapshot?.records) ||
+ typeof snapshot.stale !== 'boolean' ||
+ typeof snapshot.saturated !== 'boolean'
+ ) {
+ throw new TypeError('Camera source returned an invalid snapshot');
+ }
+ const ids = new Set();
+ for (const record of snapshot.records) {
+ if (
+ !record ||
+ typeof record.id !== 'string' ||
+ !record.id.trim() ||
+ ids.has(record.id) ||
+ !Number.isFinite(record.latitude) ||
+ Math.abs(record.latitude) > 90 ||
+ !Number.isFinite(record.longitude) ||
+ Math.abs(record.longitude) > 180 ||
+ (record.directionDeg != null &&
+ (!Number.isFinite(record.directionDeg) ||
+ record.directionDeg < 0 ||
+ record.directionDeg >= 360))
+ ) {
+ throw new TypeError('Camera source returned an invalid record');
+ }
+ ids.add(record.id);
+ }
+ return snapshot;
+}
+
+/** Build a linked attribution from plain text and an HTTPS URL only. */
+export function alprCreditMarkup(attribution) {
+ if (!attribution?.text || !attribution?.href) return null;
+ const href = new URL(attribution.href);
+ if (href.protocol !== 'https:' || href.username || href.password) {
+ throw new TypeError('Camera attribution requires a public HTTPS link');
+ }
+ const escape = (value) =>
+ String(value).replace(
+ /[&<>"']/g,
+ (char) =>
+ ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ })[char],
+ );
+ return `ALPR: ${escape(attribution.text)}`;
+}
diff --git a/src/layers/alpr/policy.js b/src/layers/alpr/policy.js
new file mode 100644
index 000000000..b945b99a2
--- /dev/null
+++ b/src/layers/alpr/policy.js
@@ -0,0 +1,54 @@
+/**
+ * @file Community-mapped automatic license plate reader (ALPR) camera layer.
+ *
+ * Data source: OpenStreetMap contributors, including the DeFlock community
+ * (`man_made=surveillance` + `surveillance:type=ALPR`) — see
+ * https://wiki.openstreetmap.org/wiki/Tag:surveillance:type=ALPR and
+ * https://deflock.org. OSM data remains ODbL 1.0, separate from the MIT code.
+ * Fetched viewport-bounded through the existing generic
+ * `/api/overpass` proxy (same one `traffic.js` and `militaryInstallations.js`
+ * use) — no new server route needed for a single narrow tag pair.
+ *
+ * This is mapped surveillance infrastructure, not a live camera feed: no
+ * plate records, no vendor accounts, nothing beyond what a contributor chose
+ * to publish to OSM. Coverage and tag accuracy are not guaranteed.
+ *
+ * @module data/alprCameras
+ */
+
+export const LAYER_ID = 'alpr-cameras';
+
+export const OVERPASS_URL = '/api/overpass';
+
+export const REQUEST_DEBOUNCE_MS = 500;
+
+/** Keep public Overpass queries city-scale, never globe-wide. */
+export const MAX_VIEWPORT_DEGREES = 3;
+
+/** Overpass `out body N;` cap — also detects possibly truncated coverage. */
+export const QUERY_LIMIT = 1500;
+
+/** Render cap. Kept at or above QUERY_LIMIT on purpose: if it ever sat below
+ * it, cameras between the two would be silently dropped while `saturated`
+ * stayed false and `count` still reported them — a lie about coverage. */
+export const MAX_RENDERED = 1500;
+
+/** Meters — length of the facing-direction indicator line, when a camera reports one. */
+export const DIRECTION_CONE_M = 25;
+
+export const EARTH_MEAN_RADIUS_M = 6371008.8;
+
+/** Query boxes are snapped outward to this grid so nearby camera moves reuse
+ * one Overpass request body (the shared proxy keys its cache and in-flight
+ * dedupe by the exact body) instead of each becoming a distinct upstream fetch. */
+export const QUERY_SNAP_DEGREES = 0.05;
+
+/** A view still fully inside the last snapped query box reuses those records
+ * for this long before asking Overpass again. */
+export const QUERY_REUSE_MS = 10 * 60 * 1000;
+
+/** One vendor-neutral color, distinct from CCTV's cyan markers. */
+export const ALPR_COLOR = '#c084fc';
+
+/** OSM attribution may collapse after five seconds; full credit stays in Data attribution. */
+export const CREDIT_DISPLAY_MS = 5000;
diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js
new file mode 100644
index 000000000..f56787c79
--- /dev/null
+++ b/src/layers/alpr/presentation.js
@@ -0,0 +1,404 @@
+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, source }) {
+ const { governorRequestRender } = services.render;
+ const {
+ clearSelectedEntityContextForLayer,
+ getSelectedEntityContext,
+ registerEntityContext,
+ removeEntityContextsForLayer,
+ selectEntityContext,
+ } = services.context;
+
+ const { cachedGroundFloor } = services.groundFloor;
+
+ function markerColor() {
+ return Cesium.Color.fromCssColorString(ALPR_COLOR);
+ }
+
+ // The horizon rectangle is unstable during low-angle orbits and can exclude
+ // the ground point in the center of the screen. Bound nearby coverage around
+ // that point instead; the camera-to-ground range keeps zoom-out queries capped.
+ function viewportBox(viewer) {
+ const camera = viewer?.camera;
+ const canvas = viewer?.scene.canvas;
+ if (typeof camera?.pickEllipsoid === 'function' && canvas) {
+ const width = canvas.clientWidth || canvas.width;
+ const height = canvas.clientHeight || canvas.height;
+ if (!width || !height) return null;
+ const focus = camera.pickEllipsoid(
+ new Cesium.Cartesian2(width / 2, height / 2),
+ viewer.scene.globe.ellipsoid,
+ );
+ if (!focus) return null;
+ const location = Cesium.Cartographic.fromCartesian(focus);
+ const range = Cesium.Cartesian3.distance(camera.positionWC, focus);
+ const radius = Math.max(1000, 2 * range);
+ const latitude = Cesium.Math.toDegrees(location.latitude);
+ const longitude = Cesium.Math.toDegrees(location.longitude);
+ const latSpan = radius / 111000;
+ const lonSpan = latSpan / Math.cos(location.latitude);
+ if (
+ !Number.isFinite(latSpan + lonSpan) ||
+ 2 * Math.max(latSpan, lonSpan) > MAX_VIEWPORT_DEGREES ||
+ Math.abs(latitude) + latSpan > 90 ||
+ Math.abs(longitude) + lonSpan > 180
+ )
+ return null;
+ return {
+ south: latitude - latSpan,
+ west: longitude - lonSpan,
+ north: latitude + latSpan,
+ east: longitude + lonSpan,
+ };
+ }
+ // Rectangle-only viewers retain the same bounded-area contract.
+ const rectangle = viewer?.camera?.computeViewRectangle(
+ viewer.scene.globe.ellipsoid,
+ );
+ 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');
+ const visibleIds = new Set(visible.map((record) => record.id));
+ for (const entity of [...state.dataSource.entities.values]) {
+ if (!visibleIds.has(entity.id)) state.dataSource.entities.remove(entity);
+ }
+ removeEntityContextsForLayer(LAYER_ID, { retainIds: visibleIds });
+ for (const record of visible) {
+ const existing = state.dataSource.entities.getById(record.id);
+ if (existing?.gevAlprRecord === record) {
+ existing.point.pixelSize = record.id === state.selectedId ? 12 : 8;
+ existing.point.color =
+ record.id === state.selectedId ? Cesium.Color.WHITE : markerColor();
+ continue;
+ }
+ const color = markerColor();
+ const selected = record.id === state.selectedId;
+ const position = Cesium.Cartesian3.fromDegrees(
+ 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,
+ };
+ }
+ let entity = existing;
+ // Keep the Cesium entity and its ground-clamping subscription while its
+ // geometry is unchanged. Updating metadata must not rebuild the marker.
+ const previous = entity?.gevAlprRecord;
+ if (!entity) entity = state.dataSource.entities.add(entityDef);
+ else {
+ if (
+ previous.latitude !== record.latitude ||
+ previous.longitude !== record.longitude
+ )
+ entity.position = position;
+ if (
+ previous.latitude !== record.latitude ||
+ previous.longitude !== record.longitude ||
+ previous.directionDeg !== record.directionDeg
+ )
+ entity.polyline = entityDef.polyline;
+ entity.point.pixelSize = selected ? 12 : 8;
+ entity.point.color = selected ? Cesium.Color.WHITE : color;
+ }
+ entity.gevAlprRecord = record;
+ entity.gevTrackedId = record.id;
+ // The mapped camera datum has no elevation; it is not the clamped marker's
+ // visual anchor. Only the selected marker samples the rendered surface,
+ // at most once per second, through Cesium's public height APIs.
+ entity.gevAlprDisplayPosition ??= null;
+ entity.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: ${source.attribution?.name || source.label || 'Camera source'}`,
+ ].filter(Boolean),
+ accent: color.toCssColorString(),
+ };
+ registerEntityContext(entity, {
+ id: record.id,
+ layerId: LAYER_ID,
+ dataSource: state.dataSource,
+ layerName: 'ALPR Cameras',
+ source:
+ source.attribution?.description || source.label || 'Camera source',
+ 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) state.selectedId = null;
+ updateSelectedAnchor();
+ // Start only when mapped data is actually displayed, not while an upstream
+ // request or a zoom-in prompt could consume the five-second introduction.
+ if (visible.length) presentOnMapCredit();
+ }
+
+ function focusNearest() {
+ const camera = state.viewer?.camera;
+ if (!state.enabled || !camera?.positionWC || state.viewer.trackedEntity)
+ return false;
+ let nearest = null,
+ distance = Infinity;
+ for (const entity of state.dataSource.entities.values) {
+ const position = entity.position.getValue(Cesium.JulianDate.now());
+ const candidate = Cesium.Cartesian3.distanceSquared(
+ camera.positionWC,
+ position,
+ );
+ if (candidate < distance) {
+ nearest = entity;
+ distance = candidate;
+ }
+ }
+ if (!nearest) return false;
+ const record = state.recordById.get(nearest.id);
+ const location = Cesium.Cartographic.fromDegrees(
+ record.longitude,
+ record.latitude,
+ );
+ let height;
+ if (state.viewer.scene.sampleHeightSupported) {
+ try {
+ height = state.viewer.scene.sampleHeight(location, [nearest]);
+ } catch {
+ /* tiles may still be streaming */
+ }
+ }
+ if (!Number.isFinite(height) && state.viewer.scene.globe.show)
+ height = state.viewer.scene.globe.getHeight?.(location);
+ if (!Number.isFinite(height) || height < -500 || height > 10000)
+ height = cachedGroundFloor(record.latitude, record.longitude);
+ const center = Cesium.Cartesian3.fromDegrees(
+ record.longitude,
+ record.latitude,
+ Number.isFinite(height) ? height : 0,
+ );
+ if (!selectRecord(nearest.id)) return false;
+ camera.flyToBoundingSphere(new Cesium.BoundingSphere(center, 30), {
+ duration: 1.2,
+ offset: new Cesium.HeadingPitchRange(
+ camera.heading || 0,
+ -Math.PI / 4,
+ 800,
+ ),
+ });
+ governorRequestRender('alpr-focus');
+ return true;
+ }
+
+ function selectRecord(id) {
+ const entity = state.dataSource?.entities.getById(id);
+ if (!entity || !state.recordById.has(id)) return false;
+ 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,
+ focusNearest,
+ clearSelection,
+ updateSelectedAnchor,
+ installInteraction,
+ };
+}
diff --git a/src/layers/alpr/source.js b/src/layers/alpr/source.js
new file mode 100644
index 000000000..bf9288269
--- /dev/null
+++ b/src/layers/alpr/source.js
@@ -0,0 +1,82 @@
+import {
+ OVERPASS_URL,
+ MAX_VIEWPORT_DEGREES,
+ QUERY_SNAP_DEGREES,
+ QUERY_LIMIT,
+} from './policy.js';
+import { buildOverpassQuery, normalizeAlprNode } from './model.js';
+/** Construct the bounded OSM request adapter without starting a request. */
+export function createOverpassAlprSource({
+ fetchImpl = (...args) => globalThis.fetch(...args),
+} = {}) {
+ 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) {
+ try {
+ await response.body?.cancel();
+ } catch {
+ /* already closed */
+ }
+ 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 {
+ records: [
+ ...new Map(
+ payload.elements
+ .slice(0, QUERY_LIMIT)
+ .map(normalizeAlprNode)
+ .filter(Boolean)
+ .map((record) => [record.id, record]),
+ ).values(),
+ ],
+ stale,
+ saturated: payload.elements.length >= QUERY_LIMIT,
+ };
+ }
+ return {
+ fetch: fetchAlprNodes,
+ label: 'OpenStreetMap · community mapped',
+ attribution: {
+ name: 'OpenStreetMap',
+ description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
+ text: '© OpenStreetMap',
+ href: 'https://www.openstreetmap.org/copyright',
+ },
+ };
+}
diff --git a/src/layers/alpr/source.test.mjs b/src/layers/alpr/source.test.mjs
new file mode 100644
index 000000000..797007a88
--- /dev/null
+++ b/src/layers/alpr/source.test.mjs
@@ -0,0 +1,117 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createOverpassAlprSource } from './source.js';
+import { validateAlprSnapshot, alprCreditMarkup } from './model.js';
+const box = { south: 30, west: -98, north: 30.1, east: -97.9 };
+test('the source rejects invalid and unbounded queries before fetching', async () => {
+ let calls = 0;
+ 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' });
+});
+
+test('Overpass normalization returns camera records and preserves limited/stale coverage', async () => {
+ const source = createOverpassAlprSource({
+ fetchImpl: async () => ({
+ ok: true,
+ headers: new Headers({ 'x-overpass-cache': 'STALE' }),
+ json: async () => ({
+ elements: [
+ {
+ type: 'node',
+ id: 12,
+ lat: 30,
+ lon: -98,
+ tags: {
+ 'surveillance:type': 'camera;ALPR',
+ 'camera:direction': '90',
+ },
+ },
+ ],
+ }),
+ }),
+ });
+ const snapshot = await source.fetch(box);
+ assert.equal(snapshot.records[0].id, 'alpr:12');
+ assert.equal(snapshot.records[0].latitude, 30);
+ assert.equal(snapshot.records[0].directionDeg, 90);
+ assert.equal(snapshot.stale, true);
+ assert.equal(snapshot.saturated, false);
+ assert.equal('elements' in snapshot, false);
+});
+
+test('an unsuccessful source response releases its body before reporting an error', async () => {
+ let cancelled = 0;
+ const source = createOverpassAlprSource({
+ fetchImpl: async () => ({
+ ok: false,
+ status: 429,
+ body: {
+ async cancel() {
+ cancelled++;
+ },
+ },
+ }),
+ });
+ await assert.rejects(source.fetch(box), /rate-limited/);
+ assert.equal(cancelled, 1);
+});
+
+test('source snapshots reject malformed coordinates and duplicate identities', () => {
+ const record = { id: 'camera:1', latitude: 30, longitude: -98 };
+ for (const records of [
+ [{ ...record, latitude: NaN }],
+ [record, record],
+ [{ ...record, id: '' }],
+ ]) {
+ assert.throws(
+ () => validateAlprSnapshot({ records, stale: false, saturated: false }),
+ /invalid record/,
+ );
+ }
+ assert.throws(
+ () => validateAlprSnapshot({ elements: [] }),
+ /invalid snapshot/,
+ );
+});
+
+test('provider attribution escapes markup and rejects executable links', () => {
+ assert.equal(alprCreditMarkup(null), null);
+ assert.throws(
+ () => alprCreditMarkup({ text: 'test', href: 'javascript:alert(1)' }),
+ /HTTPS/,
+ );
+ const html = alprCreditMarkup({
+ text: '
',
+ href: 'https://example.org/?a=1&b=2',
+ });
+ assert.ok(html.includes('<img onerror=alert(1)>'));
+ assert.ok(html.includes('?a=1&b=2'));
+ assert.ok(!html.includes('
record.cameraRetry?.retryAt > 0,
+ );
+ const otherCameraFailure =
+ summary.records.some(
+ (record) =>
+ record.id !== 'alpr-cameras' &&
+ (state?.activeIds || []).includes(record.id) &&
+ (record.error || record.unavailable || record.keyRequired),
+ ) || (state?.failedEventIds || []).some((id) => id !== 'alpr-cameras');
+ if (camera && !summary.active.length && !otherCameraFailure) {
+ const seconds = Math.max(
+ 0,
+ Math.ceil((camera.cameraRetry.retryAt - Date.now()) / 1000),
+ );
+ return {
+ state: 'retry',
+ label: (
+ camera.cameraRetry.error || 'Overpass temporarily unavailable'
+ ).toUpperCase(),
+ detail: `ALPR cameras · ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`,
+ };
+ }
+
const site = summary.records.find(
(record) => record.installationRetry?.retryAt > 0,
);
@@ -403,6 +435,16 @@ export function presentLoadingFeedback(state, summary, nowMs) {
return { state: state.terminal, label, detail: '' };
}
const active = summary.active;
+ if (active.length === 1 && active[0].cameraRetry && !summary.disabling) {
+ return {
+ state: 'loading',
+ label: active[0].cameraRetry.retrying
+ ? 'RETRYING ALPR CAMERAS'
+ : 'FETCHING ALPR CAMERAS',
+ detail: 'OpenStreetMap · Overpass',
+ };
+ }
+
if (
active.length === 1 &&
active[0].installationRetry &&
diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs
index 78e6b6bbd..09e81d43c 100644
--- a/src/loadingFeedback.test.mjs
+++ b/src/loadingFeedback.test.mjs
@@ -711,3 +711,31 @@ test('guidance does not suppress independent manager and feed failures', () => {
assert.equal(record.error, 'Network unavailable');
}
});
+
+test('ALPR retries show a countdown, preserve other failures, and clear when disabled', () => {
+ const camera = { ...retrySite({ error: 'Overpass rate-limited' }), id: 'alpr-cameras', name: 'ALPR cameras' };
+ const summary = aggregateLayerLoading([camera]);
+ const view = presentLoadingFeedback(createLoadingFeedbackState(), summary, 0);
+ assert.equal(view.state, 'retry');
+ assert.equal(view.label, 'OVERPASS RATE-LIMITED');
+ assert.match(view.detail, /ALPR cameras · retrying in 30s/);
+ const failed = { visible: true, phase: 'terminal', terminal: 'error', activeIds: ['alpr-cameras', 'flights'] };
+ const otherFailure = aggregateLayerLoading([camera, { id: 'flights', enabled: true, stats: { error: 'Failed' } }]);
+ assert.equal(presentLoadingFeedback(failed, otherFailure, 0).label, 'LOAD FAILED');
+ assert.equal(presentLoadingFeedback({ ...failed, failedEventIds: ['flights'] }, summary, 0).label, 'LOAD FAILED');
+ assert.equal(presentLoadingFeedback(createLoadingFeedbackState(), aggregateLayerLoading([{ ...camera, enabled: false }]), 0), null);
+});
+
+test('ALPR retry success does not inherit its prior error, including turning the layer off', () => {
+ const camera = stats => ({ id: 'alpr-cameras', enabled: true, stats });
+ const loading = aggregateLayerLoading([camera({ status: 'loading', loading: true, retrying: true })]);
+ let state = reduceLoadingFeedback(createLoadingFeedbackState(), loading, 0);
+ state = reduceLoadingFeedback(state, loading, 200);
+ assert.equal(presentLoadingFeedback(state, loading, 200).label, 'RETRYING ALPR CAMERAS');
+ const done = aggregateLayerLoading([camera({ status: 'ready', count: 3 })]);
+ state = reduceLoadingFeedback(state, done, 300);
+ assert.equal(presentLoadingFeedback(state, done, 300).label, 'LOAD COMPLETE');
+ const stopping = normalizeLayerLoading({ ...camera({ status: 'unavailable', error: 'Old failure' }), lifecycleState: 'disabling' });
+ assert.equal(stopping.error, null);
+ assert.equal(stopping.unavailable, false);
+});
diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs
index eb21a0a5d..5279fee9b 100644
--- a/src/radioMarkup.test.mjs
+++ b/src/radioMarkup.test.mjs
@@ -191,7 +191,8 @@ test('no unchanged Realtime tool definition drifts silently', () => {
.update(JSON.stringify(unchanged))
.digest('hex')
.slice(0, 16);
- assert.equal(digest, '802ed694b8887b88', 'an unchanged Realtime tool definition drifted');
+ // ALPR intentionally extends the two layer enums; retain the complete pin.
+ assert.equal(digest, '6963175a0c9a76de', 'an unchanged Realtime tool definition drifted');
});
test('Radio volume and mission speed share the Sharpen slider visual language', () => {
diff --git a/src/standalone/data.js b/src/standalone/data.js
index 1c3e20366..717a2ae18 100644
--- a/src/standalone/data.js
+++ b/src/standalone/data.js
@@ -1,6 +1,7 @@
import { DataLayerManager } from '../data/manager.js';
import flightsLayer from '../data/flights.js';
import militaryFlightsLayer from '../data/militaryFlights.js';
+import alprCamerasLayer from '../data/alprCameras.js';
import earthquakesLayer from '../data/earthquakes.js';
import satellitesLayer from '../data/satellites.js';
import rocketLaunchesLayer from '../data/rocketLaunches.js';
@@ -35,6 +36,7 @@ export function createStandaloneData({
dataManager.register(flightsLayer);
dataManager.register(militaryFlightsLayer);
dataManager.register(earthquakesLayer);
+ dataManager.register(alprCamerasLayer);
dataManager.register(satellitesLayer);
dataManager.register(rocketLaunchesLayer);
rocketLaunchesLayer.attachDataManager(dataManager);
diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js
index 6bd94bf9f..fafcb5d67 100644
--- a/src/ui/layerPanel.js
+++ b/src/ui/layerPanel.js
@@ -136,9 +136,7 @@ export class LayerPanel {
const count = document.createElement('span');
count.className = 'data-count';
- count.textContent = layer.stats.count
- ? this._formatCount(layer.stats.count)
- : '—';
+ count.textContent = this._layerCountText(layer.stats);
const toggle = document.createElement('button');
toggle.type = 'button';
@@ -202,7 +200,9 @@ export class LayerPanel {
const chip = this._rowControlsFor(layer.id)?.chips?.find(
(entry) => entry.id === button.dataset.chipId,
);
- if (chip?.params)
+ if (!chip || chip.disabled || !this.isEnabled(layer.id)) return;
+ if (typeof chip.onClick === 'function') chip.onClick();
+ else if (chip.params)
this.setLayerParams(layer.id, chip.params, { origin: 'user' });
});
row.appendChild(controls);
@@ -213,13 +213,12 @@ export class LayerPanel {
}
}
- /**
- * Read a layer's optional row-control descriptor, tolerating a throw so one
- * misbehaving layer cannot blank the whole panel. Resolved from the registry
- * rather than the `getAll()` projection, which deliberately omits `module`.
- * @param {string} layerId Registered layer id.
- * @returns {{ chips?: Array