mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
- Voice layer manifest (src/voice/layerManifest.js) generates the layer enums, aliases and analyst field hints; a test fails when a catalog layer has no entry. - analyst_query validates fields, operators, units, values and scopes, counts up to 250,000 loaded records per layer, reports complete/partial/unanswered, tells LAYER_OFF / NOT_READY / FEED_UNAVAILABLE apart from zero, refuses mismatched follow-ups and never lets a superseded query replace follow-up memory. Seven more layers expose analyst records. - Tool results carry a code-built say line and a display card; the voice card in the mic control shows captions, plan steps and the result (src/voice/resultDisplay.js is the one adapter). Slow tools narrate their current step out of band; holding Space while the assistant talks cuts it off. Turn spans go to the debug log; caption transcription is metered; the log omits what was said unless GEV_VOICE_LOG_CONTENT=1. - Point and ask: the Space-down pointer snapshot resolves "this", "that one" and "around here" through 'pointer' targets, and "the second one" through referent:n from the last numbered list. - Prompt states each policy once (instructions + tools 48.5k -> 46.7k chars). - Voice benchmark harness (scripts/qa-voice-bench.mjs, scripts/voice-bench/) shares its phrase table and grading with scripts/qa-voice-routing.mjs.
493 lines
17 KiB
JavaScript
493 lines
17 KiB
JavaScript
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';
|
|
|
|
/**
|
|
* 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<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 { 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: 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');
|
|
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: '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: 'Camera badges',
|
|
color: ALPR_COLOR,
|
|
count,
|
|
blurb:
|
|
'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<Object>} {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'
|
|
? `${state.dataSource?.entities.values.length || 0} nearby${
|
|
Number.isInteger(state.onScreen)
|
|
? ` · ${state.onScreen} on screen`
|
|
: ''
|
|
}`
|
|
: '',
|
|
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
|
|
? 'retrying mapped ALPR cameras'
|
|
: 'loading mapped ALPR cameras'
|
|
: state.noCoverage
|
|
? 'No ALPR data for this area — US and Canada only'
|
|
: state.status === 'zoom-in'
|
|
? 'Zoom in to load mapped cameras'
|
|
: [
|
|
state.stale ? 'Showing cached locations' : '',
|
|
state.saturated || state.renderSaturated
|
|
? 'Coverage limited — zoom in'
|
|
: '',
|
|
state.status === 'empty' ? 'No ALPR data for this area' : '',
|
|
state.status === 'ready' && state.onScreen === 0
|
|
? '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';
|