From 0feaab0a72463f3a36b1e6275b934608c0af3e4e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 17:33:48 -0700 Subject: [PATCH] refactor(alpr): accept normalized camera snapshots and source attribution --- docs/CURRENT-STATE.md | 4 +- package.json | 2 +- scripts/package-boundaries.json | 372 ++++++++------------------------ src/data/alprCameras.test.mjs | 24 ++- src/layers/alpr/index.js | 46 ++-- src/layers/alpr/model.js | 54 +++++ src/layers/alpr/presentation.js | 9 +- src/layers/alpr/source.js | 33 ++- src/layers/alpr/source.test.mjs | 81 +++++++ 9 files changed, 307 insertions(+), 318 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 373f01e8d..90cbe96dc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4,7 +4,9 @@ The optional **ALPR Cameras** layer shows community-mapped OpenStreetMap locatio not camera footage or plate records. City-scale queries use the existing Overpass provider, with capped results, retry, cached-data and incomplete-coverage notices. Its `./layers/alpr` entry exposes a per-instance layer factory and bounded source -adapter. The standalone registration supplies selection, picking, terrain and +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`. diff --git a/package.json b/package.json index 471285140..084a2614f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gods-eye-view", "private": true, "version": "0.1.1", - "description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", + "description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", "type": "module", "license": "MIT", "author": "Bilawal Sidhu", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 66bf3adf2..fcb93195d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,47 +10,27 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "application": { - "exports": [ - "./application" - ], - "modules": [ - "src/app/application.js" - ], + "exports": ["./application"], + "modules": ["src/app/application.js"], "external": [] }, "viewer": { - "exports": [ - "./application/viewer" - ], - "modules": [ - "src/app/viewer.js" - ], - "external": [ - "cesium" - ] + "exports": ["./application/viewer"], + "modules": ["src/app/viewer.js"], + "external": ["cesium"] }, "vite-build": { "runtime": "node", - "exports": [ - "./build/vite" - ], - "modules": [ - "build/vite.js" - ], - "external": [ - "vite-plugin-cesium" - ] + "exports": ["./build/vite"], + "modules": ["build/vite.js"], + "external": ["vite-plugin-cesium"] }, "live-providers": { "runtime": "node", - "exports": [ - "./server/providers/live" - ], + "exports": ["./server/providers/live"], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -65,24 +45,16 @@ "src/data/aisStreamAdapter.js", "src/data/aisWatchdog.js" ], - "external": [ - "ws" - ] + "external": ["ws"] }, "adsb-lol-source": { - "exports": [ - "./sources/adsb-lol" - ], - "modules": [ - "src/data/adsbLolFallback.js" - ], + "exports": ["./sources/adsb-lol"], + "modules": ["src/data/adsbLolFallback.js"], "external": [] }, "place-providers": { "runtime": "node", - "exports": [ - "./server/providers/places" - ], + "exports": ["./server/providers/places"], "modules": [ "server/providers/places.js", "server/providers/places/google.js", @@ -97,19 +69,13 @@ "external": [] }, "place-payloads": { - "exports": [ - "./sources/places" - ], - "modules": [ - "src/data/placeProviderPayloads.js" - ], + "exports": ["./sources/places"], + "modules": ["src/data/placeProviderPayloads.js"], "external": [] }, "space-providers": { "runtime": "node", - "exports": [ - "./server/providers/space" - ], + "exports": ["./server/providers/space"], "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", @@ -120,19 +86,13 @@ "external": [] }, "space-requests": { - "exports": [ - "./sources/space" - ], - "modules": [ - "src/data/spaceProviderRequests.js" - ], + "exports": ["./sources/space"], + "modules": ["src/data/spaceProviderRequests.js"], "external": [] }, "terrain-provider": { "runtime": "node", - "exports": [ - "./server/providers/terrain" - ], + "exports": ["./server/providers/terrain"], "modules": [ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js" @@ -140,50 +100,30 @@ "external": [] }, "terrain-source": { - "exports": [ - "./sources/terrain" - ], - "modules": [ - "src/data/terrainHeightsProxy.js" - ], + "exports": ["./sources/terrain"], + "modules": ["src/data/terrainHeightsProxy.js"], "external": [] }, "traffic-provider": { "runtime": "node", - "exports": [ - "./server/providers/traffic" - ], - "modules": [ - "server/providers/traffic.js", - "src/data/tomtomTiles.js" - ], + "exports": ["./server/providers/traffic"], + "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], "external": [] }, "traffic-source": { - "exports": [ - "./sources/traffic" - ], - "modules": [ - "src/data/tomtomTiles.js" - ], + "exports": ["./sources/traffic"], + "modules": ["src/data/tomtomTiles.js"], "external": [] }, "firms-provider": { "runtime": "node", - "exports": [ - "./server/providers/firms" - ], - "modules": [ - "server/providers/firms.js", - "src/data/firmsCsv.js" - ], + "exports": ["./server/providers/firms"], + "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], "external": [] }, "gbfs-provider": { "runtime": "node", - "exports": [ - "./server/providers/gbfs" - ], + "exports": ["./server/providers/gbfs"], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -192,19 +132,13 @@ "external": [] }, "gbfs-source": { - "exports": [ - "./sources/gbfs" - ], - "modules": [ - "src/data/gbfsSource.js" - ], + "exports": ["./sources/gbfs"], + "modules": ["src/data/gbfsSource.js"], "external": [] }, "cctv-provider": { "runtime": "node", - "exports": [ - "./server/providers/cctv" - ], + "exports": ["./server/providers/cctv"], "modules": [ "server/providers/cctv.js", "server/providers/cctv/catalog.js", @@ -221,9 +155,7 @@ }, "radio-provider": { "runtime": "node", - "exports": [ - "./server/providers/radio" - ], + "exports": ["./server/providers/radio"], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -237,9 +169,7 @@ }, "overpass-provider": { "runtime": "node", - "exports": [ - "./server/providers/overpass" - ], + "exports": ["./server/providers/overpass"], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -258,9 +188,7 @@ }, "military-installations-provider": { "runtime": "node", - "exports": [ - "./server/providers/military-installations" - ], + "exports": ["./server/providers/military-installations"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -277,9 +205,7 @@ }, "regional-provider": { "runtime": "node", - "exports": [ - "./server/providers/regional" - ], + "exports": ["./server/providers/regional"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -298,9 +224,7 @@ }, "openai-provider": { "runtime": "node", - "exports": [ - "./server/providers/openai" - ], + "exports": ["./server/providers/openai"], "modules": [ "server/providers/common/rate-limit.js", "server/providers/common/request.js", @@ -320,9 +244,7 @@ }, "standalone-key-setup": { "runtime": "node", - "exports": [ - "./server/standalone/key-setup" - ], + "exports": ["./server/standalone/key-setup"], "modules": [ "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", @@ -333,9 +255,7 @@ "external": [] }, "place-search": { - "exports": [ - "./search" - ], + "exports": ["./search"], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -345,27 +265,17 @@ "external": [] }, "panel-disclosure": { - "exports": [ - "./ui/panels" - ], - "modules": [ - "src/ui/panelDisclosure.js" - ], + "exports": ["./ui/panels"], + "modules": ["src/ui/panelDisclosure.js"], "external": [] }, "surface-keyboard": { - "exports": [ - "./ui/surfaces" - ], - "modules": [ - "src/ui/surfaceKeyboard.js" - ], + "exports": ["./ui/surfaces"], + "modules": ["src/ui/surfaceKeyboard.js"], "external": [] }, "panel-rail-layout": { - "exports": [ - "./ui/layout" - ], + "exports": ["./ui/layout"], "modules": [ "src/ui/panelRails.js", "src/ui/leftPanelRail.js", @@ -377,9 +287,7 @@ "external": [] }, "visual-input": { - "exports": [ - "./ui/input" - ], + "exports": ["./ui/input"], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -388,19 +296,12 @@ "external": [] }, "display-controls": { - "exports": [ - "./ui/display" - ], - "modules": [ - "src/ui/displayControls.js" - ], + "exports": ["./ui/display"], + "modules": ["src/ui/displayControls.js"], "external": [] }, "visual-effects": { - "exports": [ - "./ui/effects", - "./ui/effects/bloom" - ], + "exports": ["./ui/effects", "./ui/effects/bloom"], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -413,14 +314,10 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "map-source-controls": { - "exports": [ - "./ui/maps" - ], + "exports": ["./ui/maps"], "modules": [ "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -430,10 +327,7 @@ "external": [] }, "layer-panel": { - "exports": [ - "./ui/layers", - "./ui/layers/feedback" - ], + "exports": ["./ui/layers", "./ui/layers/feedback"], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -444,9 +338,7 @@ "external": [] }, "location-controls": { - "exports": [ - "./ui/location" - ], + "exports": ["./ui/location"], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -457,9 +349,7 @@ "external": [] }, "radio-controls": { - "exports": [ - "./ui/radio" - ], + "exports": ["./ui/radio"], "modules": [ "src/ui/radio.js", "src/ui/radioControls.js", @@ -470,9 +360,7 @@ "external": [] }, "cctv-controls": { - "exports": [ - "./ui/cctv" - ], + "exports": ["./ui/cctv"], "modules": [ "src/ui/cctv.js", "src/ui/cctvBindings.js", @@ -484,10 +372,7 @@ "external": [] }, "context-controls": { - "exports": [ - "./ui/context", - "./ui/context/policy" - ], + "exports": ["./ui/context", "./ui/context/policy"], "modules": [ "src/ui/context.js", "src/ui/contextActions.js", @@ -529,14 +414,10 @@ "src/cockpitVisionPolicy.js", "src/ui/panelRailGeometry.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "scene-controls": { - "exports": [ - "./ui/scenes" - ], + "exports": ["./ui/scenes"], "modules": [ "src/ui/scenes.js", "src/ui/sceneControls.js", @@ -545,9 +426,7 @@ "external": [] }, "ui-shell": { - "exports": [ - "./ui/shell" - ], + "exports": ["./ui/shell"], "modules": [ "src/bloom.js", "src/cctvFocusPolicy.js", @@ -644,14 +523,10 @@ "src/app/stateChannel.js", "src/ui/frameRateMonitor.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "ui-styles": { - "exports": [ - "./ui/styles" - ], + "exports": ["./ui/styles"], "modules": [ "src/ui/styles.js", "style.css", @@ -678,9 +553,7 @@ "external": [] }, "scenes": { - "exports": [ - "./scenes" - ], + "exports": ["./scenes"], "modules": [ "src/scenes/director.js", "src/scenes/recipes.js", @@ -692,14 +565,10 @@ "src/ui/scenePresentation.js", "src/contextModePolicy.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "live-observations": { - "exports": [ - "./sources/live" - ], + "exports": ["./sources/live"], "modules": [ "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -710,9 +579,7 @@ "external": [] }, "civil-flights": { - "exports": [ - "./layers/flights" - ], + "exports": ["./layers/flights"], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -743,14 +610,10 @@ "src/layers/flights/testing.js", "src/layers/flights/tracking.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "military-flights": { - "exports": [ - "./layers/military" - ], + "exports": ["./layers/military"], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -778,14 +641,10 @@ "src/layers/military/testing.js", "src/layers/military/tracking.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "aircraft-mechanics": { - "exports": [ - "./aircraft" - ], + "exports": ["./aircraft"], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -805,14 +664,10 @@ "src/layers/aircraft/classification.js", "src/layers/aircraft/index.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "vessel-layer": { - "exports": [ - "./layers/vessels" - ], + "exports": ["./layers/vessels"], "modules": [ "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -830,27 +685,19 @@ "src/data/iconOrientation.js", "src/data/vesselLabels.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "earthquakes": { - "exports": [ - "./layers/earthquakes" - ], + "exports": ["./layers/earthquakes"], "modules": [ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "fire-layer": { - "exports": [ - "./layers/firms" - ], + "exports": ["./layers/firms"], "modules": [ "src/data/firmsAdapt.js", "src/data/firmsLabels.js", @@ -869,14 +716,10 @@ "src/layers/firms/state.js", "src/layers/firms/viewport.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "satellites-layer": { - "exports": [ - "./layers/satellites" - ], + "exports": ["./layers/satellites"], "modules": [ "src/data/issPass.js", "src/data/satelliteClass.js", @@ -895,15 +738,10 @@ "src/layers/satellites/testing.js", "src/layers/satellites/tracking.js" ], - "external": [ - "cesium", - "satellite.js" - ] + "external": ["cesium", "satellite.js"] }, "launches-layer": { - "exports": [ - "./layers/launches" - ], + "exports": ["./layers/launches"], "modules": [ "src/layers/launches/camera.js", "src/layers/launches/controls.js", @@ -925,14 +763,10 @@ "src/layers/launches/state.js", "src/layers/launches/testing.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "installations-layer": { - "exports": [ - "./layers/installations" - ], + "exports": ["./layers/installations"], "modules": [ "src/data/installationFeedback.js", "src/data/militaryInstallationData.js", @@ -948,14 +782,10 @@ "src/layers/installations/state.js", "src/layers/installations/viewport.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "awareness-layer": { - "exports": [ - "./layers/awareness" - ], + "exports": ["./layers/awareness"], "modules": [ "src/cockpitMath.js", "src/data/iconOrientation.js", @@ -978,14 +808,10 @@ "src/layers/awareness/testing.js", "src/ui/panelRailGeometry.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "traffic-layer": { - "exports": [ - "./layers/traffic" - ], + "exports": ["./layers/traffic"], "modules": [ "src/data/flowMatch.js", "src/data/tomtomTiles.js", @@ -1010,16 +836,10 @@ "src/layers/traffic/timing.js", "src/layers/traffic/viewport.js" ], - "external": [ - "cesium", - "pbf", - "@mapbox/vector-tile" - ] + "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, "bikeshare-layer": { - "exports": [ - "./layers/bikeshare" - ], + "exports": ["./layers/bikeshare"], "modules": [ "src/layers/bikeshare/controls.js", "src/layers/bikeshare/index.js", @@ -1036,14 +856,10 @@ "src/layers/bikeshare/testing.js", "src/layers/bikeshare/viewport.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "cctv-layer": { - "exports": [ - "./layers/cctv" - ], + "exports": ["./layers/cctv"], "modules": [ "src/data/cctvCards.js", "src/data/cctvGizmo.js", @@ -1076,14 +892,10 @@ "src/layers/cctv/testing.js", "src/overlays/worldOverlayTokens.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "alpr-cameras": { - "exports": [ - "./layers/alpr" - ], + "exports": ["./layers/alpr"], "modules": [ "src/layers/alpr/index.js", "src/layers/alpr/model.js", @@ -1091,8 +903,6 @@ "src/layers/alpr/presentation.js", "src/layers/alpr/source.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] } } diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index bc5d63583..89a77426c 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -891,7 +891,15 @@ test('two factories keep their requests, records and destruction independent', a const second = createAlprCamerasLayer({ services, source: { - fetch: async () => ({ elements: [cameraNode(99)], stale: false }), + 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), @@ -901,11 +909,21 @@ test('two factories keep their requests, records and destruction independent', a await second.update(); first.destroy(); assert.equal(firstSignal.aborted, true); - resolveFirst({ elements: [cameraNode(42)], stale: false }); + 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, 'alpr:99'); + 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 { diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index a3842c868..d89913499 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -12,16 +12,24 @@ import { boxContains, destinationPointDeg, alprRetryDelayMs, - isAlprSurveillanceType, - normalizeAlprNode, - buildOverpassQuery, + validateAlprSnapshot, + alprCreditMarkup, } from './model.js'; import { createAlprPresentation } from './presentation.js'; -/** Own one layer's requests, timers, records, and viewer subscriptions. */ +/** + * 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. + */ 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; @@ -67,7 +75,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { clearSelection, updateSelectedAnchor, installInteraction, - } = createAlprPresentation({ state, services }); + } = createAlprPresentation({ state, services, source }); function setAlprStatus(status, error = null) { if (state.status === status && state.error === error) return; @@ -156,24 +164,14 @@ export function createAlprCamerasLayer({ source, services } = {}) { setAlprStatus('loading'); renderRecords(); try { - const { elements, stale } = await source.fetch( - queryBox, - requestAbort.signal, - ); + const snapshot = await source.fetch(queryBox, requestAbort.signal); if ( requestAbort.signal.aborted || state.abort !== requestAbort || !state.enabled ) return; - const records = [ - ...new Map( - elements - .map(normalizeAlprNode) - .filter(Boolean) - .map((record) => [record.id, record]), - ).values(), - ]; + const { records, stale, saturated } = validateAlprSnapshot(snapshot); state.records = records; state.recordById = new Map(records.map((r) => [r.id, r])); state.lastUpdate = Date.now(); @@ -181,8 +179,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { // Saturated when the QUERY truncated OR the render cap would hide cameras: // either way the view holds more than the screen shows, and the user must // be told rather than left with a count that disagrees with the map. - state.saturated = - elements.length >= QUERY_LIMIT || records.length > MAX_RENDERED; + state.saturated = saturated || records.length > MAX_RENDERED; state.lastQueryBox = queryBox; clearUnavailableRetry(); // Stale and saturated are independent facts; a cached response that also @@ -206,7 +203,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { state.stale = Boolean(state.lastUpdate); setAlprStatus( 'unavailable', - error?.message || 'Overpass temporarily unavailable', + error?.message || 'Camera source temporarily unavailable', ); scheduleUnavailableRetry(); } finally { @@ -223,17 +220,16 @@ export function createAlprCamerasLayer({ source, services } = {}) { id: LAYER_ID, name: 'ALPR Cameras', icon: '📷', - source: 'OpenStreetMap · community mapped', + 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 = new Cesium.Credit( - 'ALPR: © OpenStreetMap', - true, - ); + state.credit = creditMarkup + ? new Cesium.Credit(creditMarkup, true) + : null; viewer.dataSources.add(state.dataSource); state.moveEndRemove = viewer.camera.moveEnd.addEventListener(scheduleLoad); diff --git a/src/layers/alpr/model.js b/src/layers/alpr/model.js index 05f6c73bf..9abc9e3ca 100644 --- a/src/layers/alpr/model.js +++ b/src/layers/alpr/model.js @@ -155,3 +155,57 @@ export function buildOverpassQuery(south, west, north, east) { `(${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/presentation.js b/src/layers/alpr/presentation.js index 64c098d61..9226e74a5 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -9,7 +9,7 @@ import { } from './policy.js'; import { boxContains, destinationPointDeg } from './model.js'; -export function createAlprPresentation({ state, services }) { +export function createAlprPresentation({ state, services, source }) { const { governorRequestRender } = services.render; const { clearSelectedEntityContextForLayer, @@ -130,7 +130,7 @@ export function createAlprPresentation({ state, services }) { } const entity = state.dataSource.entities.add(entityDef); entity.gevTrackedId = record.id; - // The raw OSM datum has no elevation; it is not the clamped marker's + // 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; @@ -141,7 +141,7 @@ export function createAlprPresentation({ state, services }) { record.manufacturer ? `Manufacturer: ${record.manufacturer}` : null, record.operator ? `Operator: ${record.operator}` : null, record.cameraType ? `Type: ${record.cameraType}` : null, - 'Source: OpenStreetMap', + `Source: ${source.attribution?.name || source.label || 'Camera source'}`, ].filter(Boolean), accent: color.toCssColorString(), }; @@ -150,7 +150,8 @@ export function createAlprPresentation({ state, services }) { layerId: LAYER_ID, dataSource: state.dataSource, layerName: 'ALPR Cameras', - source: 'OpenStreetMap contributors (ODbL 1.0; community mapped)', + source: + source.attribution?.description || source.label || 'Camera source', label: 'ALPR camera', latitude: record.latitude, longitude: record.longitude, diff --git a/src/layers/alpr/source.js b/src/layers/alpr/source.js index 0b95cc04b..bf9288269 100644 --- a/src/layers/alpr/source.js +++ b/src/layers/alpr/source.js @@ -2,8 +2,9 @@ import { OVERPASS_URL, MAX_VIEWPORT_DEGREES, QUERY_SNAP_DEGREES, + QUERY_LIMIT, } from './policy.js'; -import { buildOverpassQuery } from './model.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), @@ -33,6 +34,11 @@ export function createOverpassAlprSource({ signal, }); if (!response.ok) { + try { + await response.body?.cancel(); + } catch { + /* already closed */ + } const message = response.status === 429 ? 'Overpass rate-limited' @@ -49,7 +55,28 @@ export function createOverpassAlprSource({ if (!Array.isArray(payload?.elements) || payload.remark) { throw new Error('Overpass returned an incomplete camera response'); } - return { elements: payload.elements, stale }; + 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 }; + 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 index 9624f7c62..797007a88 100644 --- a/src/layers/alpr/source.test.mjs +++ b/src/layers/alpr/source.test.mjs @@ -1,6 +1,7 @@ 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; @@ -34,3 +35,83 @@ test('cancellation during body parsing rejects even when the transport ignores i }); 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('