fix: keep ALPR camera markers stable during rotation

This commit is contained in:
Sameh Khamis committed 2026-09-13 20:04:38 -07:00
1 parent 277cd45154
commit 9547658dc7
6 files changed
+251 -9

No files matched your search

+151
View File
@@ -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();
}
});
+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;
+14
View File
@@ -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<string> }), 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;
+71 -6
View File
@@ -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.