mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
1 parent
9d587c1d37
commit
46b3aceafc
23 files changed
+2081
-181
No files matched your search
@@ -34,6 +34,7 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
|
|||||||
// engine in their renderers: cesium only in render/cesium*.js, three and
|
// 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.
|
// Navara only in render/navara*.js. The list grows with each moved layer.
|
||||||
const RENDERED_LAYER_DIRS = [
|
const RENDERED_LAYER_DIRS = [
|
||||||
|
'src/layers/alpr/',
|
||||||
'src/layers/radio/',
|
'src/layers/radio/',
|
||||||
'src/layers/bikeshare/',
|
'src/layers/bikeshare/',
|
||||||
'src/layers/earthquakes/',
|
'src/layers/earthquakes/',
|
||||||
|
|||||||
@@ -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)) : '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -573,9 +573,16 @@
|
|||||||
"src/layers/alpr/index.js",
|
"src/layers/alpr/index.js",
|
||||||
"src/layers/alpr/model.js",
|
"src/layers/alpr/model.js",
|
||||||
"src/layers/alpr/policy.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/visuals.js",
|
||||||
"src/layers/alpr/overlay.js",
|
"src/layers/alpr/render/cesiumOverlay.js",
|
||||||
"src/layers/alpr/overlay.test.mjs",
|
"src/layers/alpr/overlay.test.mjs",
|
||||||
"src/layers/alpr/source.js",
|
"src/layers/alpr/source.js",
|
||||||
"src/layers/alpr/source.test.mjs",
|
"src/layers/alpr/source.test.mjs",
|
||||||
|
|||||||
@@ -1568,9 +1568,7 @@
|
|||||||
"src/layers/alpr/index.js",
|
"src/layers/alpr/index.js",
|
||||||
"src/layers/alpr/model.js",
|
"src/layers/alpr/model.js",
|
||||||
"src/layers/alpr/policy.js",
|
"src/layers/alpr/policy.js",
|
||||||
"src/layers/alpr/presentation.js",
|
|
||||||
"src/layers/alpr/visuals.js",
|
"src/layers/alpr/visuals.js",
|
||||||
"src/layers/alpr/overlay.js",
|
|
||||||
"src/layers/alpr/source.js",
|
"src/layers/alpr/source.js",
|
||||||
"src/data/inputOwnership.js",
|
"src/data/inputOwnership.js",
|
||||||
"src/layers/alpr/records.js",
|
"src/layers/alpr/records.js",
|
||||||
@@ -1598,7 +1596,7 @@
|
|||||||
"src/geo/debug.js",
|
"src/geo/debug.js",
|
||||||
"src/geo/developerError.js"
|
"src/geo/developerError.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
|
"external": ["@mapbox/vector-tile", "pbf"]
|
||||||
},
|
},
|
||||||
"radio-layer": {
|
"radio-layer": {
|
||||||
"exports": ["./layers/radio"],
|
"exports": ["./layers/radio"],
|
||||||
@@ -2032,9 +2030,10 @@
|
|||||||
"src/layers/alpr/index.js",
|
"src/layers/alpr/index.js",
|
||||||
"src/layers/alpr/model.js",
|
"src/layers/alpr/model.js",
|
||||||
"src/layers/alpr/policy.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/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/alpr/source.js",
|
||||||
"src/layers/awareness/controls.js",
|
"src/layers/awareness/controls.js",
|
||||||
"src/layers/awareness/dependencies.js",
|
"src/layers/awareness/dependencies.js",
|
||||||
@@ -2708,6 +2707,8 @@
|
|||||||
"src/layers/radio/render/cesium.js",
|
"src/layers/radio/render/cesium.js",
|
||||||
"src/layers/firms/render/navara.js",
|
"src/layers/firms/render/navara.js",
|
||||||
"src/layers/radio/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/radio/screenClusters.js",
|
||||||
"src/layers/firms/render/navaraGeometry.js",
|
"src/layers/firms/render/navaraGeometry.js",
|
||||||
"src/layers/firms/render/navaraSprites.js",
|
"src/layers/firms/render/navaraSprites.js",
|
||||||
@@ -2882,9 +2883,10 @@
|
|||||||
"src/layers/alpr/index.js",
|
"src/layers/alpr/index.js",
|
||||||
"src/layers/alpr/model.js",
|
"src/layers/alpr/model.js",
|
||||||
"src/layers/alpr/policy.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/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/alpr/source.js",
|
||||||
"src/layers/awareness/controls.js",
|
"src/layers/awareness/controls.js",
|
||||||
"src/layers/awareness/dependencies.js",
|
"src/layers/awareness/dependencies.js",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* qa-alpr-journey.mjs — ALPR cameras on a realistic Austin camera journey.
|
* 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]
|
* [--passes off,on,on] [--out qa-shots/alpr-journey] [--no-assert]
|
||||||
*
|
*
|
||||||
* Drives the same eased camera path several times: a neighbourhood by Camp
|
* 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
|
* 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
|
* 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
|
* marker stability while the camera is still (camera-set changes after the stop
|
||||||
* has settled). Asserts, unless --no-assert: neighbourhood and downtown show
|
* 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
|
* 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 path from 'node:path';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
import { installAlprFixture } from './fixtures/w2/alpr.mjs';
|
||||||
|
import { redact, isExpectedPreviewSetupError } from './qa-browserEvidence.mjs';
|
||||||
import {
|
import {
|
||||||
createScreencast,
|
createScreencast,
|
||||||
frameStats,
|
frameStats,
|
||||||
@@ -60,12 +62,20 @@ const option = (name, fallback) => {
|
|||||||
const i = argv.indexOf(name);
|
const i = argv.indexOf(name);
|
||||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
|
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
|
||||||
};
|
};
|
||||||
const appUrl =
|
const appUrl = argv.find((arg) => /^https?:/.test(arg));
|
||||||
argv.find((arg) => /^https?:/.test(arg)) || 'http://localhost:4173';
|
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 outDir = path.resolve(option('--out', 'qa-shots/alpr-journey'));
|
||||||
const passes = option('--passes', 'off,on,on').split(',');
|
const passes = option('--passes', 'off,on,on').split(',');
|
||||||
const assertOn = !argv.includes('--no-assert');
|
const assertOn = !argv.includes('--no-assert');
|
||||||
const dropRenders = argv.includes('--self-test-drop-renders');
|
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 });
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
const cyanCount = async (file) => {
|
const cyanCount = async (file) => {
|
||||||
@@ -170,6 +180,8 @@ const result = {
|
|||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
passes: [],
|
passes: [],
|
||||||
consoleErrors: [],
|
consoleErrors: [],
|
||||||
|
expectedPreviewErrors: [],
|
||||||
|
failedRequests: [],
|
||||||
failures: [],
|
failures: [],
|
||||||
};
|
};
|
||||||
const fail = (message) => {
|
const fail = (message) => {
|
||||||
@@ -179,14 +191,43 @@ const fail = (message) => {
|
|||||||
let page;
|
let page;
|
||||||
try {
|
try {
|
||||||
page = await browser.newPage();
|
page = await browser.newPage();
|
||||||
|
if (argv.includes('--fixture')) await installAlprFixture(page);
|
||||||
await page.setViewport({ width: 1440, height: 900 });
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
await page.evaluateOnNewDocument(() =>
|
await page.evaluateOnNewDocument(() =>
|
||||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
|
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
|
||||||
);
|
);
|
||||||
page.on('console', (message) => {
|
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);
|
const url = new URL(appUrl);
|
||||||
url.searchParams.set('welcome', '0');
|
url.searchParams.set('welcome', '0');
|
||||||
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
||||||
@@ -252,6 +293,8 @@ try {
|
|||||||
await page
|
await page
|
||||||
.waitForFunction(
|
.waitForFunction(
|
||||||
() => {
|
() => {
|
||||||
|
if (window.__godsEyeView.engine.kind === 'navara')
|
||||||
|
return window.__godsEyeView.engine.surface.tilesLoaded();
|
||||||
const { scene } = window.__godsEyeView.viewer;
|
const { scene } = window.__godsEyeView.viewer;
|
||||||
if (scene.globe.show) return scene.globe.tilesLoaded;
|
if (scene.globe.show) return scene.globe.tilesLoaded;
|
||||||
for (let i = 0; i < scene.primitives.length; i++) {
|
for (let i = 0; i < scene.primitives.length; i++) {
|
||||||
@@ -308,8 +351,31 @@ try {
|
|||||||
// changed state without asking for a render.
|
// changed state without asking for a render.
|
||||||
let staleFrame = null;
|
let staleFrame = null;
|
||||||
if (alpr) {
|
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 () => {
|
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 { scene } = window.__godsEyeView.viewer;
|
||||||
const mode = scene.requestRenderMode;
|
const mode = scene.requestRenderMode;
|
||||||
scene.requestRenderMode = false;
|
scene.requestRenderMode = false;
|
||||||
@@ -317,12 +383,23 @@ try {
|
|||||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
scene.requestRenderMode = mode;
|
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 = {
|
staleFrame = {
|
||||||
shown,
|
shown,
|
||||||
forced,
|
forced,
|
||||||
stale: Math.abs(forced - shown) > Math.max(8, forced * 0.15),
|
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 moving = frameStats(await readFrames(page, moveStart, holdStart));
|
||||||
const holding = frameStats(await readFrames(page, holdStart, holdEnd));
|
const holding = frameStats(await readFrames(page, holdStart, holdEnd));
|
||||||
|
|||||||
@@ -6,15 +6,18 @@
|
|||||||
* `<dir>/clip.mp4` through ffmpeg's concat demuxer with per-frame
|
* `<dir>/clip.mp4` through ffmpeg's concat demuxer with per-frame
|
||||||
* durations, so held frames last as long as they did on screen.
|
* durations, so held frames last as long as they did on screen.
|
||||||
* - `installFrameProbe(page)` measures every animation frame in the page:
|
* - `installFrameProbe(page)` measures every animation frame in the page:
|
||||||
* the requestAnimationFrame interval and, for frames Cesium rendered, the
|
* the requestAnimationFrame interval and, for rendered frames, the
|
||||||
* CPU time between `scene.preUpdate` and `scene.postRender`.
|
* CPU time between engine preUpdate and postRender events.
|
||||||
* - `frameStats(frames)` reduces a probe window to count / mean / p50 / p95.
|
* - `frameStats(frames)` reduces a probe window to count / mean / p50 / p95.
|
||||||
* - `moveCamera(page, view, seconds)` eases the camera like a drag or wheel
|
* - `moveCamera(page, view, seconds)` eases the camera like a drag or wheel
|
||||||
* zoom rather than teleporting it.
|
* zoom rather than teleporting it.
|
||||||
*/
|
*/
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
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. */
|
/** Percentile of a numeric list (nearest rank); NaN for an empty list. */
|
||||||
export function percentile(values, p) {
|
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) {
|
export function frameStats(frames) {
|
||||||
const intervals = frames.map((f) => f.dt).filter(Number.isFinite);
|
const intervals = frames.map((f) => f.dt).filter(Number.isFinite);
|
||||||
const renders = frames.map((f) => f.renderMs).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) {
|
export async function installFrameProbe(page) {
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
if (window.__gevFrameProbe) return;
|
if (window.__gevFrameProbe) return;
|
||||||
const { viewer } = window.__godsEyeView;
|
const { viewer, engine } = window.__godsEyeView;
|
||||||
|
const events = viewer?.scene ?? engine.frames;
|
||||||
const probe = { frames: [], started: 0 };
|
const probe = { frames: [], started: 0 };
|
||||||
window.__gevFrameProbe = probe;
|
window.__gevFrameProbe = probe;
|
||||||
let last = performance.now();
|
let last = performance.now();
|
||||||
let renderStart = null;
|
let renderStart = null;
|
||||||
let pendingRender = null;
|
let pendingRender = null;
|
||||||
viewer.scene.preUpdate.addEventListener(() => {
|
events.preUpdate.addEventListener(() => {
|
||||||
renderStart = performance.now();
|
renderStart = performance.now();
|
||||||
});
|
});
|
||||||
viewer.scene.postRender.addEventListener(() => {
|
events.postRender.addEventListener(() => {
|
||||||
if (renderStart !== null) pendingRender = performance.now() - renderStart;
|
if (renderStart !== null) pendingRender = performance.now() - renderStart;
|
||||||
renderStart = null;
|
renderStart = null;
|
||||||
});
|
});
|
||||||
@@ -98,6 +102,34 @@ export function pageNow(page) {
|
|||||||
* @param {number} seconds Flight duration; 0 jumps.
|
* @param {number} seconds Flight duration; 0 jumps.
|
||||||
*/
|
*/
|
||||||
export async function moveCamera(page, view, seconds) {
|
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(
|
await page.evaluate(
|
||||||
(v, duration) =>
|
(v, duration) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
@@ -191,7 +223,8 @@ export async function createScreencast(page, dir, options = {}) {
|
|||||||
const list = path.join(dir, 'frames.txt');
|
const list = path.join(dir, 'frames.txt');
|
||||||
fs.writeFileSync(list, `${lines.join('\n')}\n`);
|
fs.writeFileSync(list, `${lines.join('\n')}\n`);
|
||||||
const clip = path.join(dir, 'clip.mp4');
|
const clip = path.join(dir, 'clip.mp4');
|
||||||
execFileSync('ffmpeg', [
|
// Keep servicing Puppeteer's request interception while encoding.
|
||||||
|
await execFileAsync('ffmpeg', [
|
||||||
'-y',
|
'-y',
|
||||||
'-loglevel',
|
'-loglevel',
|
||||||
'error',
|
'error',
|
||||||
|
|||||||
@@ -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 * as credits from '../../data/dataCredits.js';
|
||||||
import { createAlprCamerasLayer } from '../../layers/alpr/index.js';
|
import { createAlprCamerasLayer } from '../../layers/alpr/index.js';
|
||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
@@ -8,10 +10,18 @@ import { keyholeLabelAlphaFromGeometry } from '../../celestialRing.js';
|
|||||||
import { refreshTrackedReadout } from '../../data/trackedReadout.js';
|
import { refreshTrackedReadout } from '../../data/trackedReadout.js';
|
||||||
|
|
||||||
/** Construct one layer using the application scene owners and a supplied source. */
|
/** 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;
|
const { groundFloor } = surface;
|
||||||
return createAlprCamerasLayer({
|
return createAlprCamerasLayer({
|
||||||
source,
|
source,
|
||||||
|
rendererFactory,
|
||||||
services: {
|
services: {
|
||||||
credits,
|
credits,
|
||||||
render,
|
render,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
|||||||
'military-installations',
|
'military-installations',
|
||||||
'bikeshare',
|
'bikeshare',
|
||||||
'radio',
|
'radio',
|
||||||
|
'alpr-cameras',
|
||||||
])
|
])
|
||||||
assert.equal(layerAvailableOn(id, 'navara'), true, id);
|
assert.equal(layerAvailableOn(id, 'navara'), true, id);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -40,6 +41,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
|||||||
'military',
|
'military',
|
||||||
'local-adsb',
|
'local-adsb',
|
||||||
'earthquakes',
|
'earthquakes',
|
||||||
|
'alpr-cameras',
|
||||||
'satellites',
|
'satellites',
|
||||||
'rocket-launches',
|
'rocket-launches',
|
||||||
'radio',
|
'radio',
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ const NAVARA_CREDIT_KEYS = new Set([
|
|||||||
*/
|
*/
|
||||||
export async function registerNavaraLayerRenderers() {
|
export async function registerNavaraLayerRenderers() {
|
||||||
const loaders = {
|
const loaders = {
|
||||||
|
'alpr-cameras': async () =>
|
||||||
|
(await import('../layers/alpr/render/navara.js'))
|
||||||
|
.createAlprNavaraRenderer,
|
||||||
radio: async () =>
|
radio: async () =>
|
||||||
(await import('../layers/radio/render/navara.js'))
|
(await import('../layers/radio/render/navara.js'))
|
||||||
.createRadioNavaraRenderer,
|
.createRadioNavaraRenderer,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createAlprCesiumRenderer } from '../layers/alpr/render/cesium.js';
|
||||||
// src/data/alprCameras.test.mjs
|
// src/data/alprCameras.test.mjs
|
||||||
// Pure helpers and actual layer lifecycle with a minimal viewer/input harness.
|
// Pure helpers and actual layer lifecycle with a minimal viewer/input harness.
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
@@ -854,6 +855,7 @@ test('two factories keep their requests, records and destruction independent', a
|
|||||||
};
|
};
|
||||||
let resolveFirst, firstSignal;
|
let resolveFirst, firstSignal;
|
||||||
const first = createAlprCamerasLayer({
|
const first = createAlprCamerasLayer({
|
||||||
|
rendererFactory: createAlprCesiumRenderer,
|
||||||
services,
|
services,
|
||||||
source: {
|
source: {
|
||||||
fetch: (_box, signal) => {
|
fetch: (_box, signal) => {
|
||||||
@@ -865,6 +867,7 @@ test('two factories keep their requests, records and destruction independent', a
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const second = createAlprCamerasLayer({
|
const second = createAlprCamerasLayer({
|
||||||
|
rendererFactory: createAlprCesiumRenderer,
|
||||||
services,
|
services,
|
||||||
source: {
|
source: {
|
||||||
label: 'Test camera directory',
|
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({
|
const layer = createAlprCamerasLayer({
|
||||||
|
rendererFactory: createAlprCesiumRenderer,
|
||||||
services,
|
services,
|
||||||
source: {
|
source: {
|
||||||
fetch: async () => ({
|
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,
|
longitude: -97.749 + (i % 15) * 0.001,
|
||||||
}));
|
}));
|
||||||
const layer = createAlprCamerasLayer({
|
const layer = createAlprCamerasLayer({
|
||||||
|
rendererFactory: createAlprCesiumRenderer,
|
||||||
source: {
|
source: {
|
||||||
fetch: async () => ({ records, stale: false, saturated: false }),
|
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,
|
directionDeg: i === 0 ? null : 90,
|
||||||
}));
|
}));
|
||||||
const layer = createAlprCamerasLayer({
|
const layer = createAlprCamerasLayer({
|
||||||
|
rendererFactory: createAlprCesiumRenderer,
|
||||||
source: {
|
source: {
|
||||||
fetch: async () => ({ records, stale: false, saturated: false }),
|
fetch: async () => ({ records, stale: false, saturated: false }),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -117,7 +117,10 @@ test('every scene click handler consults ownership before it picks', () => {
|
|||||||
['src/data/trackingClickGesture.js', 'onClick(click, gesture);'],
|
['src/data/trackingClickGesture.js', 'onClick(click, gesture);'],
|
||||||
['src/data/localGeojsonCore.js', 'viewer.scene.pick(click.position)'],
|
['src/data/localGeojsonCore.js', 'viewer.scene.pick(click.position)'],
|
||||||
['src/data/cctvGizmo.js', 'pickGizmoPart(event.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',
|
'src/layers/bikeshare/render/cesium/selection.js',
|
||||||
'viewer.scene.pick(click.position)',
|
'viewer.scene.pick(click.position)',
|
||||||
|
|||||||
+42
-88
@@ -1,5 +1,4 @@
|
|||||||
import { isUnavailableCapability } from '../../sources/capability.js';
|
import { isUnavailableCapability } from '../../sources/capability.js';
|
||||||
import * as Cesium from 'cesium';
|
|
||||||
import {
|
import {
|
||||||
LAYER_ID,
|
LAYER_ID,
|
||||||
REQUEST_DEBOUNCE_MS,
|
REQUEST_DEBOUNCE_MS,
|
||||||
@@ -15,9 +14,7 @@ import {
|
|||||||
destinationPointDeg,
|
destinationPointDeg,
|
||||||
alprRetryDelayMs,
|
alprRetryDelayMs,
|
||||||
validateAlprSnapshot,
|
validateAlprSnapshot,
|
||||||
alprCreditMarkup,
|
|
||||||
} from './model.js';
|
} from './model.js';
|
||||||
import { createAlprPresentation } from './presentation.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Own one layer's requests, records, display and viewer subscriptions.
|
* 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
|
* removeEntityContextsForLayer(layerId, { retainIds: Set<string> }), preserving
|
||||||
* retained selection without dispatching a new selection event.
|
* retained selection without dispatching a new selection event.
|
||||||
*/
|
*/
|
||||||
export function createAlprCamerasLayer({ source, services } = {}) {
|
export function createAlprCamerasLayer({
|
||||||
|
source,
|
||||||
|
services,
|
||||||
|
rendererFactory,
|
||||||
|
} = {}) {
|
||||||
if (typeof source?.fetch !== 'function')
|
if (typeof source?.fetch !== 'function')
|
||||||
throw new TypeError('ALPR requires a camera source');
|
throw new TypeError('ALPR requires a camera source');
|
||||||
const { governorRequestRender } = services.render;
|
const { governorRequestRender } = services.render;
|
||||||
@@ -41,11 +42,6 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
viewer: null,
|
|
||||||
dataSource: null,
|
|
||||||
credit: null,
|
|
||||||
creditTimer: null,
|
|
||||||
creditPresented: false,
|
|
||||||
enabled: false,
|
enabled: false,
|
||||||
records: [],
|
records: [],
|
||||||
recordById: new Map(),
|
recordById: new Map(),
|
||||||
@@ -63,10 +59,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
retryDelayMs: 0,
|
retryDelayMs: 0,
|
||||||
retryAt: 0,
|
retryAt: 0,
|
||||||
retrying: false,
|
retrying: false,
|
||||||
moveEndRemove: null,
|
|
||||||
postRenderRemove: null,
|
|
||||||
lastAnchorSampleAt: 0,
|
lastAnchorSampleAt: 0,
|
||||||
clickHandler: null,
|
|
||||||
debounceTimer: null,
|
debounceTimer: null,
|
||||||
/** Snapped box of the last successful query; a view still inside it reuses its records. */
|
/** Snapped box of the last successful query; a view still inside it reuses its records. */
|
||||||
lastQueryBox: null,
|
lastQueryBox: null,
|
||||||
@@ -77,21 +70,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
/** Cameras in view beyond the render cap. */
|
/** Cameras in view beyond the render cap. */
|
||||||
renderSaturated: false,
|
renderSaturated: false,
|
||||||
};
|
};
|
||||||
const {
|
let renderer = null;
|
||||||
initOverlay,
|
|
||||||
destroyOverlay,
|
|
||||||
viewportBox,
|
|
||||||
clearRendered,
|
|
||||||
hideOnMapCredit,
|
|
||||||
presentOnMapCredit,
|
|
||||||
renderRecords,
|
|
||||||
selectRecord,
|
|
||||||
focusNearest,
|
|
||||||
clearSelection,
|
|
||||||
updateSelectedAnchor,
|
|
||||||
installInteraction,
|
|
||||||
prepareFloors,
|
|
||||||
} = createAlprPresentation({ state, services, source });
|
|
||||||
|
|
||||||
function setAlprStatus(status, error = null) {
|
function setAlprStatus(status, error = null) {
|
||||||
if (state.status === status && state.error === error) return;
|
if (state.status === status && state.error === error) return;
|
||||||
@@ -141,7 +120,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
clearTimeout(state.debounceTimer);
|
clearTimeout(state.debounceTimer);
|
||||||
// A view the accepted tiles already cover is a local filter: show it now.
|
// 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.
|
// Only a view that needs a request waits out the debounce.
|
||||||
const box = viewportBox(state.viewer);
|
const box = renderer.viewportBox();
|
||||||
if (box && canReuse(box)) {
|
if (box && canReuse(box)) {
|
||||||
state.debounceTimer = null;
|
state.debounceTimer = null;
|
||||||
loadCameras();
|
loadCameras();
|
||||||
@@ -154,11 +133,11 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCameras() {
|
async function loadCameras() {
|
||||||
if (!state.enabled || !state.viewer) return;
|
if (!state.enabled || !renderer?.ready) return;
|
||||||
clearTimeout(state.debounceTimer);
|
clearTimeout(state.debounceTimer);
|
||||||
state.debounceTimer = null;
|
state.debounceTimer = null;
|
||||||
clearUnavailableRetry({ resetBackoff: false });
|
clearUnavailableRetry({ resetBackoff: false });
|
||||||
const box = viewportBox(state.viewer);
|
const box = renderer.viewportBox();
|
||||||
if (!box) {
|
if (!box) {
|
||||||
state.abort?.abort();
|
state.abort?.abort();
|
||||||
state.abort = null;
|
state.abort = null;
|
||||||
@@ -166,7 +145,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.retrying = false;
|
state.retrying = false;
|
||||||
clearUnavailableRetry();
|
clearUnavailableRetry();
|
||||||
renderRecords();
|
renderer.renderRecords();
|
||||||
setAlprStatus('zoom-in');
|
setAlprStatus('zoom-in');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -185,11 +164,9 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
state.pendingQueryBox = null;
|
state.pendingQueryBox = null;
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
}
|
}
|
||||||
renderRecords();
|
renderer.renderRecords();
|
||||||
state.retrying = false;
|
state.retrying = false;
|
||||||
setAlprStatus(
|
setAlprStatus(renderer.count() ? 'ready' : 'empty');
|
||||||
state.dataSource.entities.values.length ? 'ready' : 'empty',
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A move inside an in-flight query must not abort and restart that query.
|
// 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.lastQueryBox = null;
|
||||||
state.lastQueryZoom = null;
|
state.lastQueryZoom = null;
|
||||||
clearUnavailableRetry();
|
clearUnavailableRetry();
|
||||||
renderRecords();
|
renderer.renderRecords();
|
||||||
setAlprStatus('zoom-in');
|
setAlprStatus('zoom-in');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { records, stale, saturated } = validateAlprSnapshot(snapshot);
|
const { records, stale, saturated } = validateAlprSnapshot(snapshot);
|
||||||
// Resolve marker floors (bounded wait, warm cells answer at once) so new
|
// Resolve marker floors (bounded wait, warm cells answer at once) so new
|
||||||
// markers are placed on the floor instead of clamped then moved.
|
// markers are placed on the floor instead of clamped then moved.
|
||||||
await prepareFloors(records);
|
await renderer.prepareFloors(records);
|
||||||
if (
|
if (
|
||||||
requestAbort.signal.aborted ||
|
requestAbort.signal.aborted ||
|
||||||
state.abort !== requestAbort ||
|
state.abort !== requestAbort ||
|
||||||
@@ -256,14 +233,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
clearUnavailableRetry();
|
clearUnavailableRetry();
|
||||||
// Stale and saturated are independent facts; a cached response that also
|
// Stale and saturated are independent facts; a cached response that also
|
||||||
// hit the cap must still warn about coverage, not just about freshness.
|
// hit the cap must still warn about coverage, not just about freshness.
|
||||||
renderRecords();
|
renderer.renderRecords();
|
||||||
setAlprStatus(
|
setAlprStatus(renderer.count() ? (stale ? 'stale' : 'ready') : 'empty');
|
||||||
state.dataSource.entities.values.length
|
|
||||||
? stale
|
|
||||||
? 'stale'
|
|
||||||
: 'ready'
|
|
||||||
: 'empty',
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
error?.name === 'AbortError' ||
|
error?.name === 'AbortError' ||
|
||||||
@@ -296,33 +267,28 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
source: source.label || 'Mapped camera locations',
|
source: source.label || 'Mapped camera locations',
|
||||||
updateInterval: 0,
|
updateInterval: 0,
|
||||||
statsRefreshInterval: 1000,
|
statsRefreshInterval: 1000,
|
||||||
init(viewer) {
|
init(viewer, options = {}) {
|
||||||
if (state.viewer) throw new Error('ALPR layer is already initialized');
|
if (renderer?.ready) throw new Error('ALPR layer is already initialized');
|
||||||
state.viewer = viewer;
|
if (typeof rendererFactory !== 'function')
|
||||||
state.dataSource = new Cesium.CustomDataSource('alpr-cameras');
|
throw new TypeError('An ALPR renderer factory is required');
|
||||||
const creditMarkup = alprCreditMarkup(source.attribution);
|
renderer = rendererFactory({
|
||||||
state.credit = creditMarkup
|
state,
|
||||||
? new Cesium.Credit(creditMarkup, true)
|
services,
|
||||||
: null;
|
source,
|
||||||
viewer.dataSources.add(state.dataSource);
|
engine: options.engine,
|
||||||
state.moveEndRemove =
|
});
|
||||||
viewer.camera.moveEnd.addEventListener(scheduleLoad);
|
renderer.init(viewer, { onMove: scheduleLoad });
|
||||||
state.postRenderRemove =
|
|
||||||
viewer.scene.postRender?.addEventListener(updateSelectedAnchor);
|
|
||||||
initOverlay();
|
|
||||||
installInteraction(viewer);
|
|
||||||
},
|
},
|
||||||
enable() {
|
enable() {
|
||||||
if (state.enabled) return;
|
if (state.enabled) return;
|
||||||
state.enabled = true;
|
state.enabled = true;
|
||||||
state.creditPresented = false;
|
|
||||||
registerPickOwner(LAYER_ID, (id) => state.recordById.has(id));
|
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.
|
// DataLayerManager calls update() right after enable(); it owns the first fetch.
|
||||||
},
|
},
|
||||||
disable() {
|
disable() {
|
||||||
state.enabled = false;
|
state.enabled = false;
|
||||||
hideOnMapCredit();
|
renderer?.hideOnMapCredit();
|
||||||
unregisterPickOwner(LAYER_ID);
|
unregisterPickOwner(LAYER_ID);
|
||||||
clearUnavailableRetry();
|
clearUnavailableRetry();
|
||||||
clearTimeout(state.debounceTimer);
|
clearTimeout(state.debounceTimer);
|
||||||
@@ -331,30 +297,19 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
state.pendingQueryBox = null;
|
state.pendingQueryBox = null;
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.retrying = false;
|
state.retrying = false;
|
||||||
if (state.dataSource) state.dataSource.show = false;
|
renderer?.setVisible(false);
|
||||||
clearSelection();
|
renderer?.clearSelection();
|
||||||
clearRendered();
|
renderer?.clearRendered();
|
||||||
setAlprStatus('idle');
|
setAlprStatus('idle');
|
||||||
},
|
},
|
||||||
update() {
|
update() {
|
||||||
return loadCameras();
|
return loadCameras();
|
||||||
},
|
},
|
||||||
destroy(viewer = state.viewer) {
|
destroy() {
|
||||||
source.destroy?.();
|
source.destroy?.();
|
||||||
this.disable();
|
this.disable();
|
||||||
destroyOverlay();
|
renderer?.destroy();
|
||||||
state.moveEndRemove?.();
|
renderer = null;
|
||||||
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;
|
|
||||||
state.records = [];
|
state.records = [];
|
||||||
state.recordById = new Map();
|
state.recordById = new Map();
|
||||||
state.lastQueryBox = null;
|
state.lastQueryBox = null;
|
||||||
@@ -364,21 +319,20 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
state.status = 'idle';
|
state.status = 'idle';
|
||||||
state.stale = false;
|
state.stale = false;
|
||||||
state.saturated = false;
|
state.saturated = false;
|
||||||
state.viewer = null;
|
|
||||||
},
|
},
|
||||||
getRowControls() {
|
getRowControls() {
|
||||||
const count = state.dataSource?.entities.values.length || 0;
|
const count = renderer?.count() || 0;
|
||||||
return {
|
return {
|
||||||
chips: [
|
chips: [
|
||||||
{
|
{
|
||||||
id: 'find-camera',
|
id: 'find-camera',
|
||||||
label: 'SHOW NEAREST',
|
label: 'SHOW NEAREST',
|
||||||
title: state.viewer?.trackedEntity
|
title: renderer?.following()
|
||||||
? 'Stop following the current object before navigating to a camera'
|
? 'Stop following the current object before navigating to a camera'
|
||||||
: 'Move to the nearest loaded camera and show its details',
|
: 'Move to the nearest loaded camera and show its details',
|
||||||
disabled:
|
disabled:
|
||||||
!state.enabled || !count || Boolean(state.viewer?.trackedEntity),
|
!state.enabled || !count || Boolean(renderer?.following()),
|
||||||
onClick: focusNearest,
|
onClick: () => renderer?.focusNearest(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
legend: [
|
legend: [
|
||||||
@@ -425,10 +379,10 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
},
|
},
|
||||||
getStats() {
|
getStats() {
|
||||||
return {
|
return {
|
||||||
count: state.dataSource?.entities.values.length || 0,
|
count: renderer?.count() || 0,
|
||||||
countLabel:
|
countLabel:
|
||||||
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
|
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
|
||||||
? `${state.dataSource?.entities.values.length || 0} nearby${
|
? `${renderer?.count() || 0} nearby${
|
||||||
Number.isInteger(state.onScreen)
|
Number.isInteger(state.onScreen)
|
||||||
? ` · ${state.onScreen} on screen`
|
? ` · ${state.onScreen} on screen`
|
||||||
: ''
|
: ''
|
||||||
@@ -440,7 +394,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
saturated: state.saturated || state.renderSaturated,
|
saturated: state.saturated || state.renderSaturated,
|
||||||
renderRevision: state.renderRevision,
|
renderRevision: state.renderRevision,
|
||||||
onScreen: state.onScreen ?? null,
|
onScreen: state.onScreen ?? null,
|
||||||
shown: state.dataSource?.entities.values.length || 0,
|
shown: renderer?.count() || 0,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
loading: state.loading,
|
loading: state.loading,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createAlprOverlay } from './overlay.js';
|
import { createAlprOverlay } from './render/cesiumOverlay.js';
|
||||||
import {
|
import {
|
||||||
directionWedgePositions,
|
directionWedgePositions,
|
||||||
alprLabelDetails,
|
alprLabelDetails,
|
||||||
@@ -46,6 +46,7 @@ function harness(t, count = 2, { floor = null } = {}) {
|
|||||||
paints = 0;
|
paints = 0;
|
||||||
const overlay = createAlprOverlay({
|
const overlay = createAlprOverlay({
|
||||||
state,
|
state,
|
||||||
|
native: state,
|
||||||
services: {
|
services: {
|
||||||
groundFloor: { cachedGroundFloor: () => floor },
|
groundFloor: { cachedGroundFloor: () => floor },
|
||||||
overlays: {
|
overlays: {
|
||||||
|
|||||||
@@ -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_BELOW_FLOOR_M,
|
||||||
SURFACE_ABOVE_FLOOR_M,
|
SURFACE_ABOVE_FLOOR_M,
|
||||||
SURFACE_SAMPLES_PER_PAINT,
|
SURFACE_SAMPLES_PER_PAINT,
|
||||||
} from './policy.js';
|
} from '../policy.js';
|
||||||
import {
|
import {
|
||||||
MARKER_IMAGE,
|
MARKER_IMAGE,
|
||||||
SELECTED_IMAGE,
|
SELECTED_IMAGE,
|
||||||
@@ -17,11 +17,12 @@ import {
|
|||||||
markerScale,
|
markerScale,
|
||||||
paintDirectionWedge,
|
paintDirectionWedge,
|
||||||
validAlprGroundHeight,
|
validAlprGroundHeight,
|
||||||
} from './visuals.js';
|
} from '../visuals.js';
|
||||||
|
|
||||||
/** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */
|
/** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */
|
||||||
export function createAlprOverlay({
|
export function createAlprOverlay({
|
||||||
state,
|
state,
|
||||||
|
native,
|
||||||
services,
|
services,
|
||||||
onSurfaceChange = () => {},
|
onSurfaceChange = () => {},
|
||||||
}) {
|
}) {
|
||||||
@@ -86,7 +87,7 @@ export function createAlprOverlay({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetAnchors() {
|
function resetAnchors() {
|
||||||
for (const entity of state.dataSource.entities.values) {
|
for (const entity of native.dataSource.entities.values) {
|
||||||
entity.gevAlprCanvasPosition = null;
|
entity.gevAlprCanvasPosition = null;
|
||||||
entity.gevAlprWedge = null;
|
entity.gevAlprWedge = null;
|
||||||
entity.gevAlprDisplayPosition = null;
|
entity.gevAlprDisplayPosition = null;
|
||||||
@@ -99,7 +100,7 @@ export function createAlprOverlay({
|
|||||||
|
|
||||||
function anchorFor(record, entity, budget) {
|
function anchorFor(record, entity, budget) {
|
||||||
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
|
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
|
||||||
const scene = state.viewer.scene;
|
const scene = native.viewer.scene;
|
||||||
const location = Cesium.Cartographic.fromDegrees(
|
const location = Cesium.Cartographic.fromDegrees(
|
||||||
record.longitude,
|
record.longitude,
|
||||||
record.latitude,
|
record.latitude,
|
||||||
@@ -166,8 +167,8 @@ export function createAlprOverlay({
|
|||||||
* rejected. Returns whether anchors remain to check.
|
* rejected. Returns whether anchors remain to check.
|
||||||
*/
|
*/
|
||||||
function refineAnchors(budget) {
|
function refineAnchors(budget) {
|
||||||
const camera = state.viewer.camera;
|
const camera = native.viewer.camera;
|
||||||
const scene = state.viewer.scene;
|
const scene = native.viewer.scene;
|
||||||
const still =
|
const still =
|
||||||
lastCamera &&
|
lastCamera &&
|
||||||
camera.positionWC &&
|
camera.positionWC &&
|
||||||
@@ -191,7 +192,7 @@ export function createAlprOverlay({
|
|||||||
if (!scene.sampleHeightSupported) return false;
|
if (!scene.sampleHeightSupported) return false;
|
||||||
let pending = false;
|
let pending = false;
|
||||||
for (const record of records) {
|
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;
|
const anchor = entity?.gevAlprCanvasPosition;
|
||||||
if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue;
|
if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue;
|
||||||
if (!still || !surfaceSettled(scene) || budget.samples <= 0) {
|
if (!still || !surfaceSettled(scene) || budget.samples <= 0) {
|
||||||
@@ -234,7 +235,7 @@ export function createAlprOverlay({
|
|||||||
const painted = [];
|
const painted = [];
|
||||||
const budget = { samples: ANCHOR_SAMPLES_PER_PAINT };
|
const budget = { samples: ANCHOR_SAMPLES_PER_PAINT };
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
const entity = state.dataSource.entities.getById(record.id);
|
const entity = native.dataSource.entities.getById(record.id);
|
||||||
if (!entity) continue;
|
if (!entity) continue;
|
||||||
const selected = record.id === state.selectedId;
|
const selected = record.id === state.selectedId;
|
||||||
const image = images[selected ? 1 : 0];
|
const image = images[selected ? 1 : 0];
|
||||||
@@ -257,7 +258,7 @@ export function createAlprOverlay({
|
|||||||
setShown(entity, false);
|
setShown(entity, false);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const scene = state.viewer.scene;
|
const scene = native.viewer.scene;
|
||||||
const origin = Cesium.SceneTransforms.worldToWindowCoordinates(
|
const origin = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||||
scene,
|
scene,
|
||||||
anchor,
|
anchor,
|
||||||
@@ -306,7 +307,7 @@ export function createAlprOverlay({
|
|||||||
image,
|
image,
|
||||||
alpha,
|
alpha,
|
||||||
scale: markerScale(
|
scale: markerScale(
|
||||||
Cesium.Cartesian3.distance(state.viewer.camera.positionWC, anchor),
|
Cesium.Cartesian3.distance(native.viewer.camera.positionWC, anchor),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
if (selected) entity.gevAlprDisplayPosition = anchor;
|
if (selected) entity.gevAlprDisplayPosition = anchor;
|
||||||
@@ -372,8 +373,8 @@ export function createAlprOverlay({
|
|||||||
sync(visible) {
|
sync(visible) {
|
||||||
if (!lane) return;
|
if (!lane) return;
|
||||||
for (const record of records)
|
for (const record of records)
|
||||||
nativeVisible(state.dataSource.entities.getById(record.id), true);
|
nativeVisible(native.dataSource.entities.getById(record.id), true);
|
||||||
const position = state.viewer.camera.positionWC;
|
const position = native.viewer.camera.positionWC;
|
||||||
records = visible
|
records = visible
|
||||||
.filter(
|
.filter(
|
||||||
(record) =>
|
(record) =>
|
||||||
@@ -422,7 +423,7 @@ export function createAlprOverlay({
|
|||||||
clearTimeout(retryTimer);
|
clearTimeout(retryTimer);
|
||||||
retryTimer = null;
|
retryTimer = null;
|
||||||
for (const record of records)
|
for (const record of records)
|
||||||
nativeVisible(state.dataSource?.entities.getById(record.id), true);
|
nativeVisible(native.dataSource?.entities.getById(record.id), true);
|
||||||
records = [];
|
records = [];
|
||||||
hits = [];
|
hits = [];
|
||||||
lane?.setActive(false);
|
lane?.setActive(false);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
import { isPointerFree } from '../../../data/inputOwnership.js';
|
||||||
import {
|
import {
|
||||||
LAYER_ID,
|
LAYER_ID,
|
||||||
MAX_VIEWPORT_DEGREES,
|
MAX_VIEWPORT_DEGREES,
|
||||||
@@ -11,19 +11,19 @@ import {
|
|||||||
CREDIT_DISPLAY_MS,
|
CREDIT_DISPLAY_MS,
|
||||||
MARKER_FLOOR_LIFT_M,
|
MARKER_FLOOR_LIFT_M,
|
||||||
MARKER_SCALE_BY_DISTANCE,
|
MARKER_SCALE_BY_DISTANCE,
|
||||||
} from './policy.js';
|
} from '../policy.js';
|
||||||
import { boxContains } from './model.js';
|
import { boxContains } from '../model.js';
|
||||||
import { createAlprFloorResolver } from './floors.js';
|
import { createAlprFloorResolver } from '../floors.js';
|
||||||
import { createAlprOverlay } from './overlay.js';
|
import { createAlprOverlay } from './cesiumOverlay.js';
|
||||||
import {
|
import {
|
||||||
MARKER_IMAGE,
|
MARKER_IMAGE,
|
||||||
SELECTED_IMAGE,
|
SELECTED_IMAGE,
|
||||||
alprDisplayId,
|
alprDisplayId,
|
||||||
alprLabelDetails,
|
alprLabelDetails,
|
||||||
validAlprGroundHeight,
|
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 { governorRequestRender } = services.render;
|
||||||
const {
|
const {
|
||||||
clearSelectedEntityContextForLayer,
|
clearSelectedEntityContextForLayer,
|
||||||
@@ -36,6 +36,7 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
const { cachedGroundFloor } = services.groundFloor;
|
const { cachedGroundFloor } = services.groundFloor;
|
||||||
const overlay = createAlprOverlay({
|
const overlay = createAlprOverlay({
|
||||||
state,
|
state,
|
||||||
|
native,
|
||||||
services,
|
services,
|
||||||
onSurfaceChange: refreshMarkerPlacements,
|
onSurfaceChange: refreshMarkerPlacements,
|
||||||
});
|
});
|
||||||
@@ -70,7 +71,7 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
|
|
||||||
/** Records the current view would display (viewport box, nearest first when capped). */
|
/** Records the current view would display (viewport box, nearest first when capped). */
|
||||||
function viewRecords(records) {
|
function viewRecords(records) {
|
||||||
const box = viewportBox(state.viewer);
|
const box = viewportBox(native.viewer);
|
||||||
if (!box) return { box, visible: [], capped: false };
|
if (!box) return { box, visible: [], capped: false };
|
||||||
let visible = records.filter((record) =>
|
let visible = records.filter((record) =>
|
||||||
boxContains(box, {
|
boxContains(box, {
|
||||||
@@ -129,11 +130,11 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
* @returns {number|null} Count, or null without a scene to project into.
|
* @returns {number|null} Count, or null without a scene to project into.
|
||||||
*/
|
*/
|
||||||
function countOnScreen() {
|
function countOnScreen() {
|
||||||
const scene = state.viewer?.scene;
|
const scene = native.viewer?.scene;
|
||||||
const canvas = scene?.canvas;
|
const canvas = scene?.canvas;
|
||||||
const width = canvas?.clientWidth || canvas?.width;
|
const width = canvas?.clientWidth || canvas?.width;
|
||||||
const height = canvas?.clientHeight || canvas?.height;
|
const height = canvas?.clientHeight || canvas?.height;
|
||||||
const camera = state.viewer?.camera;
|
const camera = native.viewer?.camera;
|
||||||
if (!scene || !width || !height || !camera?.positionWC) return null;
|
if (!scene || !width || !height || !camera?.positionWC) return null;
|
||||||
const occluder = new Cesium.EllipsoidalOccluder(
|
const occluder = new Cesium.EllipsoidalOccluder(
|
||||||
Cesium.Ellipsoid.WGS84,
|
Cesium.Ellipsoid.WGS84,
|
||||||
@@ -141,7 +142,7 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
);
|
);
|
||||||
let count = 0;
|
let count = 0;
|
||||||
try {
|
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());
|
const position = entity.position?.getValue?.(Cesium.JulianDate.now());
|
||||||
if (!position || !occluder.isPointVisible(position)) continue;
|
if (!position || !occluder.isPointVisible(position)) continue;
|
||||||
const point = Cesium.SceneTransforms.worldToWindowCoordinates(
|
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. */
|
/** Refresh all native placements, including the appearances held by the canvas. */
|
||||||
function refreshMarkerPlacements() {
|
function refreshMarkerPlacements() {
|
||||||
if (!state.enabled || !state.dataSource) return;
|
if (!state.enabled || !native.dataSource) return;
|
||||||
clampedMarkers.clear();
|
clampedMarkers.clear();
|
||||||
for (const entity of state.dataSource.entities.values) {
|
for (const entity of native.dataSource.entities.values) {
|
||||||
const { position, clamped } = markerPlacement(entity.gevAlprRecord);
|
const { position, clamped } = markerPlacement(entity.gevAlprRecord);
|
||||||
setMarkerPosition(entity, position, clamped);
|
setMarkerPosition(entity, position, clamped);
|
||||||
if (clamped) clampedMarkers.set(entity.id, entity);
|
if (clamped) clampedMarkers.set(entity.id, entity);
|
||||||
@@ -211,7 +212,7 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
if (floorResolve !== request) return;
|
if (floorResolve !== request) return;
|
||||||
floorResolve = null;
|
floorResolve = null;
|
||||||
if (!state.enabled || !state.dataSource) return;
|
if (!state.enabled || !native.dataSource) return;
|
||||||
let moved = false;
|
let moved = false;
|
||||||
for (const entity of batch) {
|
for (const entity of batch) {
|
||||||
if (clampedMarkers.get(entity.id) !== entity) continue;
|
if (clampedMarkers.get(entity.id) !== entity) continue;
|
||||||
@@ -309,16 +310,16 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
overlay.clear();
|
overlay.clear();
|
||||||
visibleRecords = [];
|
visibleRecords = [];
|
||||||
clampedMarkers.clear();
|
clampedMarkers.clear();
|
||||||
if (state.dataSource?.entities) state.dataSource.entities.removeAll();
|
if (native.dataSource?.entities) native.dataSource.entities.removeAll();
|
||||||
removeEntityContextsForLayer(LAYER_ID);
|
removeEntityContextsForLayer(LAYER_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideOnMapCredit() {
|
function hideOnMapCredit() {
|
||||||
services.credits?.hideOsmCredit?.(state.viewer, LAYER_ID);
|
services.credits?.hideOsmCredit?.(native.viewer, LAYER_ID);
|
||||||
clearTimeout(state.creditTimer);
|
clearTimeout(native.creditTimer);
|
||||||
state.creditTimer = null;
|
native.creditTimer = null;
|
||||||
if (state.credit)
|
if (native.credit)
|
||||||
state.viewer?.creditDisplay?.removeStaticCredit(state.credit);
|
native.viewer?.creditDisplay?.removeStaticCredit(native.credit);
|
||||||
governorRequestRender('alpr-credit');
|
governorRequestRender('alpr-credit');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,13 +329,13 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
source.attribution?.name === 'OpenStreetMap' &&
|
source.attribution?.name === 'OpenStreetMap' &&
|
||||||
services.credits?.showOsmCredit
|
services.credits?.showOsmCredit
|
||||||
) {
|
) {
|
||||||
services.credits.showOsmCredit(state.viewer, LAYER_ID);
|
services.credits.showOsmCredit(native.viewer, LAYER_ID);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!state.enabled || state.creditPresented || !state.credit) return;
|
if (!state.enabled || native.creditPresented || !native.credit) return;
|
||||||
state.creditPresented = true;
|
native.creditPresented = true;
|
||||||
state.viewer.creditDisplay?.addStaticCredit(state.credit);
|
native.viewer.creditDisplay?.addStaticCredit(native.credit);
|
||||||
state.creditTimer = setTimeout(hideOnMapCredit, CREDIT_DISPLAY_MS);
|
native.creditTimer = setTimeout(hideOnMapCredit, CREDIT_DISPLAY_MS);
|
||||||
governorRequestRender('alpr-credit');
|
governorRequestRender('alpr-credit');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,17 +359,17 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
governorRequestRender('alpr-render');
|
governorRequestRender('alpr-render');
|
||||||
const visibleIds = new Set(visible.map((record) => record.id));
|
const visibleIds = new Set(visible.map((record) => record.id));
|
||||||
let changed = false;
|
let changed = false;
|
||||||
state.dataSource.entities.suspendEvents();
|
native.dataSource.entities.suspendEvents();
|
||||||
for (const entity of [...state.dataSource.entities.values]) {
|
for (const entity of [...native.dataSource.entities.values]) {
|
||||||
if (!visibleIds.has(entity.id)) {
|
if (!visibleIds.has(entity.id)) {
|
||||||
state.dataSource.entities.remove(entity);
|
native.dataSource.entities.remove(entity);
|
||||||
clampedMarkers.delete(entity.id);
|
clampedMarkers.delete(entity.id);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
removeEntityContextsForLayer(LAYER_ID, { retainIds: visibleIds });
|
removeEntityContextsForLayer(LAYER_ID, { retainIds: visibleIds });
|
||||||
for (const record of visible) {
|
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) {
|
if (existing?.gevAlprRecord === record) {
|
||||||
updateAppearance(existing, record.id === state.selectedId);
|
updateAppearance(existing, record.id === state.selectedId);
|
||||||
continue;
|
continue;
|
||||||
@@ -402,7 +403,7 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
// geometry is unchanged. Updating metadata must not rebuild the marker.
|
// geometry is unchanged. Updating metadata must not rebuild the marker.
|
||||||
const previous = entity?.gevAlprRecord;
|
const previous = entity?.gevAlprRecord;
|
||||||
if (!entity) {
|
if (!entity) {
|
||||||
entity = state.dataSource.entities.add(entityDef);
|
entity = native.dataSource.entities.add(entityDef);
|
||||||
entity.gevAlprClamped = clamped;
|
entity.gevAlprClamped = clamped;
|
||||||
changed = true;
|
changed = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -447,7 +448,7 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
registerEntityContext(entity, {
|
registerEntityContext(entity, {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
layerId: LAYER_ID,
|
layerId: LAYER_ID,
|
||||||
dataSource: state.dataSource,
|
dataSource: native.dataSource,
|
||||||
layerName: 'ALPR Cameras',
|
layerName: 'ALPR Cameras',
|
||||||
source:
|
source:
|
||||||
source.attribution?.description || source.label || 'Camera 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;
|
if (changed) state.renderRevision += 1;
|
||||||
state.onScreen = countOnScreen();
|
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);
|
if (entity.gevAlprClamped) clampedMarkers.set(entity.id, entity);
|
||||||
resolveClampedFloors();
|
resolveClampedFloors();
|
||||||
const selectedEntity = state.selectedId
|
const selectedEntity = state.selectedId
|
||||||
? state.dataSource.entities.getById(state.selectedId)
|
? native.dataSource.entities.getById(state.selectedId)
|
||||||
: null;
|
: null;
|
||||||
if (!selectedEntity) state.selectedId = null;
|
if (!selectedEntity) state.selectedId = null;
|
||||||
updateSelectedAnchor();
|
updateSelectedAnchor();
|
||||||
@@ -486,12 +487,12 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function focusNearest() {
|
function focusNearest() {
|
||||||
const camera = state.viewer?.camera;
|
const camera = native.viewer?.camera;
|
||||||
if (!state.enabled || !camera?.positionWC || state.viewer.trackedEntity)
|
if (!state.enabled || !camera?.positionWC || native.viewer.trackedEntity)
|
||||||
return false;
|
return false;
|
||||||
let nearest = null,
|
let nearest = null,
|
||||||
distance = Infinity;
|
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 position = entity.position.getValue(Cesium.JulianDate.now());
|
||||||
const candidate = Cesium.Cartesian3.distanceSquared(
|
const candidate = Cesium.Cartesian3.distanceSquared(
|
||||||
camera.positionWC,
|
camera.positionWC,
|
||||||
@@ -509,15 +510,15 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
record.latitude,
|
record.latitude,
|
||||||
);
|
);
|
||||||
let height;
|
let height;
|
||||||
if (state.viewer.scene.sampleHeightSupported) {
|
if (native.viewer.scene.sampleHeightSupported) {
|
||||||
try {
|
try {
|
||||||
height = state.viewer.scene.sampleHeight(location, [nearest]);
|
height = native.viewer.scene.sampleHeight(location, [nearest]);
|
||||||
} catch {
|
} catch {
|
||||||
/* tiles may still be streaming */
|
/* tiles may still be streaming */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!validAlprGroundHeight(height) && state.viewer.scene.globe.show)
|
if (!validAlprGroundHeight(height) && native.viewer.scene.globe.show)
|
||||||
height = state.viewer.scene.globe.getHeight?.(location);
|
height = native.viewer.scene.globe.getHeight?.(location);
|
||||||
if (!validAlprGroundHeight(height))
|
if (!validAlprGroundHeight(height))
|
||||||
height = cachedGroundFloor(record.latitude, record.longitude);
|
height = cachedGroundFloor(record.latitude, record.longitude);
|
||||||
const center = Cesium.Cartesian3.fromDegrees(
|
const center = Cesium.Cartesian3.fromDegrees(
|
||||||
@@ -539,7 +540,7 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectRecord(id) {
|
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;
|
if (!entity || !state.recordById.has(id)) return false;
|
||||||
clearSelection();
|
clearSelection();
|
||||||
state.selectedId = id;
|
state.selectedId = id;
|
||||||
@@ -555,7 +556,7 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
const entity = state.selectedId
|
const entity = state.selectedId
|
||||||
? state.dataSource?.entities.getById(state.selectedId)
|
? native.dataSource?.entities.getById(state.selectedId)
|
||||||
: null;
|
: null;
|
||||||
const record = state.recordById.get(state.selectedId);
|
const record = state.recordById.get(state.selectedId);
|
||||||
if (entity && record) updateAppearance(entity, false);
|
if (entity && record) updateAppearance(entity, false);
|
||||||
@@ -573,7 +574,7 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const entity = state.dataSource?.entities.getById(state.selectedId);
|
const entity = native.dataSource?.entities.getById(state.selectedId);
|
||||||
const record = state.recordById.get(state.selectedId);
|
const record = state.recordById.get(state.selectedId);
|
||||||
if (entity?.gevAlprCanvasPosition) {
|
if (entity?.gevAlprCanvasPosition) {
|
||||||
entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition;
|
entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition;
|
||||||
@@ -582,7 +583,7 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
if (!entity || !record || Date.now() - state.lastAnchorSampleAt < 1000)
|
if (!entity || !record || Date.now() - state.lastAnchorSampleAt < 1000)
|
||||||
return;
|
return;
|
||||||
state.lastAnchorSampleAt = Date.now();
|
state.lastAnchorSampleAt = Date.now();
|
||||||
const scene = state.viewer.scene;
|
const scene = native.viewer.scene;
|
||||||
const location = Cesium.Cartographic.fromDegrees(
|
const location = Cesium.Cartographic.fromDegrees(
|
||||||
record.longitude,
|
record.longitude,
|
||||||
record.latitude,
|
record.latitude,
|
||||||
@@ -615,11 +616,11 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function installInteraction(viewer) {
|
function installInteraction(viewer) {
|
||||||
if (state.clickHandler) return;
|
if (native.clickHandler) return;
|
||||||
state.clickHandler = new Cesium.ScreenSpaceEventHandler(
|
native.clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||||
viewer.scene.canvas,
|
viewer.scene.canvas,
|
||||||
);
|
);
|
||||||
state.clickHandler.setInputAction((click) => {
|
native.clickHandler.setInputAction((click) => {
|
||||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||||
if (!isPointerFree()) return;
|
if (!isPointerFree()) return;
|
||||||
if (!state.enabled) return;
|
if (!state.enabled) return;
|
||||||
@@ -640,7 +641,7 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
initOverlay: () => overlay.init(),
|
initOverlay: () => overlay.init(),
|
||||||
destroyOverlay: () => overlay.destroy(),
|
destroyOverlay: () => overlay.destroy(),
|
||||||
markerColor,
|
markerColor,
|
||||||
viewportBox,
|
viewportBox: () => viewportBox(native.viewer),
|
||||||
clearRendered,
|
clearRendered,
|
||||||
hideOnMapCredit,
|
hideOnMapCredit,
|
||||||
presentOnMapCredit,
|
presentOnMapCredit,
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -50,8 +50,12 @@ export function layoutLeftPanelRail({
|
|||||||
// The existing narrow-screen composition has its own full-width stack.
|
// The existing narrow-screen composition has its own full-width stack.
|
||||||
// Keep this desktop lane engine from fighting those dedicated rules.
|
// Keep this desktop lane engine from fighting those dedicated rules.
|
||||||
if (windowRef.matchMedia('(max-width: 720px)').matches) {
|
if (windowRef.matchMedia('(max-width: 720px)').matches) {
|
||||||
stack.classList.remove('layout-focus');
|
if (stack.classList.contains('layout-focus')) {
|
||||||
stack.classList.remove('layout-tail');
|
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-top');
|
||||||
stack.style.removeProperty('--left-stack-safe-bottom');
|
stack.style.removeProperty('--left-stack-safe-bottom');
|
||||||
stack.style.removeProperty('--left-stack-centered-height');
|
stack.style.removeProperty('--left-stack-centered-height');
|
||||||
|
|||||||
@@ -197,6 +197,22 @@ test('missing rails are inert without browser globals', () => {
|
|||||||
layoutRightPanelRail({});
|
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', () => {
|
test('left layout records collapsed heights and aligns the right rail without changing disclosure', () => {
|
||||||
const f = fixture('left');
|
const f = fixture('left');
|
||||||
f.run();
|
f.run();
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
|
|
||||||
.hud-summary-wrap {
|
.hud-summary-wrap {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
|
width: 420px;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +244,7 @@
|
|||||||
|
|
||||||
.hud-summary {
|
.hud-summary {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
min-height: 16px;
|
min-height: max(16px, 1lh);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
letter-spacing: 1.2px;
|
letter-spacing: 1.2px;
|
||||||
opacity: 0.88;
|
opacity: 0.88;
|
||||||
|
|||||||
Reference in new issue
Block a user