import { isUnavailableCapability } from '../../sources/capability.js'; 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'; import { t, tn, msg } from '../../i18n/index.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 }, optionally with `coverage` (the bounds its * records fully cover, reused for later views inside it) and `zoom` (their * tile precision; `source.detailZoom(box)` names the zoom a view needs). 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 }), 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 { 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, /** Extract zoom of the accepted records (null for sources without tiles). */ lastQueryZoom: null, /** Increments whenever the displayed camera set changes (QA stability probe). */ renderRevision: 0, /** Cameras in view beyond the render cap. */ renderSaturated: false, }; const { initOverlay, destroyOverlay, viewportBox, clearRendered, hideOnMapCredit, presentOnMapCredit, renderRecords, selectRecord, focusNearest, clearSelection, updateSelectedAnchor, installInteraction, prepareFloors, } = 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; } /** Whether accepted records already answer `box` without a request. */ function canReuse(box) { const detailZoom = source.detailZoom?.(box) ?? null; return Boolean( state.lastQueryBox && boxContains(state.lastQueryBox, box) && (state.lastQueryZoom == null || detailZoom == null || state.lastQueryZoom >= detailZoom - 1) && state.lastUpdate && Date.now() - state.lastUpdate < QUERY_REUSE_MS && !state.stale && state.status !== 'unavailable', ); } function scheduleLoad() { if (!state.enabled) return; clearUnavailableRetry({ resetBackoff: false }); clearTimeout(state.debounceTimer); // A view the accepted tiles already cover is a local filter: show it now. // Only a view that needs a request waits out the debounce. const box = viewportBox(state.viewer); if (box && canReuse(box)) { state.debounceTimer = null; loadCameras(); return; } 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. // Reuse also needs the accepted records to be precise enough for this view: // one zoom coarser than the view would pick is a few metres, never more. if (canReuse(box)) { 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)) { // The accepted records may cover only part of the new view. Re-filtering // them during floor preparation creates an intermediate marker set. 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'); // Keep the current markers until the new view's records land, so a still // camera sees one update instead of old records re-filtered, then new ones. try { const snapshot = await source.fetch(queryBox, requestAbort.signal); if ( requestAbort.signal.aborted || state.abort !== requestAbort || !state.enabled ) return; if (snapshot.zoomIn) { state.records = []; state.recordById.clear(); state.noCoverage = false; state.lastQueryBox = null; state.lastQueryZoom = null; clearUnavailableRetry(); renderRecords(); setAlprStatus('zoom-in'); return; } const { records, stale, saturated } = validateAlprSnapshot(snapshot); // Resolve marker floors (bounded wait, warm cells answer at once) so new // markers are placed on the floor instead of clamped then moved. await prepareFloors(records); if ( requestAbort.signal.aborted || state.abort !== requestAbort || !state.enabled ) return; state.noCoverage = snapshot.noCoverage === true; 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; the render cap is judged against // the cameras in view (renderSaturated). Either way the row says so. state.saturated = saturated; // The fetched tiles cover more than the view: pans inside them are free. state.lastQueryBox = snapshot.coverage && boxContains(snapshot.coverage, queryBox) ? snapshot.coverage : queryBox; state.lastQueryZoom = Number.isInteger(snapshot.zoom) ? snapshot.zoom : null; 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', ); if (!isUnavailableCapability(error)) 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: t(source.label || msg('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'); const creditMarkup = alprCreditMarkup(source.attribution); 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); initOverlay(); 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) { source.destroy?.(); this.disable(); destroyOverlay(); 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.lastQueryZoom = 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: t('SHOW NEAREST'), title: state.viewer?.trackedEntity ? t( 'Stop following the current object before navigating to a camera', ) : t('Move to the nearest loaded camera and show its details'), disabled: !state.enabled || !count || Boolean(state.viewer?.trackedEntity), onClick: focusNearest, }, ], legend: [ { label: t('Camera badges'), color: ALPR_COLOR, count, blurb: t( 'Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.', ), }, ], }; }, /** * Snapshot loaded ALPR cameras (the nearby query box) as plain JSON-safe records for the analyst * query engine. On demand only (once per spoken query); [] while the * layer is off. * @param {number} [maxCount=Infinity] Most records to return. * @returns {Array} {id, lat, lon, operator, manufacturer, cameraType, directionDeg}. */ getAnalystRecords(maxCount = Infinity) { if (!state.enabled) return []; const result = []; for (const record of state.records || []) { if (result.length >= maxCount) break; if ( !Number.isFinite(record.latitude) || !Number.isFinite(record.longitude) ) continue; result.push({ id: record.id, lat: record.latitude, lon: record.longitude, operator: record.operator ?? null, manufacturer: record.manufacturer ?? null, cameraType: record.cameraType ?? null, directionDeg: Number.isFinite(record.directionDeg) ? record.directionDeg : null, }); } return result; }, getStats() { return { count: state.dataSource?.entities.values.length || 0, countLabel: state.enabled && !state.noCoverage && state.status !== 'zoom-in' ? Number.isInteger(state.onScreen) ? tn( state.dataSource?.entities.values.length || 0, '{count} nearby · {onScreen} on screen', '{count} nearby · {onScreen} on screen', { onScreen: state.onScreen }, ) : tn( state.dataSource?.entities.values.length || 0, '{count} nearby', '{count} nearby', ) : '', noCoverage: Boolean(state.noCoverage), lastUpdate: state.lastUpdate, stale: state.stale, saturated: state.saturated || state.renderSaturated, renderRevision: state.renderRevision, onScreen: state.onScreen ?? null, shown: state.dataSource?.entities.values.length || 0, 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 ? t('retrying mapped ALPR cameras') : t('loading mapped ALPR cameras') : state.noCoverage ? t('No ALPR data for this area — US and Canada only') : state.status === 'zoom-in' ? t('Zoom in to load mapped cameras') : [ state.stale ? t('Showing cached locations') : '', state.saturated || state.renderSaturated ? t('Coverage limited — zoom in') : '', state.status === 'empty' ? t('No ALPR data for this area') : '', state.status === 'ready' && state.onScreen === 0 ? t('None on screen — nearby cameras are outside the view') : '', ] .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, createAlprTileSource } from './source.js';