mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +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
|
## 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,
|
- Add optional, source-labeled OpenStreetMap ALPR camera locations, bounded city queries,
|
||||||
cached-response and coverage notices, selection cards, share links, and voice toggles.
|
cached-response and coverage notices, selection cards, share links, and voice toggles.
|
||||||
- Separate the request adapter, camera model, presentation, and instance lifecycle.
|
- 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
|
registration supplies selection, picking, terrain and
|
||||||
render services. Selected cards and Data attribution identify OpenStreetMap.
|
render services. Selected cards and Data attribution identify OpenStreetMap.
|
||||||
Layer state and the existing voice layer tools include `alpr-cameras`.
|
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
|
Data Centers, Dams and Submarine Cables release their built Cesium data sources
|
||||||
and record references when disabled. Parsed datasets remain cached for the layer
|
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();
|
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();
|
const store = getContextStore();
|
||||||
for (const [id, record] of store.entities) {
|
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)) {
|
if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
|
||||||
store.selectedEntityId = null;
|
store.selectedEntityId = null;
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import { createAlprPresentation } from './presentation.js';
|
|||||||
* Manufacturer, operator and cameraType are optional display fields.
|
* Manufacturer, operator and cameraType are optional display fields.
|
||||||
* Source label and attribution { name, description, text, href } identify the
|
* Source label and attribution { name, description, text, href } identify the
|
||||||
* actual provider; href must be HTTPS. Provider payload parsing stays in source.
|
* 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 } = {}) {
|
export function createAlprCamerasLayer({ source, services } = {}) {
|
||||||
if (typeof source?.fetch !== 'function')
|
if (typeof source?.fetch !== 'function')
|
||||||
@@ -52,6 +55,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
saturated: false,
|
saturated: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
abort: null,
|
abort: null,
|
||||||
|
pendingQueryBox: null,
|
||||||
retryTimer: null,
|
retryTimer: null,
|
||||||
retryDelayMs: 0,
|
retryDelayMs: 0,
|
||||||
retryAt: 0,
|
retryAt: 0,
|
||||||
@@ -122,6 +126,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
if (!box) {
|
if (!box) {
|
||||||
state.abort?.abort();
|
state.abort?.abort();
|
||||||
state.abort = null;
|
state.abort = null;
|
||||||
|
state.pendingQueryBox = null;
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.retrying = false;
|
state.retrying = false;
|
||||||
clearUnavailableRetry();
|
clearUnavailableRetry();
|
||||||
@@ -146,6 +151,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
if (state.abort) {
|
if (state.abort) {
|
||||||
state.abort.abort();
|
state.abort.abort();
|
||||||
state.abort = null;
|
state.abort = null;
|
||||||
|
state.pendingQueryBox = null;
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
}
|
}
|
||||||
renderRecords();
|
renderRecords();
|
||||||
@@ -155,10 +161,16 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
);
|
);
|
||||||
return;
|
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);
|
const queryBox = snapAlprBox(box);
|
||||||
state.abort?.abort();
|
state.abort?.abort();
|
||||||
const requestAbort = new AbortController();
|
const requestAbort = new AbortController();
|
||||||
state.abort = requestAbort;
|
state.abort = requestAbort;
|
||||||
|
state.pendingQueryBox = queryBox;
|
||||||
state.loading = true;
|
state.loading = true;
|
||||||
state.retrying = state.status === 'unavailable' || state.retryDelayMs > 0;
|
state.retrying = state.status === 'unavailable' || state.retryDelayMs > 0;
|
||||||
setAlprStatus('loading');
|
setAlprStatus('loading');
|
||||||
@@ -209,6 +221,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
} finally {
|
} finally {
|
||||||
if (state.abort === requestAbort) {
|
if (state.abort === requestAbort) {
|
||||||
state.abort = null;
|
state.abort = null;
|
||||||
|
state.pendingQueryBox = null;
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.retrying = false;
|
state.retrying = false;
|
||||||
governorRequestRender('alpr-status');
|
governorRequestRender('alpr-status');
|
||||||
@@ -253,6 +266,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
clearTimeout(state.debounceTimer);
|
clearTimeout(state.debounceTimer);
|
||||||
state.abort?.abort();
|
state.abort?.abort();
|
||||||
state.abort = null;
|
state.abort = null;
|
||||||
|
state.pendingQueryBox = null;
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.retrying = false;
|
state.retrying = false;
|
||||||
if (state.dataSource) state.dataSource.show = false;
|
if (state.dataSource) state.dataSource.show = false;
|
||||||
|
|||||||
@@ -25,7 +25,43 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
return Cesium.Color.fromCssColorString(ALPR_COLOR);
|
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) {
|
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(
|
const rectangle = viewer?.camera?.computeViewRectangle(
|
||||||
viewer.scene.globe.ellipsoid,
|
viewer.scene.globe.ellipsoid,
|
||||||
);
|
);
|
||||||
@@ -91,8 +127,19 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||||
}
|
}
|
||||||
governorRequestRender('alpr-render');
|
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) {
|
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 color = markerColor();
|
||||||
const selected = record.id === state.selectedId;
|
const selected = record.id === state.selectedId;
|
||||||
const position = Cesium.Cartesian3.fromDegrees(
|
const position = Cesium.Cartesian3.fromDegrees(
|
||||||
@@ -128,12 +175,32 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
clampToGround: true,
|
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;
|
entity.gevTrackedId = record.id;
|
||||||
// The mapped camera datum has no elevation; it is not the clamped marker's
|
// The mapped camera datum has no elevation; it is not the clamped marker's
|
||||||
// visual anchor. Only the selected marker samples the rendered surface,
|
// visual anchor. Only the selected marker samples the rendered surface,
|
||||||
// at most once per second, through Cesium's public height APIs.
|
// at most once per second, through Cesium's public height APIs.
|
||||||
entity.gevAlprDisplayPosition = null;
|
entity.gevAlprDisplayPosition ??= null;
|
||||||
entity.gevDisplayPosition = () => entity.gevAlprDisplayPosition;
|
entity.gevDisplayPosition = () => entity.gevAlprDisplayPosition;
|
||||||
entity.gevLabelModel = {
|
entity.gevLabelModel = {
|
||||||
title: 'ALPR CAMERA',
|
title: 'ALPR CAMERA',
|
||||||
@@ -171,9 +238,7 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
const selectedEntity = state.selectedId
|
const selectedEntity = state.selectedId
|
||||||
? state.dataSource.entities.getById(state.selectedId)
|
? state.dataSource.entities.getById(state.selectedId)
|
||||||
: null;
|
: null;
|
||||||
if (selectedEntity) selectEntityContext(selectedEntity);
|
if (!selectedEntity) state.selectedId = null;
|
||||||
else state.selectedId = null;
|
|
||||||
state.lastAnchorSampleAt = 0;
|
|
||||||
updateSelectedAnchor();
|
updateSelectedAnchor();
|
||||||
// Start only when mapped data is actually displayed, not while an upstream
|
// Start only when mapped data is actually displayed, not while an upstream
|
||||||
// request or a zoom-in prompt could consume the five-second introduction.
|
// request or a zoom-in prompt could consume the five-second introduction.
|
||||||
|
|||||||
Reference in new issue
Block a user