mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
feat: add mapped ALPR cameras with bounded source loading
Adapt the ALPR camera contribution to reusable layer components, retaining source attribution, bounded queries, selection and voice controls. Co-authored-by: Bilawal Sidhu <[email protected]>
This commit is contained in:
1 parent
ba7fda74d7
commit
4374c0fa60
28 files changed
+2358
-100
No files matched your search
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
createAlprCamerasLayer,
|
||||
createOverpassAlprSource,
|
||||
} from '../layers/alpr/index.js';
|
||||
import * as render from '../renderGovernor.js';
|
||||
import * as context from './contextStore.js';
|
||||
import * as picking from './pickRegistry.js';
|
||||
import * as groundFloor from './groundFloor.js';
|
||||
export * from '../layers/alpr/index.js';
|
||||
export default createAlprCamerasLayer({
|
||||
source: createOverpassAlprSource(),
|
||||
services: { render, context, picking, groundFloor },
|
||||
});
|
||||
@@ -0,0 +1,915 @@
|
||||
// src/data/alprCameras.test.mjs
|
||||
// Pure helpers and actual layer lifecycle with a minimal viewer/input harness.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import alprCamerasLayer, { createAlprCamerasLayer } from './alprCameras.js';
|
||||
import { DATA_CREDITS } from './dataCredits.js';
|
||||
import {
|
||||
clearSelectedEntityContextForLayer,
|
||||
getContextStore,
|
||||
getSelectedEntityContext,
|
||||
registerEntityContext,
|
||||
selectEntityContext,
|
||||
} from './contextStore.js';
|
||||
import {
|
||||
MAX_RENDERED,
|
||||
QUERY_LIMIT,
|
||||
QUERY_SNAP_DEGREES,
|
||||
QUERY_REUSE_MS,
|
||||
alprRetryDelayMs,
|
||||
boxContains,
|
||||
buildOverpassQuery,
|
||||
snapAlprBox,
|
||||
destinationPointDeg,
|
||||
isAlprSurveillanceType,
|
||||
normalizeAlprNode,
|
||||
} from './alprCameras.js';
|
||||
|
||||
test('normalizeAlprNode: maps a full OSM node to a plain record', () => {
|
||||
const record = normalizeAlprNode({
|
||||
type: 'node',
|
||||
id: 12345,
|
||||
lat: 30.2672,
|
||||
lon: -97.7431,
|
||||
tags: {
|
||||
man_made: 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
operator: 'Flock Safety',
|
||||
'camera:type': 'fixed',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:direction': '270',
|
||||
ref: 'ATX-001',
|
||||
check_date: '2026-06-01',
|
||||
},
|
||||
});
|
||||
assert.deepEqual(record, {
|
||||
id: 'alpr:12345',
|
||||
osmId: 12345,
|
||||
latitude: 30.2672,
|
||||
longitude: -97.7431,
|
||||
operator: 'Flock Safety',
|
||||
manufacturer: null,
|
||||
cameraType: 'fixed',
|
||||
zone: 'traffic',
|
||||
directionDeg: 270,
|
||||
ref: 'ATX-001',
|
||||
lastVerified: '2026-06-01',
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeAlprNode: rejects non-node elements and missing coordinates', () => {
|
||||
assert.equal(normalizeAlprNode(null), null);
|
||||
assert.equal(normalizeAlprNode({ type: 'way', id: 1, tags: {} }), null);
|
||||
assert.equal(
|
||||
normalizeAlprNode({ type: 'node', id: 1, lat: NaN, lon: 1, tags: {} }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeAlprNode: a present-but-blank direction tag reads as missing, not zero', () => {
|
||||
const record = normalizeAlprNode({
|
||||
type: 'node',
|
||||
id: 1,
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
tags: {
|
||||
man_made: 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
'camera:direction': '',
|
||||
},
|
||||
});
|
||||
assert.equal(record.directionDeg, null);
|
||||
});
|
||||
|
||||
test('destinationPointDeg: due-north offset increases latitude, keeps longitude', () => {
|
||||
const dest = destinationPointDeg(30, -97, 0, 100);
|
||||
assert.ok(dest.latitude > 30);
|
||||
assert.ok(Math.abs(dest.longitude - -97) < 1e-6);
|
||||
});
|
||||
|
||||
test('alprRetryDelayMs: doubles from a 30s floor to a 240s ceiling', () => {
|
||||
assert.equal(alprRetryDelayMs(0), 30000);
|
||||
assert.equal(alprRetryDelayMs(30000), 60000);
|
||||
assert.equal(alprRetryDelayMs(200000), 240000);
|
||||
assert.equal(alprRetryDelayMs(240000), 240000);
|
||||
});
|
||||
|
||||
test('isAlprSurveillanceType: accepts semicolon multi-values and mixed case, rejects other cameras', () => {
|
||||
assert.equal(isAlprSurveillanceType('ALPR'), true);
|
||||
assert.equal(isAlprSurveillanceType('alpr'), true);
|
||||
assert.equal(isAlprSurveillanceType('camera;ALPR'), true);
|
||||
assert.equal(isAlprSurveillanceType('ALPR; camera'), true);
|
||||
assert.equal(isAlprSurveillanceType('camera'), false);
|
||||
assert.equal(isAlprSurveillanceType('ALPRX'), false);
|
||||
assert.equal(isAlprSurveillanceType(undefined), false);
|
||||
});
|
||||
|
||||
test('normalizeAlprNode: keeps a camera;ALPR multi-value node and drops a plain camera', () => {
|
||||
const multi = normalizeAlprNode({
|
||||
type: 'node',
|
||||
id: 2,
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
tags: { man_made: 'surveillance', 'surveillance:type': 'camera;ALPR' },
|
||||
});
|
||||
assert.equal(multi?.id, 'alpr:2');
|
||||
const plain = normalizeAlprNode({
|
||||
type: 'node',
|
||||
id: 3,
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
tags: { man_made: 'surveillance', 'surveillance:type': 'camera' },
|
||||
});
|
||||
assert.equal(plain, null);
|
||||
});
|
||||
|
||||
test('buildOverpassQuery: matches multi-value tags case-insensitively and asks for the full cap', () => {
|
||||
const query = buildOverpassQuery(30, -98, 31, -97);
|
||||
assert.match(
|
||||
query,
|
||||
/\["surveillance:type"~"[^"]*ALPR[^"]*",i\]/,
|
||||
'regex, case-insensitive match',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
query,
|
||||
/"surveillance:type"="ALPR"/,
|
||||
'no exact-only match',
|
||||
);
|
||||
assert.ok(query.endsWith(`out body ${QUERY_LIMIT};`));
|
||||
assert.ok(
|
||||
QUERY_LIMIT > 0 && QUERY_LIMIT <= 1500,
|
||||
'bounded query, not a global camera catalog',
|
||||
);
|
||||
assert.ok(
|
||||
MAX_RENDERED >= QUERY_LIMIT,
|
||||
'a render cap below the query cap silently hides cameras while count reports them',
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeAlprNode: a whitespace-only direction tag reads as missing, not due north', () => {
|
||||
const record = normalizeAlprNode({
|
||||
type: 'node',
|
||||
id: 9,
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
tags: {
|
||||
man_made: 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
'camera:direction': ' ',
|
||||
},
|
||||
});
|
||||
assert.equal(record.directionDeg, null);
|
||||
});
|
||||
|
||||
test('snapAlprBox: snaps outward to the query grid and clamps to valid ranges', () => {
|
||||
const snapped = snapAlprBox({
|
||||
south: 30.2671,
|
||||
west: -97.7431,
|
||||
north: 30.3012,
|
||||
east: -97.7002,
|
||||
});
|
||||
assert.deepEqual(snapped, {
|
||||
south: 30.25,
|
||||
west: -97.75,
|
||||
north: 30.35,
|
||||
east: -97.7,
|
||||
});
|
||||
assert.ok(
|
||||
snapped.south <= 30.2671 &&
|
||||
snapped.north >= 30.3012 &&
|
||||
snapped.west <= -97.7431 &&
|
||||
snapped.east >= -97.7002,
|
||||
);
|
||||
assert.equal(
|
||||
snapAlprBox({ south: 89.99, west: 179.99, north: 90, east: 180 }).north,
|
||||
90,
|
||||
);
|
||||
assert.equal(
|
||||
snapAlprBox({ south: -90, west: -180, north: -89.99, east: -179.99 }).west,
|
||||
-180,
|
||||
);
|
||||
assert.ok(
|
||||
QUERY_SNAP_DEGREES > 0 && QUERY_SNAP_DEGREES <= 0.1,
|
||||
'grid stays a fraction of a metro so reuse cannot over-fetch',
|
||||
);
|
||||
});
|
||||
|
||||
test('boxContains: a nudged view stays inside its snapped box; a real pan leaves it', () => {
|
||||
const outer = snapAlprBox({
|
||||
south: 30.2671,
|
||||
west: -97.7431,
|
||||
north: 30.3012,
|
||||
east: -97.7002,
|
||||
});
|
||||
assert.equal(
|
||||
boxContains(outer, {
|
||||
south: 30.27,
|
||||
west: -97.74,
|
||||
north: 30.3,
|
||||
east: -97.71,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
boxContains(outer, {
|
||||
south: 30.36,
|
||||
west: -97.74,
|
||||
north: 30.4,
|
||||
east: -97.71,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(boxContains(null, outer), false);
|
||||
});
|
||||
|
||||
const cameraNode = (id = 42, extra = {}) => ({
|
||||
type: 'node',
|
||||
id,
|
||||
lat: 30.2672,
|
||||
lon: -97.7431,
|
||||
tags: {
|
||||
man_made: 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
manufacturer: 'Flock Safety',
|
||||
},
|
||||
...extra,
|
||||
});
|
||||
const cameraResponse = (elements = [cameraNode()], stale = false) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {
|
||||
get: (name) => (name === 'x-overpass-cache' && stale ? 'STALE' : null),
|
||||
},
|
||||
json: async () => ({ elements }),
|
||||
});
|
||||
|
||||
// Real layer, Cesium entities and context store. Only the DOM, upstream fetch,
|
||||
// camera rectangle and input event delivery are substituted (no GL in Node).
|
||||
function cameraHarness(layer = alprCamerasLayer) {
|
||||
const originals = {
|
||||
document: globalThis.document,
|
||||
window: globalThis.window,
|
||||
fetch: globalThis.fetch,
|
||||
now: Date.now,
|
||||
};
|
||||
const events = new EventTarget();
|
||||
globalThis.document = { addEventListener() {}, removeEventListener() {} };
|
||||
globalThis.window = events;
|
||||
let now = originals.now();
|
||||
Date.now = () => now;
|
||||
let response = async () => cameraResponse();
|
||||
const requests = [];
|
||||
globalThis.fetch = (...args) => {
|
||||
requests.push(args);
|
||||
return response(...args);
|
||||
};
|
||||
let box = { south: 30.26, west: -97.75, north: 30.28, east: -97.73 };
|
||||
let source, click, picked;
|
||||
const credits = new Set();
|
||||
const viewer = {
|
||||
creditDisplay: {
|
||||
addStaticCredit: (credit) => credits.add(credit),
|
||||
removeStaticCredit: (credit) => credits.delete(credit),
|
||||
},
|
||||
camera: {
|
||||
moveEnd: new Cesium.Event(),
|
||||
computeViewRectangle: () =>
|
||||
box
|
||||
? Cesium.Rectangle.fromDegrees(
|
||||
box.west,
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
)
|
||||
: null,
|
||||
},
|
||||
scene: {
|
||||
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
postRender: new Cesium.Event(),
|
||||
pick: () => picked,
|
||||
},
|
||||
dataSources: {
|
||||
add: (value) => {
|
||||
source = value;
|
||||
},
|
||||
remove() {},
|
||||
},
|
||||
};
|
||||
const setInput = Cesium.ScreenSpaceEventHandler.prototype.setInputAction;
|
||||
Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function (
|
||||
fn,
|
||||
type,
|
||||
modifier,
|
||||
) {
|
||||
if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK) click = fn;
|
||||
return setInput.call(this, fn, type, modifier);
|
||||
};
|
||||
try {
|
||||
layer.init(viewer);
|
||||
} finally {
|
||||
Cesium.ScreenSpaceEventHandler.prototype.setInputAction = setInput;
|
||||
}
|
||||
layer.enable();
|
||||
return {
|
||||
viewer,
|
||||
requests,
|
||||
credits,
|
||||
get source() {
|
||||
return source;
|
||||
},
|
||||
setFetch(fn) {
|
||||
response = fn;
|
||||
},
|
||||
setBox(value) {
|
||||
box = value;
|
||||
},
|
||||
expire() {
|
||||
now += QUERY_REUSE_MS + 1;
|
||||
},
|
||||
advance(ms) {
|
||||
now += ms;
|
||||
},
|
||||
click(id) {
|
||||
const entity = typeof id === 'string' ? source.entities.getById(id) : id;
|
||||
picked = entity ? { id: entity } : undefined;
|
||||
click({ position: new Cesium.Cartesian2() });
|
||||
},
|
||||
restore() {
|
||||
layer.destroy(viewer);
|
||||
Date.now = originals.now;
|
||||
for (const name of ['window', 'document', 'fetch']) {
|
||||
if (originals[name] === undefined) delete globalThis[name];
|
||||
else globalThis[name] = originals[name];
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('OSM attribution introduces displayed data for five seconds, then stays discoverable in the overflow', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
0,
|
||||
'do not use up the introduction while data is still loading',
|
||||
);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.credits.size, 1);
|
||||
const [credit] = h.credits;
|
||||
assert.equal(credit.showOnScreen, true);
|
||||
assert.match(credit.html, /class="gev-alpr-credit">ALPR:/);
|
||||
assert.match(
|
||||
credit.html,
|
||||
/href="https:\/\/www.openstreetmap.org\/copyright"/,
|
||||
);
|
||||
assert.match(credit.html, /© OpenStreetMap<\/a>/);
|
||||
t.mock.timers.tick(4999);
|
||||
assert.equal(h.credits.size, 1);
|
||||
t.mock.timers.tick(1);
|
||||
assert.equal(h.credits.size, 0);
|
||||
const overflow = DATA_CREDITS.find((entry) => entry.key === 'alpr-osm');
|
||||
assert.match(
|
||||
overflow.html,
|
||||
/ALPR camera locations \(automatic license plate readers\)/,
|
||||
);
|
||||
assert.match(
|
||||
overflow.html,
|
||||
/href="https:\/\/www.openstreetmap.org\/copyright"/,
|
||||
);
|
||||
assert.match(overflow.html, /ODbL 1\.0/);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
0,
|
||||
'refresh must not keep reintroducing the credit',
|
||||
);
|
||||
alprCamerasLayer.disable();
|
||||
assert.equal(h.credits.size, 0);
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
t.mock.timers.tick(4000);
|
||||
alprCamerasLayer.enable();
|
||||
assert.equal(h.credits.size, 1);
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
0,
|
||||
'duplicate enable must not restart the timer',
|
||||
);
|
||||
alprCamerasLayer.disable();
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.credits.size, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
assert.equal(h.credits.size, 0);
|
||||
t.mock.timers.tick(5000);
|
||||
assert.equal(h.credits.size, 0, 'destroy cancels the presentation timer');
|
||||
});
|
||||
|
||||
test('disabling midway through the introduction cancels its timer before a new enable', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
await alprCamerasLayer.update();
|
||||
t.mock.timers.tick(3000);
|
||||
alprCamerasLayer.disable();
|
||||
assert.equal(h.credits.size, 0);
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
t.mock.timers.tick(2000);
|
||||
assert.equal(
|
||||
h.credits.size,
|
||||
1,
|
||||
'old deadline must not hide the new presentation',
|
||||
);
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(h.credits.size, 0);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('all manufacturers share one ALPR title and color; only supplied metadata appears underneath', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
h.setFetch(async () =>
|
||||
cameraResponse([
|
||||
cameraNode(42, {
|
||||
tags: {
|
||||
'surveillance:type': 'ALPR',
|
||||
manufacturer: 'Flock Safety',
|
||||
operator: 'City Police',
|
||||
'camera:type': 'fixed',
|
||||
},
|
||||
}),
|
||||
cameraNode(43, {
|
||||
tags: {
|
||||
'surveillance:type': 'ALPR',
|
||||
manufacturer: 'Motorola Solutions',
|
||||
},
|
||||
}),
|
||||
cameraNode(44, { tags: { 'surveillance:type': 'ALPR' } }),
|
||||
]),
|
||||
);
|
||||
await alprCamerasLayer.update();
|
||||
const entities = h.source.entities.values;
|
||||
assert.equal(alprCamerasLayer.name, 'ALPR Cameras');
|
||||
assert.deepEqual(
|
||||
entities.map((entity) => entity.gevLabelModel.title),
|
||||
Array(3).fill('ALPR CAMERA'),
|
||||
);
|
||||
assert.equal(
|
||||
new Set(
|
||||
entities.map((entity) =>
|
||||
entity.point.color.getValue().toCssColorString(),
|
||||
),
|
||||
).size,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(entities[0].gevLabelModel.details, [
|
||||
'Manufacturer: Flock Safety',
|
||||
'Operator: City Police',
|
||||
'Type: fixed',
|
||||
'Source: OpenStreetMap',
|
||||
]);
|
||||
assert.deepEqual(entities[1].gevLabelModel.details, [
|
||||
'Manufacturer: Motorola Solutions',
|
||||
'Source: OpenStreetMap',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
entities[2].gevLabelModel.details,
|
||||
['Source: OpenStreetMap'],
|
||||
'unknown metadata is not guessed, but the source is always named',
|
||||
);
|
||||
for (const entity of entities) {
|
||||
h.click(entity.id);
|
||||
assert.equal(getSelectedEntityContext().label, 'ALPR camera');
|
||||
assert.equal(getSelectedEntityContext().layerName, 'ALPR Cameras');
|
||||
}
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid OSM ids and out-of-range coordinates never become camera entities', () => {
|
||||
for (const id of [undefined, null, 0, -1, 1.5, '42', NaN])
|
||||
assert.equal(normalizeAlprNode(cameraNode(id, { id })), null);
|
||||
for (const coords of [
|
||||
{ lat: 95 },
|
||||
{ lat: -95 },
|
||||
{ lon: 181 },
|
||||
{ lon: -181 },
|
||||
]) {
|
||||
assert.equal(normalizeAlprNode(cameraNode(42, coords)), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking the selected camera or empty map deselects, including after refresh', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
await alprCamerasLayer.update();
|
||||
for (const next of ['alpr:42', null]) {
|
||||
h.click('alpr:42');
|
||||
assert.equal(getSelectedEntityContext()?.id, 'alpr:42');
|
||||
h.click(next);
|
||||
assert.equal(getSelectedEntityContext(), null);
|
||||
h.expire();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(getSelectedEntityContext(), null);
|
||||
assert.equal(
|
||||
h.source.entities.getById('alpr:42').point.pixelSize.getValue(),
|
||||
8,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a refresh cannot reclaim selection from another layer or an explicit clear', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
await alprCamerasLayer.update();
|
||||
h.click('alpr:42');
|
||||
const aircraft = { id: 'flight:TEST' };
|
||||
registerEntityContext(aircraft, { id: aircraft.id, layerId: 'flights' });
|
||||
selectEntityContext(aircraft);
|
||||
h.expire();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(getSelectedEntityContext()?.id, aircraft.id);
|
||||
h.click('alpr:42');
|
||||
clearSelectedEntityContextForLayer('alpr-cameras');
|
||||
h.expire();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(getSelectedEntityContext(), null);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('only a surviving selected camera keeps selection through a dataset refresh', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
await alprCamerasLayer.update();
|
||||
h.click('alpr:42');
|
||||
h.expire();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(getSelectedEntityContext()?.id, 'alpr:42');
|
||||
h.setFetch(async () => cameraResponse([cameraNode(43)]));
|
||||
h.expire();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(getSelectedEntityContext(), null);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('disable removes contexts and highlight; cache reuse after re-enable does not reselect', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
await alprCamerasLayer.update();
|
||||
h.click('alpr:42');
|
||||
alprCamerasLayer.disable();
|
||||
assert.equal(getSelectedEntityContext(), null);
|
||||
assert.equal(getContextStore().entities.size, 0);
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(getSelectedEntityContext(), null);
|
||||
assert.equal(
|
||||
h.source.entities.getById('alpr:42').point.pixelSize.getValue(),
|
||||
8,
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('retry clears the old terminal error while the new request is loading', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
h.setFetch(async () => ({ ...cameraResponse(), ok: false, status: 429 }));
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'unavailable');
|
||||
let resolve;
|
||||
h.setFetch(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
const pending = alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().loading, true);
|
||||
assert.equal(alprCamerasLayer.getStats().error, null);
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'loading');
|
||||
resolve(cameraResponse());
|
||||
await pending;
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'ready');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a cancelled older request cannot overwrite a newer request with a late network error', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
let rejectOld, resolveNew;
|
||||
h.setFetch(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
rejectOld = reject;
|
||||
}),
|
||||
);
|
||||
const old = alprCamerasLayer.update();
|
||||
h.setBox({ south: 31, west: -98, north: 31.02, east: -97.98 });
|
||||
h.setFetch(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveNew = resolve;
|
||||
}),
|
||||
);
|
||||
const fresh = alprCamerasLayer.update();
|
||||
rejectOld(new TypeError('Network error on a cancelled request'));
|
||||
await old;
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'loading');
|
||||
assert.equal(alprCamerasLayer.getStats().error, null);
|
||||
resolveNew(cameraResponse([]));
|
||||
await fresh;
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'empty');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid or partial successful payloads are failures, not an empty all-clear', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
for (const payload of [
|
||||
{},
|
||||
{ elements: null },
|
||||
{ elements: [], remark: 'runtime error: Query timed out' },
|
||||
]) {
|
||||
h.setFetch(async () => ({
|
||||
...cameraResponse(),
|
||||
json: async () => payload,
|
||||
}));
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'unavailable');
|
||||
}
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('duplicate nodes render once with a count that agrees with the entity set', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
h.setFetch(async () => cameraResponse([cameraNode(), cameraNode()]));
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'ready');
|
||||
assert.equal(alprCamerasLayer.getStats().count, 1);
|
||||
assert.equal(h.source.entities.values.length, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('zooming out hides local markers, releases selection and makes no unbounded request', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
await alprCamerasLayer.update();
|
||||
h.click('alpr:42');
|
||||
h.setBox(null);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'zoom-in');
|
||||
assert.equal(alprCamerasLayer.getStats().count, 0);
|
||||
assert.equal(getSelectedEntityContext(), null);
|
||||
assert.equal(h.source.entities.values.length, 0);
|
||||
assert.equal(h.requests.length, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('state, country and dateline views never request data; returning to the city reuses its cache', async () => {
|
||||
const h = cameraHarness();
|
||||
const city = { south: 30.26, west: -97.75, north: 30.28, east: -97.73 };
|
||||
const wideViews = [
|
||||
{ south: 26, west: -107, north: 36, east: -93 },
|
||||
{ south: 24, west: -125, north: 50, east: -66 },
|
||||
{ south: 30, west: -99, north: 31, east: -95 },
|
||||
{ south: -1, west: 179, north: 1, east: -179 },
|
||||
];
|
||||
try {
|
||||
for (const box of wideViews) {
|
||||
h.setBox(box);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.requests.length, 0, 'fresh wide view must not query');
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'zoom-in');
|
||||
assert.equal(h.credits.size, 0, 'no data presentation has occurred');
|
||||
}
|
||||
h.setBox(city);
|
||||
await alprCamerasLayer.update();
|
||||
h.click('alpr:42');
|
||||
for (const box of wideViews) {
|
||||
h.setBox(box);
|
||||
await alprCamerasLayer.update();
|
||||
const stats = alprCamerasLayer.getStats();
|
||||
assert.equal(stats.status, 'zoom-in');
|
||||
assert.equal(stats.loading, false);
|
||||
assert.equal(stats.error, null);
|
||||
assert.equal(stats.retryAt, 0);
|
||||
assert.equal(stats.count, 0);
|
||||
assert.equal(getSelectedEntityContext(), null);
|
||||
assert.equal(h.requests.length, 1, 'zoom out must not query');
|
||||
}
|
||||
h.setBox(city);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().count, 1);
|
||||
assert.equal(h.requests.length, 1, 'return uses fresh cached city data');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('nearby camera moves reuse the snapped query but count and register only in-view nodes', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
h.setFetch(async () =>
|
||||
cameraResponse([cameraNode(), cameraNode(43, { lon: -97.71 })]),
|
||||
);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().count, 1);
|
||||
h.setBox({ south: 30.26, west: -97.72, north: 30.28, east: -97.7 });
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.deepEqual(
|
||||
h.source.entities.values.map((e) => e.id),
|
||||
['alpr:43'],
|
||||
);
|
||||
assert.deepEqual([...getContextStore().entities.keys()], ['alpr:43']);
|
||||
h.expire();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.requests.length, 2);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('empty mapped coverage, stale data and a saturated response remain distinct', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
h.setFetch(async () => cameraResponse([]));
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'empty');
|
||||
assert.match(
|
||||
alprCamerasLayer.getStats().loadingLabel,
|
||||
/coverage is incomplete/,
|
||||
);
|
||||
h.expire();
|
||||
h.setFetch(async () =>
|
||||
cameraResponse(
|
||||
Array.from({ length: QUERY_LIMIT }, (_, i) => cameraNode(i + 1)),
|
||||
true,
|
||||
),
|
||||
);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'stale');
|
||||
assert.equal(alprCamerasLayer.getStats().error, null);
|
||||
assert.equal(alprCamerasLayer.getStats().saturated, true);
|
||||
assert.match(
|
||||
alprCamerasLayer.getStats().loadingLabel,
|
||||
/cached.*Coverage limited/,
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('selected readout samples the rendered ground, not zero elevation or the direction line midpoint', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
let samples = 0;
|
||||
h.viewer.scene.sampleHeightSupported = true;
|
||||
h.viewer.scene.sampleHeight = (point, excluded) => {
|
||||
samples += 1;
|
||||
assert.equal(excluded[0].id, 'alpr:42');
|
||||
return 187;
|
||||
};
|
||||
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 anchor = Cesium.Cartographic.fromCartesian(
|
||||
entity.gevDisplayPosition(),
|
||||
);
|
||||
assert.ok(Math.abs(anchor.height - 187) < 0.001);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(anchor.longitude) + 97.7431) < 1e-7,
|
||||
);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
h.viewer.scene.postRender.raiseEvent();
|
||||
entity.gevDisplayPosition();
|
||||
}
|
||||
assert.equal(samples, 1, 'no per-frame raycasts');
|
||||
h.advance(1000);
|
||||
h.viewer.scene.postRender.raiseEvent();
|
||||
assert.equal(samples, 2);
|
||||
h.click(null);
|
||||
h.advance(1000);
|
||||
h.viewer.scene.postRender.raiseEvent();
|
||||
assert.equal(samples, 2, 'no terrain sampling without a selection');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a late network failure after disable cannot resurrect retries or an error state', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
let reject;
|
||||
h.setFetch(
|
||||
() =>
|
||||
new Promise((resolve, rejectRequest) => {
|
||||
reject = rejectRequest;
|
||||
}),
|
||||
);
|
||||
const pending = alprCamerasLayer.update();
|
||||
alprCamerasLayer.disable();
|
||||
reject(new TypeError('cancelled connection'));
|
||||
await pending;
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'idle');
|
||||
assert.equal(alprCamerasLayer.getStats().error, null);
|
||||
assert.equal(alprCamerasLayer.getStats().retryAt, 0);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('two factories keep their requests, records and destruction independent', async () => {
|
||||
const services = {
|
||||
render: { governorRequestRender() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
groundFloor: {
|
||||
cachedGroundFloor() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
context: {
|
||||
clearSelectedEntityContextForLayer() {},
|
||||
getSelectedEntityContext() {
|
||||
return null;
|
||||
},
|
||||
registerEntityContext() {},
|
||||
removeEntityContextsForLayer() {},
|
||||
selectEntityContext() {},
|
||||
},
|
||||
};
|
||||
let resolveFirst, firstSignal;
|
||||
const first = createAlprCamerasLayer({
|
||||
services,
|
||||
source: {
|
||||
fetch: (_box, signal) => {
|
||||
firstSignal = signal;
|
||||
return new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
const second = createAlprCamerasLayer({
|
||||
services,
|
||||
source: {
|
||||
fetch: async () => ({ elements: [cameraNode(99)], stale: false }),
|
||||
},
|
||||
});
|
||||
const a = cameraHarness(first),
|
||||
b = cameraHarness(second);
|
||||
try {
|
||||
const pending = first.update();
|
||||
await second.update();
|
||||
first.destroy();
|
||||
assert.equal(firstSignal.aborted, true);
|
||||
resolveFirst({ elements: [cameraNode(42)], stale: false });
|
||||
await pending;
|
||||
assert.equal(first.getStats().count, 0);
|
||||
assert.equal(second.getStats().count, 1);
|
||||
assert.equal(b.source.entities.values[0].id, 'alpr:99');
|
||||
assert.equal(a.viewer.camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(a.viewer.scene.postRender.numberOfListeners, 0);
|
||||
} finally {
|
||||
b.restore();
|
||||
a.restore();
|
||||
}
|
||||
});
|
||||
@@ -81,6 +81,14 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
key: 'alpr-osm',
|
||||
html:
|
||||
'ALPR camera locations (automatic license plate readers): ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL 1.0</a>); ' +
|
||||
'community mapping includes <a href="https://deflock.org" target="_blank" rel="noopener">DeFlock</a>',
|
||||
},
|
||||
{
|
||||
key: 'military-installations-osm',
|
||||
html:
|
||||
|
||||
@@ -276,6 +276,7 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
|
||||
*/
|
||||
export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
Object.freeze({ id: 'ais-live-vessels', token: 'a', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'alpr-cameras', token: 'p', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'bikeshare', token: 'b', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'cctv', token: 'c', disposition: 'enabled+options', optionOwner: 'cctv' }),
|
||||
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
|
||||
|
||||
@@ -156,8 +156,8 @@ function encode(state) {
|
||||
|
||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||
assert.equal(validateLayerStateRegistry(), true);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 16);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 16);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 17);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 17);
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.throws(
|
||||
() => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
|
||||
|
||||
@@ -194,6 +194,9 @@ export function getActiveTrackedReadoutId() {
|
||||
return _activeEntryId;
|
||||
}
|
||||
|
||||
/** Static-context layers whose click selection publishes the readout card. */
|
||||
const READOUT_CONTEXT_LAYERS = new Set(['military-installations', 'alpr-cameras']);
|
||||
|
||||
/**
|
||||
* Initialize the model bridge and selection listeners. No render listener is
|
||||
* installed; the already-initialized world-overlay host owns the frame lane.
|
||||
@@ -210,7 +213,7 @@ export function initTrackedReadout(viewer) {
|
||||
}) || null;
|
||||
_contextSelectedHandler = (event) => {
|
||||
const record = event.detail;
|
||||
if (record?.layerId === 'military-installations') {
|
||||
if (READOUT_CONTEXT_LAYERS.has(record?.layerId)) {
|
||||
_selectedContext = record;
|
||||
publishEntity(record.entity);
|
||||
return;
|
||||
|
||||
@@ -198,6 +198,18 @@ test('selection lifecycle ignores vessels, accepts installations, and clears wit
|
||||
assert.equal(getActiveTrackedReadoutId(), 'installations:fort-test');
|
||||
assert.equal(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].title, 'FORT TEST');
|
||||
|
||||
// ALPR cameras are static context too: a click publishes the same card.
|
||||
const camera = {
|
||||
gevTrackedId: 'alpr:42',
|
||||
gevDisplayPosition: () => ({ x: 4, y: 5, z: 6 }),
|
||||
gevLabelModel: { title: 'FLOCK SAFETY ALPR', details: ['CITY PD'], accent: '#ff66c4' },
|
||||
};
|
||||
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
||||
detail: { layerId: 'alpr-cameras', entity: camera },
|
||||
}));
|
||||
assert.equal(getActiveTrackedReadoutId(), 'alpr:42');
|
||||
assert.equal(recorder.calls.filter(({ op }) => op === 'set').at(-1).entries[0].title, 'FLOCK SAFETY ALPR');
|
||||
|
||||
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
|
||||
detail: { layerId: 'ais-live-vessels', entity: installation },
|
||||
}));
|
||||
|
||||
Reference in new issue
Block a user