mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +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';
|
||||
Reference in new issue
Block a user