mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix: keep ALPR camera markers stable during rotation
This commit is contained in:
1 parent
277cd45154
commit
9547658dc7
6 files changed
+251
-9
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user