feat(alpr): render camera markers on Navara

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

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

No files matched your search

+1
View File
@@ -34,6 +34,7 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
// engine in their renderers: cesium only in render/cesium*.js, three and
// Navara only in render/navara*.js. The list grows with each moved layer.
const RENDERED_LAYER_DIRS = [
'src/layers/alpr/',
'src/layers/radio/',
'src/layers/bikeshare/',
'src/layers/earthquakes/',
+132
View File
@@ -0,0 +1,132 @@
import { PbfWriter } from 'pbf';
// Recorded journeys cover both a dense neighbourhood and the wider Austin view.
export const cameras = [
...Array.from({ length: 225 }, (_, i) => ({
latitude: 30.262 + (Math.floor(i / 15) - 7) * 0.003,
longitude: -97.7431 + ((i % 15) - 7) * 0.003,
})),
...Array.from({ length: 100 }, (_, i) => ({
latitude: 30.308 + (Math.floor(i / 10) - 4.5) * 0.001,
longitude: -97.77 + ((i % 10) - 4.5) * 0.001,
})),
].map((position, i) => ({
...position,
osmId: 9000000 + i,
operator: i % 3 ? 'W2 fixture' : 'Cámara pública',
brand: 'Fixture camera',
cameraType: 'fixed',
direction: i % 5 ? String((i * 37) % 360) : '',
}));
export function cameraTile(z, x, y, records = cameras) {
const extent = 4096;
const scale = 2 ** z;
const rows = records.flatMap((record) => {
const latitude = (record.latitude * Math.PI) / 180;
const tx = ((record.longitude + 180) / 360) * scale;
const ty = ((1 - Math.asinh(Math.tan(latitude)) / Math.PI) / 2) * scale;
if (Math.floor(tx) !== x || Math.floor(ty) !== y) return [];
return [
{
record,
x: Math.round((tx - x) * extent),
y: Math.round((ty - y) * extent),
},
];
});
const keys = [
'osmType',
'osmId',
'operator',
'brand',
'cameraType',
'direction',
];
const writer = new PbfWriter();
writer.writeMessage(
3,
(_, layer) => {
layer.writeVarintField(15, 2);
layer.writeStringField(1, 'cameras');
layer.writeVarintField(5, extent);
for (const key of keys) layer.writeStringField(3, key);
rows.forEach(({ record, x, y }, index) => {
for (const key of keys)
layer.writeMessage(
4,
(_, value) =>
value.writeStringField(
1,
key === 'osmType' ? 'node' : String(record[key]),
),
null,
);
layer.writeMessage(
2,
(_, feature) => {
feature.writeVarintField(1, record.osmId);
feature.writePackedVarint(
2,
keys.flatMap((_, i) => [i, index * keys.length + i]),
);
feature.writeVarintField(3, 1);
feature.writePackedVarint(4, [9, x * 2, y * 2]);
},
null,
);
});
},
null,
);
return Buffer.from(writer.finish());
}
/** Real TileJSON/MVT transport; no replacement of the layer or source parser. */
export async function installAlprFixture(page) {
await page.setRequestInterception(true);
page.on('request', (request) => {
if (request.isInterceptResolutionHandled()) return;
const url = new URL(request.url());
// The journey also asks for nearby-place labels while moving. Pin that
// auxiliary service; these runs do not test Google Places availability.
if (
['localhost', '127.0.0.1'].includes(url.hostname) &&
url.pathname === '/api/google/nearby-places'
) {
void request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ places: [] }),
});
return;
}
if (url.hostname !== 'tiles.dontgetflocked.com') {
void request.continue();
return;
}
const headers = { 'access-control-allow-origin': '*' };
if (url.pathname.endsWith('.json')) {
const country = url.pathname.includes('-ca-') ? 'ca' : 'us';
void request.respond({
status: 200,
headers,
contentType: 'application/json',
body: JSON.stringify({
tiles: [
`https://tiles.dontgetflocked.com/cameras-${country}-hourly/{z}/{x}/{y}.mvt`,
],
bounds: country === 'ca' ? [-124, 42, -63, 54] : [-160, 17, -64, 59],
}),
});
return;
}
const match = url.pathname.match(/\/(\d+)\/(\d+)\/(\d+)\.mvt$/);
void request.respond({
status: match ? 200 : 404,
headers,
contentType: 'application/x-protobuf',
body: match ? cameraTile(...match.slice(1).map(Number)) : '',
});
});
}
+9 -2
View File
@@ -573,9 +573,16 @@
"src/layers/alpr/index.js",
"src/layers/alpr/model.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/render/cesiumPresentation.js",
"src/layers/alpr/render/cesium.js",
"src/layers/alpr/render/navara.js",
"src/layers/alpr/render/navara.test.mjs",
"src/layers/alpr/render/navaraOverlay.js",
"src/layers/alpr/render/navaraOverlay.test.mjs",
"scripts/fixtures/w2/alpr.mjs",
"scripts/qa-w2-alpr.mjs",
"src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js",
"src/layers/alpr/render/cesiumOverlay.js",
"src/layers/alpr/overlay.test.mjs",
"src/layers/alpr/source.js",
"src/layers/alpr/source.test.mjs",
+9 -7
View File
@@ -1568,9 +1568,7 @@
"src/layers/alpr/index.js",
"src/layers/alpr/model.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js",
"src/layers/alpr/source.js",
"src/data/inputOwnership.js",
"src/layers/alpr/records.js",
@@ -1598,7 +1596,7 @@
"src/geo/debug.js",
"src/geo/developerError.js"
],
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
"external": ["@mapbox/vector-tile", "pbf"]
},
"radio-layer": {
"exports": ["./layers/radio"],
@@ -2032,9 +2030,10 @@
"src/layers/alpr/index.js",
"src/layers/alpr/model.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/render/cesiumPresentation.js",
"src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js",
"src/layers/alpr/render/cesiumOverlay.js",
"src/layers/alpr/render/cesium.js",
"src/layers/alpr/source.js",
"src/layers/awareness/controls.js",
"src/layers/awareness/dependencies.js",
@@ -2708,6 +2707,8 @@
"src/layers/radio/render/cesium.js",
"src/layers/firms/render/navara.js",
"src/layers/radio/render/navara.js",
"src/layers/alpr/render/navara.js",
"src/layers/alpr/render/navaraOverlay.js",
"src/layers/radio/screenClusters.js",
"src/layers/firms/render/navaraGeometry.js",
"src/layers/firms/render/navaraSprites.js",
@@ -2882,9 +2883,10 @@
"src/layers/alpr/index.js",
"src/layers/alpr/model.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/render/cesiumPresentation.js",
"src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js",
"src/layers/alpr/render/cesiumOverlay.js",
"src/layers/alpr/render/cesium.js",
"src/layers/alpr/source.js",
"src/layers/awareness/controls.js",
"src/layers/awareness/dependencies.js",
+85 -8
View File
@@ -2,7 +2,7 @@
/**
* qa-alpr-journey.mjs — ALPR cameras on a realistic Austin camera journey.
*
* Run: node scripts/qa-alpr-journey.mjs http://localhost:4173 [--headful]
* Run: node scripts/qa-alpr-journey.mjs <dev-server-url> [--fixture]
* [--passes off,on,on] [--out qa-shots/alpr-journey] [--no-assert]
*
* Drives the same eased camera path several times: a neighbourhood by Camp
@@ -14,7 +14,7 @@
*
* Per stop it records: time from the camera stopping to cameras shown (or an
* explicit "zoom in" state), the camera count and row status, rAF interval and
* Cesium render CPU time (mean / p50 / p95) while moving and while holding, and
* engine render CPU time (mean / p50 / p95) while moving and while holding, and
* marker stability while the camera is still (camera-set changes after the stop
* has settled). Asserts, unless --no-assert: neighbourhood and downtown show
* cameras within 2 s on the warm pass; the whole-city stop shows cameras or a
@@ -40,6 +40,8 @@ import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import sharp from 'sharp';
import { installAlprFixture } from './fixtures/w2/alpr.mjs';
import { redact, isExpectedPreviewSetupError } from './qa-browserEvidence.mjs';
import {
createScreencast,
frameStats,
@@ -60,12 +62,20 @@ const option = (name, fallback) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
};
const appUrl =
argv.find((arg) => /^https?:/.test(arg)) || 'http://localhost:4173';
const appUrl = argv.find((arg) => /^https?:/.test(arg));
if (!appUrl) throw new Error('An explicit dev-server URL is required');
if (
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
new URL(appUrl).port,
)
)
throw new Error('ALPR QA may not use a reserved port');
const outDir = path.resolve(option('--out', 'qa-shots/alpr-journey'));
const passes = option('--passes', 'off,on,on').split(',');
const assertOn = !argv.includes('--no-assert');
const dropRenders = argv.includes('--self-test-drop-renders');
if (dropRenders && new URL(appUrl).searchParams.get('engine') === 'navara')
throw new Error('The legacy render-drop self-test requires Cesium');
fs.mkdirSync(outDir, { recursive: true });
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const cyanCount = async (file) => {
@@ -170,6 +180,8 @@ const result = {
startedAt: new Date().toISOString(),
passes: [],
consoleErrors: [],
expectedPreviewErrors: [],
failedRequests: [],
failures: [],
};
const fail = (message) => {
@@ -179,14 +191,43 @@ const fail = (message) => {
let page;
try {
page = await browser.newPage();
if (argv.includes('--fixture')) await installAlprFixture(page);
await page.setViewport({ width: 1440, height: 900 });
await page.evaluateOnNewDocument(() =>
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
);
page.on('console', (message) => {
if (message.type() === 'error') result.consoleErrors.push(message.text());
if (
isExpectedPreviewSetupError(message, {
preview: argv.includes('--preview'),
baseUrl: appUrl,
})
) {
result.expectedPreviewErrors.push('/api/setup/status: 404');
return;
}
if (message.type() === 'error') {
let resource = '(location unavailable)';
try {
const url = new URL(message.location()?.url);
resource = url.origin + url.pathname;
} catch {
/* no resource location */
}
result.consoleErrors.push(redact(`${message.text()} — ${resource}`));
}
});
page.on('pageerror', (error) =>
result.consoleErrors.push(redact(error.message)),
);
page.on('requestfailed', (request) => {
if (result.failedRequests.length >= 200) return;
const url = new URL(request.url());
result.failedRequests.push({
resource: redact(url.origin + url.pathname),
error: request.failure()?.errorText,
});
});
page.on('pageerror', (error) => result.consoleErrors.push(error.message));
const url = new URL(appUrl);
url.searchParams.set('welcome', '0');
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 });
@@ -252,6 +293,8 @@ try {
await page
.waitForFunction(
() => {
if (window.__godsEyeView.engine.kind === 'navara')
return window.__godsEyeView.engine.surface.tilesLoaded();
const { scene } = window.__godsEyeView.viewer;
if (scene.globe.show) return scene.globe.tilesLoaded;
for (let i = 0; i < scene.primitives.length; i++) {
@@ -308,8 +351,31 @@ try {
// changed state without asking for a render.
let staleFrame = null;
if (alpr) {
const shown = await cyanCount(await page.screenshot({ type: 'png' }));
const shownImage = await page.screenshot({ type: 'png' });
const shown = await cyanCount(shownImage);
await page.evaluate(async () => {
const { engine } = window.__godsEyeView;
if (engine.kind === 'navara') {
await new Promise((resolve, reject) => {
let count = 4;
const timeout = setTimeout(() => {
remove();
reject(new Error('ALPR forced-frame deadline'));
}, 10000);
const remove = engine.frames.postRender.addEventListener(() => {
if (--count === 0) {
remove();
clearTimeout(timeout);
resolve();
} else
requestAnimationFrame(() =>
engine.render.request('alpr-qa-frame'),
);
});
engine.render.request('alpr-qa-frame');
});
return;
}
const { scene } = window.__godsEyeView.viewer;
const mode = scene.requestRenderMode;
scene.requestRenderMode = false;
@@ -317,12 +383,23 @@ try {
await new Promise((resolve) => requestAnimationFrame(resolve));
scene.requestRenderMode = mode;
});
const forced = await cyanCount(await page.screenshot({ type: 'png' }));
const forcedImage = await page.screenshot({ type: 'png' });
const forced = await cyanCount(forcedImage);
staleFrame = {
shown,
forced,
stale: Math.abs(forced - shown) > Math.max(8, forced * 0.15),
};
if (staleFrame.stale) {
fs.writeFileSync(
path.join(outDir, `${passName}-${stop.name}-shown.png`),
shownImage,
);
fs.writeFileSync(
path.join(outDir, `${passName}-${stop.name}-forced.png`),
forcedImage,
);
}
}
const moving = frameStats(await readFrames(page, moveStart, holdStart));
const holding = frameStats(await readFrames(page, holdStart, holdEnd));
+41 -8
View File
@@ -6,15 +6,18 @@
* `<dir>/clip.mp4` through ffmpeg's concat demuxer with per-frame
* durations, so held frames last as long as they did on screen.
* - `installFrameProbe(page)` measures every animation frame in the page:
* the requestAnimationFrame interval and, for frames Cesium rendered, the
* CPU time between `scene.preUpdate` and `scene.postRender`.
* the requestAnimationFrame interval and, for rendered frames, the
* CPU time between engine preUpdate and postRender events.
* - `frameStats(frames)` reduces a probe window to count / mean / p50 / p95.
* - `moveCamera(page, view, seconds)` eases the camera like a drag or wheel
* zoom rather than teleporting it.
*/
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
/** Percentile of a numeric list (nearest rank); NaN for an empty list. */
export function percentile(values, p) {
@@ -25,7 +28,7 @@ export function percentile(values, p) {
];
}
/** Summarize probe frames: rAF interval and Cesium render CPU time (ms). */
/** Summarize probe frames: rAF interval and engine render CPU time (ms). */
export function frameStats(frames) {
const intervals = frames.map((f) => f.dt).filter(Number.isFinite);
const renders = frames.map((f) => f.renderMs).filter(Number.isFinite);
@@ -49,16 +52,17 @@ export function frameStats(frames) {
export async function installFrameProbe(page) {
await page.evaluate(() => {
if (window.__gevFrameProbe) return;
const { viewer } = window.__godsEyeView;
const { viewer, engine } = window.__godsEyeView;
const events = viewer?.scene ?? engine.frames;
const probe = { frames: [], started: 0 };
window.__gevFrameProbe = probe;
let last = performance.now();
let renderStart = null;
let pendingRender = null;
viewer.scene.preUpdate.addEventListener(() => {
events.preUpdate.addEventListener(() => {
renderStart = performance.now();
});
viewer.scene.postRender.addEventListener(() => {
events.postRender.addEventListener(() => {
if (renderStart !== null) pendingRender = performance.now() - renderStart;
renderStart = null;
});
@@ -98,6 +102,34 @@ export function pageNow(page) {
* @param {number} seconds Flight duration; 0 jumps.
*/
export async function moveCamera(page, view, seconds) {
if (
await page.evaluate(() => window.__godsEyeView.engine?.kind === 'navara')
) {
await page.evaluate(
async (v, duration) => {
const geo = await import('/src/geo/index.js');
const camera = window.__godsEyeView.engine.camera;
camera.cancelFlight();
await new Promise((resolve) =>
camera.flyTo({
destination: geo.cartesian3.fromDegrees(v.lon, v.lat, v.height),
orientation: {
heading: ((v.heading ?? 0) * Math.PI) / 180,
pitch: ((v.pitch ?? -45) * Math.PI) / 180,
roll: 0,
},
duration,
maximumHeight: Math.max(v.height, camera.height()),
complete: resolve,
cancel: resolve,
}),
);
},
view,
seconds,
);
return;
}
await page.evaluate(
(v, duration) =>
new Promise((resolve) => {
@@ -191,7 +223,8 @@ export async function createScreencast(page, dir, options = {}) {
const list = path.join(dir, 'frames.txt');
fs.writeFileSync(list, `${lines.join('\n')}\n`);
const clip = path.join(dir, 'clip.mp4');
execFileSync('ffmpeg', [
// Keep servicing Puppeteer's request interception while encoding.
await execFileAsync('ffmpeg', [
'-y',
'-loglevel',
'error',