mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat: add mapped ALPR cameras with bounded source loading
Adapt the ALPR camera contribution to reusable layer components, retaining source attribution, bounded queries, selection and voice controls. Co-authored-by: Bilawal Sidhu <[email protected]>
This commit is contained in:
1 parent
ba7fda74d7
commit
4374c0fa60
28 files changed
+2358
-100
No files matched your search
@@ -0,0 +1,343 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
LAYER_ID,
|
||||
REQUEST_DEBOUNCE_MS,
|
||||
QUERY_LIMIT,
|
||||
MAX_RENDERED,
|
||||
QUERY_SNAP_DEGREES,
|
||||
QUERY_REUSE_MS,
|
||||
} from './policy.js';
|
||||
import {
|
||||
snapAlprBox,
|
||||
boxContains,
|
||||
destinationPointDeg,
|
||||
alprRetryDelayMs,
|
||||
isAlprSurveillanceType,
|
||||
normalizeAlprNode,
|
||||
buildOverpassQuery,
|
||||
} from './model.js';
|
||||
import { createAlprPresentation } from './presentation.js';
|
||||
|
||||
/** Own one layer's requests, timers, records, and viewer subscriptions. */
|
||||
export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
if (typeof source?.fetch !== 'function')
|
||||
throw new TypeError('ALPR requires a camera source');
|
||||
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,
|
||||
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,
|
||||
clearSelection,
|
||||
updateSelectedAnchor,
|
||||
installInteraction,
|
||||
} = createAlprPresentation({ state, services });
|
||||
|
||||
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.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.loading = false;
|
||||
}
|
||||
renderRecords();
|
||||
state.retrying = false;
|
||||
setAlprStatus(
|
||||
state.dataSource.entities.values.length ? 'ready' : 'empty',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const queryBox = snapAlprBox(box);
|
||||
state.abort?.abort();
|
||||
const requestAbort = new AbortController();
|
||||
state.abort = requestAbort;
|
||||
state.loading = true;
|
||||
state.retrying = state.status === 'unavailable' || state.retryDelayMs > 0;
|
||||
setAlprStatus('loading');
|
||||
renderRecords();
|
||||
try {
|
||||
const { elements, stale } = 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(),
|
||||
];
|
||||
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 =
|
||||
elements.length >= QUERY_LIMIT || 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 || 'Overpass temporarily unavailable',
|
||||
);
|
||||
scheduleUnavailableRetry();
|
||||
} finally {
|
||||
if (state.abort === requestAbort) {
|
||||
state.abort = null;
|
||||
state.loading = false;
|
||||
state.retrying = false;
|
||||
governorRequestRender('alpr-status');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const alprCamerasLayer = {
|
||||
id: LAYER_ID,
|
||||
name: 'ALPR Cameras',
|
||||
icon: '📷',
|
||||
source: 'OpenStreetMap · community mapped',
|
||||
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,
|
||||
);
|
||||
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.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;
|
||||
},
|
||||
getStats() {
|
||||
return {
|
||||
count: state.dataSource?.entities.values.length || 0,
|
||||
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';
|
||||
@@ -0,0 +1,157 @@
|
||||
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};`
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,284 @@
|
||||
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 }) {
|
||||
const { governorRequestRender } = services.render;
|
||||
const {
|
||||
clearSelectedEntityContextForLayer,
|
||||
getSelectedEntityContext,
|
||||
registerEntityContext,
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
} = services.context;
|
||||
|
||||
const { cachedGroundFloor } = services.groundFloor;
|
||||
|
||||
function markerColor() {
|
||||
return Cesium.Color.fromCssColorString(ALPR_COLOR);
|
||||
}
|
||||
|
||||
function viewportBox(viewer) {
|
||||
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');
|
||||
clearRendered();
|
||||
for (const record of visible) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
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
|
||||
// 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: OpenStreetMap',
|
||||
].filter(Boolean),
|
||||
accent: color.toCssColorString(),
|
||||
};
|
||||
registerEntityContext(entity, {
|
||||
id: record.id,
|
||||
layerId: LAYER_ID,
|
||||
dataSource: state.dataSource,
|
||||
layerName: 'ALPR Cameras',
|
||||
source: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
|
||||
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) selectEntityContext(selectedEntity);
|
||||
else state.selectedId = null;
|
||||
state.lastAnchorSampleAt = 0;
|
||||
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 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,
|
||||
clearSelection,
|
||||
updateSelectedAnchor,
|
||||
installInteraction,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
OVERPASS_URL,
|
||||
MAX_VIEWPORT_DEGREES,
|
||||
QUERY_SNAP_DEGREES,
|
||||
} from './policy.js';
|
||||
import { buildOverpassQuery } 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) {
|
||||
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 { elements: payload.elements, stale };
|
||||
}
|
||||
return { fetch: fetchAlprNodes };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createOverpassAlprSource } from './source.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' });
|
||||
});
|
||||
Reference in new issue
Block a user