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('![]()