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, legend?: Array }|null} Descriptor. - */ + /** Qualify a loaded count when it does not mean items currently on screen. */ + _layerCountText(stats) { + if (typeof stats.countLabel === 'string' && stats.countLabel.trim()) + return stats.countLabel; + return stats.count ? this._formatCount(stats.count) : '—'; + } /** * Render a layer's row chips and color legend, and keep the whole block @@ -307,9 +306,7 @@ export class LayerPanel { const count = row.querySelector('.data-count'); if (count) { - count.textContent = layer.stats.count - ? this._formatCount(layer.stats.count) - : '—'; + count.textContent = this._layerCountText(layer.stats); } const meta = row.querySelector('.data-toggle-meta'); diff --git a/src/ui/styles/foundation.css b/src/ui/styles/foundation.css index 73582b962..828eb9922 100644 --- a/src/ui/styles/foundation.css +++ b/src/ui/styles/foundation.css @@ -299,6 +299,25 @@ body.cockpit-mode #cockpit-cloud-effects.active { color: rgba(232, 234, 237, 0.7); } +/* Keep the ALPR source overflow reachable even after its brief on-map credit + collapses. Wrap whole entries only while the optional layer is on. */ +body:has(.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-btn.active) + #cesium-credits { + white-space: normal; +} +body:has(.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-btn.active) + #cesium-credits + .cesium-credit-wrapper { + display: inline-block !important; + white-space: nowrap; +} +body:has(.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-btn.active) + #cesium-credits + .cesium-credit-expand-link { + /* Keep the complete link at the left edge, clear of the Power Up button. */ + display: block !important; +} + /* Cesium's stock attribution lightbox grows with every registered data source. Keep the title and close affordance fixed while the complete, unabridged credit list wraps and scrolls inside a compact panel. */ diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 87153ebd5..4127334e5 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -1273,3 +1273,11 @@ #global-context-panel #cockpit-entry kbd { padding: 2px 4px; } + +/* Qualified nearby counts must also fit beside the wider loading chip. */ +.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-top { + flex-wrap: wrap; +} +.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-right { + margin-left: auto; +} diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 53c74ec79..d380edd96 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -177,6 +177,12 @@ const LAYER_ALIASES = new Map([ ['firms', 'local-firms'], ['fires', 'local-firms'], ['active fires', 'local-firms'], + ['alpr', 'alpr-cameras'], + ['alpr cameras', 'alpr-cameras'], + ['flock cameras', 'alpr-cameras'], + ['license plate readers', 'alpr-cameras'], + ['license plate cameras', 'alpr-cameras'], + ['plate readers', 'alpr-cameras'], ]); const CITY_ALIASES = new Map([ diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 5fa93514e..c7a0677bc 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3140,3 +3140,27 @@ test('voice Radio: the keyless path applies no country filter the keyed path wou const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ }); function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options }); } function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); } + +test('ALPR common names toggle only the registered camera layer through the normal voice action', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const viewer = { clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, + camera: { moveEnd: { addEventListener() {} } } }; + const calls = []; + let enabled = false; + const dataManager = { + layers: new Map([['alpr-cameras', { module: {} }]]), + getAll: () => [{ id: 'alpr-cameras', name: 'ALPR Cameras' }], + isEnabled: () => enabled, + setEnabled: async (id, value) => { calls.push([id, value]); enabled = value; return true; }, + }; + const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager }); + for (const alias of ['alpr-cameras', 'alpr', 'alpr cameras', 'flock cameras', 'license plate readers', 'license plate cameras', 'plate readers']) { + for (const value of [true, false]) { + const result = await runner('set_layer_visibility', { layerId: alias, enabled: value }); + assert.equal(result.ok, true); + assert.equal(result.layerId, 'alpr-cameras'); + assert.deepEqual(calls.at(-1), ['alpr-cameras', value]); + } + } +});