diff --git a/CHANGELOG.md b/CHANGELOG.md index ffaeed332..5ba7cb4c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## ALPR camera locations +- 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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d3cd03af6..9b3b51821 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,6 +9,12 @@ 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`. +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 diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 89a77426c..0970f1920 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -931,3 +931,154 @@ test('two factories keep their requests, records and destruction independent', a a.restore(); } }); + +test('orbit cache hits and metadata refreshes preserve marker geometry and selection events', async () => { + const h = cameraHarness(); + try { + h.setFetch(async () => + cameraResponse([ + cameraNode(42, { + tags: { 'surveillance:type': 'ALPR', 'camera:direction': '90' }, + }), + ]), + ); + await alprCamerasLayer.update(); + h.click('alpr:42'); + const entity = h.source.entities.getById('alpr:42'); + const position = entity.position, + line = entity.polyline; + let selections = 0, + clears = 0, + collectionChanges = 0; + window.addEventListener('gev:entity-selected', () => selections++); + window.addEventListener('gev:entity-selection-cleared', () => clears++); + h.source.entities.collectionChanged.addEventListener( + (_collection, added, removed) => { + collectionChanges += added.length + removed.length; + }, + ); + h.setBox({ south: 30.261, west: -97.749, north: 30.279, east: -97.731 }); + await alprCamerasLayer.update(); + h.expire(); + h.setFetch(async () => + cameraResponse([ + cameraNode(42, { + tags: { + 'surveillance:type': 'ALPR', + 'camera:direction': '90', + operator: 'Updated directory', + }, + }), + ]), + ); + await alprCamerasLayer.update(); + assert.equal(h.source.entities.getById('alpr:42'), entity); + assert.equal(entity.position, position); + assert.equal(entity.polyline, line); + assert.equal( + getSelectedEntityContext().properties.operator, + 'Updated directory', + ); + assert.equal(selections, 0); + assert.equal(clears, 0); + assert.equal(collectionChanges, 0); + assert.equal(h.requests.length, 2, 'only expiry needs another request'); + h.expire(); + h.setFetch(async () => cameraResponse([cameraNode(42, { lon: -97.742 })])); + await alprCamerasLayer.update(); + assert.equal(h.source.entities.getById('alpr:42'), entity); + assert.equal( + entity.polyline, + undefined, + 'removed direction must not persist', + ); + assert.ok( + Cesium.Cartesian3.equals( + entity.position.getValue(), + Cesium.Cartesian3.fromDegrees(-97.742, 30.2672), + ), + ); + } finally { + h.restore(); + } +}); + +test('moves inside a pending query do not restart it; moving to another city cancels it', async () => { + const h = cameraHarness(); + try { + let resolve; + h.setFetch( + () => + new Promise((r) => { + resolve = r; + }), + ); + const first = alprCamerasLayer.update(); + const signal = h.requests[0][1].signal; + h.setBox({ south: 30.261, west: -97.749, north: 30.279, east: -97.731 }); + await alprCamerasLayer.update(); + assert.equal(h.requests.length, 1); + assert.equal(signal.aborted, false); + assert.equal(alprCamerasLayer.getStats().loading, true); + const finishFirst = resolve; + h.setBox({ south: 51.49, west: -0.14, north: 51.5, east: -0.12 }); + const second = alprCamerasLayer.update(); + assert.equal(signal.aborted, true); + assert.equal(h.requests.length, 2); + finishFirst(cameraResponse()); + await first; + assert.equal( + alprCamerasLayer.getStats().loading, + true, + 'late old response cannot finish current request', + ); + resolve(cameraResponse([cameraNode(43, { lat: 51.495, lon: -0.13 })])); + await second; + assert.deepEqual( + h.source.entities.values.map((e) => e.id), + ['alpr:43'], + ); + } finally { + h.restore(); + } +}); + +test('ground-centered orbits ignore horizon rectangles while sky, distant and dateline views stay bounded', async () => { + const h = cameraHarness(); + const camera = h.viewer.camera; + const focus = Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672); + h.viewer.scene.canvas.clientWidth = 1440; + h.viewer.scene.canvas.clientHeight = 900; + camera.pickEllipsoid = () => focus; + camera.positionWC = Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 1200); + try { + await alprCamerasLayer.update(); + const entity = h.source.entities.getById('alpr:42'); + h.click('alpr:42'); + for (const box of [null, { south: 31, west: -99, north: 36, east: -95 }]) { + h.setBox(box); + await alprCamerasLayer.update(); + assert.equal(h.source.entities.getById('alpr:42'), entity); + assert.equal(getSelectedEntityContext().id, 'alpr:42'); + assert.equal(h.requests.length, 1); + } + camera.positionWC = Cesium.Cartesian3.fromDegrees( + -97.7431, + 30.2672, + 800000, + ); + await alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().status, 'zoom-in'); + assert.equal(h.source.entities.values.length, 0); + camera.pickEllipsoid = () => undefined; + await alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().status, 'zoom-in'); + camera.pickEllipsoid = () => Cesium.Cartesian3.fromDegrees(179.999, 0); + camera.positionWC = Cesium.Cartesian3.fromDegrees(179.999, 0, 1200); + await alprCamerasLayer.update(); + assert.equal(alprCamerasLayer.getStats().status, 'zoom-in'); + assert.equal(h.requests.length, 1); + } finally { + h.restore(); + } +}); diff --git a/src/data/contextStore.js b/src/data/contextStore.js index 6217c010c..81ca311d0 100644 --- a/src/data/contextStore.js +++ b/src/data/contextStore.js @@ -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; diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index d89913499..005ecc6cc 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -25,6 +25,9 @@ import { createAlprPresentation } from './presentation.js'; * 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') @@ -52,6 +55,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { saturated: false, loading: false, abort: null, + pendingQueryBox: null, retryTimer: null, retryDelayMs: 0, retryAt: 0, @@ -122,6 +126,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { if (!box) { state.abort?.abort(); state.abort = null; + state.pendingQueryBox = null; state.loading = false; state.retrying = false; clearUnavailableRetry(); @@ -146,6 +151,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { if (state.abort) { state.abort.abort(); state.abort = null; + state.pendingQueryBox = null; state.loading = false; } renderRecords(); @@ -155,10 +161,16 @@ export function createAlprCamerasLayer({ source, services } = {}) { ); 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'); @@ -209,6 +221,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { } finally { if (state.abort === requestAbort) { state.abort = null; + state.pendingQueryBox = null; state.loading = false; state.retrying = false; governorRequestRender('alpr-status'); @@ -253,6 +266,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { 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; diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index 9226e74a5..8e335e1ae 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -25,7 +25,43 @@ export function createAlprPresentation({ state, services, source }) { 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, ); @@ -91,8 +127,19 @@ export function createAlprPresentation({ state, services, source }) { clearSelectedEntityContextForLayer(LAYER_ID); } governorRequestRender('alpr-render'); - clearRendered(); + 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( @@ -128,12 +175,32 @@ export function createAlprPresentation({ state, services, source }) { clampToGround: true, }; } - const entity = state.dataSource.entities.add(entityDef); + 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.gevAlprDisplayPosition ??= null; entity.gevDisplayPosition = () => entity.gevAlprDisplayPosition; entity.gevLabelModel = { title: 'ALPR CAMERA', @@ -171,9 +238,7 @@ export function createAlprPresentation({ state, services, source }) { const selectedEntity = state.selectedId ? state.dataSource.entities.getById(state.selectedId) : null; - if (selectedEntity) selectEntityContext(selectedEntity); - else state.selectedId = null; - state.lastAnchorSampleAt = 0; + 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.