feat(alpr): render camera markers on Navara

Separate native scene ownership from shared ALPR source and selection state,
and add bounded Navara sprites with the existing direction/card overlay.
Preserve marker artwork, picking, floor retries and source attribution.

Fix lost post-render wakeups and redundant mobile rail mutations, and keep
HUD typewriter dimensions stable so static views settle without extra frames.
Add parser-fed dev/preview and legacy evidence, including retained failures.
Common W2 terrain and combined performance gates remain open.
This commit is contained in:
German Khokhlov committed 2026-10-28 21:06:08 +04:00
1 parent 9d587c1d37
commit 46b3aceafc
23 files changed
+2081 -181

No files matched your search

+1
View File
@@ -34,6 +34,7 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
// engine in their renderers: cesium only in render/cesium*.js, three and
// Navara only in render/navara*.js. The list grows with each moved layer.
const RENDERED_LAYER_DIRS = [
'src/layers/alpr/',
'src/layers/radio/',
'src/layers/bikeshare/',
'src/layers/earthquakes/',
+132
View File
@@ -0,0 +1,132 @@
import { PbfWriter } from 'pbf';
// Recorded journeys cover both a dense neighbourhood and the wider Austin view.
export const cameras = [
...Array.from({ length: 225 }, (_, i) => ({
latitude: 30.262 + (Math.floor(i / 15) - 7) * 0.003,
longitude: -97.7431 + ((i % 15) - 7) * 0.003,
})),
...Array.from({ length: 100 }, (_, i) => ({
latitude: 30.308 + (Math.floor(i / 10) - 4.5) * 0.001,
longitude: -97.77 + ((i % 10) - 4.5) * 0.001,
})),
].map((position, i) => ({
...position,
osmId: 9000000 + i,
operator: i % 3 ? 'W2 fixture' : 'Cámara pública',
brand: 'Fixture camera',
cameraType: 'fixed',
direction: i % 5 ? String((i * 37) % 360) : '',
}));
export function cameraTile(z, x, y, records = cameras) {
const extent = 4096;
const scale = 2 ** z;
const rows = records.flatMap((record) => {
const latitude = (record.latitude * Math.PI) / 180;
const tx = ((record.longitude + 180) / 360) * scale;
const ty = ((1 - Math.asinh(Math.tan(latitude)) / Math.PI) / 2) * scale;
if (Math.floor(tx) !== x || Math.floor(ty) !== y) return [];
return [
{
record,
x: Math.round((tx - x) * extent),
y: Math.round((ty - y) * extent),
},
];
});
const keys = [
'osmType',
'osmId',
'operator',
'brand',
'cameraType',
'direction',
];
const writer = new PbfWriter();
writer.writeMessage(
3,
(_, layer) => {
layer.writeVarintField(15, 2);
layer.writeStringField(1, 'cameras');
layer.writeVarintField(5, extent);
for (const key of keys) layer.writeStringField(3, key);
rows.forEach(({ record, x, y }, index) => {
for (const key of keys)
layer.writeMessage(
4,
(_, value) =>
value.writeStringField(
1,
key === 'osmType' ? 'node' : String(record[key]),
),
null,
);
layer.writeMessage(
2,
(_, feature) => {
feature.writeVarintField(1, record.osmId);
feature.writePackedVarint(
2,
keys.flatMap((_, i) => [i, index * keys.length + i]),
);
feature.writeVarintField(3, 1);
feature.writePackedVarint(4, [9, x * 2, y * 2]);
},
null,
);
});
},
null,
);
return Buffer.from(writer.finish());
}
/** Real TileJSON/MVT transport; no replacement of the layer or source parser. */
export async function installAlprFixture(page) {
await page.setRequestInterception(true);
page.on('request', (request) => {
if (request.isInterceptResolutionHandled()) return;
const url = new URL(request.url());
// The journey also asks for nearby-place labels while moving. Pin that
// auxiliary service; these runs do not test Google Places availability.
if (
['localhost', '127.0.0.1'].includes(url.hostname) &&
url.pathname === '/api/google/nearby-places'
) {
void request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ places: [] }),
});
return;
}
if (url.hostname !== 'tiles.dontgetflocked.com') {
void request.continue();
return;
}
const headers = { 'access-control-allow-origin': '*' };
if (url.pathname.endsWith('.json')) {
const country = url.pathname.includes('-ca-') ? 'ca' : 'us';
void request.respond({
status: 200,
headers,
contentType: 'application/json',
body: JSON.stringify({
tiles: [
`https://tiles.dontgetflocked.com/cameras-${country}-hourly/{z}/{x}/{y}.mvt`,
],
bounds: country === 'ca' ? [-124, 42, -63, 54] : [-160, 17, -64, 59],
}),
});
return;
}
const match = url.pathname.match(/\/(\d+)\/(\d+)\/(\d+)\.mvt$/);
void request.respond({
status: match ? 200 : 404,
headers,
contentType: 'application/x-protobuf',
body: match ? cameraTile(...match.slice(1).map(Number)) : '',
});
});
}
+9 -2
View File
@@ -573,9 +573,16 @@
"src/layers/alpr/index.js",
"src/layers/alpr/model.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/render/cesiumPresentation.js",
"src/layers/alpr/render/cesium.js",
"src/layers/alpr/render/navara.js",
"src/layers/alpr/render/navara.test.mjs",
"src/layers/alpr/render/navaraOverlay.js",
"src/layers/alpr/render/navaraOverlay.test.mjs",
"scripts/fixtures/w2/alpr.mjs",
"scripts/qa-w2-alpr.mjs",
"src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js",
"src/layers/alpr/render/cesiumOverlay.js",
"src/layers/alpr/overlay.test.mjs",
"src/layers/alpr/source.js",
"src/layers/alpr/source.test.mjs",
+9 -7
View File
@@ -1568,9 +1568,7 @@
"src/layers/alpr/index.js",
"src/layers/alpr/model.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js",
"src/layers/alpr/source.js",
"src/data/inputOwnership.js",
"src/layers/alpr/records.js",
@@ -1598,7 +1596,7 @@
"src/geo/debug.js",
"src/geo/developerError.js"
],
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
"external": ["@mapbox/vector-tile", "pbf"]
},
"radio-layer": {
"exports": ["./layers/radio"],
@@ -2032,9 +2030,10 @@
"src/layers/alpr/index.js",
"src/layers/alpr/model.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/render/cesiumPresentation.js",
"src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js",
"src/layers/alpr/render/cesiumOverlay.js",
"src/layers/alpr/render/cesium.js",
"src/layers/alpr/source.js",
"src/layers/awareness/controls.js",
"src/layers/awareness/dependencies.js",
@@ -2708,6 +2707,8 @@
"src/layers/radio/render/cesium.js",
"src/layers/firms/render/navara.js",
"src/layers/radio/render/navara.js",
"src/layers/alpr/render/navara.js",
"src/layers/alpr/render/navaraOverlay.js",
"src/layers/radio/screenClusters.js",
"src/layers/firms/render/navaraGeometry.js",
"src/layers/firms/render/navaraSprites.js",
@@ -2882,9 +2883,10 @@
"src/layers/alpr/index.js",
"src/layers/alpr/model.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/render/cesiumPresentation.js",
"src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js",
"src/layers/alpr/render/cesiumOverlay.js",
"src/layers/alpr/render/cesium.js",
"src/layers/alpr/source.js",
"src/layers/awareness/controls.js",
"src/layers/awareness/dependencies.js",
+85 -8
View File
@@ -2,7 +2,7 @@
/**
* qa-alpr-journey.mjs — ALPR cameras on a realistic Austin camera journey.
*
* Run: node scripts/qa-alpr-journey.mjs http://localhost:4173 [--headful]
* Run: node scripts/qa-alpr-journey.mjs <dev-server-url> [--fixture]
* [--passes off,on,on] [--out qa-shots/alpr-journey] [--no-assert]
*
* Drives the same eased camera path several times: a neighbourhood by Camp
@@ -14,7 +14,7 @@
*
* Per stop it records: time from the camera stopping to cameras shown (or an
* explicit "zoom in" state), the camera count and row status, rAF interval and
* Cesium render CPU time (mean / p50 / p95) while moving and while holding, and
* engine render CPU time (mean / p50 / p95) while moving and while holding, and
* marker stability while the camera is still (camera-set changes after the stop
* has settled). Asserts, unless --no-assert: neighbourhood and downtown show
* cameras within 2 s on the warm pass; the whole-city stop shows cameras or a
@@ -40,6 +40,8 @@ import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import sharp from 'sharp';
import { installAlprFixture } from './fixtures/w2/alpr.mjs';
import { redact, isExpectedPreviewSetupError } from './qa-browserEvidence.mjs';
import {
createScreencast,
frameStats,
@@ -60,12 +62,20 @@ const option = (name, fallback) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
};
const appUrl =
argv.find((arg) => /^https?:/.test(arg)) || 'http://localhost:4173';
const appUrl = argv.find((arg) => /^https?:/.test(arg));
if (!appUrl) throw new Error('An explicit dev-server URL is required');
if (
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
new URL(appUrl).port,
)
)
throw new Error('ALPR QA may not use a reserved port');
const outDir = path.resolve(option('--out', 'qa-shots/alpr-journey'));
const passes = option('--passes', 'off,on,on').split(',');
const assertOn = !argv.includes('--no-assert');
const dropRenders = argv.includes('--self-test-drop-renders');
if (dropRenders && new URL(appUrl).searchParams.get('engine') === 'navara')
throw new Error('The legacy render-drop self-test requires Cesium');
fs.mkdirSync(outDir, { recursive: true });
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const cyanCount = async (file) => {
@@ -170,6 +180,8 @@ const result = {
startedAt: new Date().toISOString(),
passes: [],
consoleErrors: [],
expectedPreviewErrors: [],
failedRequests: [],
failures: [],
};
const fail = (message) => {
@@ -179,14 +191,43 @@ const fail = (message) => {
let page;
try {
page = await browser.newPage();
if (argv.includes('--fixture')) await installAlprFixture(page);
await page.setViewport({ width: 1440, height: 900 });
await page.evaluateOnNewDocument(() =>
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
);
page.on('console', (message) => {
if (message.type() === 'error') result.consoleErrors.push(message.text());
if (
isExpectedPreviewSetupError(message, {
preview: argv.includes('--preview'),
baseUrl: appUrl,
})
) {
result.expectedPreviewErrors.push('/api/setup/status: 404');
return;
}
if (message.type() === 'error') {
let resource = '(location unavailable)';
try {
const url = new URL(message.location()?.url);
resource = url.origin + url.pathname;
} catch {
/* no resource location */
}
result.consoleErrors.push(redact(`${message.text()} — ${resource}`));
}
});
page.on('pageerror', (error) =>
result.consoleErrors.push(redact(error.message)),
);
page.on('requestfailed', (request) => {
if (result.failedRequests.length >= 200) return;
const url = new URL(request.url());
result.failedRequests.push({
resource: redact(url.origin + url.pathname),
error: request.failure()?.errorText,
});
});
page.on('pageerror', (error) => result.consoleErrors.push(error.message));
const url = new URL(appUrl);
url.searchParams.set('welcome', '0');
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 });
@@ -252,6 +293,8 @@ try {
await page
.waitForFunction(
() => {
if (window.__godsEyeView.engine.kind === 'navara')
return window.__godsEyeView.engine.surface.tilesLoaded();
const { scene } = window.__godsEyeView.viewer;
if (scene.globe.show) return scene.globe.tilesLoaded;
for (let i = 0; i < scene.primitives.length; i++) {
@@ -308,8 +351,31 @@ try {
// changed state without asking for a render.
let staleFrame = null;
if (alpr) {
const shown = await cyanCount(await page.screenshot({ type: 'png' }));
const shownImage = await page.screenshot({ type: 'png' });
const shown = await cyanCount(shownImage);
await page.evaluate(async () => {
const { engine } = window.__godsEyeView;
if (engine.kind === 'navara') {
await new Promise((resolve, reject) => {
let count = 4;
const timeout = setTimeout(() => {
remove();
reject(new Error('ALPR forced-frame deadline'));
}, 10000);
const remove = engine.frames.postRender.addEventListener(() => {
if (--count === 0) {
remove();
clearTimeout(timeout);
resolve();
} else
requestAnimationFrame(() =>
engine.render.request('alpr-qa-frame'),
);
});
engine.render.request('alpr-qa-frame');
});
return;
}
const { scene } = window.__godsEyeView.viewer;
const mode = scene.requestRenderMode;
scene.requestRenderMode = false;
@@ -317,12 +383,23 @@ try {
await new Promise((resolve) => requestAnimationFrame(resolve));
scene.requestRenderMode = mode;
});
const forced = await cyanCount(await page.screenshot({ type: 'png' }));
const forcedImage = await page.screenshot({ type: 'png' });
const forced = await cyanCount(forcedImage);
staleFrame = {
shown,
forced,
stale: Math.abs(forced - shown) > Math.max(8, forced * 0.15),
};
if (staleFrame.stale) {
fs.writeFileSync(
path.join(outDir, `${passName}-${stop.name}-shown.png`),
shownImage,
);
fs.writeFileSync(
path.join(outDir, `${passName}-${stop.name}-forced.png`),
forcedImage,
);
}
}
const moving = frameStats(await readFrames(page, moveStart, holdStart));
const holding = frameStats(await readFrames(page, holdStart, holdEnd));
+41 -8
View File
@@ -6,15 +6,18 @@
* `<dir>/clip.mp4` through ffmpeg's concat demuxer with per-frame
* durations, so held frames last as long as they did on screen.
* - `installFrameProbe(page)` measures every animation frame in the page:
* the requestAnimationFrame interval and, for frames Cesium rendered, the
* CPU time between `scene.preUpdate` and `scene.postRender`.
* the requestAnimationFrame interval and, for rendered frames, the
* CPU time between engine preUpdate and postRender events.
* - `frameStats(frames)` reduces a probe window to count / mean / p50 / p95.
* - `moveCamera(page, view, seconds)` eases the camera like a drag or wheel
* zoom rather than teleporting it.
*/
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
/** Percentile of a numeric list (nearest rank); NaN for an empty list. */
export function percentile(values, p) {
@@ -25,7 +28,7 @@ export function percentile(values, p) {
];
}
/** Summarize probe frames: rAF interval and Cesium render CPU time (ms). */
/** Summarize probe frames: rAF interval and engine render CPU time (ms). */
export function frameStats(frames) {
const intervals = frames.map((f) => f.dt).filter(Number.isFinite);
const renders = frames.map((f) => f.renderMs).filter(Number.isFinite);
@@ -49,16 +52,17 @@ export function frameStats(frames) {
export async function installFrameProbe(page) {
await page.evaluate(() => {
if (window.__gevFrameProbe) return;
const { viewer } = window.__godsEyeView;
const { viewer, engine } = window.__godsEyeView;
const events = viewer?.scene ?? engine.frames;
const probe = { frames: [], started: 0 };
window.__gevFrameProbe = probe;
let last = performance.now();
let renderStart = null;
let pendingRender = null;
viewer.scene.preUpdate.addEventListener(() => {
events.preUpdate.addEventListener(() => {
renderStart = performance.now();
});
viewer.scene.postRender.addEventListener(() => {
events.postRender.addEventListener(() => {
if (renderStart !== null) pendingRender = performance.now() - renderStart;
renderStart = null;
});
@@ -98,6 +102,34 @@ export function pageNow(page) {
* @param {number} seconds Flight duration; 0 jumps.
*/
export async function moveCamera(page, view, seconds) {
if (
await page.evaluate(() => window.__godsEyeView.engine?.kind === 'navara')
) {
await page.evaluate(
async (v, duration) => {
const geo = await import('/src/geo/index.js');
const camera = window.__godsEyeView.engine.camera;
camera.cancelFlight();
await new Promise((resolve) =>
camera.flyTo({
destination: geo.cartesian3.fromDegrees(v.lon, v.lat, v.height),
orientation: {
heading: ((v.heading ?? 0) * Math.PI) / 180,
pitch: ((v.pitch ?? -45) * Math.PI) / 180,
roll: 0,
},
duration,
maximumHeight: Math.max(v.height, camera.height()),
complete: resolve,
cancel: resolve,
}),
);
},
view,
seconds,
);
return;
}
await page.evaluate(
(v, duration) =>
new Promise((resolve) => {
@@ -191,7 +223,8 @@ export async function createScreencast(page, dir, options = {}) {
const list = path.join(dir, 'frames.txt');
fs.writeFileSync(list, `${lines.join('\n')}\n`);
const clip = path.join(dir, 'clip.mp4');
execFileSync('ffmpeg', [
// Keep servicing Puppeteer's request interception while encoding.
await execFileAsync('ffmpeg', [
'-y',
'-loglevel',
'error',
+11 -1
View File
@@ -1,3 +1,5 @@
import { createAlprCesiumRenderer } from '../../layers/alpr/render/cesium.js';
import { rendererFor } from './renderers.js';
import * as credits from '../../data/dataCredits.js';
import { createAlprCamerasLayer } from '../../layers/alpr/index.js';
import * as render from '../../renderGovernor.js';
@@ -8,10 +10,18 @@ import { keyholeLabelAlphaFromGeometry } from '../../celestialRing.js';
import { refreshTrackedReadout } from '../../data/trackedReadout.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationAlpr({ surface, source }) {
export function createApplicationAlpr({
surface,
source,
rendererFactory = ({ engine, ...context }) =>
engine?.kind === 'navara'
? rendererFor('alpr-cameras', engine, context)
: createAlprCesiumRenderer(context),
}) {
const { groundFloor } = surface;
return createAlprCamerasLayer({
source,
rendererFactory,
services: {
credits,
render,
+2
View File
@@ -29,6 +29,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'military-installations',
'bikeshare',
'radio',
'alpr-cameras',
])
assert.equal(layerAvailableOn(id, 'navara'), true, id);
assert.deepEqual(
@@ -40,6 +41,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'military',
'local-adsb',
'earthquakes',
'alpr-cameras',
'satellites',
'rocket-launches',
'radio',
+3
View File
@@ -65,6 +65,9 @@ const NAVARA_CREDIT_KEYS = new Set([
*/
export async function registerNavaraLayerRenderers() {
const loaders = {
'alpr-cameras': async () =>
(await import('../layers/alpr/render/navara.js'))
.createAlprNavaraRenderer,
radio: async () =>
(await import('../layers/radio/render/navara.js'))
.createRadioNavaraRenderer,
+6
View File
@@ -1,3 +1,4 @@
import { createAlprCesiumRenderer } from '../layers/alpr/render/cesium.js';
// src/data/alprCameras.test.mjs
// Pure helpers and actual layer lifecycle with a minimal viewer/input harness.
import { test } from 'node:test';
@@ -854,6 +855,7 @@ test('two factories keep their requests, records and destruction independent', a
};
let resolveFirst, firstSignal;
const first = createAlprCamerasLayer({
rendererFactory: createAlprCesiumRenderer,
services,
source: {
fetch: (_box, signal) => {
@@ -865,6 +867,7 @@ test('two factories keep their requests, records and destruction independent', a
},
});
const second = createAlprCamerasLayer({
rendererFactory: createAlprCesiumRenderer,
services,
source: {
label: 'Test camera directory',
@@ -938,6 +941,7 @@ test('markers sit on the cached floor; unknown floors clamp only until they reso
},
};
const layer = createAlprCamerasLayer({
rendererFactory: createAlprCesiumRenderer,
services,
source: {
fetch: async () => ({
@@ -1305,6 +1309,7 @@ test('slow ALPR floors share real terrain requests across preparation, render an
longitude: -97.749 + (i % 15) * 0.001,
}));
const layer = createAlprCamerasLayer({
rendererFactory: createAlprCesiumRenderer,
source: {
fetch: async () => ({ records, stale: false, saturated: false }),
},
@@ -1390,6 +1395,7 @@ test('map-source changes reposition all ALPR entities and saved native appearanc
directionDeg: i === 0 ? null : 90,
}));
const layer = createAlprCamerasLayer({
rendererFactory: createAlprCesiumRenderer,
source: {
fetch: async () => ({ records, stale: false, saturated: false }),
},
+4 -1
View File
@@ -117,7 +117,10 @@ test('every scene click handler consults ownership before it picks', () => {
['src/data/trackingClickGesture.js', 'onClick(click, gesture);'],
['src/data/localGeojsonCore.js', 'viewer.scene.pick(click.position)'],
['src/data/cctvGizmo.js', 'pickGizmoPart(event.position)'],
['src/layers/alpr/presentation.js', 'viewer.scene.pick(click.position)'],
[
'src/layers/alpr/render/cesiumPresentation.js',
'viewer.scene.pick(click.position)',
],
[
'src/layers/bikeshare/render/cesium/selection.js',
'viewer.scene.pick(click.position)',
+42 -88
View File
@@ -1,5 +1,4 @@
import { isUnavailableCapability } from '../../sources/capability.js';
import * as Cesium from 'cesium';
import {
LAYER_ID,
REQUEST_DEBOUNCE_MS,
@@ -15,9 +14,7 @@ import {
destinationPointDeg,
alprRetryDelayMs,
validateAlprSnapshot,
alprCreditMarkup,
} from './model.js';
import { createAlprPresentation } from './presentation.js';
/**
* Own one layer's requests, records, display and viewer subscriptions.
@@ -33,7 +30,11 @@ import { createAlprPresentation } from './presentation.js';
* removeEntityContextsForLayer(layerId, { retainIds: Set<string> }), preserving
* retained selection without dispatching a new selection event.
*/
export function createAlprCamerasLayer({ source, services } = {}) {
export function createAlprCamerasLayer({
source,
services,
rendererFactory,
} = {}) {
if (typeof source?.fetch !== 'function')
throw new TypeError('ALPR requires a camera source');
const { governorRequestRender } = services.render;
@@ -41,11 +42,6 @@ export function createAlprCamerasLayer({ source, services } = {}) {
const { registerPickOwner, unregisterPickOwner } = services.picking;
const state = {
viewer: null,
dataSource: null,
credit: null,
creditTimer: null,
creditPresented: false,
enabled: false,
records: [],
recordById: new Map(),
@@ -63,10 +59,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
retryDelayMs: 0,
retryAt: 0,
retrying: false,
moveEndRemove: null,
postRenderRemove: null,
lastAnchorSampleAt: 0,
clickHandler: null,
debounceTimer: null,
/** Snapped box of the last successful query; a view still inside it reuses its records. */
lastQueryBox: null,
@@ -77,21 +70,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
/** Cameras in view beyond the render cap. */
renderSaturated: false,
};
const {
initOverlay,
destroyOverlay,
viewportBox,
clearRendered,
hideOnMapCredit,
presentOnMapCredit,
renderRecords,
selectRecord,
focusNearest,
clearSelection,
updateSelectedAnchor,
installInteraction,
prepareFloors,
} = createAlprPresentation({ state, services, source });
let renderer = null;
function setAlprStatus(status, error = null) {
if (state.status === status && state.error === error) return;
@@ -141,7 +120,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
clearTimeout(state.debounceTimer);
// A view the accepted tiles already cover is a local filter: show it now.
// Only a view that needs a request waits out the debounce.
const box = viewportBox(state.viewer);
const box = renderer.viewportBox();
if (box && canReuse(box)) {
state.debounceTimer = null;
loadCameras();
@@ -154,11 +133,11 @@ export function createAlprCamerasLayer({ source, services } = {}) {
}
async function loadCameras() {
if (!state.enabled || !state.viewer) return;
if (!state.enabled || !renderer?.ready) return;
clearTimeout(state.debounceTimer);
state.debounceTimer = null;
clearUnavailableRetry({ resetBackoff: false });
const box = viewportBox(state.viewer);
const box = renderer.viewportBox();
if (!box) {
state.abort?.abort();
state.abort = null;
@@ -166,7 +145,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
state.loading = false;
state.retrying = false;
clearUnavailableRetry();
renderRecords();
renderer.renderRecords();
setAlprStatus('zoom-in');
return;
}
@@ -185,11 +164,9 @@ export function createAlprCamerasLayer({ source, services } = {}) {
state.pendingQueryBox = null;
state.loading = false;
}
renderRecords();
renderer.renderRecords();
state.retrying = false;
setAlprStatus(
state.dataSource.entities.values.length ? 'ready' : 'empty',
);
setAlprStatus(renderer.count() ? 'ready' : 'empty');
return;
}
// A move inside an in-flight query must not abort and restart that query.
@@ -223,14 +200,14 @@ export function createAlprCamerasLayer({ source, services } = {}) {
state.lastQueryBox = null;
state.lastQueryZoom = null;
clearUnavailableRetry();
renderRecords();
renderer.renderRecords();
setAlprStatus('zoom-in');
return;
}
const { records, stale, saturated } = validateAlprSnapshot(snapshot);
// Resolve marker floors (bounded wait, warm cells answer at once) so new
// markers are placed on the floor instead of clamped then moved.
await prepareFloors(records);
await renderer.prepareFloors(records);
if (
requestAbort.signal.aborted ||
state.abort !== requestAbort ||
@@ -256,14 +233,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
clearUnavailableRetry();
// Stale and saturated are independent facts; a cached response that also
// hit the cap must still warn about coverage, not just about freshness.
renderRecords();
setAlprStatus(
state.dataSource.entities.values.length
? stale
? 'stale'
: 'ready'
: 'empty',
);
renderer.renderRecords();
setAlprStatus(renderer.count() ? (stale ? 'stale' : 'ready') : 'empty');
} catch (error) {
if (
error?.name === 'AbortError' ||
@@ -296,33 +267,28 @@ export function createAlprCamerasLayer({ source, services } = {}) {
source: source.label || 'Mapped camera locations',
updateInterval: 0,
statsRefreshInterval: 1000,
init(viewer) {
if (state.viewer) throw new Error('ALPR layer is already initialized');
state.viewer = viewer;
state.dataSource = new Cesium.CustomDataSource('alpr-cameras');
const creditMarkup = alprCreditMarkup(source.attribution);
state.credit = creditMarkup
? new Cesium.Credit(creditMarkup, true)
: null;
viewer.dataSources.add(state.dataSource);
state.moveEndRemove =
viewer.camera.moveEnd.addEventListener(scheduleLoad);
state.postRenderRemove =
viewer.scene.postRender?.addEventListener(updateSelectedAnchor);
initOverlay();
installInteraction(viewer);
init(viewer, options = {}) {
if (renderer?.ready) throw new Error('ALPR layer is already initialized');
if (typeof rendererFactory !== 'function')
throw new TypeError('An ALPR renderer factory is required');
renderer = rendererFactory({
state,
services,
source,
engine: options.engine,
});
renderer.init(viewer, { onMove: scheduleLoad });
},
enable() {
if (state.enabled) return;
state.enabled = true;
state.creditPresented = false;
registerPickOwner(LAYER_ID, (id) => state.recordById.has(id));
state.dataSource.show = true;
renderer.setVisible(true);
// DataLayerManager calls update() right after enable(); it owns the first fetch.
},
disable() {
state.enabled = false;
hideOnMapCredit();
renderer?.hideOnMapCredit();
unregisterPickOwner(LAYER_ID);
clearUnavailableRetry();
clearTimeout(state.debounceTimer);
@@ -331,30 +297,19 @@ export function createAlprCamerasLayer({ source, services } = {}) {
state.pendingQueryBox = null;
state.loading = false;
state.retrying = false;
if (state.dataSource) state.dataSource.show = false;
clearSelection();
clearRendered();
renderer?.setVisible(false);
renderer?.clearSelection();
renderer?.clearRendered();
setAlprStatus('idle');
},
update() {
return loadCameras();
},
destroy(viewer = state.viewer) {
destroy() {
source.destroy?.();
this.disable();
destroyOverlay();
state.moveEndRemove?.();
state.moveEndRemove = null;
state.postRenderRemove?.();
state.postRenderRemove = null;
state.clickHandler?.destroy();
state.clickHandler = null;
clearRendered();
if (state.dataSource && viewer)
viewer.dataSources.remove(state.dataSource, true);
state.dataSource = null;
state.credit = null;
state.creditPresented = false;
renderer?.destroy();
renderer = null;
state.records = [];
state.recordById = new Map();
state.lastQueryBox = null;
@@ -364,21 +319,20 @@ export function createAlprCamerasLayer({ source, services } = {}) {
state.status = 'idle';
state.stale = false;
state.saturated = false;
state.viewer = null;
},
getRowControls() {
const count = state.dataSource?.entities.values.length || 0;
const count = renderer?.count() || 0;
return {
chips: [
{
id: 'find-camera',
label: 'SHOW NEAREST',
title: state.viewer?.trackedEntity
title: renderer?.following()
? 'Stop following the current object before navigating to a camera'
: 'Move to the nearest loaded camera and show its details',
disabled:
!state.enabled || !count || Boolean(state.viewer?.trackedEntity),
onClick: focusNearest,
!state.enabled || !count || Boolean(renderer?.following()),
onClick: () => renderer?.focusNearest(),
},
],
legend: [
@@ -425,10 +379,10 @@ export function createAlprCamerasLayer({ source, services } = {}) {
},
getStats() {
return {
count: state.dataSource?.entities.values.length || 0,
count: renderer?.count() || 0,
countLabel:
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
? `${state.dataSource?.entities.values.length || 0} nearby${
? `${renderer?.count() || 0} nearby${
Number.isInteger(state.onScreen)
? ` · ${state.onScreen} on screen`
: ''
@@ -440,7 +394,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
saturated: state.saturated || state.renderSaturated,
renderRevision: state.renderRevision,
onScreen: state.onScreen ?? null,
shown: state.dataSource?.entities.values.length || 0,
shown: renderer?.count() || 0,
error: state.error,
status: state.status,
loading: state.loading,
+2 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createAlprOverlay } from './overlay.js';
import { createAlprOverlay } from './render/cesiumOverlay.js';
import {
directionWedgePositions,
alprLabelDetails,
@@ -46,6 +46,7 @@ function harness(t, count = 2, { floor = null } = {}) {
paints = 0;
const overlay = createAlprOverlay({
state,
native: state,
services: {
groundFloor: { cachedGroundFloor: () => floor },
overlays: {
+64
View File
@@ -0,0 +1,64 @@
import * as Cesium from 'cesium';
import { alprCreditMarkup } from '../model.js';
import { createAlprPresentation } from './cesiumPresentation.js';
/** Native scene resources; shared requests and records never own the viewer. */
export function createAlprCesiumRenderer({ state, services, source }) {
const native = {
viewer: null,
dataSource: null,
credit: null,
creditTimer: null,
creditPresented: false,
clickHandler: null,
};
let removeMove = null;
let removeFrame = null;
const presentation = createAlprPresentation({
state,
native,
services,
source,
});
return {
...presentation,
get ready() {
return Boolean(native.viewer);
},
count: () => native.dataSource?.entities.values.length || 0,
following: () => Boolean(native.viewer?.trackedEntity),
init(viewer, { onMove }) {
native.viewer = viewer;
native.dataSource = new Cesium.CustomDataSource('alpr-cameras');
const markup = alprCreditMarkup(source.attribution);
native.credit = markup ? new Cesium.Credit(markup, true) : null;
viewer.dataSources.add(native.dataSource);
removeMove = viewer.camera.moveEnd.addEventListener(onMove);
removeFrame = viewer.scene.postRender?.addEventListener(
presentation.updateSelectedAnchor,
);
presentation.initOverlay();
presentation.installInteraction(viewer);
},
setVisible(visible) {
if (visible) native.creditPresented = false;
if (native.dataSource) native.dataSource.show = visible;
},
destroy() {
presentation.destroyOverlay();
removeMove?.();
removeMove = null;
removeFrame?.();
removeFrame = null;
native.clickHandler?.destroy();
native.clickHandler = null;
presentation.clearRendered();
if (native.dataSource && native.viewer)
native.viewer.dataSources.remove(native.dataSource, true);
native.dataSource = null;
native.credit = null;
native.creditPresented = false;
native.viewer = null;
},
};
}
@@ -8,7 +8,7 @@ import {
SURFACE_BELOW_FLOOR_M,
SURFACE_ABOVE_FLOOR_M,
SURFACE_SAMPLES_PER_PAINT,
} from './policy.js';
} from '../policy.js';
import {
MARKER_IMAGE,
SELECTED_IMAGE,
@@ -17,11 +17,12 @@ import {
markerScale,
paintDirectionWedge,
validAlprGroundHeight,
} from './visuals.js';
} from '../visuals.js';
/** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */
export function createAlprOverlay({
state,
native,
services,
onSurfaceChange = () => {},
}) {
@@ -86,7 +87,7 @@ export function createAlprOverlay({
}
function resetAnchors() {
for (const entity of state.dataSource.entities.values) {
for (const entity of native.dataSource.entities.values) {
entity.gevAlprCanvasPosition = null;
entity.gevAlprWedge = null;
entity.gevAlprDisplayPosition = null;
@@ -99,7 +100,7 @@ export function createAlprOverlay({
function anchorFor(record, entity, budget) {
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
const scene = state.viewer.scene;
const scene = native.viewer.scene;
const location = Cesium.Cartographic.fromDegrees(
record.longitude,
record.latitude,
@@ -166,8 +167,8 @@ export function createAlprOverlay({
* rejected. Returns whether anchors remain to check.
*/
function refineAnchors(budget) {
const camera = state.viewer.camera;
const scene = state.viewer.scene;
const camera = native.viewer.camera;
const scene = native.viewer.scene;
const still =
lastCamera &&
camera.positionWC &&
@@ -191,7 +192,7 @@ export function createAlprOverlay({
if (!scene.sampleHeightSupported) return false;
let pending = false;
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
const entity = native.dataSource.entities.getById(record.id);
const anchor = entity?.gevAlprCanvasPosition;
if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue;
if (!still || !surfaceSettled(scene) || budget.samples <= 0) {
@@ -234,7 +235,7 @@ export function createAlprOverlay({
const painted = [];
const budget = { samples: ANCHOR_SAMPLES_PER_PAINT };
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
const entity = native.dataSource.entities.getById(record.id);
if (!entity) continue;
const selected = record.id === state.selectedId;
const image = images[selected ? 1 : 0];
@@ -257,7 +258,7 @@ export function createAlprOverlay({
setShown(entity, false);
continue;
}
const scene = state.viewer.scene;
const scene = native.viewer.scene;
const origin = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
anchor,
@@ -306,7 +307,7 @@ export function createAlprOverlay({
image,
alpha,
scale: markerScale(
Cesium.Cartesian3.distance(state.viewer.camera.positionWC, anchor),
Cesium.Cartesian3.distance(native.viewer.camera.positionWC, anchor),
),
});
if (selected) entity.gevAlprDisplayPosition = anchor;
@@ -372,8 +373,8 @@ export function createAlprOverlay({
sync(visible) {
if (!lane) return;
for (const record of records)
nativeVisible(state.dataSource.entities.getById(record.id), true);
const position = state.viewer.camera.positionWC;
nativeVisible(native.dataSource.entities.getById(record.id), true);
const position = native.viewer.camera.positionWC;
records = visible
.filter(
(record) =>
@@ -422,7 +423,7 @@ export function createAlprOverlay({
clearTimeout(retryTimer);
retryTimer = null;
for (const record of records)
nativeVisible(state.dataSource?.entities.getById(record.id), true);
nativeVisible(native.dataSource?.entities.getById(record.id), true);
records = [];
hits = [];
lane?.setActive(false);
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
import { isPointerFree } from '../../../data/inputOwnership.js';
import {
LAYER_ID,
MAX_VIEWPORT_DEGREES,
@@ -11,19 +11,19 @@ import {
CREDIT_DISPLAY_MS,
MARKER_FLOOR_LIFT_M,
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
import { boxContains } from './model.js';
import { createAlprFloorResolver } from './floors.js';
import { createAlprOverlay } from './overlay.js';
} from '../policy.js';
import { boxContains } from '../model.js';
import { createAlprFloorResolver } from '../floors.js';
import { createAlprOverlay } from './cesiumOverlay.js';
import {
MARKER_IMAGE,
SELECTED_IMAGE,
alprDisplayId,
alprLabelDetails,
validAlprGroundHeight,
} from './visuals.js';
} from '../visuals.js';
export function createAlprPresentation({ state, services, source }) {
export function createAlprPresentation({ state, native, services, source }) {
const { governorRequestRender } = services.render;
const {
clearSelectedEntityContextForLayer,
@@ -36,6 +36,7 @@ export function createAlprPresentation({ state, services, source }) {
const { cachedGroundFloor } = services.groundFloor;
const overlay = createAlprOverlay({
state,
native,
services,
onSurfaceChange: refreshMarkerPlacements,
});
@@ -70,7 +71,7 @@ export function createAlprPresentation({ state, services, source }) {
/** Records the current view would display (viewport box, nearest first when capped). */
function viewRecords(records) {
const box = viewportBox(state.viewer);
const box = viewportBox(native.viewer);
if (!box) return { box, visible: [], capped: false };
let visible = records.filter((record) =>
boxContains(box, {
@@ -129,11 +130,11 @@ export function createAlprPresentation({ state, services, source }) {
* @returns {number|null} Count, or null without a scene to project into.
*/
function countOnScreen() {
const scene = state.viewer?.scene;
const scene = native.viewer?.scene;
const canvas = scene?.canvas;
const width = canvas?.clientWidth || canvas?.width;
const height = canvas?.clientHeight || canvas?.height;
const camera = state.viewer?.camera;
const camera = native.viewer?.camera;
if (!scene || !width || !height || !camera?.positionWC) return null;
const occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
@@ -141,7 +142,7 @@ export function createAlprPresentation({ state, services, source }) {
);
let count = 0;
try {
for (const entity of state.dataSource.entities.values) {
for (const entity of native.dataSource.entities.values) {
const position = entity.position?.getValue?.(Cesium.JulianDate.now());
if (!position || !occluder.isPointVisible(position)) continue;
const point = Cesium.SceneTransforms.worldToWindowCoordinates(
@@ -182,9 +183,9 @@ export function createAlprPresentation({ state, services, source }) {
/** Refresh all native placements, including the appearances held by the canvas. */
function refreshMarkerPlacements() {
if (!state.enabled || !state.dataSource) return;
if (!state.enabled || !native.dataSource) return;
clampedMarkers.clear();
for (const entity of state.dataSource.entities.values) {
for (const entity of native.dataSource.entities.values) {
const { position, clamped } = markerPlacement(entity.gevAlprRecord);
setMarkerPosition(entity, position, clamped);
if (clamped) clampedMarkers.set(entity.id, entity);
@@ -211,7 +212,7 @@ export function createAlprPresentation({ state, services, source }) {
.then(() => {
if (floorResolve !== request) return;
floorResolve = null;
if (!state.enabled || !state.dataSource) return;
if (!state.enabled || !native.dataSource) return;
let moved = false;
for (const entity of batch) {
if (clampedMarkers.get(entity.id) !== entity) continue;
@@ -309,16 +310,16 @@ export function createAlprPresentation({ state, services, source }) {
overlay.clear();
visibleRecords = [];
clampedMarkers.clear();
if (state.dataSource?.entities) state.dataSource.entities.removeAll();
if (native.dataSource?.entities) native.dataSource.entities.removeAll();
removeEntityContextsForLayer(LAYER_ID);
}
function hideOnMapCredit() {
services.credits?.hideOsmCredit?.(state.viewer, LAYER_ID);
clearTimeout(state.creditTimer);
state.creditTimer = null;
if (state.credit)
state.viewer?.creditDisplay?.removeStaticCredit(state.credit);
services.credits?.hideOsmCredit?.(native.viewer, LAYER_ID);
clearTimeout(native.creditTimer);
native.creditTimer = null;
if (native.credit)
native.viewer?.creditDisplay?.removeStaticCredit(native.credit);
governorRequestRender('alpr-credit');
}
@@ -328,13 +329,13 @@ export function createAlprPresentation({ state, services, source }) {
source.attribution?.name === 'OpenStreetMap' &&
services.credits?.showOsmCredit
) {
services.credits.showOsmCredit(state.viewer, LAYER_ID);
services.credits.showOsmCredit(native.viewer, LAYER_ID);
return;
}
if (!state.enabled || state.creditPresented || !state.credit) return;
state.creditPresented = true;
state.viewer.creditDisplay?.addStaticCredit(state.credit);
state.creditTimer = setTimeout(hideOnMapCredit, CREDIT_DISPLAY_MS);
if (!state.enabled || native.creditPresented || !native.credit) return;
native.creditPresented = true;
native.viewer.creditDisplay?.addStaticCredit(native.credit);
native.creditTimer = setTimeout(hideOnMapCredit, CREDIT_DISPLAY_MS);
governorRequestRender('alpr-credit');
}
@@ -358,17 +359,17 @@ export function createAlprPresentation({ state, services, source }) {
governorRequestRender('alpr-render');
const visibleIds = new Set(visible.map((record) => record.id));
let changed = false;
state.dataSource.entities.suspendEvents();
for (const entity of [...state.dataSource.entities.values]) {
native.dataSource.entities.suspendEvents();
for (const entity of [...native.dataSource.entities.values]) {
if (!visibleIds.has(entity.id)) {
state.dataSource.entities.remove(entity);
native.dataSource.entities.remove(entity);
clampedMarkers.delete(entity.id);
changed = true;
}
}
removeEntityContextsForLayer(LAYER_ID, { retainIds: visibleIds });
for (const record of visible) {
const existing = state.dataSource.entities.getById(record.id);
const existing = native.dataSource.entities.getById(record.id);
if (existing?.gevAlprRecord === record) {
updateAppearance(existing, record.id === state.selectedId);
continue;
@@ -402,7 +403,7 @@ export function createAlprPresentation({ state, services, source }) {
// geometry is unchanged. Updating metadata must not rebuild the marker.
const previous = entity?.gevAlprRecord;
if (!entity) {
entity = state.dataSource.entities.add(entityDef);
entity = native.dataSource.entities.add(entityDef);
entity.gevAlprClamped = clamped;
changed = true;
} else {
@@ -447,7 +448,7 @@ export function createAlprPresentation({ state, services, source }) {
registerEntityContext(entity, {
id: record.id,
layerId: LAYER_ID,
dataSource: state.dataSource,
dataSource: native.dataSource,
layerName: 'ALPR Cameras',
source:
source.attribution?.description || source.label || 'Camera source',
@@ -467,14 +468,14 @@ export function createAlprPresentation({ state, services, source }) {
},
});
}
state.dataSource.entities.resumeEvents();
native.dataSource.entities.resumeEvents();
if (changed) state.renderRevision += 1;
state.onScreen = countOnScreen();
for (const entity of state.dataSource.entities.values)
for (const entity of native.dataSource.entities.values)
if (entity.gevAlprClamped) clampedMarkers.set(entity.id, entity);
resolveClampedFloors();
const selectedEntity = state.selectedId
? state.dataSource.entities.getById(state.selectedId)
? native.dataSource.entities.getById(state.selectedId)
: null;
if (!selectedEntity) state.selectedId = null;
updateSelectedAnchor();
@@ -486,12 +487,12 @@ export function createAlprPresentation({ state, services, source }) {
}
function focusNearest() {
const camera = state.viewer?.camera;
if (!state.enabled || !camera?.positionWC || state.viewer.trackedEntity)
const camera = native.viewer?.camera;
if (!state.enabled || !camera?.positionWC || native.viewer.trackedEntity)
return false;
let nearest = null,
distance = Infinity;
for (const entity of state.dataSource.entities.values) {
for (const entity of native.dataSource.entities.values) {
const position = entity.position.getValue(Cesium.JulianDate.now());
const candidate = Cesium.Cartesian3.distanceSquared(
camera.positionWC,
@@ -509,15 +510,15 @@ export function createAlprPresentation({ state, services, source }) {
record.latitude,
);
let height;
if (state.viewer.scene.sampleHeightSupported) {
if (native.viewer.scene.sampleHeightSupported) {
try {
height = state.viewer.scene.sampleHeight(location, [nearest]);
height = native.viewer.scene.sampleHeight(location, [nearest]);
} catch {
/* tiles may still be streaming */
}
}
if (!validAlprGroundHeight(height) && state.viewer.scene.globe.show)
height = state.viewer.scene.globe.getHeight?.(location);
if (!validAlprGroundHeight(height) && native.viewer.scene.globe.show)
height = native.viewer.scene.globe.getHeight?.(location);
if (!validAlprGroundHeight(height))
height = cachedGroundFloor(record.latitude, record.longitude);
const center = Cesium.Cartesian3.fromDegrees(
@@ -539,7 +540,7 @@ export function createAlprPresentation({ state, services, source }) {
}
function selectRecord(id) {
const entity = state.dataSource?.entities.getById(id);
const entity = native.dataSource?.entities.getById(id);
if (!entity || !state.recordById.has(id)) return false;
clearSelection();
state.selectedId = id;
@@ -555,7 +556,7 @@ export function createAlprPresentation({ state, services, source }) {
function clearSelection() {
const entity = state.selectedId
? state.dataSource?.entities.getById(state.selectedId)
? native.dataSource?.entities.getById(state.selectedId)
: null;
const record = state.recordById.get(state.selectedId);
if (entity && record) updateAppearance(entity, false);
@@ -573,7 +574,7 @@ export function createAlprPresentation({ state, services, source }) {
clearSelection();
return;
}
const entity = state.dataSource?.entities.getById(state.selectedId);
const entity = native.dataSource?.entities.getById(state.selectedId);
const record = state.recordById.get(state.selectedId);
if (entity?.gevAlprCanvasPosition) {
entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition;
@@ -582,7 +583,7 @@ export function createAlprPresentation({ state, services, source }) {
if (!entity || !record || Date.now() - state.lastAnchorSampleAt < 1000)
return;
state.lastAnchorSampleAt = Date.now();
const scene = state.viewer.scene;
const scene = native.viewer.scene;
const location = Cesium.Cartographic.fromDegrees(
record.longitude,
record.latitude,
@@ -615,11 +616,11 @@ export function createAlprPresentation({ state, services, source }) {
}
function installInteraction(viewer) {
if (state.clickHandler) return;
state.clickHandler = new Cesium.ScreenSpaceEventHandler(
if (native.clickHandler) return;
native.clickHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
);
state.clickHandler.setInputAction((click) => {
native.clickHandler.setInputAction((click) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!state.enabled) return;
@@ -640,7 +641,7 @@ export function createAlprPresentation({ state, services, source }) {
initOverlay: () => overlay.init(),
destroyOverlay: () => overlay.destroy(),
markerColor,
viewportBox,
viewportBox: () => viewportBox(native.viewer),
clearRendered,
hideOnMapCredit,
presentOnMapCredit,
+600
View File
@@ -0,0 +1,600 @@
import * as geo from '../../../geo/index.js';
import { distanceScale } from '../../../globe/cpuPicks.js';
import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
import { resolvePickId } from '../../../data/pickRegistry.js';
import { boxContains, alprCreditMarkup } from '../model.js';
import { createAlprFloorResolver } from '../floors.js';
import { createAlprNavaraOverlay } from './navaraOverlay.js';
import {
MARKER_IMAGE,
SELECTED_IMAGE,
alprDisplayId,
alprLabelDetails,
validAlprGroundHeight,
} from '../visuals.js';
import {
LAYER_ID,
MAX_VIEWPORT_DEGREES,
MAX_RENDERED,
ALPR_COLOR,
ALPR_SELECTED_COLOR,
MARKER_ICON_SIZE,
SELECTED_MARKER_ICON_SIZE,
MARKER_FLOOR_LIFT_M,
MARKER_SCALE_BY_DISTANCE,
} from '../policy.js';
/** Plain marker handles and bounded sprite sets; source ownership stays in the layer. */
export function createAlprNavaraRenderer(engine, { state, services, source }) {
const markers = new Map();
const floors = createAlprFloorResolver(services.groundFloor);
let normal,
selected,
initialized = false;
let visibleRecords = [],
generation = 0,
floorRequest = null;
let removeMove, removeFrame, removeClick, removeCredit;
let lastCamera = null,
selectionStartedAt = 0;
let spritesDirty = false;
const request = () => engine.render.request('alpr-render');
const cachedFloor = (record) =>
services.groundFloor.cachedGroundFloor(record.latitude, record.longitude);
const sampleHeight = (location) => {
if (!engine.surface.supported) return undefined;
try {
return engine.surface.sampleHeight(location, [normal, selected]);
} catch {
return undefined;
}
};
function surfaceHeight(record) {
const location = geo.cartographic.fromDegrees(
record.longitude,
record.latitude,
);
let height = sampleHeight(location);
if (!validAlprGroundHeight(height) && engine.surface.terrainHeightSupported)
height = engine.surface.terrainHeight(location);
if (!validAlprGroundHeight(height)) height = cachedFloor(record);
return height;
}
function placement(record) {
let height = cachedFloor(record);
const clamped = !validAlprGroundHeight(height);
if (clamped) height = surfaceHeight(record);
return {
clamped,
resolved: validAlprGroundHeight(height),
position: geo.cartesian3.fromDegrees(
record.longitude,
record.latitude,
validAlprGroundHeight(height) ? height + MARKER_FLOOR_LIFT_M : 0,
),
};
}
function viewportBox() {
const { width, height } = engine.host.size();
if (!width || !height) return null;
const focus = engine.camera.pickEllipsoid({ x: width / 2, y: height / 2 });
if (!focus) return null;
const location = geo.cartographic.fromCartesian(focus);
const radius = Math.max(
1000,
2 * geo.cartesian3.distance(engine.camera.livePosition, focus),
);
const latitude = geo.math.toDegrees(location.latitude);
const longitude = geo.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,
};
}
function viewRecords(records) {
const box = viewportBox();
if (!box) return { visible: [], capped: false };
let visible = records.filter((r) =>
boxContains(box, {
south: r.latitude,
north: r.latitude,
west: r.longitude,
east: r.longitude,
}),
);
const capped = visible.length > MAX_RENDERED;
if (capped) {
const lat = (box.south + box.north) / 2,
lon = (box.west + box.east) / 2;
const scale = Math.cos((lat * Math.PI) / 180);
visible = visible
.map((record) => ({
record,
d:
(record.latitude - lat) ** 2 +
((record.longitude - lon) * scale) ** 2,
}))
.sort((a, b) => a.d - b.d)
.slice(0, MAX_RENDERED)
.map(({ record }) => record);
}
return { visible, capped };
}
function syncMarker(marker) {
if (!initialized) return;
const position =
marker.canvasOwned && marker.gevAlprCanvasPosition
? marker.gevAlprCanvasPosition
: marker.position;
const isSelected = marker.id === state.selectedId;
const scale = marker.canvasOwned
? 1
: distanceScale(
MARKER_SCALE_BY_DISTANCE,
geo.cartesian3.distance(engine.camera.livePosition, position),
);
const show =
state.enabled &&
marker.shown &&
marker.resolved &&
engine.projector.horizonVisible(position);
const alpha = marker.canvasOwned ? 0.01 : 1;
const previous = marker.drawn;
if (
previous &&
previous.position === position &&
previous.scale === scale &&
previous.show === show &&
previous.alpha === alpha &&
previous.selected === isSelected
)
return;
marker.drawn = { position, scale, show, alpha, selected: isSelected };
const fields = {
position,
scale,
show,
color: [1, 1, 1, alpha],
cell: 'camera',
pickId: marker.id,
};
normal.set(marker.index, { ...fields, show: show && !isSelected });
if (isSelected) {
selected.setCount(1);
selected.set(0, fields);
}
if (!spritesDirty) {
// Canvas ownership can change during postRender. Navara clears its
// request flag after that event; wake the next frame afterwards, while
// retaining GPU uploads in preRender before the buffers are drawn.
queueMicrotask(requestDirtySprites);
}
spritesDirty = true;
}
function requestDirtySprites() {
if (spritesDirty) request();
}
function flushSprites() {
if (!spritesDirty) return;
normal.commit();
selected.commit();
spritesDirty = false;
}
const overlay = createAlprNavaraOverlay({
engine,
state,
services,
markers,
sampleHeight,
syncMarker,
onSurfaceChange: refreshPlacements,
});
function refreshPlacements() {
if (!state.enabled) return;
generation++;
floorRequest = null;
floors.cancel();
for (const marker of markers.values()) {
Object.assign(marker, placement(marker.record));
marker.drawn = null;
syncMarker(marker);
}
resolveFloors();
request();
}
async function prepareFloors(records) {
const cold = viewRecords(records)
.visible.filter((r) => !validAlprGroundHeight(cachedFloor(r)))
.map((r) => ({ lat: r.latitude, lon: r.longitude }));
if (!cold.length) return;
try {
await floors.prepare(cold);
} catch {
/* bounded unresolved floors */
}
}
function resolveFloors() {
if (
floorRequest ||
typeof services.groundFloor.resolveGroundFloorCells !== 'function'
)
return;
const batch = [...markers.values()].filter((m) => m.clamped);
if (!batch.length) return;
const epoch = generation;
const promise = Promise.resolve(
floors.resolve(
batch.map((m) => ({ lat: m.record.latitude, lon: m.record.longitude })),
),
)
.catch(() => {})
.then(() => {
if (epoch !== generation || floorRequest !== promise) return;
floorRequest = null;
if (!state.enabled) return;
for (const marker of batch) {
if (markers.get(marker.id) !== marker) continue;
Object.assign(marker, placement(marker.record));
syncMarker(marker);
}
});
floorRequest = promise;
}
function updateAppearance(marker, active) {
if (marker.gevLabelModel) {
marker.gevLabelModel.accent = active ? ALPR_SELECTED_COLOR : ALPR_COLOR;
marker.gevLabelModel.leaderAnimationStartedAt = active
? selectionStartedAt
: 0;
}
syncMarker(marker);
}
function clearSelection() {
const marker = markers.get(state.selectedId);
state.selectedId = null;
selectionStartedAt = 0;
if (initialized) {
selected.setCount(0);
selected.commit();
}
if (marker) updateAppearance(marker, false);
overlay.sync(visibleRecords);
services.context.clearSelectedEntityContextForLayer(LAYER_ID);
request();
}
function updateSelectedAnchor() {
if (!state.enabled || !state.selectedId) return;
if (services.context.getSelectedEntityContext()?.id !== state.selectedId) {
clearSelection();
return;
}
const marker = markers.get(state.selectedId);
if (!marker) return;
if (marker.gevAlprCanvasPosition) {
marker.displayPosition = marker.gevAlprCanvasPosition;
return;
}
if (Date.now() - state.lastAnchorSampleAt < 1000) return;
state.lastAnchorSampleAt = Date.now();
const height = surfaceHeight(marker.record);
if (!validAlprGroundHeight(height)) return;
const next = geo.cartesian3.fromDegrees(
marker.record.longitude,
marker.record.latitude,
height,
);
if (
!marker.displayPosition ||
geo.cartesian3.distance(next, marker.displayPosition) > 0.1
) {
marker.displayPosition = next;
request();
}
}
function selectRecord(id) {
const marker = markers.get(id);
if (!marker || !state.recordById.has(id)) return false;
clearSelection();
state.selectedId = id;
selectionStartedAt = performance.now();
updateAppearance(marker, true);
state.lastAnchorSampleAt = 0;
services.context.selectEntityContext(marker);
updateSelectedAnchor();
overlay.sync(visibleRecords);
request();
return true;
}
function registerContext(marker) {
const record = marker.record;
marker.gevTrackedId = record.id;
marker.gevDisplayPosition = () => marker.displayPosition;
marker.gevLabelModel = {
title: alprDisplayId(record),
details: alprLabelDetails(record, source),
accent: ALPR_COLOR,
cardStyle: 'tactical',
selected: true,
leaderStyle: 'elbow',
leaderAnimationMs: 440,
leaderAnimationStartedAt:
record.id === state.selectedId ? selectionStartedAt : 0,
leaderDrawRatio: 0.68,
anchorRadiusPx: SELECTED_MARKER_ICON_SIZE / 2,
anchorRadiusScale: null,
};
services.context.registerEntityContext(marker, {
id: record.id,
layerId: LAYER_ID,
layerName: 'ALPR Cameras',
source:
source.attribution?.description || source.label || 'Camera source',
label: 'ALPR camera',
latitude: record.latitude,
longitude: record.longitude,
properties: {
operator: record.operator,
manufacturer: record.manufacturer,
cameraType: record.cameraType,
zone: record.zone,
directionDeg: record.directionDeg,
ref: record.ref,
lastVerified: record.lastVerified,
osmId: record.osmId,
sourceTag: record.source,
},
});
}
function countOnScreen() {
const { width, height } = engine.host.size();
let count = 0;
for (const marker of markers.values()) {
if (!engine.projector.horizonVisible(marker.position)) continue;
const p = engine.projector.worldToWindow(marker.position);
if (p && p.x >= 0 && p.x <= width && p.y >= 0 && p.y <= height) count++;
}
return count;
}
function hideOnMapCredit() {
removeCredit?.();
removeCredit = null;
}
function presentOnMapCredit() {
if (!state.enabled || removeCredit) return;
const markup = alprCreditMarkup(source.attribution);
if (markup) removeCredit = engine.credits.addStatic(markup);
}
function renderRecords() {
const { visible, capped } = viewRecords(state.records);
state.renderSaturated = capped;
visibleRecords = visible;
const current = services.context.getSelectedEntityContext();
if (
current?.layerId !== LAYER_ID ||
current.id !== state.selectedId ||
!visible.some((r) => r.id === state.selectedId)
)
clearSelection();
const ids = new Set(visible.map((r) => r.id));
let changed = false;
for (const id of markers.keys())
if (!ids.has(id)) {
markers.delete(id);
changed = true;
}
services.context.removeEntityContextsForLayer(LAYER_ID, { retainIds: ids });
for (const record of visible) {
let marker = markers.get(record.id);
if (!marker) {
marker = {
id: record.id,
record,
...placement(record),
shown: true,
canvasOwned: false,
displayPosition: null,
};
markers.set(record.id, marker);
changed = true;
} else if (marker.record !== record) {
const previous = marker.record;
if (
previous.latitude !== record.latitude ||
previous.longitude !== record.longitude
) {
Object.assign(marker, placement(record));
marker.displayPosition = null;
marker.gevAlprCanvasPosition = null;
}
if (
previous.latitude !== record.latitude ||
previous.longitude !== record.longitude ||
previous.directionDeg !== record.directionDeg
) {
marker.gevAlprCanvasPosition = null;
marker.gevAlprWedge = null;
}
marker.record = record;
}
registerContext(marker);
}
normal.setCount(markers.size);
let index = 0;
for (const marker of markers.values()) {
marker.index = index++;
marker.drawn = null;
updateAppearance(marker, marker.id === state.selectedId);
}
flushSprites();
if (changed) state.renderRevision++;
state.onScreen = countOnScreen();
resolveFloors();
updateSelectedAnchor();
overlay.sync(visible);
const chosen = markers.get(state.selectedId);
if (chosen) services.overlays?.refreshReadout?.(chosen);
if (visible.length) presentOnMapCredit();
else hideOnMapCredit();
request();
}
function clearRendered() {
hideOnMapCredit();
generation++;
floors.cancel();
floorRequest = null;
overlay.clear();
visibleRecords = [];
markers.clear();
if (initialized) {
normal.setCount(0);
selected.setCount(0);
normal.commit();
selected.commit();
spritesDirty = false;
}
services.context.removeEntityContextsForLayer(LAYER_ID);
}
function frame() {
const camera = engine.camera.livePosition;
if (
!lastCamera ||
!geo.cartesian3.equalsEpsilon(camera, lastCamera, 0, 0.00001)
) {
lastCamera = { ...camera };
for (const marker of markers.values()) syncMarker(marker);
}
flushSprites();
updateSelectedAnchor();
}
return {
get ready() {
return initialized;
},
count: () => markers.size,
following: () => engine.camera.following(),
viewportBox,
prepareFloors,
renderRecords,
clearRendered,
clearSelection,
hideOnMapCredit,
init(_viewer, { onMove }) {
if (initialized) throw new Error('ALPR renderer is already initialized');
const create = (id, sizePx, capacity, image, order) =>
engine.movers.createSet({
id,
kind: 'sprite',
capacity,
order,
pickable: true,
depthTest: 'always',
sharpen: true,
sprite: {
sizePx,
atlas: { url: image, cells: { camera: [0, 0, 1, 1] } },
},
});
normal = create(
LAYER_ID,
MARKER_ICON_SIZE,
MAX_RENDERED,
MARKER_IMAGE,
50,
);
selected = create(
'alpr-selected',
SELECTED_MARKER_ICON_SIZE,
1,
SELECTED_IMAGE,
51,
);
normal.setCount(0);
selected.setCount(0);
normal.commit();
selected.commit();
initialized = true;
removeMove = engine.camera.moveEnd.addEventListener(onMove);
removeFrame = engine.frames.preRender.addEventListener(frame);
overlay.init();
},
setVisible(value) {
removeClick?.();
removeClick = null;
if (value)
removeClick = selectionDispatcherFor(engine).register(LAYER_ID, {
owns: (id) => state.recordById.has(id),
onClick({ position, picked }) {
if (!state.enabled) return;
const id = overlay.pick(position) || resolvePickId(picked);
if (
id &&
state.recordById.has(id) &&
(id !== state.selectedId ||
services.context.getSelectedEntityContext()?.id !== id)
)
selectRecord(id);
else if (state.selectedId) clearSelection();
},
});
},
focusNearest() {
if (!state.enabled || engine.camera.following()) return false;
let nearest,
distance = Infinity;
for (const marker of markers.values()) {
const next = geo.cartesian3.distanceSquared(
engine.camera.livePosition,
marker.position,
);
if (next < distance) {
nearest = marker;
distance = next;
}
}
if (!nearest || !selectRecord(nearest.id)) return false;
const height = surfaceHeight(nearest.record);
const center = geo.cartesian3.fromDegrees(
nearest.record.longitude,
nearest.record.latitude,
validAlprGroundHeight(height) ? height : 0,
);
engine.camera.flyToSphere(
{ center, radius: 30 },
{
duration: 1.2,
heading: engine.camera.pose().heading || 0,
pitch: -Math.PI / 4,
range: 800,
},
);
request();
return true;
},
destroy() {
if (!initialized) return;
clearRendered();
overlay.destroy();
removeClick?.();
removeClick = null;
removeMove?.();
removeMove = null;
removeFrame?.();
removeFrame = null;
normal?.destroy();
selected?.destroy();
initialized = false;
lastCamera = null;
},
};
}
+330
View File
@@ -0,0 +1,330 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../../geo/index.js';
import { createAlprNavaraRenderer } from './navara.js';
function event() {
const listeners = new Set();
return {
listeners,
addEventListener(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
fire() {
for (const fn of listeners) fn();
},
};
}
function harness(t) {
const sets = [],
contexts = new Map(),
inputs = new Map(),
flights = [];
const move = event(),
frame = event();
let commits = 0,
selectedContext = null,
following = false,
credits = 0,
renderRequested = false;
const state = {
enabled: true,
selectedId: null,
lastAnchorSampleAt: 0,
renderRevision: 0,
records: [],
recordById: new Map(),
};
const engine = {
host: { size: () => ({ width: 800, height: 600 }) },
render: {
request() {
renderRequested = true;
},
},
camera: {
livePosition: geo.cartesian3.fromDegrees(-97, 30, 10000),
pickEllipsoid: () => geo.cartesian3.fromDegrees(-97, 30),
following: () => following,
pose: () => ({ heading: 0.5 }),
moveEnd: move,
flyToSphere(sphere, options) {
flights.push({ sphere, options });
},
},
frames: { preRender: frame },
input: {
on(name, fn) {
inputs.set(name, fn);
return () => inputs.delete(name);
},
},
projector: {
horizonVisible: () => true,
worldToWindow: () => ({ x: 400, y: 300 }),
},
surface: {
supported: true,
terrainHeightSupported: false,
sampleHeight: () => 150,
},
credits: {
addStatic(_markup, options) {
assert.notEqual(
options?.onScreen,
true,
'loading ALPR must not open an attribution popup over the map',
);
credits++;
return () => credits--;
},
},
movers: {
createSet(options) {
const set = {
options,
rows: [],
count: 0,
dead: false,
setCount(n) {
assert.ok(!this.dead);
assert.ok(n <= options.capacity);
this.count = n;
},
set(i, row) {
assert.ok(i < this.count);
this.rows[i] = row;
},
commit() {
assert.ok(!this.dead);
this.committedRows = this.rows.slice(0, this.count);
commits++;
},
destroy() {
assert.ok(!this.dead);
this.dead = true;
},
};
sets.push(set);
return set;
},
},
};
const services = {
groundFloor: { cachedGroundFloor: () => 150 },
context: {
getSelectedEntityContext: () => selectedContext,
clearSelectedEntityContextForLayer() {
if (selectedContext?.layerId === 'alpr-cameras') selectedContext = null;
},
removeEntityContextsForLayer(_id, { retainIds = new Set() } = {}) {
for (const id of contexts.keys())
if (!retainIds.has(id)) contexts.delete(id);
},
registerEntityContext(entity, metadata) {
contexts.set(metadata.id, { ...metadata, entity });
},
selectEntityContext(entity) {
selectedContext = contexts.get(entity.id);
},
},
};
const renderer = createAlprNavaraRenderer(engine, {
state,
services,
source: {
label: 'Fixture',
attribution: {
text: 'Fixture cameras',
href: 'https://example.com/cameras',
},
},
});
renderer.init(null, { onMove() {} });
renderer.setVisible(true);
t.after(() => {
state.enabled = false;
renderer.setVisible(false);
renderer.destroy();
});
const load = (records) => {
state.records = records;
state.recordById = new Map(records.map((r) => [r.id, r]));
renderer.renderRecords();
frame.fire();
};
return {
renderer,
state,
services,
engine,
sets,
contexts,
inputs,
flights,
move,
frame,
load,
get selected() {
return selectedContext;
},
get commits() {
return commits;
},
get credits() {
return credits;
},
get renderRequested() {
return renderRequested;
},
finishFrame() {
renderRequested = false;
},
follow(value) {
following = value;
},
otherSelection() {
selectedContext = { id: 'flight:1', layerId: 'flights' };
},
};
}
const records = (count) =>
Array.from({ length: count }, (_, i) => ({
id: `alpr:${i}`,
latitude: 30 + i * 0.00001,
longitude: -97,
osmId: i,
}));
test('ALPR preserves a postRender update after the view clears its current frame request', async (t) => {
const h = harness(t);
h.load(records(1));
h.inputs.get('click')({
position: { x: 400, y: 300 },
picked: { id: 'alpr:0' },
});
h.finishFrame();
await Promise.resolve();
assert.equal(h.renderRequested, true, 'the pending change wakes a new frame');
h.frame.fire();
assert.equal(h.sets[0].committedRows[0].show, false);
assert.equal(h.sets[1].committedRows[0]?.pickId, 'alpr:0');
const commits = h.commits;
h.finishFrame();
h.frame.fire();
await Promise.resolve();
assert.equal(
h.commits,
commits,
'settled sprites do not keep requesting frames',
);
assert.equal(
h.renderRequested,
false,
'settled sprites do not wake another frame',
);
});
test('retired ALPR floor requests cannot rewrite a newer marker or a destroyed set', async (t) => {
const h = harness(t);
const pending = [];
let cached;
h.services.groundFloor.cachedGroundFloor = () => cached;
h.services.groundFloor.resolveGroundFloorCells = (_points, { signal }) =>
new Promise((resolve) => pending.push({ resolve, signal }));
h.load(records(1));
assert.equal(pending.length, 1);
h.renderer.clearRendered();
assert.equal(pending[0].signal.aborted, true);
h.load([{ ...records(1)[0], latitude: 30.01 }]);
assert.equal(pending.length, 2);
const marker = h.contexts.get('alpr:0').entity;
const position = marker.position;
cached = 450;
pending[0].resolve();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(
marker.position,
position,
'old completion cannot place the new marker',
);
h.renderer.destroy();
assert.equal(pending[1].signal.aborted, true);
const commits = h.commits;
pending[1].resolve();
await new Promise((resolve) => setImmediate(resolve));
h.frame.fire();
assert.equal(h.commits, commits);
assert.equal(h.contexts.size, 0);
assert.equal(h.inputs.size, 0);
assert.equal(h.frame.listeners.size, 0);
assert.equal(h.move.listeners.size, 0);
});
test('ALPR batches a dense viewport, respects the cap and never follows a tracked target', (t) => {
const h = harness(t);
const before = h.commits;
h.load(records(1600));
assert.equal(h.renderer.count(), 1500);
assert.equal(h.state.renderSaturated, true);
assert.ok(
h.commits - before < 10,
'one buffer commit per batch, not per marker',
);
assert.equal(h.credits, 1);
h.follow(true);
assert.equal(h.renderer.focusNearest(), false);
assert.equal(h.flights.length, 0);
h.follow(false);
assert.equal(h.renderer.focusNearest(), true);
assert.equal(h.flights[0].options.range, 800);
assert.equal(h.flights[0].options.pitch, -Math.PI / 4);
assert.equal(h.selected.id, 'alpr:0');
});
test('ALPR preserves selected metadata and releases an evicted or replaced selection', (t) => {
const h = harness(t);
h.load(records(2));
h.inputs.get('click')({
position: { x: 400, y: 300 },
picked: { id: 'alpr:0' },
});
assert.equal(h.selected.id, 'alpr:0');
h.load(records(2).map((r) => ({ ...r, operator: 'Updated' })));
assert.equal(h.state.selectedId, 'alpr:0');
assert.ok(
h.contexts
.get('alpr:0')
.entity.gevLabelModel.details.some((line) => line.includes('UPDATED')),
);
h.otherSelection();
h.load(records(2));
assert.equal(h.state.selectedId, null);
assert.equal(h.selected.id, 'flight:1');
h.inputs.get('click')({
position: { x: 400, y: 300 },
picked: { id: 'alpr:1' },
});
h.load(records(1));
assert.equal(h.state.selectedId, null);
assert.equal(h.contexts.has('alpr:1'), false);
});
test('ten ALPR toggles clear contexts, picks and credits without allocating new sets', (t) => {
const h = harness(t);
for (let i = 0; i < 10; i++) {
h.state.enabled = true;
h.renderer.setVisible(true);
h.load(records(3));
h.state.enabled = false;
h.renderer.setVisible(false);
h.renderer.clearSelection();
h.renderer.clearRendered();
assert.equal(h.contexts.size, 0);
assert.equal(h.inputs.size, 0);
assert.equal(h.credits, 0);
assert.ok(h.sets.every((set) => set.count === 0));
}
assert.equal(h.sets.length, 2);
});
+424
View File
@@ -0,0 +1,424 @@
import * as geo from '../../../geo/index.js';
import {
MAX_CANVAS_FRUSTUMS,
MARKER_ICON_SIZE,
SELECTED_MARKER_ICON_SIZE,
MARKER_FLOOR_LIFT_M,
ANCHOR_SAMPLES_PER_PAINT,
SURFACE_BELOW_FLOOR_M,
SURFACE_ABOVE_FLOOR_M,
SURFACE_SAMPLES_PER_PAINT,
} from '../policy.js';
import {
MARKER_IMAGE,
SELECTED_IMAGE,
BRACKETS_IMAGE,
directionWedgePositions,
markerScale,
paintDirectionWedge,
validAlprGroundHeight,
} from '../visuals.js';
/** Canvas artwork over Navara pick sprites, with bounded surface refinement. */
export function createAlprNavaraOverlay({
engine,
state,
markers,
sampleHeight,
syncMarker,
services,
onSurfaceChange = () => {},
}) {
let lane,
removeMapListener,
retryTimer,
retryUntil = 0;
let records = [],
hits = [],
images = [],
destroyed = false,
lastCamera = null;
const requestPaint = () => {
if (!destroyed && state.enabled) lane?.requestPaint();
};
function nativeVisible(entity, visible) {
if (!entity) return;
canvasOwned(entity, !visible);
setShown(entity, visible);
}
function canvasOwned(marker, owned) {
if (marker.canvasOwned === owned) return;
marker.canvasOwned = owned;
syncMarker(marker);
}
function setShown(marker, visible) {
if (marker.shown === visible) return;
marker.shown = visible;
syncMarker(marker);
}
function resetAnchors() {
for (const entity of markers.values()) {
entity.gevAlprCanvasPosition = null;
entity.gevAlprWedge = null;
entity.displayPosition = null;
nativeVisible(entity, true);
}
state.lastAnchorSampleAt = 0;
retryUntil = Date.now() + 15000;
requestPaint();
}
function anchorFor(record, entity, budget) {
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
const location = geo.cartographic.fromDegrees(
record.longitude,
record.latitude,
);
// A floor-placed marker anchors where its native badge sits; only a
// marker still waiting for its floor samples the rendered surface, and
// each paint samples a few at most (Navara raycasts the loaded surface).
let height;
const floor = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
);
if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M;
else if (engine.surface.supported) {
if (budget.samples <= 0) return null;
budget.samples -= 1;
try {
height = sampleHeight(location);
} catch {
/* streaming tiles */
}
}
if (!validAlprGroundHeight(height) && engine.surface.terrainHeightSupported)
height = engine.surface.terrainHeight(location);
if (!validAlprGroundHeight(height)) return null;
setAnchor(record, entity, height);
return entity.gevAlprCanvasPosition;
}
function floorHeightAt(latitude, longitude) {
const floor = services.groundFloor.cachedGroundFloor(latitude, longitude);
return validAlprGroundHeight(floor) ? floor + MARKER_FLOOR_LIFT_M : null;
}
function setAnchor(record, entity, height) {
entity.gevAlprCanvasPosition = geo.cartesian3.fromDegrees(
record.longitude,
record.latitude,
height,
);
entity.resolved = true;
syncMarker(entity);
entity.gevAlprWedge = directionWedgePositions(
record,
height,
floorHeightAt,
);
}
/**
* Replace floor anchors with the rendered surface under each overlay camera.
* Floors come from ~111 m cells, so on a slope a badge could float or sink
* by tens of metres. Runs only while the camera is still and tiles have
* settled, a few surface samples per paint, and each anchor at most once;
* samples far outside the floor prior (a roof or a streaming tile) are
* rejected. Returns whether anchors remain to check.
*/
function refineAnchors(budget) {
const camera = engine.camera.pose();
const still =
lastCamera &&
camera.position &&
geo.cartesian3.equalsEpsilon(
camera.position,
lastCamera.position,
0,
0.05,
) &&
geo.cartesian3.equalsEpsilon(
camera.direction,
lastCamera.direction,
0,
1e-6,
);
if (camera.position && camera.direction)
lastCamera = {
position: geo.cartesian3.clone(camera.position),
direction: geo.cartesian3.clone(camera.direction),
};
if (!engine.surface.supported) return false;
let pending = false;
for (const record of records) {
const entity = markers.get(record.id);
const anchor = entity?.gevAlprCanvasPosition;
if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue;
if (!still || !engine.surface.tilesLoaded() || budget.samples <= 0) {
pending = true;
continue;
}
budget.samples -= 1;
let sample;
try {
sample = sampleHeight(
geo.cartographic.fromDegrees(record.longitude, record.latitude),
);
} catch {
/* streaming tiles */
}
const current = geo.cartographic.fromCartesian(anchor).height;
const floor = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
);
const accepted =
validAlprGroundHeight(sample) &&
(!validAlprGroundHeight(floor) ||
(sample >= floor - SURFACE_BELOW_FLOOR_M &&
sample <= floor + SURFACE_ABOVE_FLOOR_M));
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - current) > 1)
setAnchor(record, entity, sample + MARKER_FLOOR_LIFT_M);
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
if (entity.id === state.selectedId)
entity.displayPosition = entity.gevAlprCanvasPosition;
}
return pending;
}
function paint({
ctx,
width,
height,
keyhole,
viewProjectionMatrix: matrix,
}) {
hits = [];
if (!state.enabled || destroyed) return;
// The host already captured the camera and CSS viewport for this frame.
// Reuse it for every wedge vertex instead of rereading DOM geometry.
const project = ({ x, y, z }) => {
const w = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15];
if (!(w > 0)) return null;
return {
x:
(((matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]) / w +
1) *
width) /
2,
y:
((1 -
(matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]) / w) *
height) /
2,
};
};
let unresolved = false;
const painted = [];
const budget = { samples: ANCHOR_SAMPLES_PER_PAINT };
for (const record of records) {
const entity = markers.get(record.id);
if (!entity) continue;
const selected = record.id === state.selectedId;
const image = images[selected ? 1 : 0];
if (
!image?.complete ||
!image.naturalWidth ||
(selected && !images[2]?.naturalWidth)
) {
nativeVisible(entity, true);
continue;
}
const anchor = anchorFor(record, entity, budget);
if (!anchor) {
nativeVisible(entity, true);
unresolved = true;
continue;
}
canvasOwned(entity, true);
if (!engine.projector.horizonVisible(anchor)) {
setShown(entity, false);
continue;
}
const origin = project(anchor);
if (!origin) {
setShown(entity, false);
continue;
}
const alpha =
services.overlays.keyholeAlpha?.(origin.x, origin.y, keyhole) ?? 1;
if (!(alpha > 0)) {
setShown(entity, false);
continue;
}
const wedge = entity.gevAlprWedge;
if (wedge) {
const left = project(wedge[1]);
const right = project(wedge[2]);
if (
left &&
right &&
Math.max(origin.x, left.x, right.x) >= -3 &&
Math.min(origin.x, left.x, right.x) <= width + 3 &&
Math.max(origin.y, left.y, right.y) >= -3 &&
Math.min(origin.y, left.y, right.y) <= height + 3
) {
ctx.save();
ctx.globalAlpha *= alpha;
paintDirectionWedge(ctx, origin, left, right, selected);
ctx.restore();
}
}
if (
origin.x < -60 ||
origin.x > width + 60 ||
origin.y < -60 ||
origin.y > height + 60
) {
setShown(entity, false);
continue;
}
setShown(entity, true);
painted.push({
record,
origin,
selected,
image,
alpha,
scale: markerScale(
geo.cartesian3.distance(engine.camera.livePosition, anchor),
),
});
if (selected) entity.displayPosition = anchor;
}
// Paint all glyphs after the wedges so one camera's cone cannot wash out another.
for (const { record, origin, selected, image, alpha, scale } of painted) {
const size =
(selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE) * scale;
ctx.save();
ctx.globalAlpha *= alpha;
ctx.drawImage(
image,
origin.x - size / 2,
origin.y - size / 2,
size,
size,
);
if (selected)
ctx.drawImage(
images[2],
origin.x - size / 2,
origin.y - size / 2,
size,
size,
);
ctx.restore();
hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 });
}
const refining = refineAnchors({
samples: Math.min(budget.samples, SURFACE_SAMPLES_PER_PAINT),
});
if ((unresolved || refining) && !retryTimer && Date.now() < retryUntil) {
retryTimer = setTimeout(() => {
retryTimer = null;
requestPaint();
}, 250);
}
}
return {
init() {
destroyed = false;
removeMapListener = services.overlays?.subscribeMapStack?.((event) => {
if (event.detail?.status !== 'ready' || !state.enabled) return;
// Same-regime provider changes also invalidate absolute placements.
onSurfaceChange();
resetAnchors();
});
if (!services.overlays?.registerPaintLane || typeof Image === 'undefined')
return;
lane = services.overlays.registerPaintLane('selected', paint, {
id: `alpr-${crypto.randomUUID()}`,
active: false,
});
images = [MARKER_IMAGE, SELECTED_IMAGE, BRACKETS_IMAGE].map((src) => {
const image = new Image();
image.onload = requestPaint;
image.onerror = requestPaint;
image.src = src;
return image;
});
},
sync(visible) {
if (!lane) return;
for (const record of records) nativeVisible(markers.get(record.id), true);
const position = engine.camera.livePosition;
records = visible
.filter(
(record) =>
Number.isFinite(record.directionDeg) &&
record.id !== state.selectedId,
)
.map((record) => ({
record,
distance: position
? geo.cartesian3.distanceSquared(
position,
geo.cartesian3.fromDegrees(record.longitude, record.latitude),
)
: 0,
}))
.sort((a, b) => a.distance - b.distance)
.slice(0, MAX_CANVAS_FRUSTUMS)
.map((item) => item.record);
const selected = visible.find((record) => record.id === state.selectedId);
if (selected) {
if (records.length >= MAX_CANVAS_FRUSTUMS) records.pop();
records.push(selected);
}
hits = [];
retryUntil = Date.now() + 15000;
lane.setActive(state.enabled && records.length > 0);
requestPaint();
},
pick(position) {
if (!state.enabled || !position) return null;
let closest,
distance = Infinity;
for (const hit of hits) {
const next = (hit.x - position.x) ** 2 + (hit.y - position.y) ** 2;
if (next <= hit.radius ** 2 && next <= distance) {
closest = hit.id;
distance = next;
}
}
return closest || null;
},
clear() {
clearTimeout(retryTimer);
retryTimer = null;
for (const record of records) nativeVisible(markers.get(record.id), true);
records = [];
hits = [];
lane?.setActive(false);
},
destroy() {
this.clear();
destroyed = true;
removeMapListener?.();
removeMapListener = null;
lane?.unregister();
lane = null;
for (const image of images) {
image.onload = null;
image.onerror = null;
}
images = [];
},
};
}
@@ -0,0 +1,228 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../../geo/index.js';
import { createAlprNavaraOverlay } from './navaraOverlay.js';
function harness(t, count = 2) {
const previousImage = globalThis.Image;
globalThis.Image = class {
complete = true;
naturalWidth = 128;
};
const records = Array.from({ length: count }, (_, i) => ({
id: `camera:${i}`,
latitude: 30 + i / 10000,
longitude: -97,
directionDeg: 90,
}));
const markers = new Map(
records.map((record) => [
record.id,
{
id: record.id,
record,
shown: true,
canvasOwned: false,
position: geo.cartesian3.fromDegrees(
record.longitude,
record.latitude,
151.5,
),
},
]),
);
const state = { enabled: true, selectedId: null };
let paint,
mapListener,
active,
samples = 0,
removed = 0;
const pose = {
position: geo.cartesian3.fromDegrees(-97, 30, 500),
direction: geo.cartesian3.UNIT_X,
};
const engine = {
camera: { livePosition: pose.position, pose: () => pose },
projector: {
worldToWindow: () => ({ x: 100, y: 100 }),
horizonVisible: () => true,
},
surface: {
supported: true,
tilesLoaded: () => true,
terrainHeightSupported: false,
},
};
const draw = [];
const ctx = {
globalAlpha: 1,
save() {},
restore() {},
beginPath() {},
moveTo() {},
lineTo() {},
closePath() {},
stroke() {},
fill() {
draw.push('wedge');
},
createLinearGradient: () => ({ addColorStop() {} }),
drawImage(image) {
draw.push(image.src);
},
};
const overlay = createAlprNavaraOverlay({
engine,
state,
markers,
sampleHeight(location) {
samples++;
return geo.math.toDegrees(location.latitude) > 30.00005 ? 400 : 172;
},
syncMarker() {},
services: {
groundFloor: { cachedGroundFloor: () => 150 },
overlays: {
registerPaintLane(_name, callback) {
paint = callback;
return {
requestPaint() {},
setActive(value) {
active = value;
},
unregister() {
removed++;
},
};
},
keyholeAlpha: () => 1,
subscribeMapStack(callback) {
mapListener = callback;
return () => {
mapListener = null;
};
},
},
},
});
overlay.init();
t.after(() => {
overlay.destroy();
if (previousImage === undefined) delete globalThis.Image;
else globalThis.Image = previousImage;
});
return {
state,
markers,
records,
overlay,
engine,
draw,
paint: (frame = {}) =>
paint({
ctx,
width: 800,
height: 600,
viewProjectionMatrix: [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
-0.75,
2 / 3,
0,
1,
],
...frame,
}),
mapChanged: () => mapListener({ detail: { status: 'ready' } }),
get samples() {
return samples;
},
get active() {
return active;
},
get removed() {
return removed;
},
};
}
test('ALPR overlay retains the 64-camera budget, paints selection last and invents no bearing', (t) => {
const h = harness(t, 70);
h.records[69].directionDeg = null;
h.state.selectedId = 'camera:69';
h.overlay.sync(h.records);
h.paint();
assert.equal([...h.markers.values()].filter((m) => m.canvasOwned).length, 64);
assert.equal(h.draw.filter((x) => x === 'wedge').length, 63);
assert.ok(h.draw.at(-1).endsWith('alpr-marker-selected-brackets.png'));
assert.equal(h.overlay.pick({ x: 100, y: 100 }), 'camera:69');
assert.equal(h.markers.get('camera:69').gevAlprWedge, null);
});
test('ALPR projects from the host frame and skips fully clipped wedges, preserving edge strokes', (t) => {
const h = harness(t, 1);
h.engine.projector.worldToWindow = () => {
throw new Error('A paint must not reread camera and DOM size per vertex');
};
h.overlay.sync(h.records);
h.paint();
assert.equal(h.overlay.pick({ x: 100, y: 100 }), 'camera:0');
const matrix = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1.005, 0, 0, 1];
h.draw.length = 0;
h.paint({ viewProjectionMatrix: matrix });
assert.ok(
h.draw.includes('wedge'),
'a stroke within 3px of the edge still paints',
);
matrix[12] = -2;
h.draw.length = 0;
h.paint({ viewProjectionMatrix: matrix });
assert.equal(h.draw.length, 0, 'fully clipped artwork allocates no gradient');
assert.equal(h.overlay.pick({ x: 100, y: 100 }), null);
matrix[15] = -1;
h.paint({ viewProjectionMatrix: matrix });
assert.equal(h.draw.length, 0, 'points behind the camera never paint');
});
test('ALPR overlay samples two settled anchors per paint and rejects roof-height outliers', (t) => {
const h = harness(t);
h.overlay.sync(h.records);
h.paint();
assert.equal(h.samples, 0);
h.paint();
assert.equal(h.samples, 2);
const height = (id) =>
geo.cartographic.fromCartesian(h.markers.get(id).gevAlprCanvasPosition)
.height;
assert.ok(Math.abs(height('camera:0') - 173.5) < 0.01);
assert.ok(Math.abs(height('camera:1') - 151.5) < 0.01);
h.paint();
assert.equal(h.samples, 2);
h.mapChanged();
assert.equal(h.markers.get('camera:0').gevAlprCanvasPosition, null);
});
test('ALPR overlay horizon and disposal remove hits, painting and ownership', (t) => {
const h = harness(t);
h.overlay.sync(h.records);
h.paint();
h.engine.projector.horizonVisible = () => false;
h.paint();
assert.equal(h.overlay.pick({ x: 100, y: 100 }), null);
h.state.enabled = false;
h.overlay.clear();
assert.equal(h.active, false);
assert.ok([...h.markers.values()].every((m) => !m.canvasOwned && m.shown));
h.overlay.destroy();
assert.equal(h.removed, 1);
});
+6 -2
View File
@@ -50,8 +50,12 @@ export function layoutLeftPanelRail({
// The existing narrow-screen composition has its own full-width stack.
// Keep this desktop lane engine from fighting those dedicated rules.
if (windowRef.matchMedia('(max-width: 720px)').matches) {
stack.classList.remove('layout-focus');
stack.classList.remove('layout-tail');
if (stack.classList.contains('layout-focus')) {
stack.classList.remove('layout-focus');
}
if (stack.classList.contains('layout-tail')) {
stack.classList.remove('layout-tail');
}
stack.style.removeProperty('--left-stack-safe-top');
stack.style.removeProperty('--left-stack-safe-bottom');
stack.style.removeProperty('--left-stack-centered-height');
+16
View File
@@ -197,6 +197,22 @@ test('missing rails are inert without browser globals', () => {
layoutRightPanelRail({});
});
test('repeated mobile layout does not rewrite absent left rail classes', () => {
const f = fixture('left', { mobile: true });
f.stack.classList.add('layout-focus', 'layout-tail');
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), false);
assert.equal(f.stack.classList.contains('layout-tail'), false);
let removals = 0;
const remove = f.stack.classList.remove;
f.stack.classList.remove = (...names) => {
removals += 1;
remove(...names);
};
f.run();
assert.equal(removals, 0, 'no redundant class mutation can retrigger layout');
});
test('left layout records collapsed heights and aligns the right rail without changing disclosure', () => {
const f = fixture('left');
f.run();
+2 -1
View File
@@ -232,6 +232,7 @@
.hud-summary-wrap {
margin-top: 6px;
width: 420px;
max-width: 420px;
}
@@ -243,7 +244,7 @@
.hud-summary {
margin-top: 2px;
min-height: 16px;
min-height: max(16px, 1lh);
font-size: 11px;
letter-spacing: 1.2px;
opacity: 0.88;