Merge pull request #434 from bilawalsidhu/refactor/alpr-camera-source

feat: add mapped ALPR cameras through a reusable source interface
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-13 20:46:52 -07:00
commit 3b91feffdc
33 files changed
+2701 -28

No files matched your search

+14
View File
@@ -1,5 +1,19 @@
# Changelog
## ALPR camera locations
- Label the loaded camera count as nearby, show a purple-dot legend, and add
SHOW NEAREST to frame and select a loaded camera when none are on screen,
using the available 3D-tile or globe terrain height.
- Keep nearby camera markers and selection stable during rotation, use bounded
ground-centered coverage instead of the horizon rectangle, and reuse in-flight queries.
- Add optional, source-labeled OpenStreetMap ALPR camera locations, bounded city queries,
cached-response and coverage notices, selection cards, share links, and voice toggles.
- Separate the request adapter, camera model, presentation, and instance lifecycle.
Source cancellation also guards late response bodies and rejects invalid query bounds.
## Release disabled infrastructure rendering
- Remove built Data Center, Dam and Submarine Cable entities when their layers
+27
View File
@@ -15,6 +15,7 @@ How to read this:
| Source | Used for | License / terms | Attribution |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| **OpenStreetMap ALPR camera locations** (including DeFlock community mapping) | Optional mapped automatic license-plate-reader camera layer | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/); commercial use permitted with applicable attribution and database share-alike obligations | [© OpenStreetMap contributors](https://www.openstreetmap.org/copyright); [DeFlock](https://deflock.org) community mapping |
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
@@ -92,6 +93,32 @@ The submarine-cable GeoJSON is **CC BY-NC-SA 3.0** (Attribution-**NonCommercial*
The richer structured dataset is licensed separately/commercially by TeleGeography.
### ALPR camera mapping
The optional ALPR layer queries OpenStreetMap nodes tagged `man_made=surveillance`
and `surveillance:type=ALPR` (including case-insensitive semicolon multi-values)
through the shared Overpass proxy. Source/tag reference:
[OSM ALPR tagging](https://wiki.openstreetmap.org/wiki/Tag:surveillance:type%3DALPR).
No DeFlock software, branded assets or separate database is bundled. The layer
shows contributor-supplied locations and tags, not plate records, camera video,
current operating status, or exhaustive coverage. All manufacturers share the
ALPR category and marker color. Manufacturer, operator and camera type are shown
only when tagged, not inferred or independently verified. Every selected card
names OpenStreetMap in a smaller, muted source line beneath those details.
Queries are limited to a city-scale viewport (at most 3° per axis), at most
1,500 returned nodes, with outward-snapped query reuse and explicit stale/limited
coverage notices. A linked © OpenStreetMap credit appears for five
seconds when camera locations first display after enabling, then collapses;
Data attribution retains the full source/license entry, following the
[OSMF interactive-map guidance](https://osmfoundation.org/wiki/Licence/Attribution_Guidelines).
OSM data remains separately attributed under ODbL; querying
at runtime does not replace the license obligations. ODbL permits commercial
use and does not license the application code. If publicly using a derivative
database, follow its share-alike and access requirements; keep independent
datasets separate rather than treating the whole collection as MIT data.
Overpass hosting capacity and usage policies are separate from the data license.
### ODbL share-alike (datacenters, dams)
The OSM-derived datasets are under the **Open Database License**. ODbL's share-alike applies to the **data / derived database, not this MIT-licensed code** — the two coexist (exactly how Open Infrastructure Map ships: MIT software + ODbL data). If you publicly distribute a _modified_ version of these databases, you must offer it under ODbL. Keep the "© OpenStreetMap contributors" notice (link: https://www.openstreetmap.org/copyright).
+22
View File
@@ -1,5 +1,27 @@
# God's Eye View Current State
The optional **ALPR Cameras** layer shows community-mapped OpenStreetMap locations,
not camera footage or plate records. City-scale queries use the existing Overpass
provider, with capped results, retry, cached-data and incomplete-coverage notices.
Its `./layers/alpr` entry exposes a per-instance layer factory and bounded source
adapter. Sources return normalized records, stale/coverage flags and their own
attribution; rendering does not parse Overpass payloads. The standalone
registration supplies selection, picking, terrain and
render services. Selected cards and Data attribution identify OpenStreetMap.
Layer state and the existing voice layer tools include `alpr-cameras`.
The row count explicitly says **nearby**: loaded records can be outside the
screen. A purple-dot legend identifies the fixed 8-pixel markers (12 pixels when
selected). **SHOW NEAREST** frames and selects the nearest loaded camera; it is
disabled with no loaded cameras or while following a tracked entity. This action
chooses from the current source snapshot and uses rendered surface height from
3D tiles or globe terrain when available.
Coverage follows a bounded neighborhood around the screen-center ground point,
with a radius of twice the camera-to-ground range (at least 1 km), rather than
extending to the horizon. Sky, dateline-crossing and wider-than-city views do not
query. Movement within an accepted or pending query reuses it. Markers retain
their Cesium identity and ground clamping while their geometry is unchanged;
refreshes preserve selection without replaying a click event.
Data Centers, Dams and Submarine Cables release their built Cesium data sources
and record references when disabled. Parsed datasets remain cached for the layer
lifetime, so re-enable rebuilds entities without downloading or parsing again;
+1
View File
@@ -156,6 +156,7 @@
"./layers/traffic": "./src/layers/traffic/index.js",
"./layers/bikeshare": "./src/layers/bikeshare/index.js",
"./layers/cctv": "./src/layers/cctv/index.js",
"./layers/alpr": "./src/layers/alpr/index.js",
"./layers/radio": "./src/layers/radio/index.js",
"./layers/submarine-cables": "./src/layers/submarineCables/index.js",
"./sources/bundled-cables": "./src/layers/submarineCables/bundledSource.js",
+8
View File
@@ -469,6 +469,14 @@
"src/data/cctvNswSource.test.mjs",
"src/data/cctvTxdotSource.test.mjs",
"src/layers/cctv/source.test.mjs",
"src/data/alprCameras.js",
"src/data/alprCameras.test.mjs",
"src/layers/alpr/index.js",
"src/layers/alpr/model.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/source.js",
"src/layers/alpr/source.test.mjs",
"src/layers/radio/catalogModel.js",
"src/layers/radio/categories.js",
"src/layers/radio/clustering.js",
+11
View File
@@ -898,6 +898,17 @@
],
"external": ["cesium"]
},
"alpr-cameras": {
"exports": ["./layers/alpr"],
"modules": [
"src/layers/alpr/index.js",
"src/layers/alpr/model.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/source.js"
],
"external": ["cesium"]
},
"radio-layer": {
"exports": ["./layers/radio"],
"modules": [
+5 -3
View File
@@ -3287,9 +3287,11 @@ async function main() {
const bbBefore = window.__dfFindBB('aaa097');
if (!bbBefore) return { error: 'aaa097 billboard missing' };
const d0 = window.__dfCarto(bbBefore.position);
// Plant a floor well ABOVE where it currently renders, across the block
// it can move within, so an UNFLOORED tracked entity is unmistakable.
const seeded = d0.h + 40;
// Plant above both the current billboard and this group's 400 m
// identity-probe floor. A poll can refresh the raw render altitude
// after d0 was read; a lower seed makes the negative model-ownership
// assertion impossible even when the clamp correctly stands aside.
const seeded = Math.max(d0.h, 400) + 40;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
gf.reportMeshFloorCell(cell(d0.lat) + dy * 0.001, cell(d0.lon) + dx * 0.001, seeded);
+3 -1
View File
@@ -137,7 +137,7 @@ const GEV_REALTIME_TOOLS = [
layerId: {
type: 'string',
description:
'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio.',
'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras.',
enum: [
'flights',
'military',
@@ -153,6 +153,7 @@ const GEV_REALTIME_TOOLS = [
'local-dams',
'telegeography-submarine-cables',
'local-firms',
'alpr-cameras',
],
},
enabled: { type: 'boolean' },
@@ -185,6 +186,7 @@ const GEV_REALTIME_TOOLS = [
'local-dams',
'telegeography-submarine-cables',
'local-firms',
'alpr-cameras',
],
description: 'Optional layer row to scroll into view and highlight.',
},
+13
View File
@@ -0,0 +1,13 @@
import {
createAlprCamerasLayer,
createOverpassAlprSource,
} from '../layers/alpr/index.js';
import * as render from '../renderGovernor.js';
import * as context from './contextStore.js';
import * as picking from './pickRegistry.js';
import * as groundFloor from './groundFloor.js';
export * from '../layers/alpr/index.js';
export default createAlprCamerasLayer({
source: createOverpassAlprSource(),
services: { render, context, picking, groundFloor },
});
File diff suppressed because it is too large. Load diff
+6 -3
View File
@@ -159,11 +159,14 @@ export function clearSelectedEntityContextForLayer(layerId, { evicted = false }
}
}
/** Remove obsolete context records when a viewport-scoped layer refreshes. */
export function removeEntityContextsForLayer(layerId) {
/**
* Remove obsolete context records when a viewport-scoped layer refreshes.
* Optional retainIds keeps surviving records and their current selection intact.
*/
export function removeEntityContextsForLayer(layerId, { retainIds } = {}) {
const store = getContextStore();
for (const [id, record] of store.entities) {
if (record?.layerId === layerId) store.entities.delete(id);
if (record?.layerId === layerId && !retainIds?.has(id)) store.entities.delete(id);
}
if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
store.selectedEntityId = null;
+8
View File
@@ -81,6 +81,14 @@ export const DATA_CREDITS = [
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'(ODbL 1.0)',
},
{
key: 'alpr-osm',
html:
'ALPR camera locations (automatic license plate readers): ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'(<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL 1.0</a>); ' +
'community mapping includes <a href="https://deflock.org" target="_blank" rel="noopener">DeFlock</a>',
},
{
key: 'military-installations-osm',
html:
+1
View File
@@ -276,6 +276,7 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
*/
export const LAYER_STATE_REGISTRY = Object.freeze([
Object.freeze({ id: 'ais-live-vessels', token: 'a', disposition: 'enabled-only' }),
Object.freeze({ id: 'alpr-cameras', token: 'p', disposition: 'enabled-only' }),
Object.freeze({ id: 'bikeshare', token: 'b', disposition: 'enabled-only' }),
Object.freeze({ id: 'cctv', token: 'c', disposition: 'enabled+options', optionOwner: 'cctv' }),
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
+2 -2
View File
@@ -156,8 +156,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 16);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 16);
assert.equal(REGISTERED_LAYER_IDS.length, 17);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 17);
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.throws(
() => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
+34
View File
@@ -3302,3 +3302,37 @@ test('panel remount releases old listeners and destruction revokes retained cont
else globalThis.document = originalDocument;
}
});
test('row action chips use live disabled state and descriptive counts without writing parameters', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
let calls = 0, blocked = false;
layer.module.getStats = () => ({ count: 3, countLabel: '3 nearby' });
layer.module.getRowControls = () => ({ chips: [{ id: 'find', label: 'SHOW NEAREST', disabled: blocked, onClick: () => calls++ }] });
layer.module.setParams = () => { throw new Error('navigation must not write persistent params'); };
mgr.register(layer.module);
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
await mgr.setEnabled('satellites', true);
const row = container.querySelector('[data-layer-id="satellites"]');
assert.equal(row.querySelector('.data-count').textContent, '3 nearby');
const controls = row.querySelector('.data-toggle-controls');
const chip = collectByClass(controls, 'data-toggle-chip')[0];
controls.listeners.click({ target: chip });
assert.equal(calls, 1);
blocked = true;
controls.listeners.click({ target: chip });
assert.equal(calls, 1, 'live disabled descriptor wins before row repaint');
blocked = false;
await mgr.setEnabled('satellites', false);
controls.listeners.click({ target: chip });
assert.equal(calls, 1, 'disabled layer cannot navigate');
} finally {
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
+4 -1
View File
@@ -194,6 +194,9 @@ export function getActiveTrackedReadoutId() {
return _activeEntryId;
}
/** Static-context layers whose click selection publishes the readout card. */
const READOUT_CONTEXT_LAYERS = new Set(['military-installations', 'alpr-cameras']);
/**
* Initialize the model bridge and selection listeners. No render listener is
* installed; the already-initialized world-overlay host owns the frame lane.
@@ -210,7 +213,7 @@ export function initTrackedReadout(viewer) {
}) || null;
_contextSelectedHandler = (event) => {
const record = event.detail;
if (record?.layerId === 'military-installations') {
if (READOUT_CONTEXT_LAYERS.has(record?.layerId)) {
_selectedContext = record;
publishEntity(record.entity);
return;
+12
View File
@@ -198,6 +198,18 @@ test('selection lifecycle ignores vessels, accepts installations, and clears wit
assert.equal(getActiveTrackedReadoutId(), 'installations:fort-test');
assert.equal(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].title, 'FORT TEST');
// ALPR cameras are static context too: a click publishes the same card.
const camera = {
gevTrackedId: 'alpr:42',
gevDisplayPosition: () => ({ x: 4, y: 5, z: 6 }),
gevLabelModel: { title: 'FLOCK SAFETY ALPR', details: ['CITY PD'], accent: '#ff66c4' },
};
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
detail: { layerId: 'alpr-cameras', entity: camera },
}));
assert.equal(getActiveTrackedReadoutId(), 'alpr:42');
assert.equal(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].title, 'FLOCK SAFETY ALPR');
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
detail: { layerId: 'ais-live-vessels', entity: installation },
}));
+3 -2
View File
@@ -656,14 +656,15 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins
// ── Voice: instruction-only, tool schema unchanged ─────────────────────
test('the voice TOOL SCHEMA matches the pinned release — the mission mapping is instructions only', () => {
// ALPR deliberately adds its ID to the two layer menus and visibility aliases.
// Canonical serialization pins every tool name, description, property and
// ordering while allowing source formatting. Derived from the unchanged
// release schema before formatting (the previous source-byte pin passed).
const block = JSON.stringify(GEV_REALTIME_TOOLS);
assert.equal(block.length, 26121, 'serialized tool schema length drifted');
assert.equal(block.length, 26208, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'11680affb4a7aebf142642c8185b28f2ee7d523407054c5b0c3962d933b045eb',
'135d4ec66239777da34a8476cdf8348574421d7afd2e981cc3490909a5bc8686',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
+384
View File
@@ -0,0 +1,384 @@
import * as Cesium from 'cesium';
import {
LAYER_ID,
REQUEST_DEBOUNCE_MS,
QUERY_LIMIT,
MAX_RENDERED,
QUERY_SNAP_DEGREES,
QUERY_REUSE_MS,
ALPR_COLOR,
} from './policy.js';
import {
snapAlprBox,
boxContains,
destinationPointDeg,
alprRetryDelayMs,
validateAlprSnapshot,
alprCreditMarkup,
} from './model.js';
import { createAlprPresentation } from './presentation.js';
/**
* Own one layer's requests, records, display and viewer subscriptions.
* A source fetches a bounded box with an AbortSignal and resolves
* { records, stale, saturated }. Records use stable string ids and latitude /
* longitude in degrees; optional directionDeg is a compass bearing [0, 360).
* Manufacturer, operator and cameraType are optional display fields.
* Source label and attribution { name, description, text, href } identify the
* actual provider; href must be HTTPS. Provider payload parsing stays in source.
* Context services remove obsolete records with
* removeEntityContextsForLayer(layerId, { retainIds: Set<string> }), preserving
* retained selection without dispatching a new selection event.
*/
export function createAlprCamerasLayer({ source, services } = {}) {
if (typeof source?.fetch !== 'function')
throw new TypeError('ALPR requires a camera source');
const creditMarkup = alprCreditMarkup(source.attribution);
const { governorRequestRender } = services.render;
const { registerPickOwner, unregisterPickOwner } = services.picking;
const state = {
viewer: null,
dataSource: null,
credit: null,
creditTimer: null,
creditPresented: false,
enabled: false,
records: [],
recordById: new Map(),
selectedId: null,
lastUpdate: null,
error: null,
status: 'idle',
stale: false,
/** Whether the query hit QUERY_LIMIT — the view likely holds more cameras than shown. */
saturated: false,
loading: false,
abort: null,
pendingQueryBox: null,
retryTimer: null,
retryDelayMs: 0,
retryAt: 0,
retrying: false,
moveEndRemove: null,
postRenderRemove: null,
lastAnchorSampleAt: 0,
clickHandler: null,
debounceTimer: null,
/** Snapped box of the last successful query; a view still inside it reuses its records. */
lastQueryBox: null,
};
const {
markerColor,
viewportBox,
clearRendered,
hideOnMapCredit,
presentOnMapCredit,
renderRecords,
selectRecord,
focusNearest,
clearSelection,
updateSelectedAnchor,
installInteraction,
} = createAlprPresentation({ state, services, source });
function setAlprStatus(status, error = null) {
if (state.status === status && state.error === error) return;
state.status = status;
state.error = error;
governorRequestRender('alpr-status');
}
function scheduleUnavailableRetry() {
if (!state.enabled) return;
clearTimeout(state.retryTimer);
state.retryDelayMs = alprRetryDelayMs(state.retryDelayMs);
state.retryAt = Date.now() + state.retryDelayMs;
state.retryTimer = setTimeout(() => {
state.retryTimer = null;
state.retryAt = 0;
if (state.enabled && !state.loading) loadCameras();
}, state.retryDelayMs);
}
function clearUnavailableRetry({ resetBackoff = true } = {}) {
clearTimeout(state.retryTimer);
state.retryTimer = null;
state.retryAt = 0;
if (resetBackoff) state.retryDelayMs = 0;
}
function scheduleLoad() {
if (!state.enabled) return;
clearUnavailableRetry({ resetBackoff: false });
clearTimeout(state.debounceTimer);
state.debounceTimer = setTimeout(() => {
state.debounceTimer = null;
loadCameras();
}, REQUEST_DEBOUNCE_MS);
}
async function loadCameras() {
if (!state.enabled || !state.viewer) return;
clearTimeout(state.debounceTimer);
state.debounceTimer = null;
clearUnavailableRetry({ resetBackoff: false });
const box = viewportBox(state.viewer);
if (!box) {
state.abort?.abort();
state.abort = null;
state.pendingQueryBox = null;
state.loading = false;
state.retrying = false;
clearUnavailableRetry();
renderRecords();
setAlprStatus('zoom-in');
return;
}
// A settled view still inside the last snapped query box, with fresh
// records, needs no new request: tiny pans and orbits
// otherwise became distinct upstream fetches against the shared proxy. Any
// request still in flight was for a viewport the user has since left, so it
// is aborted rather than allowed to replace the records that already cover
// this view. Failed queries never replace the last successful box.
if (
state.lastQueryBox &&
boxContains(state.lastQueryBox, box) &&
state.lastUpdate &&
Date.now() - state.lastUpdate < QUERY_REUSE_MS &&
!state.stale &&
state.status !== 'unavailable'
) {
if (state.abort) {
state.abort.abort();
state.abort = null;
state.pendingQueryBox = null;
state.loading = false;
}
renderRecords();
state.retrying = false;
setAlprStatus(
state.dataSource.entities.values.length ? 'ready' : 'empty',
);
return;
}
// A move inside an in-flight query must not abort and restart that query.
if (state.abort && boxContains(state.pendingQueryBox, box)) {
renderRecords();
return;
}
const queryBox = snapAlprBox(box);
state.abort?.abort();
const requestAbort = new AbortController();
state.abort = requestAbort;
state.pendingQueryBox = queryBox;
state.loading = true;
state.retrying = state.status === 'unavailable' || state.retryDelayMs > 0;
setAlprStatus('loading');
renderRecords();
try {
const snapshot = await source.fetch(queryBox, requestAbort.signal);
if (
requestAbort.signal.aborted ||
state.abort !== requestAbort ||
!state.enabled
)
return;
const { records, stale, saturated } = validateAlprSnapshot(snapshot);
state.records = records;
state.recordById = new Map(records.map((r) => [r.id, r]));
state.lastUpdate = Date.now();
state.stale = stale;
// Saturated when the QUERY truncated OR the render cap would hide cameras:
// either way the view holds more than the screen shows, and the user must
// be told rather than left with a count that disagrees with the map.
state.saturated = saturated || records.length > MAX_RENDERED;
state.lastQueryBox = queryBox;
clearUnavailableRetry();
// Stale and saturated are independent facts; a cached response that also
// hit the cap must still warn about coverage, not just about freshness.
renderRecords();
setAlprStatus(
state.dataSource.entities.values.length
? stale
? 'stale'
: 'ready'
: 'empty',
);
} catch (error) {
if (
error?.name === 'AbortError' ||
requestAbort.signal.aborted ||
state.abort !== requestAbort ||
!state.enabled
)
return;
state.stale = Boolean(state.lastUpdate);
setAlprStatus(
'unavailable',
error?.message || 'Camera source temporarily unavailable',
);
scheduleUnavailableRetry();
} finally {
if (state.abort === requestAbort) {
state.abort = null;
state.pendingQueryBox = null;
state.loading = false;
state.retrying = false;
governorRequestRender('alpr-status');
}
}
}
const alprCamerasLayer = {
id: LAYER_ID,
name: 'ALPR Cameras',
icon: '📷',
source: source.label || 'Mapped camera locations',
updateInterval: 0,
statsRefreshInterval: 1000,
init(viewer) {
if (state.viewer) throw new Error('ALPR layer is already initialized');
state.viewer = viewer;
state.dataSource = new Cesium.CustomDataSource('alpr-cameras');
state.credit = creditMarkup
? new Cesium.Credit(creditMarkup, true)
: null;
viewer.dataSources.add(state.dataSource);
state.moveEndRemove =
viewer.camera.moveEnd.addEventListener(scheduleLoad);
state.postRenderRemove =
viewer.scene.postRender?.addEventListener(updateSelectedAnchor);
installInteraction(viewer);
},
enable() {
if (state.enabled) return;
state.enabled = true;
state.creditPresented = false;
registerPickOwner(LAYER_ID, (id) => state.recordById.has(id));
state.dataSource.show = true;
// DataLayerManager calls update() right after enable(); it owns the first fetch.
},
disable() {
state.enabled = false;
hideOnMapCredit();
unregisterPickOwner(LAYER_ID);
clearUnavailableRetry();
clearTimeout(state.debounceTimer);
state.abort?.abort();
state.abort = null;
state.pendingQueryBox = null;
state.loading = false;
state.retrying = false;
if (state.dataSource) state.dataSource.show = false;
clearSelection();
clearRendered();
setAlprStatus('idle');
},
update() {
return loadCameras();
},
destroy(viewer = state.viewer) {
this.disable();
state.moveEndRemove?.();
state.moveEndRemove = null;
state.postRenderRemove?.();
state.postRenderRemove = null;
state.clickHandler?.destroy();
state.clickHandler = null;
clearRendered();
if (state.dataSource && viewer)
viewer.dataSources.remove(state.dataSource, true);
state.dataSource = null;
state.credit = null;
state.creditPresented = false;
state.records = [];
state.recordById = new Map();
state.lastQueryBox = null;
state.lastUpdate = null;
state.error = null;
state.status = 'idle';
state.stale = false;
state.saturated = false;
state.viewer = null;
},
getRowControls() {
const count = state.dataSource?.entities.values.length || 0;
return {
chips: [
{
id: 'find-camera',
label: 'SHOW NEAREST',
title: state.viewer?.trackedEntity
? 'Stop following the current object before navigating to a camera'
: 'Move to the nearest loaded camera and show its details',
disabled:
!state.enabled || !count || Boolean(state.viewer?.trackedEntity),
onClick: focusNearest,
},
],
legend: [
{
label: 'Purple dots',
color: ALPR_COLOR,
count,
blurb:
'Nearby mapped cameras may be outside the screen. Dots stay 8 pixels wide as you zoom; click one for details.',
},
],
};
},
getStats() {
return {
count: state.dataSource?.entities.values.length || 0,
countLabel: state.enabled
? `${state.dataSource?.entities.values.length || 0} nearby`
: '',
lastUpdate: state.lastUpdate,
stale: state.stale,
saturated: state.saturated,
error: state.error,
status: state.status,
loading: state.loading,
retryAt: state.retryAt,
retrying: state.retrying,
retryInSec: state.retryAt
? Math.max(0, Math.ceil((state.retryAt - Date.now()) / 1000))
: 0,
loadingLabel: state.loading
? state.retrying
? 'retrying mapped ALPR cameras'
: 'loading mapped ALPR cameras'
: state.status === 'zoom-in'
? 'Zoom in to load mapped cameras'
: [
state.stale ? 'Showing cached locations' : '',
state.saturated ? 'Coverage limited — zoom in' : '',
state.status === 'empty'
? 'No mapped cameras returned — coverage is incomplete'
: '',
]
.filter(Boolean)
.join(' · '),
};
},
};
return alprCamerasLayer;
}
export {
snapAlprBox,
boxContains,
destinationPointDeg,
alprRetryDelayMs,
isAlprSurveillanceType,
normalizeAlprNode,
buildOverpassQuery,
} from './model.js';
export {
QUERY_LIMIT,
MAX_RENDERED,
QUERY_SNAP_DEGREES,
QUERY_REUSE_MS,
} from './policy.js';
export { createOverpassAlprSource } from './source.js';
+211
View File
@@ -0,0 +1,211 @@
import * as Cesium from 'cesium';
import {
QUERY_LIMIT,
EARTH_MEAN_RADIUS_M,
QUERY_SNAP_DEGREES,
} from './policy.js';
export function textTag(value) {
const t = String(value ?? '').trim();
return t || null;
}
export function numTag(value) {
// `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only
// tag value must read as missing, never as a due-north bearing.
if (value == null) return null;
const trimmed = String(value).trim();
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
/**
* Snap a viewport box OUTWARD to the query grid, clamped to valid ranges.
* Pure so the reuse contract is pinnable.
* @param {{south:number,west:number,north:number,east:number}} box
* @param {number} [step=QUERY_SNAP_DEGREES]
*/
export function snapAlprBox(box, step = QUERY_SNAP_DEGREES) {
const down = (v) => Math.floor(v / step) * step;
const up = (v) => Math.ceil(v / step) * step;
return {
south: Math.max(-90, down(box.south)),
west: Math.max(-180, down(box.west)),
north: Math.min(90, up(box.north)),
east: Math.min(180, up(box.east)),
};
}
/** Whether `inner` lies entirely within `outer` (no dateline handling; the viewport gate already rejects east <= west). */
export function boxContains(outer, inner) {
if (!outer || !inner) return false;
return (
inner.south >= outer.south &&
inner.north <= outer.north &&
inner.west >= outer.west &&
inner.east <= outer.east
);
}
/**
* Great-circle destination point — used only to draw the short
* facing-direction line when a node carries `camera:direction`/`direction`.
* @param {number} latDeg @param {number} lonDeg
* @param {number} bearingDeg Compass bearing, degrees clockwise from north.
* @param {number} distanceM
* @returns {{latitude:number, longitude:number}}
*/
export function destinationPointDeg(latDeg, lonDeg, bearingDeg, distanceM) {
const angularDistance = distanceM / EARTH_MEAN_RADIUS_M;
const bearing = Cesium.Math.toRadians(bearingDeg);
const lat1 = Cesium.Math.toRadians(latDeg);
const lon1 = Cesium.Math.toRadians(lonDeg);
const lat2 = Math.asin(
Math.sin(lat1) * Math.cos(angularDistance) +
Math.cos(lat1) * Math.sin(angularDistance) * Math.cos(bearing),
);
const lon2 =
lon1 +
Math.atan2(
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(lat1),
Math.cos(angularDistance) - Math.sin(lat1) * Math.sin(lat2),
);
return {
latitude: Cesium.Math.toDegrees(lat2),
longitude: Cesium.Math.toDegrees(lon2),
};
}
/**
* Backoff progression for the unavailable-state retry: 30 s, doubling to a
* 240 s ceiling. Pure so the progression is pinnable without booting the layer.
*/
export function alprRetryDelayMs(prevDelayMs) {
const RETRY_MIN_MS = 30000;
const RETRY_CEIL_MS = 240000;
if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS;
return Math.min(prevDelayMs * 2, RETRY_CEIL_MS);
}
/**
* Whether a `surveillance:type` value denotes an ALPR camera. OSM allows
* semicolon multi-values (`camera;ALPR`) and mixed case, so an exact match
* would silently miss tagged cameras.
* @param {*} value - raw `surveillance:type` tag value
* @returns {boolean}
*/
export function isAlprSurveillanceType(value) {
return String(value ?? '')
.split(';')
.some((part) => part.trim().toUpperCase() === 'ALPR');
}
/** Map one raw Overpass node element to a plain camera record. Null for anything unusable. */
export function normalizeAlprNode(el) {
if (
!el ||
el.type !== 'node' ||
!Number.isSafeInteger(el.id) ||
el.id <= 0 ||
!Number.isFinite(el.lat) ||
Math.abs(el.lat) > 90 ||
!Number.isFinite(el.lon) ||
Math.abs(el.lon) > 180
)
return null;
const tags = el.tags || {};
if (!isAlprSurveillanceType(tags['surveillance:type'])) return null;
return {
id: `alpr:${el.id}`,
osmId: el.id,
latitude: el.lat,
longitude: el.lon,
operator: textTag(tags.operator),
manufacturer: textTag(tags.manufacturer),
cameraType: textTag(tags['camera:type']),
zone: textTag(tags['surveillance:zone']),
// Only numeric bearings are parsed; compass-word directions ("N"/"NE")
// are rare on this tag and simply render with no facing line.
directionDeg: normalizeDirection(
tags['camera:direction'] ?? tags.direction,
),
ref: textTag(tags.ref),
lastVerified: textTag(tags.check_date) || textTag(tags['survey:date']),
source: textTag(tags.source),
};
}
export function normalizeDirection(value) {
const degrees = numTag(value);
return degrees != null && degrees >= 0 && degrees <= 360
? degrees % 360
: null;
}
/**
* Overpass QL for the viewport. The tag match is a case-insensitive regex, not
* an exact `="ALPR"`, because OSM semicolon multi-values such as
* `surveillance:type=camera;ALPR` are common and an exact match drops them.
* Exported so the query shape is pinnable.
*/
export function buildOverpassQuery(south, west, north, east) {
return (
`[out:json][timeout:20];node["man_made"="surveillance"]["surveillance:type"~"(^|;)\\s*ALPR\\s*(;|$)",i]` +
`(${south},${west},${north},${east});out body ${QUERY_LIMIT};`
);
}
/** Validate a source-independent snapshot before replacing the accepted display. */
export function validateAlprSnapshot(snapshot) {
if (
!Array.isArray(snapshot?.records) ||
typeof snapshot.stale !== 'boolean' ||
typeof snapshot.saturated !== 'boolean'
) {
throw new TypeError('Camera source returned an invalid snapshot');
}
const ids = new Set();
for (const record of snapshot.records) {
if (
!record ||
typeof record.id !== 'string' ||
!record.id.trim() ||
ids.has(record.id) ||
!Number.isFinite(record.latitude) ||
Math.abs(record.latitude) > 90 ||
!Number.isFinite(record.longitude) ||
Math.abs(record.longitude) > 180 ||
(record.directionDeg != null &&
(!Number.isFinite(record.directionDeg) ||
record.directionDeg < 0 ||
record.directionDeg >= 360))
) {
throw new TypeError('Camera source returned an invalid record');
}
ids.add(record.id);
}
return snapshot;
}
/** Build a linked attribution from plain text and an HTTPS URL only. */
export function alprCreditMarkup(attribution) {
if (!attribution?.text || !attribution?.href) return null;
const href = new URL(attribution.href);
if (href.protocol !== 'https:' || href.username || href.password) {
throw new TypeError('Camera attribution requires a public HTTPS link');
}
const escape = (value) =>
String(value).replace(
/[&<>"']/g,
(char) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[char],
);
return `<span class="gev-alpr-credit">ALPR: <a href="${escape(href.href)}" target="_blank" rel="noopener">${escape(attribution.text)}</a></span>`;
}
+54
View File
@@ -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;
+404
View File
@@ -0,0 +1,404 @@
import * as Cesium from 'cesium';
import {
LAYER_ID,
MAX_VIEWPORT_DEGREES,
MAX_RENDERED,
DIRECTION_CONE_M,
ALPR_COLOR,
CREDIT_DISPLAY_MS,
} from './policy.js';
import { boxContains, destinationPointDeg } from './model.js';
export function createAlprPresentation({ state, services, source }) {
const { governorRequestRender } = services.render;
const {
clearSelectedEntityContextForLayer,
getSelectedEntityContext,
registerEntityContext,
removeEntityContextsForLayer,
selectEntityContext,
} = services.context;
const { cachedGroundFloor } = services.groundFloor;
function markerColor() {
return Cesium.Color.fromCssColorString(ALPR_COLOR);
}
// The horizon rectangle is unstable during low-angle orbits and can exclude
// the ground point in the center of the screen. Bound nearby coverage around
// that point instead; the camera-to-ground range keeps zoom-out queries capped.
function viewportBox(viewer) {
const camera = viewer?.camera;
const canvas = viewer?.scene.canvas;
if (typeof camera?.pickEllipsoid === 'function' && canvas) {
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
if (!width || !height) return null;
const focus = camera.pickEllipsoid(
new Cesium.Cartesian2(width / 2, height / 2),
viewer.scene.globe.ellipsoid,
);
if (!focus) return null;
const location = Cesium.Cartographic.fromCartesian(focus);
const range = Cesium.Cartesian3.distance(camera.positionWC, focus);
const radius = Math.max(1000, 2 * range);
const latitude = Cesium.Math.toDegrees(location.latitude);
const longitude = Cesium.Math.toDegrees(location.longitude);
const latSpan = radius / 111000;
const lonSpan = latSpan / Math.cos(location.latitude);
if (
!Number.isFinite(latSpan + lonSpan) ||
2 * Math.max(latSpan, lonSpan) > MAX_VIEWPORT_DEGREES ||
Math.abs(latitude) + latSpan > 90 ||
Math.abs(longitude) + lonSpan > 180
)
return null;
return {
south: latitude - latSpan,
west: longitude - lonSpan,
north: latitude + latSpan,
east: longitude + lonSpan,
};
}
// Rectangle-only viewers retain the same bounded-area contract.
const rectangle = viewer?.camera?.computeViewRectangle(
viewer.scene.globe.ellipsoid,
);
if (!rectangle) return null;
const south = Cesium.Math.toDegrees(rectangle.south);
const north = Cesium.Math.toDegrees(rectangle.north);
const west = Cesium.Math.toDegrees(rectangle.west);
const east = Cesium.Math.toDegrees(rectangle.east);
if (
!Number.isFinite(south + north + west + east) ||
east <= west ||
north - south > MAX_VIEWPORT_DEGREES ||
east - west > MAX_VIEWPORT_DEGREES
)
return null;
return { south, west, north, east };
}
function clearRendered() {
if (state.dataSource?.entities) state.dataSource.entities.removeAll();
removeEntityContextsForLayer(LAYER_ID);
}
function hideOnMapCredit() {
clearTimeout(state.creditTimer);
state.creditTimer = null;
if (state.credit)
state.viewer?.creditDisplay?.removeStaticCredit(state.credit);
governorRequestRender('alpr-credit');
}
function presentOnMapCredit() {
if (!state.enabled || state.creditPresented || !state.credit) return;
state.creditPresented = true;
state.viewer.creditDisplay?.addStaticCredit(state.credit);
state.creditTimer = setTimeout(hideOnMapCredit, CREDIT_DISPLAY_MS);
governorRequestRender('alpr-credit');
}
function renderRecords() {
const selectedContext = getSelectedEntityContext();
const box = viewportBox(state.viewer);
const visible = box
? state.records
.filter((record) =>
boxContains(box, {
south: record.latitude,
north: record.latitude,
west: record.longitude,
east: record.longitude,
}),
)
.slice(0, MAX_RENDERED)
: [];
// A refresh may retain its own selection, never reclaim one cleared or
// replaced by an aircraft, another layer, or a voice action.
if (
selectedContext?.layerId !== LAYER_ID ||
selectedContext.id !== state.selectedId ||
!visible.some((record) => record.id === state.selectedId)
) {
state.selectedId = null;
clearSelectedEntityContextForLayer(LAYER_ID);
}
governorRequestRender('alpr-render');
const visibleIds = new Set(visible.map((record) => record.id));
for (const entity of [...state.dataSource.entities.values]) {
if (!visibleIds.has(entity.id)) state.dataSource.entities.remove(entity);
}
removeEntityContextsForLayer(LAYER_ID, { retainIds: visibleIds });
for (const record of visible) {
const existing = state.dataSource.entities.getById(record.id);
if (existing?.gevAlprRecord === record) {
existing.point.pixelSize = record.id === state.selectedId ? 12 : 8;
existing.point.color =
record.id === state.selectedId ? Cesium.Color.WHITE : markerColor();
continue;
}
const color = markerColor();
const selected = record.id === state.selectedId;
const position = Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
);
const entityDef = {
id: record.id,
position,
point: {
pixelSize: selected ? 12 : 8,
color: selected ? Cesium.Color.WHITE : color,
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
outlineWidth: 1,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
};
if (Number.isFinite(record.directionDeg)) {
const tip = destinationPointDeg(
record.latitude,
record.longitude,
record.directionDeg,
DIRECTION_CONE_M,
);
entityDef.polyline = {
positions: [
position,
Cesium.Cartesian3.fromDegrees(tip.longitude, tip.latitude),
],
width: 2,
material: color.withAlpha(0.85),
clampToGround: true,
};
}
let entity = existing;
// Keep the Cesium entity and its ground-clamping subscription while its
// geometry is unchanged. Updating metadata must not rebuild the marker.
const previous = entity?.gevAlprRecord;
if (!entity) entity = state.dataSource.entities.add(entityDef);
else {
if (
previous.latitude !== record.latitude ||
previous.longitude !== record.longitude
)
entity.position = position;
if (
previous.latitude !== record.latitude ||
previous.longitude !== record.longitude ||
previous.directionDeg !== record.directionDeg
)
entity.polyline = entityDef.polyline;
entity.point.pixelSize = selected ? 12 : 8;
entity.point.color = selected ? Cesium.Color.WHITE : color;
}
entity.gevAlprRecord = record;
entity.gevTrackedId = record.id;
// The mapped camera datum has no elevation; it is not the clamped marker's
// visual anchor. Only the selected marker samples the rendered surface,
// at most once per second, through Cesium's public height APIs.
entity.gevAlprDisplayPosition ??= null;
entity.gevDisplayPosition = () => entity.gevAlprDisplayPosition;
entity.gevLabelModel = {
title: 'ALPR CAMERA',
details: [
record.manufacturer ? `Manufacturer: ${record.manufacturer}` : null,
record.operator ? `Operator: ${record.operator}` : null,
record.cameraType ? `Type: ${record.cameraType}` : null,
`Source: ${source.attribution?.name || source.label || 'Camera source'}`,
].filter(Boolean),
accent: color.toCssColorString(),
};
registerEntityContext(entity, {
id: record.id,
layerId: LAYER_ID,
dataSource: state.dataSource,
layerName: 'ALPR Cameras',
source:
source.attribution?.description || source.label || 'Camera source',
label: 'ALPR camera',
latitude: record.latitude,
longitude: record.longitude,
properties: {
operator: record.operator,
manufacturer: record.manufacturer,
cameraType: record.cameraType,
zone: record.zone,
directionDeg: record.directionDeg,
ref: record.ref,
lastVerified: record.lastVerified,
osmId: record.osmId,
sourceTag: record.source,
},
});
}
const selectedEntity = state.selectedId
? state.dataSource.entities.getById(state.selectedId)
: null;
if (!selectedEntity) state.selectedId = null;
updateSelectedAnchor();
// Start only when mapped data is actually displayed, not while an upstream
// request or a zoom-in prompt could consume the five-second introduction.
if (visible.length) presentOnMapCredit();
}
function focusNearest() {
const camera = state.viewer?.camera;
if (!state.enabled || !camera?.positionWC || state.viewer.trackedEntity)
return false;
let nearest = null,
distance = Infinity;
for (const entity of state.dataSource.entities.values) {
const position = entity.position.getValue(Cesium.JulianDate.now());
const candidate = Cesium.Cartesian3.distanceSquared(
camera.positionWC,
position,
);
if (candidate < distance) {
nearest = entity;
distance = candidate;
}
}
if (!nearest) return false;
const record = state.recordById.get(nearest.id);
const location = Cesium.Cartographic.fromDegrees(
record.longitude,
record.latitude,
);
let height;
if (state.viewer.scene.sampleHeightSupported) {
try {
height = state.viewer.scene.sampleHeight(location, [nearest]);
} catch {
/* tiles may still be streaming */
}
}
if (!Number.isFinite(height) && state.viewer.scene.globe.show)
height = state.viewer.scene.globe.getHeight?.(location);
if (!Number.isFinite(height) || height < -500 || height > 10000)
height = cachedGroundFloor(record.latitude, record.longitude);
const center = Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
Number.isFinite(height) ? height : 0,
);
if (!selectRecord(nearest.id)) return false;
camera.flyToBoundingSphere(new Cesium.BoundingSphere(center, 30), {
duration: 1.2,
offset: new Cesium.HeadingPitchRange(
camera.heading || 0,
-Math.PI / 4,
800,
),
});
governorRequestRender('alpr-focus');
return true;
}
function selectRecord(id) {
const entity = state.dataSource?.entities.getById(id);
if (!entity || !state.recordById.has(id)) return false;
clearSelection();
state.selectedId = id;
entity.point.pixelSize = 12;
entity.point.color = Cesium.Color.WHITE;
state.lastAnchorSampleAt = 0;
selectEntityContext(entity);
updateSelectedAnchor();
governorRequestRender('alpr-selection');
return true;
}
function clearSelection() {
const entity = state.selectedId
? state.dataSource?.entities.getById(state.selectedId)
: null;
const record = state.recordById.get(state.selectedId);
if (entity && record) {
entity.point.pixelSize = 8;
entity.point.color = markerColor();
}
state.selectedId = null;
clearSelectedEntityContextForLayer(LAYER_ID);
governorRequestRender('alpr-selection');
}
function updateSelectedAnchor() {
if (!state.enabled || !state.selectedId) return;
const selected = getSelectedEntityContext();
if (selected?.id !== state.selectedId) {
clearSelection();
return;
}
const entity = state.dataSource?.entities.getById(state.selectedId);
const record = state.recordById.get(state.selectedId);
if (!entity || !record || Date.now() - state.lastAnchorSampleAt < 1000)
return;
state.lastAnchorSampleAt = Date.now();
const scene = state.viewer.scene;
const location = Cesium.Cartographic.fromDegrees(
record.longitude,
record.latitude,
);
let height;
if (scene.sampleHeightSupported) {
try {
height = scene.sampleHeight(location, [entity]);
} catch {
/* tiles may still be streaming */
}
}
if (!Number.isFinite(height) && scene.globe.show)
height = scene.globe.getHeight?.(location);
if (!Number.isFinite(height))
height = cachedGroundFloor(record.latitude, record.longitude);
if (!Number.isFinite(height)) return;
const next = Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
height,
);
if (
!entity.gevAlprDisplayPosition ||
Cesium.Cartesian3.distance(next, entity.gevAlprDisplayPosition) > 0.1
) {
entity.gevAlprDisplayPosition = next;
governorRequestRender('alpr-anchor');
}
}
function installInteraction(viewer) {
if (state.clickHandler) return;
state.clickHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
);
state.clickHandler.setInputAction((click) => {
if (!state.enabled) return;
const picked = viewer.scene.pick(click.position);
const id = typeof picked?.id?.id === 'string' ? picked.id.id : null;
if (
id &&
state.recordById.has(id) &&
(id !== state.selectedId || getSelectedEntityContext()?.id !== id)
)
selectRecord(id);
else if (state.selectedId) clearSelection();
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
return {
markerColor,
viewportBox,
clearRendered,
hideOnMapCredit,
presentOnMapCredit,
renderRecords,
selectRecord,
focusNearest,
clearSelection,
updateSelectedAnchor,
installInteraction,
};
}
+82
View File
@@ -0,0 +1,82 @@
import {
OVERPASS_URL,
MAX_VIEWPORT_DEGREES,
QUERY_SNAP_DEGREES,
QUERY_LIMIT,
} from './policy.js';
import { buildOverpassQuery, normalizeAlprNode } from './model.js';
/** Construct the bounded OSM request adapter without starting a request. */
export function createOverpassAlprSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
async function fetchAlprNodes(box, signal) {
signal?.throwIfAborted();
if (
!box ||
![box.south, box.west, box.north, box.east].every(Number.isFinite) ||
box.south < -90 ||
box.north > 90 ||
box.west < -180 ||
box.east > 180 ||
box.north <= box.south ||
box.east <= box.west ||
box.north - box.south >
MAX_VIEWPORT_DEGREES + 2 * QUERY_SNAP_DEGREES + 1e-9 ||
box.east - box.west > MAX_VIEWPORT_DEGREES + 2 * QUERY_SNAP_DEGREES + 1e-9
) {
throw new TypeError('ALPR requires a bounded city viewport');
}
const query = buildOverpassQuery(box.south, box.west, box.north, box.east);
const response = await fetchImpl(OVERPASS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `data=${encodeURIComponent(query)}`,
signal,
});
if (!response.ok) {
try {
await response.body?.cancel();
} catch {
/* already closed */
}
const message =
response.status === 429
? 'Overpass rate-limited'
: response.status === 504
? 'Overpass timed out'
: 'Overpass temporarily unavailable';
throw new Error(message);
}
const stale = response.headers.get('x-overpass-cache') === 'STALE';
const payload = await response.json();
signal?.throwIfAborted();
// The shared proxy already rejects query errors. Validate here too so a
// malformed or partial response never becomes an authoritative empty map.
if (!Array.isArray(payload?.elements) || payload.remark) {
throw new Error('Overpass returned an incomplete camera response');
}
return {
records: [
...new Map(
payload.elements
.slice(0, QUERY_LIMIT)
.map(normalizeAlprNode)
.filter(Boolean)
.map((record) => [record.id, record]),
).values(),
],
stale,
saturated: payload.elements.length >= QUERY_LIMIT,
};
}
return {
fetch: fetchAlprNodes,
label: 'OpenStreetMap · community mapped',
attribution: {
name: 'OpenStreetMap',
description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
text: '© OpenStreetMap',
href: 'https://www.openstreetmap.org/copyright',
},
};
}
+117
View File
@@ -0,0 +1,117 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createOverpassAlprSource } from './source.js';
import { validateAlprSnapshot, alprCreditMarkup } from './model.js';
const box = { south: 30, west: -98, north: 30.1, east: -97.9 };
test('the source rejects invalid and unbounded queries before fetching', async () => {
let calls = 0;
const source = createOverpassAlprSource({
fetchImpl() {
calls++;
},
});
for (const bad of [
null,
{ ...box, west: '0);out;' },
{ ...box, north: 90 },
{ ...box, east: -99 },
{ ...box, south: NaN },
]) {
await assert.rejects(source.fetch(bad), /bounded city viewport/);
}
assert.equal(calls, 0);
});
test('cancellation during body parsing rejects even when the transport ignores it', async () => {
const abort = new AbortController();
const source = createOverpassAlprSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
json: async () => {
abort.abort();
return { elements: [] };
},
}),
});
await assert.rejects(source.fetch(box, abort.signal), { name: 'AbortError' });
});
test('Overpass normalization returns camera records and preserves limited/stale coverage', async () => {
const source = createOverpassAlprSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers({ 'x-overpass-cache': 'STALE' }),
json: async () => ({
elements: [
{
type: 'node',
id: 12,
lat: 30,
lon: -98,
tags: {
'surveillance:type': 'camera;ALPR',
'camera:direction': '90',
},
},
],
}),
}),
});
const snapshot = await source.fetch(box);
assert.equal(snapshot.records[0].id, 'alpr:12');
assert.equal(snapshot.records[0].latitude, 30);
assert.equal(snapshot.records[0].directionDeg, 90);
assert.equal(snapshot.stale, true);
assert.equal(snapshot.saturated, false);
assert.equal('elements' in snapshot, false);
});
test('an unsuccessful source response releases its body before reporting an error', async () => {
let cancelled = 0;
const source = createOverpassAlprSource({
fetchImpl: async () => ({
ok: false,
status: 429,
body: {
async cancel() {
cancelled++;
},
},
}),
});
await assert.rejects(source.fetch(box), /rate-limited/);
assert.equal(cancelled, 1);
});
test('source snapshots reject malformed coordinates and duplicate identities', () => {
const record = { id: 'camera:1', latitude: 30, longitude: -98 };
for (const records of [
[{ ...record, latitude: NaN }],
[record, record],
[{ ...record, id: '' }],
]) {
assert.throws(
() => validateAlprSnapshot({ records, stale: false, saturated: false }),
/invalid record/,
);
}
assert.throws(
() => validateAlprSnapshot({ elements: [] }),
/invalid snapshot/,
);
});
test('provider attribution escapes markup and rejects executable links', () => {
assert.equal(alprCreditMarkup(null), null);
assert.throws(
() => alprCreditMarkup({ text: 'test', href: 'javascript:alert(1)' }),
/HTTPS/,
);
const html = alprCreditMarkup({
text: '<img onerror=alert(1)>',
href: 'https://example.org/?a=1&b=2',
});
assert.ok(html.includes('&lt;img onerror=alert(1)&gt;'));
assert.ok(html.includes('?a=1&amp;b=2'));
assert.ok(!html.includes('<img'));
});
+43 -1
View File
@@ -28,7 +28,7 @@ export function normalizeLayerLoading(layer = {}) {
stats.refreshing === true;
const count = finiteCount(stats.count);
const stoppingInstallations =
layer.id === 'military-installations' && disabling;
['military-installations', 'alpr-cameras'].includes(layer.id) && disabling;
// Guidance statuses ask the user to act (zoom in, run a search). They are
// normal operation, never a batch failure — mirrors layerFeedState's carve-out
// so a prompt stored alongside the status cannot turn the chip red.
@@ -61,6 +61,14 @@ export function normalizeLayerLoading(layer = {}) {
unavailable,
keyRequired,
degraded,
cameraRetry:
layer.id === 'alpr-cameras' && layer.enabled && !disabling
? {
retryAt: Number(stats.retryAt) || 0,
retrying: stats.retrying === true,
error,
}
: null,
installationRetry:
layer.id === 'military-installations' && layer.enabled && !disabling
? {
@@ -367,6 +375,30 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) {
/** Build the user-facing status copy for the current loading state. */
export function presentLoadingFeedback(state, summary, nowMs) {
const camera = summary.records.find(
(record) => record.cameraRetry?.retryAt > 0,
);
const otherCameraFailure =
summary.records.some(
(record) =>
record.id !== 'alpr-cameras' &&
(state?.activeIds || []).includes(record.id) &&
(record.error || record.unavailable || record.keyRequired),
) || (state?.failedEventIds || []).some((id) => id !== 'alpr-cameras');
if (camera && !summary.active.length && !otherCameraFailure) {
const seconds = Math.max(
0,
Math.ceil((camera.cameraRetry.retryAt - Date.now()) / 1000),
);
return {
state: 'retry',
label: (
camera.cameraRetry.error || 'Overpass temporarily unavailable'
).toUpperCase(),
detail: `ALPR cameras · ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`,
};
}
const site = summary.records.find(
(record) => record.installationRetry?.retryAt > 0,
);
@@ -403,6 +435,16 @@ export function presentLoadingFeedback(state, summary, nowMs) {
return { state: state.terminal, label, detail: '' };
}
const active = summary.active;
if (active.length === 1 && active[0].cameraRetry && !summary.disabling) {
return {
state: 'loading',
label: active[0].cameraRetry.retrying
? 'RETRYING ALPR CAMERAS'
: 'FETCHING ALPR CAMERAS',
detail: 'OpenStreetMap · Overpass',
};
}
if (
active.length === 1 &&
active[0].installationRetry &&
+28
View File
@@ -711,3 +711,31 @@ test('guidance does not suppress independent manager and feed failures', () => {
assert.equal(record.error, 'Network unavailable');
}
});
test('ALPR retries show a countdown, preserve other failures, and clear when disabled', () => {
const camera = { ...retrySite({ error: 'Overpass rate-limited' }), id: 'alpr-cameras', name: 'ALPR cameras' };
const summary = aggregateLayerLoading([camera]);
const view = presentLoadingFeedback(createLoadingFeedbackState(), summary, 0);
assert.equal(view.state, 'retry');
assert.equal(view.label, 'OVERPASS RATE-LIMITED');
assert.match(view.detail, /ALPR cameras · retrying in 30s/);
const failed = { visible: true, phase: 'terminal', terminal: 'error', activeIds: ['alpr-cameras', 'flights'] };
const otherFailure = aggregateLayerLoading([camera, { id: 'flights', enabled: true, stats: { error: 'Failed' } }]);
assert.equal(presentLoadingFeedback(failed, otherFailure, 0).label, 'LOAD FAILED');
assert.equal(presentLoadingFeedback({ ...failed, failedEventIds: ['flights'] }, summary, 0).label, 'LOAD FAILED');
assert.equal(presentLoadingFeedback(createLoadingFeedbackState(), aggregateLayerLoading([{ ...camera, enabled: false }]), 0), null);
});
test('ALPR retry success does not inherit its prior error, including turning the layer off', () => {
const camera = stats => ({ id: 'alpr-cameras', enabled: true, stats });
const loading = aggregateLayerLoading([camera({ status: 'loading', loading: true, retrying: true })]);
let state = reduceLoadingFeedback(createLoadingFeedbackState(), loading, 0);
state = reduceLoadingFeedback(state, loading, 200);
assert.equal(presentLoadingFeedback(state, loading, 200).label, 'RETRYING ALPR CAMERAS');
const done = aggregateLayerLoading([camera({ status: 'ready', count: 3 })]);
state = reduceLoadingFeedback(state, done, 300);
assert.equal(presentLoadingFeedback(state, done, 300).label, 'LOAD COMPLETE');
const stopping = normalizeLayerLoading({ ...camera({ status: 'unavailable', error: 'Old failure' }), lifecycleState: 'disabling' });
assert.equal(stopping.error, null);
assert.equal(stopping.unavailable, false);
});
+2 -1
View File
@@ -191,7 +191,8 @@ test('no unchanged Realtime tool definition drifts silently', () => {
.update(JSON.stringify(unchanged))
.digest('hex')
.slice(0, 16);
assert.equal(digest, '802ed694b8887b88', 'an unchanged Realtime tool definition drifted');
// ALPR intentionally extends the two layer enums; retain the complete pin.
assert.equal(digest, '6963175a0c9a76de', 'an unchanged Realtime tool definition drifted');
});
test('Radio volume and mission speed share the Sharpen slider visual language', () => {
+2
View File
@@ -1,6 +1,7 @@
import { DataLayerManager } from '../data/manager.js';
import flightsLayer from '../data/flights.js';
import militaryFlightsLayer from '../data/militaryFlights.js';
import alprCamerasLayer from '../data/alprCameras.js';
import earthquakesLayer from '../data/earthquakes.js';
import satellitesLayer from '../data/satellites.js';
import rocketLaunchesLayer from '../data/rocketLaunches.js';
@@ -35,6 +36,7 @@ export function createStandaloneData({
dataManager.register(flightsLayer);
dataManager.register(militaryFlightsLayer);
dataManager.register(earthquakesLayer);
dataManager.register(alprCamerasLayer);
dataManager.register(satellitesLayer);
dataManager.register(rocketLaunchesLayer);
rocketLaunchesLayer.attachDataManager(dataManager);
+11 -14
View File
@@ -136,9 +136,7 @@ export class LayerPanel {
const count = document.createElement('span');
count.className = 'data-count';
count.textContent = layer.stats.count
? this._formatCount(layer.stats.count)
: '—';
count.textContent = this._layerCountText(layer.stats);
const toggle = document.createElement('button');
toggle.type = 'button';
@@ -202,7 +200,9 @@ export class LayerPanel {
const chip = this._rowControlsFor(layer.id)?.chips?.find(
(entry) => entry.id === button.dataset.chipId,
);
if (chip?.params)
if (!chip || chip.disabled || !this.isEnabled(layer.id)) return;
if (typeof chip.onClick === 'function') chip.onClick();
else if (chip.params)
this.setLayerParams(layer.id, chip.params, { origin: 'user' });
});
row.appendChild(controls);
@@ -213,13 +213,12 @@ export class LayerPanel {
}
}
/**
* Read a layer's optional row-control descriptor, tolerating a throw so one
* misbehaving layer cannot blank the whole panel. Resolved from the registry
* rather than the `getAll()` projection, which deliberately omits `module`.
* @param {string} layerId Registered layer id.
* @returns {{ chips?: Array<object>, legend?: Array<object> }|null} Descriptor.
*/
/** Qualify a loaded count when it does not mean items currently on screen. */
_layerCountText(stats) {
if (typeof stats.countLabel === 'string' && stats.countLabel.trim())
return stats.countLabel;
return stats.count ? this._formatCount(stats.count) : '—';
}
/**
* Render a layer's row chips and color legend, and keep the whole block
@@ -307,9 +306,7 @@ export class LayerPanel {
const count = row.querySelector('.data-count');
if (count) {
count.textContent = layer.stats.count
? this._formatCount(layer.stats.count)
: '—';
count.textContent = this._layerCountText(layer.stats);
}
const meta = row.querySelector('.data-toggle-meta');
+19
View File
@@ -299,6 +299,25 @@ body.cockpit-mode #cockpit-cloud-effects.active {
color: rgba(232, 234, 237, 0.7);
}
/* Keep the ALPR source overflow reachable even after its brief on-map credit
collapses. Wrap whole entries only while the optional layer is on. */
body:has(.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-btn.active)
#cesium-credits {
white-space: normal;
}
body:has(.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-btn.active)
#cesium-credits
.cesium-credit-wrapper {
display: inline-block !important;
white-space: nowrap;
}
body:has(.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-btn.active)
#cesium-credits
.cesium-credit-expand-link {
/* Keep the complete link at the left edge, clear of the Power Up button. */
display: block !important;
}
/* Cesium's stock attribution lightbox grows with every registered data source.
Keep the title and close affordance fixed while the complete, unabridged
credit list wraps and scrolls inside a compact panel. */
+8
View File
@@ -1273,3 +1273,11 @@
#global-context-panel #cockpit-entry kbd {
padding: 2px 4px;
}
/* Qualified nearby counts must also fit beside the wider loading chip. */
.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-top {
flex-wrap: wrap;
}
.data-toggle-row[data-layer-id='alpr-cameras'] .data-toggle-right {
margin-left: auto;
}
+6
View File
@@ -177,6 +177,12 @@ const LAYER_ALIASES = new Map([
['firms', 'local-firms'],
['fires', 'local-firms'],
['active fires', 'local-firms'],
['alpr', 'alpr-cameras'],
['alpr cameras', 'alpr-cameras'],
['flock cameras', 'alpr-cameras'],
['license plate readers', 'alpr-cameras'],
['license plate cameras', 'alpr-cameras'],
['plate readers', 'alpr-cameras'],
]);
const CITY_ALIASES = new Map([
+24
View File
@@ -3140,3 +3140,27 @@ test('voice Radio: the keyless path applies no country filter the keyed path wou
const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ });
function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options }); }
function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); }
test('ALPR common names toggle only the registered camera layer through the normal voice action', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const viewer = { clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: { moveEnd: { addEventListener() {} } } };
const calls = [];
let enabled = false;
const dataManager = {
layers: new Map([['alpr-cameras', { module: {} }]]),
getAll: () => [{ id: 'alpr-cameras', name: 'ALPR Cameras' }],
isEnabled: () => enabled,
setEnabled: async (id, value) => { calls.push([id, value]); enabled = value; return true; },
};
const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager });
for (const alias of ['alpr-cameras', 'alpr', 'alpr cameras', 'flock cameras', 'license plate readers', 'license plate cameras', 'plate readers']) {
for (const value of [true, false]) {
const result = await runner('set_layer_visibility', { layerId: alias, enabled: value });
assert.equal(result.ok, true);
assert.equal(result.layerId, 'alpr-cameras');
assert.deepEqual(calls.at(-1), ['alpr-cameras', value]);
}
}
});