mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
refactor(alpr): accept normalized camera snapshots and source attribution
This commit is contained in:
1 parent
4374c0fa60
commit
0feaab0a72
9 files changed
+307
-318
No files matched your search
+21
-25
@@ -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(
|
||||
'<span class="gev-alpr-credit">ALPR: <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap</a></span>',
|
||||
true,
|
||||
);
|
||||
state.credit = creditMarkup
|
||||
? new Cesium.Credit(creditMarkup, true)
|
||||
: null;
|
||||
viewer.dataSources.add(state.dataSource);
|
||||
state.moveEndRemove =
|
||||
viewer.camera.moveEnd.addEventListener(scheduleLoad);
|
||||
|
||||
@@ -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 `<span class="gev-alpr-credit">ALPR: <a href="${escape(href.href)}" target="_blank" rel="noopener">${escape(attribution.text)}</a></span>`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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: '<img onerror=alert(1)>',
|
||||
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('<img'));
|
||||
});
|
||||
Reference in new issue
Block a user