From 46b3aceafce52822222751ce1500e57301c83f4c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 21:06:08 +0400 Subject: [PATCH] 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. --- scripts/check-import-directions.mjs | 1 + scripts/fixtures/w2/alpr.mjs | 132 ++++ scripts/format-scope.json | 11 +- scripts/package-boundaries.json | 16 +- scripts/qa-alpr-journey.mjs | 93 ++- scripts/qa-journey-recorder.mjs | 49 +- src/app/layers/alprCameras.js | 12 +- src/app/navaraLayers.test.mjs | 2 + src/app/navaraMode.js | 3 + src/data/alprCameras.test.mjs | 6 + src/data/inputOwnership.test.mjs | 5 +- src/layers/alpr/index.js | 130 ++-- src/layers/alpr/overlay.test.mjs | 3 +- src/layers/alpr/render/cesium.js | 64 ++ .../{overlay.js => render/cesiumOverlay.js} | 27 +- .../cesiumPresentation.js} | 99 +-- src/layers/alpr/render/navara.js | 600 ++++++++++++++++++ src/layers/alpr/render/navara.test.mjs | 330 ++++++++++ src/layers/alpr/render/navaraOverlay.js | 424 +++++++++++++ src/layers/alpr/render/navaraOverlay.test.mjs | 228 +++++++ src/ui/leftPanelRail.js | 8 +- src/ui/panelRails.test.mjs | 16 + src/ui/styles/overlays.css | 3 +- 23 files changed, 2081 insertions(+), 181 deletions(-) create mode 100644 scripts/fixtures/w2/alpr.mjs create mode 100644 src/layers/alpr/render/cesium.js rename src/layers/alpr/{overlay.js => render/cesiumOverlay.js} (95%) rename src/layers/alpr/{presentation.js => render/cesiumPresentation.js} (88%) create mode 100644 src/layers/alpr/render/navara.js create mode 100644 src/layers/alpr/render/navara.test.mjs create mode 100644 src/layers/alpr/render/navaraOverlay.js create mode 100644 src/layers/alpr/render/navaraOverlay.test.mjs diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index e6874f4f0..835c25128 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -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/', diff --git a/scripts/fixtures/w2/alpr.mjs b/scripts/fixtures/w2/alpr.mjs new file mode 100644 index 000000000..a40db1412 --- /dev/null +++ b/scripts/fixtures/w2/alpr.mjs @@ -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)) : '', + }); + }); +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c311b9e78..78f1becb9 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f413baf2c..19dfc47bd 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-alpr-journey.mjs b/scripts/qa-alpr-journey.mjs index 2732e875d..97d4d1f94 100644 --- a/scripts/qa-alpr-journey.mjs +++ b/scripts/qa-alpr-journey.mjs @@ -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 [--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)); diff --git a/scripts/qa-journey-recorder.mjs b/scripts/qa-journey-recorder.mjs index a14804ffa..cefb2c713 100644 --- a/scripts/qa-journey-recorder.mjs +++ b/scripts/qa-journey-recorder.mjs @@ -6,15 +6,18 @@ * `/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', diff --git a/src/app/layers/alprCameras.js b/src/app/layers/alprCameras.js index 336a042ce..34c9ef831 100644 --- a/src/app/layers/alprCameras.js +++ b/src/app/layers/alprCameras.js @@ -1,3 +1,5 @@ +import { createAlprCesiumRenderer } from '../../layers/alpr/render/cesium.js'; +import { rendererFor } from './renderers.js'; import * as credits from '../../data/dataCredits.js'; import { createAlprCamerasLayer } from '../../layers/alpr/index.js'; import * as render from '../../renderGovernor.js'; @@ -8,10 +10,18 @@ import { keyholeLabelAlphaFromGeometry } from '../../celestialRing.js'; import { refreshTrackedReadout } from '../../data/trackedReadout.js'; /** Construct one layer using the application scene owners and a supplied source. */ -export function createApplicationAlpr({ surface, source }) { +export function createApplicationAlpr({ + surface, + source, + rendererFactory = ({ engine, ...context }) => + engine?.kind === 'navara' + ? rendererFor('alpr-cameras', engine, context) + : createAlprCesiumRenderer(context), +}) { const { groundFloor } = surface; return createAlprCamerasLayer({ source, + rendererFactory, services: { credits, render, diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 61f05d8d8..6383bd597 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -29,6 +29,7 @@ test('the Navara mode registers migrated renderers once', async () => { 'military-installations', 'bikeshare', 'radio', + 'alpr-cameras', ]) assert.equal(layerAvailableOn(id, 'navara'), true, id); assert.deepEqual( @@ -40,6 +41,7 @@ test('the Navara mode registers migrated renderers once', async () => { 'military', 'local-adsb', 'earthquakes', + 'alpr-cameras', 'satellites', 'rocket-launches', 'radio', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 7a1f1c1dc..1d6b31189 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -65,6 +65,9 @@ const NAVARA_CREDIT_KEYS = new Set([ */ export async function registerNavaraLayerRenderers() { const loaders = { + 'alpr-cameras': async () => + (await import('../layers/alpr/render/navara.js')) + .createAlprNavaraRenderer, radio: async () => (await import('../layers/radio/render/navara.js')) .createRadioNavaraRenderer, diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 5a50220d5..f16fb593e 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -1,3 +1,4 @@ +import { createAlprCesiumRenderer } from '../layers/alpr/render/cesium.js'; // src/data/alprCameras.test.mjs // Pure helpers and actual layer lifecycle with a minimal viewer/input harness. import { test } from 'node:test'; @@ -854,6 +855,7 @@ test('two factories keep their requests, records and destruction independent', a }; let resolveFirst, firstSignal; const first = createAlprCamerasLayer({ + rendererFactory: createAlprCesiumRenderer, services, source: { fetch: (_box, signal) => { @@ -865,6 +867,7 @@ test('two factories keep their requests, records and destruction independent', a }, }); const second = createAlprCamerasLayer({ + rendererFactory: createAlprCesiumRenderer, services, source: { label: 'Test camera directory', @@ -938,6 +941,7 @@ test('markers sit on the cached floor; unknown floors clamp only until they reso }, }; const layer = createAlprCamerasLayer({ + rendererFactory: createAlprCesiumRenderer, services, source: { fetch: async () => ({ @@ -1305,6 +1309,7 @@ test('slow ALPR floors share real terrain requests across preparation, render an longitude: -97.749 + (i % 15) * 0.001, })); const layer = createAlprCamerasLayer({ + rendererFactory: createAlprCesiumRenderer, source: { fetch: async () => ({ records, stale: false, saturated: false }), }, @@ -1390,6 +1395,7 @@ test('map-source changes reposition all ALPR entities and saved native appearanc directionDeg: i === 0 ? null : 90, })); const layer = createAlprCamerasLayer({ + rendererFactory: createAlprCesiumRenderer, source: { fetch: async () => ({ records, stale: false, saturated: false }), }, diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index 097d12738..744da15e8 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -117,7 +117,10 @@ test('every scene click handler consults ownership before it picks', () => { ['src/data/trackingClickGesture.js', 'onClick(click, gesture);'], ['src/data/localGeojsonCore.js', 'viewer.scene.pick(click.position)'], ['src/data/cctvGizmo.js', 'pickGizmoPart(event.position)'], - ['src/layers/alpr/presentation.js', 'viewer.scene.pick(click.position)'], + [ + 'src/layers/alpr/render/cesiumPresentation.js', + 'viewer.scene.pick(click.position)', + ], [ 'src/layers/bikeshare/render/cesium/selection.js', 'viewer.scene.pick(click.position)', diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index 072fcf47a..8b3ed36c1 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -1,5 +1,4 @@ import { isUnavailableCapability } from '../../sources/capability.js'; -import * as Cesium from 'cesium'; import { LAYER_ID, REQUEST_DEBOUNCE_MS, @@ -15,9 +14,7 @@ import { destinationPointDeg, alprRetryDelayMs, validateAlprSnapshot, - alprCreditMarkup, } from './model.js'; -import { createAlprPresentation } from './presentation.js'; /** * Own one layer's requests, records, display and viewer subscriptions. @@ -33,7 +30,11 @@ import { createAlprPresentation } from './presentation.js'; * removeEntityContextsForLayer(layerId, { retainIds: Set }), preserving * retained selection without dispatching a new selection event. */ -export function createAlprCamerasLayer({ source, services } = {}) { +export function createAlprCamerasLayer({ + source, + services, + rendererFactory, +} = {}) { if (typeof source?.fetch !== 'function') throw new TypeError('ALPR requires a camera source'); const { governorRequestRender } = services.render; @@ -41,11 +42,6 @@ export function createAlprCamerasLayer({ source, services } = {}) { const { registerPickOwner, unregisterPickOwner } = services.picking; const state = { - viewer: null, - dataSource: null, - credit: null, - creditTimer: null, - creditPresented: false, enabled: false, records: [], recordById: new Map(), @@ -63,10 +59,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { retryDelayMs: 0, retryAt: 0, retrying: false, - moveEndRemove: null, - postRenderRemove: null, lastAnchorSampleAt: 0, - clickHandler: null, debounceTimer: null, /** Snapped box of the last successful query; a view still inside it reuses its records. */ lastQueryBox: null, @@ -77,21 +70,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { /** Cameras in view beyond the render cap. */ renderSaturated: false, }; - const { - initOverlay, - destroyOverlay, - viewportBox, - clearRendered, - hideOnMapCredit, - presentOnMapCredit, - renderRecords, - selectRecord, - focusNearest, - clearSelection, - updateSelectedAnchor, - installInteraction, - prepareFloors, - } = createAlprPresentation({ state, services, source }); + let renderer = null; function setAlprStatus(status, error = null) { if (state.status === status && state.error === error) return; @@ -141,7 +120,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { clearTimeout(state.debounceTimer); // A view the accepted tiles already cover is a local filter: show it now. // Only a view that needs a request waits out the debounce. - const box = viewportBox(state.viewer); + const box = renderer.viewportBox(); if (box && canReuse(box)) { state.debounceTimer = null; loadCameras(); @@ -154,11 +133,11 @@ export function createAlprCamerasLayer({ source, services } = {}) { } async function loadCameras() { - if (!state.enabled || !state.viewer) return; + if (!state.enabled || !renderer?.ready) return; clearTimeout(state.debounceTimer); state.debounceTimer = null; clearUnavailableRetry({ resetBackoff: false }); - const box = viewportBox(state.viewer); + const box = renderer.viewportBox(); if (!box) { state.abort?.abort(); state.abort = null; @@ -166,7 +145,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { state.loading = false; state.retrying = false; clearUnavailableRetry(); - renderRecords(); + renderer.renderRecords(); setAlprStatus('zoom-in'); return; } @@ -185,11 +164,9 @@ export function createAlprCamerasLayer({ source, services } = {}) { state.pendingQueryBox = null; state.loading = false; } - renderRecords(); + renderer.renderRecords(); state.retrying = false; - setAlprStatus( - state.dataSource.entities.values.length ? 'ready' : 'empty', - ); + setAlprStatus(renderer.count() ? 'ready' : 'empty'); return; } // A move inside an in-flight query must not abort and restart that query. @@ -223,14 +200,14 @@ export function createAlprCamerasLayer({ source, services } = {}) { state.lastQueryBox = null; state.lastQueryZoom = null; clearUnavailableRetry(); - renderRecords(); + renderer.renderRecords(); setAlprStatus('zoom-in'); return; } const { records, stale, saturated } = validateAlprSnapshot(snapshot); // Resolve marker floors (bounded wait, warm cells answer at once) so new // markers are placed on the floor instead of clamped then moved. - await prepareFloors(records); + await renderer.prepareFloors(records); if ( requestAbort.signal.aborted || state.abort !== requestAbort || @@ -256,14 +233,8 @@ export function createAlprCamerasLayer({ source, services } = {}) { clearUnavailableRetry(); // Stale and saturated are independent facts; a cached response that also // hit the cap must still warn about coverage, not just about freshness. - renderRecords(); - setAlprStatus( - state.dataSource.entities.values.length - ? stale - ? 'stale' - : 'ready' - : 'empty', - ); + renderer.renderRecords(); + setAlprStatus(renderer.count() ? (stale ? 'stale' : 'ready') : 'empty'); } catch (error) { if ( error?.name === 'AbortError' || @@ -296,33 +267,28 @@ export function createAlprCamerasLayer({ source, services } = {}) { source: source.label || 'Mapped camera locations', updateInterval: 0, statsRefreshInterval: 1000, - init(viewer) { - if (state.viewer) throw new Error('ALPR layer is already initialized'); - state.viewer = viewer; - state.dataSource = new Cesium.CustomDataSource('alpr-cameras'); - const creditMarkup = alprCreditMarkup(source.attribution); - state.credit = creditMarkup - ? new Cesium.Credit(creditMarkup, true) - : null; - viewer.dataSources.add(state.dataSource); - state.moveEndRemove = - viewer.camera.moveEnd.addEventListener(scheduleLoad); - state.postRenderRemove = - viewer.scene.postRender?.addEventListener(updateSelectedAnchor); - initOverlay(); - installInteraction(viewer); + init(viewer, options = {}) { + if (renderer?.ready) throw new Error('ALPR layer is already initialized'); + if (typeof rendererFactory !== 'function') + throw new TypeError('An ALPR renderer factory is required'); + renderer = rendererFactory({ + state, + services, + source, + engine: options.engine, + }); + renderer.init(viewer, { onMove: scheduleLoad }); }, enable() { if (state.enabled) return; state.enabled = true; - state.creditPresented = false; registerPickOwner(LAYER_ID, (id) => state.recordById.has(id)); - state.dataSource.show = true; + renderer.setVisible(true); // DataLayerManager calls update() right after enable(); it owns the first fetch. }, disable() { state.enabled = false; - hideOnMapCredit(); + renderer?.hideOnMapCredit(); unregisterPickOwner(LAYER_ID); clearUnavailableRetry(); clearTimeout(state.debounceTimer); @@ -331,30 +297,19 @@ export function createAlprCamerasLayer({ source, services } = {}) { state.pendingQueryBox = null; state.loading = false; state.retrying = false; - if (state.dataSource) state.dataSource.show = false; - clearSelection(); - clearRendered(); + renderer?.setVisible(false); + renderer?.clearSelection(); + renderer?.clearRendered(); setAlprStatus('idle'); }, update() { return loadCameras(); }, - destroy(viewer = state.viewer) { + destroy() { source.destroy?.(); this.disable(); - destroyOverlay(); - state.moveEndRemove?.(); - state.moveEndRemove = null; - state.postRenderRemove?.(); - state.postRenderRemove = null; - state.clickHandler?.destroy(); - state.clickHandler = null; - clearRendered(); - if (state.dataSource && viewer) - viewer.dataSources.remove(state.dataSource, true); - state.dataSource = null; - state.credit = null; - state.creditPresented = false; + renderer?.destroy(); + renderer = null; state.records = []; state.recordById = new Map(); state.lastQueryBox = null; @@ -364,21 +319,20 @@ export function createAlprCamerasLayer({ source, services } = {}) { state.status = 'idle'; state.stale = false; state.saturated = false; - state.viewer = null; }, getRowControls() { - const count = state.dataSource?.entities.values.length || 0; + const count = renderer?.count() || 0; return { chips: [ { id: 'find-camera', label: 'SHOW NEAREST', - title: state.viewer?.trackedEntity + title: renderer?.following() ? 'Stop following the current object before navigating to a camera' : 'Move to the nearest loaded camera and show its details', disabled: - !state.enabled || !count || Boolean(state.viewer?.trackedEntity), - onClick: focusNearest, + !state.enabled || !count || Boolean(renderer?.following()), + onClick: () => renderer?.focusNearest(), }, ], legend: [ @@ -425,10 +379,10 @@ export function createAlprCamerasLayer({ source, services } = {}) { }, getStats() { return { - count: state.dataSource?.entities.values.length || 0, + count: renderer?.count() || 0, countLabel: state.enabled && !state.noCoverage && state.status !== 'zoom-in' - ? `${state.dataSource?.entities.values.length || 0} nearby${ + ? `${renderer?.count() || 0} nearby${ Number.isInteger(state.onScreen) ? ` · ${state.onScreen} on screen` : '' @@ -440,7 +394,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { saturated: state.saturated || state.renderSaturated, renderRevision: state.renderRevision, onScreen: state.onScreen ?? null, - shown: state.dataSource?.entities.values.length || 0, + shown: renderer?.count() || 0, error: state.error, status: state.status, loading: state.loading, diff --git a/src/layers/alpr/overlay.test.mjs b/src/layers/alpr/overlay.test.mjs index 1bb9985af..b1f2f09c9 100644 --- a/src/layers/alpr/overlay.test.mjs +++ b/src/layers/alpr/overlay.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { createAlprOverlay } from './overlay.js'; +import { createAlprOverlay } from './render/cesiumOverlay.js'; import { directionWedgePositions, alprLabelDetails, @@ -46,6 +46,7 @@ function harness(t, count = 2, { floor = null } = {}) { paints = 0; const overlay = createAlprOverlay({ state, + native: state, services: { groundFloor: { cachedGroundFloor: () => floor }, overlays: { diff --git a/src/layers/alpr/render/cesium.js b/src/layers/alpr/render/cesium.js new file mode 100644 index 000000000..54e719df8 --- /dev/null +++ b/src/layers/alpr/render/cesium.js @@ -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; + }, + }; +} diff --git a/src/layers/alpr/overlay.js b/src/layers/alpr/render/cesiumOverlay.js similarity index 95% rename from src/layers/alpr/overlay.js rename to src/layers/alpr/render/cesiumOverlay.js index 202a98ed7..811fa8cdb 100644 --- a/src/layers/alpr/overlay.js +++ b/src/layers/alpr/render/cesiumOverlay.js @@ -8,7 +8,7 @@ import { SURFACE_BELOW_FLOOR_M, SURFACE_ABOVE_FLOOR_M, SURFACE_SAMPLES_PER_PAINT, -} from './policy.js'; +} from '../policy.js'; import { MARKER_IMAGE, SELECTED_IMAGE, @@ -17,11 +17,12 @@ import { markerScale, paintDirectionWedge, validAlprGroundHeight, -} from './visuals.js'; +} from '../visuals.js'; /** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */ export function createAlprOverlay({ state, + native, services, onSurfaceChange = () => {}, }) { @@ -86,7 +87,7 @@ export function createAlprOverlay({ } function resetAnchors() { - for (const entity of state.dataSource.entities.values) { + for (const entity of native.dataSource.entities.values) { entity.gevAlprCanvasPosition = null; entity.gevAlprWedge = null; entity.gevAlprDisplayPosition = null; @@ -99,7 +100,7 @@ export function createAlprOverlay({ function anchorFor(record, entity, budget) { if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition; - const scene = state.viewer.scene; + const scene = native.viewer.scene; const location = Cesium.Cartographic.fromDegrees( record.longitude, record.latitude, @@ -166,8 +167,8 @@ export function createAlprOverlay({ * rejected. Returns whether anchors remain to check. */ function refineAnchors(budget) { - const camera = state.viewer.camera; - const scene = state.viewer.scene; + const camera = native.viewer.camera; + const scene = native.viewer.scene; const still = lastCamera && camera.positionWC && @@ -191,7 +192,7 @@ export function createAlprOverlay({ if (!scene.sampleHeightSupported) return false; let pending = false; for (const record of records) { - const entity = state.dataSource.entities.getById(record.id); + const entity = native.dataSource.entities.getById(record.id); const anchor = entity?.gevAlprCanvasPosition; if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue; if (!still || !surfaceSettled(scene) || budget.samples <= 0) { @@ -234,7 +235,7 @@ export function createAlprOverlay({ const painted = []; const budget = { samples: ANCHOR_SAMPLES_PER_PAINT }; for (const record of records) { - const entity = state.dataSource.entities.getById(record.id); + const entity = native.dataSource.entities.getById(record.id); if (!entity) continue; const selected = record.id === state.selectedId; const image = images[selected ? 1 : 0]; @@ -257,7 +258,7 @@ export function createAlprOverlay({ setShown(entity, false); continue; } - const scene = state.viewer.scene; + const scene = native.viewer.scene; const origin = Cesium.SceneTransforms.worldToWindowCoordinates( scene, anchor, @@ -306,7 +307,7 @@ export function createAlprOverlay({ image, alpha, scale: markerScale( - Cesium.Cartesian3.distance(state.viewer.camera.positionWC, anchor), + Cesium.Cartesian3.distance(native.viewer.camera.positionWC, anchor), ), }); if (selected) entity.gevAlprDisplayPosition = anchor; @@ -372,8 +373,8 @@ export function createAlprOverlay({ sync(visible) { if (!lane) return; for (const record of records) - nativeVisible(state.dataSource.entities.getById(record.id), true); - const position = state.viewer.camera.positionWC; + nativeVisible(native.dataSource.entities.getById(record.id), true); + const position = native.viewer.camera.positionWC; records = visible .filter( (record) => @@ -422,7 +423,7 @@ export function createAlprOverlay({ clearTimeout(retryTimer); retryTimer = null; for (const record of records) - nativeVisible(state.dataSource?.entities.getById(record.id), true); + nativeVisible(native.dataSource?.entities.getById(record.id), true); records = []; hits = []; lane?.setActive(false); diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/render/cesiumPresentation.js similarity index 88% rename from src/layers/alpr/presentation.js rename to src/layers/alpr/render/cesiumPresentation.js index 7436773b4..80eb64c6a 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/render/cesiumPresentation.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { isPointerFree } from '../../data/inputOwnership.js'; +import { isPointerFree } from '../../../data/inputOwnership.js'; import { LAYER_ID, MAX_VIEWPORT_DEGREES, @@ -11,19 +11,19 @@ import { CREDIT_DISPLAY_MS, MARKER_FLOOR_LIFT_M, MARKER_SCALE_BY_DISTANCE, -} from './policy.js'; -import { boxContains } from './model.js'; -import { createAlprFloorResolver } from './floors.js'; -import { createAlprOverlay } from './overlay.js'; +} from '../policy.js'; +import { boxContains } from '../model.js'; +import { createAlprFloorResolver } from '../floors.js'; +import { createAlprOverlay } from './cesiumOverlay.js'; import { MARKER_IMAGE, SELECTED_IMAGE, alprDisplayId, alprLabelDetails, validAlprGroundHeight, -} from './visuals.js'; +} from '../visuals.js'; -export function createAlprPresentation({ state, services, source }) { +export function createAlprPresentation({ state, native, services, source }) { const { governorRequestRender } = services.render; const { clearSelectedEntityContextForLayer, @@ -36,6 +36,7 @@ export function createAlprPresentation({ state, services, source }) { const { cachedGroundFloor } = services.groundFloor; const overlay = createAlprOverlay({ state, + native, services, onSurfaceChange: refreshMarkerPlacements, }); @@ -70,7 +71,7 @@ export function createAlprPresentation({ state, services, source }) { /** Records the current view would display (viewport box, nearest first when capped). */ function viewRecords(records) { - const box = viewportBox(state.viewer); + const box = viewportBox(native.viewer); if (!box) return { box, visible: [], capped: false }; let visible = records.filter((record) => boxContains(box, { @@ -129,11 +130,11 @@ export function createAlprPresentation({ state, services, source }) { * @returns {number|null} Count, or null without a scene to project into. */ function countOnScreen() { - const scene = state.viewer?.scene; + const scene = native.viewer?.scene; const canvas = scene?.canvas; const width = canvas?.clientWidth || canvas?.width; const height = canvas?.clientHeight || canvas?.height; - const camera = state.viewer?.camera; + const camera = native.viewer?.camera; if (!scene || !width || !height || !camera?.positionWC) return null; const occluder = new Cesium.EllipsoidalOccluder( Cesium.Ellipsoid.WGS84, @@ -141,7 +142,7 @@ export function createAlprPresentation({ state, services, source }) { ); let count = 0; try { - for (const entity of state.dataSource.entities.values) { + for (const entity of native.dataSource.entities.values) { const position = entity.position?.getValue?.(Cesium.JulianDate.now()); if (!position || !occluder.isPointVisible(position)) continue; const point = Cesium.SceneTransforms.worldToWindowCoordinates( @@ -182,9 +183,9 @@ export function createAlprPresentation({ state, services, source }) { /** Refresh all native placements, including the appearances held by the canvas. */ function refreshMarkerPlacements() { - if (!state.enabled || !state.dataSource) return; + if (!state.enabled || !native.dataSource) return; clampedMarkers.clear(); - for (const entity of state.dataSource.entities.values) { + for (const entity of native.dataSource.entities.values) { const { position, clamped } = markerPlacement(entity.gevAlprRecord); setMarkerPosition(entity, position, clamped); if (clamped) clampedMarkers.set(entity.id, entity); @@ -211,7 +212,7 @@ export function createAlprPresentation({ state, services, source }) { .then(() => { if (floorResolve !== request) return; floorResolve = null; - if (!state.enabled || !state.dataSource) return; + if (!state.enabled || !native.dataSource) return; let moved = false; for (const entity of batch) { if (clampedMarkers.get(entity.id) !== entity) continue; @@ -309,16 +310,16 @@ export function createAlprPresentation({ state, services, source }) { overlay.clear(); visibleRecords = []; clampedMarkers.clear(); - if (state.dataSource?.entities) state.dataSource.entities.removeAll(); + if (native.dataSource?.entities) native.dataSource.entities.removeAll(); removeEntityContextsForLayer(LAYER_ID); } function hideOnMapCredit() { - services.credits?.hideOsmCredit?.(state.viewer, LAYER_ID); - clearTimeout(state.creditTimer); - state.creditTimer = null; - if (state.credit) - state.viewer?.creditDisplay?.removeStaticCredit(state.credit); + services.credits?.hideOsmCredit?.(native.viewer, LAYER_ID); + clearTimeout(native.creditTimer); + native.creditTimer = null; + if (native.credit) + native.viewer?.creditDisplay?.removeStaticCredit(native.credit); governorRequestRender('alpr-credit'); } @@ -328,13 +329,13 @@ export function createAlprPresentation({ state, services, source }) { source.attribution?.name === 'OpenStreetMap' && services.credits?.showOsmCredit ) { - services.credits.showOsmCredit(state.viewer, LAYER_ID); + services.credits.showOsmCredit(native.viewer, LAYER_ID); return; } - if (!state.enabled || state.creditPresented || !state.credit) return; - state.creditPresented = true; - state.viewer.creditDisplay?.addStaticCredit(state.credit); - state.creditTimer = setTimeout(hideOnMapCredit, CREDIT_DISPLAY_MS); + if (!state.enabled || native.creditPresented || !native.credit) return; + native.creditPresented = true; + native.viewer.creditDisplay?.addStaticCredit(native.credit); + native.creditTimer = setTimeout(hideOnMapCredit, CREDIT_DISPLAY_MS); governorRequestRender('alpr-credit'); } @@ -358,17 +359,17 @@ export function createAlprPresentation({ state, services, source }) { governorRequestRender('alpr-render'); const visibleIds = new Set(visible.map((record) => record.id)); let changed = false; - state.dataSource.entities.suspendEvents(); - for (const entity of [...state.dataSource.entities.values]) { + native.dataSource.entities.suspendEvents(); + for (const entity of [...native.dataSource.entities.values]) { if (!visibleIds.has(entity.id)) { - state.dataSource.entities.remove(entity); + native.dataSource.entities.remove(entity); clampedMarkers.delete(entity.id); changed = true; } } removeEntityContextsForLayer(LAYER_ID, { retainIds: visibleIds }); for (const record of visible) { - const existing = state.dataSource.entities.getById(record.id); + const existing = native.dataSource.entities.getById(record.id); if (existing?.gevAlprRecord === record) { updateAppearance(existing, record.id === state.selectedId); continue; @@ -402,7 +403,7 @@ export function createAlprPresentation({ state, services, source }) { // geometry is unchanged. Updating metadata must not rebuild the marker. const previous = entity?.gevAlprRecord; if (!entity) { - entity = state.dataSource.entities.add(entityDef); + entity = native.dataSource.entities.add(entityDef); entity.gevAlprClamped = clamped; changed = true; } else { @@ -447,7 +448,7 @@ export function createAlprPresentation({ state, services, source }) { registerEntityContext(entity, { id: record.id, layerId: LAYER_ID, - dataSource: state.dataSource, + dataSource: native.dataSource, layerName: 'ALPR Cameras', source: source.attribution?.description || source.label || 'Camera source', @@ -467,14 +468,14 @@ export function createAlprPresentation({ state, services, source }) { }, }); } - state.dataSource.entities.resumeEvents(); + native.dataSource.entities.resumeEvents(); if (changed) state.renderRevision += 1; state.onScreen = countOnScreen(); - for (const entity of state.dataSource.entities.values) + for (const entity of native.dataSource.entities.values) if (entity.gevAlprClamped) clampedMarkers.set(entity.id, entity); resolveClampedFloors(); const selectedEntity = state.selectedId - ? state.dataSource.entities.getById(state.selectedId) + ? native.dataSource.entities.getById(state.selectedId) : null; if (!selectedEntity) state.selectedId = null; updateSelectedAnchor(); @@ -486,12 +487,12 @@ export function createAlprPresentation({ state, services, source }) { } function focusNearest() { - const camera = state.viewer?.camera; - if (!state.enabled || !camera?.positionWC || state.viewer.trackedEntity) + const camera = native.viewer?.camera; + if (!state.enabled || !camera?.positionWC || native.viewer.trackedEntity) return false; let nearest = null, distance = Infinity; - for (const entity of state.dataSource.entities.values) { + for (const entity of native.dataSource.entities.values) { const position = entity.position.getValue(Cesium.JulianDate.now()); const candidate = Cesium.Cartesian3.distanceSquared( camera.positionWC, @@ -509,15 +510,15 @@ export function createAlprPresentation({ state, services, source }) { record.latitude, ); let height; - if (state.viewer.scene.sampleHeightSupported) { + if (native.viewer.scene.sampleHeightSupported) { try { - height = state.viewer.scene.sampleHeight(location, [nearest]); + height = native.viewer.scene.sampleHeight(location, [nearest]); } catch { /* tiles may still be streaming */ } } - if (!validAlprGroundHeight(height) && state.viewer.scene.globe.show) - height = state.viewer.scene.globe.getHeight?.(location); + if (!validAlprGroundHeight(height) && native.viewer.scene.globe.show) + height = native.viewer.scene.globe.getHeight?.(location); if (!validAlprGroundHeight(height)) height = cachedGroundFloor(record.latitude, record.longitude); const center = Cesium.Cartesian3.fromDegrees( @@ -539,7 +540,7 @@ export function createAlprPresentation({ state, services, source }) { } function selectRecord(id) { - const entity = state.dataSource?.entities.getById(id); + const entity = native.dataSource?.entities.getById(id); if (!entity || !state.recordById.has(id)) return false; clearSelection(); state.selectedId = id; @@ -555,7 +556,7 @@ export function createAlprPresentation({ state, services, source }) { function clearSelection() { const entity = state.selectedId - ? state.dataSource?.entities.getById(state.selectedId) + ? native.dataSource?.entities.getById(state.selectedId) : null; const record = state.recordById.get(state.selectedId); if (entity && record) updateAppearance(entity, false); @@ -573,7 +574,7 @@ export function createAlprPresentation({ state, services, source }) { clearSelection(); return; } - const entity = state.dataSource?.entities.getById(state.selectedId); + const entity = native.dataSource?.entities.getById(state.selectedId); const record = state.recordById.get(state.selectedId); if (entity?.gevAlprCanvasPosition) { entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition; @@ -582,7 +583,7 @@ export function createAlprPresentation({ state, services, source }) { if (!entity || !record || Date.now() - state.lastAnchorSampleAt < 1000) return; state.lastAnchorSampleAt = Date.now(); - const scene = state.viewer.scene; + const scene = native.viewer.scene; const location = Cesium.Cartographic.fromDegrees( record.longitude, record.latitude, @@ -615,11 +616,11 @@ export function createAlprPresentation({ state, services, source }) { } function installInteraction(viewer) { - if (state.clickHandler) return; - state.clickHandler = new Cesium.ScreenSpaceEventHandler( + if (native.clickHandler) return; + native.clickHandler = new Cesium.ScreenSpaceEventHandler( viewer.scene.canvas, ); - state.clickHandler.setInputAction((click) => { + native.clickHandler.setInputAction((click) => { // A tool owns the pointer (src/data/inputOwnership.js): yield the click. if (!isPointerFree()) return; if (!state.enabled) return; @@ -640,7 +641,7 @@ export function createAlprPresentation({ state, services, source }) { initOverlay: () => overlay.init(), destroyOverlay: () => overlay.destroy(), markerColor, - viewportBox, + viewportBox: () => viewportBox(native.viewer), clearRendered, hideOnMapCredit, presentOnMapCredit, diff --git a/src/layers/alpr/render/navara.js b/src/layers/alpr/render/navara.js new file mode 100644 index 000000000..4ec0a5db7 --- /dev/null +++ b/src/layers/alpr/render/navara.js @@ -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; + }, + }; +} diff --git a/src/layers/alpr/render/navara.test.mjs b/src/layers/alpr/render/navara.test.mjs new file mode 100644 index 000000000..a0cdb8249 --- /dev/null +++ b/src/layers/alpr/render/navara.test.mjs @@ -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); +}); diff --git a/src/layers/alpr/render/navaraOverlay.js b/src/layers/alpr/render/navaraOverlay.js new file mode 100644 index 000000000..befe15f6d --- /dev/null +++ b/src/layers/alpr/render/navaraOverlay.js @@ -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 = []; + }, + }; +} diff --git a/src/layers/alpr/render/navaraOverlay.test.mjs b/src/layers/alpr/render/navaraOverlay.test.mjs new file mode 100644 index 000000000..6e1e7ce78 --- /dev/null +++ b/src/layers/alpr/render/navaraOverlay.test.mjs @@ -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); +}); diff --git a/src/ui/leftPanelRail.js b/src/ui/leftPanelRail.js index 607e13083..b7b860f42 100644 --- a/src/ui/leftPanelRail.js +++ b/src/ui/leftPanelRail.js @@ -50,8 +50,12 @@ export function layoutLeftPanelRail({ // The existing narrow-screen composition has its own full-width stack. // Keep this desktop lane engine from fighting those dedicated rules. if (windowRef.matchMedia('(max-width: 720px)').matches) { - stack.classList.remove('layout-focus'); - stack.classList.remove('layout-tail'); + if (stack.classList.contains('layout-focus')) { + stack.classList.remove('layout-focus'); + } + if (stack.classList.contains('layout-tail')) { + stack.classList.remove('layout-tail'); + } stack.style.removeProperty('--left-stack-safe-top'); stack.style.removeProperty('--left-stack-safe-bottom'); stack.style.removeProperty('--left-stack-centered-height'); diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 016de58fa..a190368f1 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -197,6 +197,22 @@ test('missing rails are inert without browser globals', () => { layoutRightPanelRail({}); }); +test('repeated mobile layout does not rewrite absent left rail classes', () => { + const f = fixture('left', { mobile: true }); + f.stack.classList.add('layout-focus', 'layout-tail'); + f.run(); + assert.equal(f.stack.classList.contains('layout-focus'), false); + assert.equal(f.stack.classList.contains('layout-tail'), false); + let removals = 0; + const remove = f.stack.classList.remove; + f.stack.classList.remove = (...names) => { + removals += 1; + remove(...names); + }; + f.run(); + assert.equal(removals, 0, 'no redundant class mutation can retrigger layout'); +}); + test('left layout records collapsed heights and aligns the right rail without changing disclosure', () => { const f = fixture('left'); f.run(); diff --git a/src/ui/styles/overlays.css b/src/ui/styles/overlays.css index 227d2d6d0..1322cba83 100644 --- a/src/ui/styles/overlays.css +++ b/src/ui/styles/overlays.css @@ -232,6 +232,7 @@ .hud-summary-wrap { margin-top: 6px; + width: 420px; max-width: 420px; } @@ -243,7 +244,7 @@ .hud-summary { margin-top: 2px; - min-height: 16px; + min-height: max(16px, 1lh); font-size: 11px; letter-spacing: 1.2px; opacity: 0.88;