From da689e03ac983499f9682c46889d454195e7231b Mon Sep 17 00:00:00 2001 From: monwiss <547445+monwiss@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:15:39 -0400 Subject: [PATCH 01/20] refactor(providers): move the tile budget helpers out of the TomTom module utcDayKey, normalizeBudget and isOverBudget move unchanged to src/data/tileBudget.js. Nothing in them was TomTom-specific; the next keyed tile proxy (Xweather) counts a monthly allowance through the same two functions. tomtomTiles.js re-exports them, so the traffic provider and its tests keep their imports. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/format-scope.json | 2 ++ scripts/package-boundaries.json | 12 ++++++-- src/data/tileBudget.js | 53 +++++++++++++++++++++++++++++++++ src/data/tileBudget.test.mjs | 26 ++++++++++++++++ src/data/tomtomTiles.js | 41 ++----------------------- 5 files changed, 94 insertions(+), 40 deletions(-) create mode 100644 src/data/tileBudget.js create mode 100644 src/data/tileBudget.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index eee546dd5..9b82af4b7 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -79,6 +79,8 @@ "src/data/terrainHeightsProxy.test.mjs", "src/data/tomtomTiles.js", "src/data/tomtomTiles.test.mjs", + "src/data/tileBudget.js", + "src/data/tileBudget.test.mjs", "src/data/firmsCsv.js", "src/data/firmsCsv.test.mjs", "src/tooling/environmentProviders.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 189192f10..748f11d44 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -113,12 +113,16 @@ "traffic-provider": { "runtime": "node", "exports": ["./server/providers/traffic"], - "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], + "modules": [ + "server/providers/traffic.js", + "src/data/tomtomTiles.js", + "src/data/tileBudget.js" + ], "external": [] }, "traffic-source": { "exports": ["./sources/traffic"], - "modules": ["src/data/tomtomTiles.js"], + "modules": ["src/data/tomtomTiles.js", "src/data/tileBudget.js"], "external": [] }, "firms-csv": { @@ -1009,6 +1013,7 @@ "src/data/cameraSensitivity.js", "src/data/flowMatch.js", "src/data/tomtomTiles.js", + "src/data/tileBudget.js", "src/data/trafficBounds.js", "src/data/trafficFlowStyle.js", "src/data/trafficPresetStyle.js", @@ -1424,6 +1429,7 @@ "src/data/telegeographySubmarineCables.js", "src/data/terrainHeights.js", "src/data/tomtomTiles.js", + "src/data/tileBudget.js", "src/data/tr3bRegistry.js", "src/data/trackedCamera.js", "src/data/trackedModelRegime.js", @@ -2071,6 +2077,7 @@ "src/data/scenePick.js", "src/data/spriteOrder.js", "src/data/tomtomTiles.js", + "src/data/tileBudget.js", "src/data/tr3bRegistry.js", "src/data/trackedCamera.js", "src/data/trackedModelRegime.js", @@ -2707,6 +2714,7 @@ "exports": ["./layers/traffic/source"], "modules": [ "src/data/tomtomTiles.js", + "src/data/tileBudget.js", "src/layers/traffic/flowDecode.js", "src/layers/traffic/flowSource.js", "src/layers/traffic/source.js", diff --git a/src/data/tileBudget.js b/src/data/tileBudget.js new file mode 100644 index 000000000..dcc44a28c --- /dev/null +++ b/src/data/tileBudget.js @@ -0,0 +1,53 @@ +/** + * @file Upstream-request accounting shared by the keyed tile proxies. + * + * A soft cap per UTC period, persisted as `{date, count}` so it survives a + * restart. Nothing here is provider-specific: the caller picks the period by + * the key it passes, and decides what happens over the cap. + * + * Zero dependencies and Cesium-free, so providers and browser helpers can + * import it and node:test can exercise it directly. + * + * @module data/tileBudget + */ + +/** + * UTC calendar-day key for budget bucketing. + * + * @param {number} [epochMs=Date.now()] - Timestamp in ms. + * @returns {string} 'YYYY-MM-DD' in UTC. + */ +export function utcDayKey(epochMs = Date.now()) { + return new Date(epochMs).toISOString().slice(0, 10); +} + +/** + * Normalize a persisted budget state against today's UTC day key. + * Rolls the counter to zero on day change; replaces missing/corrupt state. + * Returns the SAME object when it is already valid for `dayKey` (cheap to + * call on every request). + * + * @param {{date:string, count:number}|null|undefined} state - Persisted state. + * @param {string} dayKey - Today's UTC day key (from `utcDayKey`). + * @returns {{date:string, count:number}} Valid state for `dayKey`. + */ +export function normalizeBudget(state, dayKey) { + const valid = + Boolean(state) && + state.date === dayKey && + Number.isFinite(state.count) && + state.count >= 0; + return valid ? state : { date: dayKey, count: 0 }; +} + +/** + * Whether the daily soft cap has been reached. + * + * @param {{count:number}} state - Normalized budget state. + * @param {number} limit - Daily tile budget; non-positive/invalid never blocks. + * @returns {boolean} True when `count >= limit`. + */ +export function isOverBudget(state, limit) { + if (!Number.isFinite(limit) || limit <= 0) return false; + return state.count >= limit; +} diff --git a/src/data/tileBudget.test.mjs b/src/data/tileBudget.test.mjs new file mode 100644 index 000000000..fa48440bb --- /dev/null +++ b/src/data/tileBudget.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isOverBudget, normalizeBudget, utcDayKey } from './tileBudget.js'; +import * as tomtom from './tomtomTiles.js'; + +test('tile budget helpers live in one module and TomTom re-exports them', () => { + assert.equal(tomtom.utcDayKey, utcDayKey); + assert.equal(tomtom.normalizeBudget, normalizeBudget); + assert.equal(tomtom.isOverBudget, isOverBudget); +}); + +test('normalizeBudget keeps a valid state and resets another period', () => { + const state = { date: '2026-09-24', count: 3 }; + assert.equal(normalizeBudget(state, '2026-09-24'), state); + assert.deepEqual(normalizeBudget(state, '2026-09-25'), { + date: '2026-09-25', + count: 0, + }); + assert.deepEqual( + normalizeBudget({ date: '2026-09-24', count: -1 }, '2026-09-24'), + { + date: '2026-09-24', + count: 0, + }, + ); +}); diff --git a/src/data/tomtomTiles.js b/src/data/tomtomTiles.js index af4fc95b3..a1dbc18cb 100644 --- a/src/data/tomtomTiles.js +++ b/src/data/tomtomTiles.js @@ -131,42 +131,7 @@ export function tilesForBounds(bounds, zoom = 12, { maxTiles = 64 } = {}) { // ─── Daily budget accounting ─────────────────────────────── /** - * UTC calendar-day key for budget bucketing. - * - * @param {number} [epochMs=Date.now()] - Timestamp in ms. - * @returns {string} 'YYYY-MM-DD' in UTC. + * Re-exported from `./tileBudget.js`, which keyed tile proxies share. The + * traffic provider and its tests keep one import for tile math and budget. */ -export function utcDayKey(epochMs = Date.now()) { - return new Date(epochMs).toISOString().slice(0, 10); -} - -/** - * Normalize a persisted budget state against today's UTC day key. - * Rolls the counter to zero on day change; replaces missing/corrupt state. - * Returns the SAME object when it is already valid for `dayKey` (cheap to - * call on every request). - * - * @param {{date:string, count:number}|null|undefined} state - Persisted state. - * @param {string} dayKey - Today's UTC day key (from `utcDayKey`). - * @returns {{date:string, count:number}} Valid state for `dayKey`. - */ -export function normalizeBudget(state, dayKey) { - const valid = - Boolean(state) && - state.date === dayKey && - Number.isFinite(state.count) && - state.count >= 0; - return valid ? state : { date: dayKey, count: 0 }; -} - -/** - * Whether the daily soft cap has been reached. - * - * @param {{count:number}} state - Normalized budget state. - * @param {number} limit - Daily tile budget; non-positive/invalid never blocks. - * @returns {boolean} True when `count >= limit`. - */ -export function isOverBudget(state, limit) { - if (!Number.isFinite(limit) || limit <= 0) return false; - return state.count >= limit; -} +export { utcDayKey, normalizeBudget, isOverBudget } from './tileBudget.js'; From bbfb23a765e0bc2927993a4884317f38f92fa4bd Mon Sep 17 00:00:00 2001 From: BreezeDelegate <271538431+BreezeDelegate@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:04:43 +0200 Subject: [PATCH 02/20] fix(ci): run allocation gates on Node 26 --- changelog.d/766-node26-allocation-gates.md | 1 + docs/CURRENT-STATE.md | 17 +++++++------- scripts/qa-l9-matrix.mjs | 24 ++++++++++---------- scripts/run-unit-tests.mjs | 22 +++++++++--------- src/data/focusAllocations.test.mjs | 2 +- src/overlays/worldOverlayAllocation.test.mjs | 2 +- src/qaL9MatrixVerdicts.test.mjs | 3 ++- src/unitTestRunner.test.mjs | 23 ++++++++----------- 8 files changed, 46 insertions(+), 48 deletions(-) create mode 100644 changelog.d/766-node26-allocation-gates.md diff --git a/changelog.d/766-node26-allocation-gates.md b/changelog.d/766-node26-allocation-gates.md new file mode 100644 index 000000000..a0fcb6a9f --- /dev/null +++ b/changelog.d/766-node26-allocation-gates.md @@ -0,0 +1 @@ +- Run the existing GC-bracketed allocation regression gates on Node 26 as well as Node 24. Keep the current byte budgets so CI catches allocation regressions on both supported runtimes (#39). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d49d92238..a7c2e2f7f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3148,9 +3148,9 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > style-switch persistence state. Browser/GPU performance comparison remains > operator-side; the final accounting records only comparable population > reductions and deterministic Node allocation/solve measurements. Those -> allocation gates are calibrated on Node.js 24.14.x; `package.json` permits -> supported product runtimes on Node 24 or 26, while the allocation runner -> separately enforces Node 24 for these two calibrated probes. Each probe +> allocation gates are calibrated on both supported Node majors (24 and 26); +> `package.json` permits those same product runtimes, and the existing CI matrix +> executes the two calibrated probes on both entries. Each probe > compiles synchronously and discards explicit-GC > transition chunks before applying the unchanged byte ceilings. The unit > runner executes ordinary test files with Node's default parallelism, then @@ -4987,12 +4987,11 @@ and unreachable upstream (502/504) separately from road geometry. > **Reading `npm test` totals:** the count depends on the Node major. The two > GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs` -> = 1 test, `src/overlays/worldOverlayAllocation.test.mjs` = 13) only RUN on the -> calibrated Node 24 runtime; on any other major the runner skips both files and -> their 14 tests are absent from the total. A branch total quoted without its -> Node version is therefore not reproducible. As of the fly_route cinematic -> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on -> `main` + 26 route pins; the same tree on Node 24 reports 2,295. +> = 1 test, `src/overlays/worldOverlayAllocation.test.mjs` = 13) RUN on both +> supported Node 24 and Node 26. Unsupported majors may still skip those files +> unless `GEV_REQUIRE_ALLOCATION_GATE=1` is set; every current supported CI +> matrix entry is calibrated and therefore executes them. Historical totals recorded from unsupported Node 25 +> runs can therefore remain lower because their 14 allocation tests were skipped. - **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. diff --git a/scripts/qa-l9-matrix.mjs b/scripts/qa-l9-matrix.mjs index 8709366de..d36bab9cb 100644 --- a/scripts/qa-l9-matrix.mjs +++ b/scripts/qa-l9-matrix.mjs @@ -239,11 +239,11 @@ function satisfiesEngines(version, range) { * re-resolve the interpreter from PATH and hand the gate a different Node than * the one the check believes it selected. * @param {string} version e.g. "v24.19.0" - * @returns {boolean} true only for a parseable Node 24. + * @returns {boolean} true for a parseable supported Node 24 or 26. */ function isCalibratedAllocationRuntime(version) { const [maj] = String(version || '').trim().replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10)); - return maj === 24; + return maj === 24 || maj === 26; } /** @@ -546,7 +546,7 @@ const check = (spec) => { CHECKS.push(spec); }; // ─── A · REPO GATES ─────────────────────────────────────────────────────── check({ - id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets are pinned to Node 24)', + id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets cover supported Node majors)', run: async () => { const pkg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'package.json'), 'utf8')); const range = pkg.engines?.node || '(unset)'; @@ -557,7 +557,7 @@ check({ if (satisfied) return pass(`node ${process.versions.node} satisfies "${range}"`); // An off-range runtime is an environment problem, not a product defect — // but it silently disables the allocation gate, so say so loudly. A3 then - // runs that gate under a discovered Node 24. + // runs that gate under a discovered calibrated Node 24 fallback. return env.node24 ? skip(`node ${process.versions.node} is OUTSIDE "${range}"; A3 runs the allocation gate under ${env.node24.label}, but prefer running the whole L9 pass on Node 24`, 'ENV') : fail(`node ${process.versions.node} is OUTSIDE "${range}" and no Node 24 runtime was found — the allocation gate cannot run at all`); @@ -579,13 +579,13 @@ check({ }); check({ - id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE (they silently skip off Node 24)', + id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE on supported Node majors', run: async () => { // npm test prints "[unit] SKIPPED n allocation microbenchmarks" and still - // exits 0 when the runtime is not Node 24. A green suite is therefore NOT - // proof the gate ran. Force it, under Node 24 when one is discoverable. + // exits 0 when the runtime is outside the calibrated supported majors. A green + // suite is therefore NOT proof the gate ran. Force it under a calibrated runtime. // - // ALWAYS invoke the Node 24 BINARY directly — never `npm test`. npm + // ALWAYS invoke the calibrated Node binary directly — never `npm test`. npm // re-resolves the interpreter from PATH and can land back on the system // Node even when THIS process is already 24: running the whole matrix under // `mise exec node@24.19.0 --`, the npm shell-out still re-execed system @@ -597,10 +597,10 @@ check({ const v = await sh(bin, ['--version'], { timeoutMs: 60000 }); const version = (v.out || '').trim(); if (!isCalibratedAllocationRuntime(version)) { - // run-unit-tests.mjs refuses to measure calibrated budgets off Node 24, + // run-unit-tests.mjs refuses to measure calibrated budgets off supported Node majors, // so a wrong binary means the gate never ran. That is this check failing // to establish its own claim — not evidence about the product. - return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 — the gate would refuse or silently skip, so this check verified nothing`); + return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 or 26 — the gate would refuse or silently skip, so this check verified nothing`); } const r = await sh(bin, [resolve(REPO_ROOT, 'scripts/run-unit-tests.mjs')], { timeoutMs: 600000, env: { GEV_REQUIRE_ALLOCATION_GATE: '1' }, @@ -611,9 +611,9 @@ check({ }; const [maj] = process.versions.node.split('.').map(Number); - if (maj === 24) return runGate(process.execPath, 'this runtime'); + if (maj === 24 || maj === 26) return runGate(process.execPath, 'this runtime'); if (env.node24) return runGate(env.node24.bin, env.node24.label); - return skip(`no Node 24 runtime found (running ${process.versions.node}); the gate SKIPS silently — install Node 24 and re-run`, 'OWNER-RUN'); + return skip(`no calibrated Node 24/26 runtime found (running ${process.versions.node}); the gate SKIPS silently — install a supported Node runtime and re-run`, 'OWNER-RUN'); }, }); diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 3712df28e..29c3e2b00 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -8,15 +8,16 @@ export const ALLOCATION_TEST_FILES = Object.freeze([ 'src/overlays/worldOverlayAllocation.test.mjs', ]); -/** Whether this runtime matches the one the allocation budgets were calibrated on. */ +/** Whether this runtime matches a supported major the allocation budgets are calibrated on. */ export function isCalibratedAllocationRuntime(version = process.versions.node) { - return Number.parseInt(String(version).split('.')[0], 10) === 24; + const major = Number.parseInt(String(version).split('.')[0], 10); + return major === 24 || major === 26; } -/** Require the runtime on which allocation budgets were calibrated. */ -export function assertNode24AllocationRuntime(version = process.versions.node) { +/** Require a runtime on which allocation budgets are calibrated. */ +export function assertCalibratedAllocationRuntime(version = process.versions.node) { if (!isCalibratedAllocationRuntime(version)) { - throw new Error(`Allocation budgets require the calibrated Node 24 runtime; received ${version}`); + throw new Error(`Allocation budgets require calibrated Node 24 or 26; received ${version}`); } return version; } @@ -86,19 +87,18 @@ export function runUnitTests() { const parallelStatus = runTests(['--test', ...plan.parallel]); if (parallelStatus !== 0) return parallelStatus; - // The GC-bracketed budgets are calibrated on Node 24 and are meaningless on - // other allocators. A contributor's suite must stay green on any supported - // engine (package.json permits >=24), so uncalibrated runtimes skip the + // The GC-bracketed budgets are calibrated on every supported Node major. + // Unsupported runtimes may still run ordinary tests, so uncalibrated runtimes skip the // probes with a warning. Set GEV_REQUIRE_ALLOCATION_GATE=1 (pinned CI / // release batteries) to make an uncalibrated runtime a hard failure. if (!isCalibratedAllocationRuntime()) { if (process.env.GEV_REQUIRE_ALLOCATION_GATE === '1') { - assertNode24AllocationRuntime(); + assertCalibratedAllocationRuntime(); } console.warn( `[unit] SKIPPED ${ALLOCATION_TEST_FILES.length} allocation microbenchmarks: ` - + `budgets are calibrated for Node 24, running ${process.versions.node}. ` - + 'Run under Node 24 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).', + + `budgets are calibrated for Node 24 and 26, running ${process.versions.node}. ` + + 'Run under Node 24 or 26 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).', ); return 0; } diff --git a/src/data/focusAllocations.test.mjs b/src/data/focusAllocations.test.mjs index 86f28db18..f58e5f121 100644 --- a/src/data/focusAllocations.test.mjs +++ b/src/data/focusAllocations.test.mjs @@ -13,7 +13,7 @@ import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs' */ test('converged focus treatment stays within the GC-bracketed allocation budget', (t) => { if (!isCalibratedAllocationRuntime()) { - return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`); + return t.skip(`allocation budgets are calibrated for Node 24 or 26; running ${process.versions.node}`); } const result = spawnSync( process.execPath, diff --git a/src/overlays/worldOverlayAllocation.test.mjs b/src/overlays/worldOverlayAllocation.test.mjs index e29785d48..70197097a 100644 --- a/src/overlays/worldOverlayAllocation.test.mjs +++ b/src/overlays/worldOverlayAllocation.test.mjs @@ -333,7 +333,7 @@ function runAllocationProbe(entryCount, profile = 'generic') { for (const workload of WORKLOADS) { test(`steady moving-source frames stay in budget ${workload.name}`, (t) => { if (!CALIBRATED_ALLOCATION_RUNTIME) { - return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`); + return t.skip(`allocation budgets are calibrated for Node 24 or 26; running ${process.versions.node}`); } const payload = runAllocationProbe(workload.entries, workload.profile); diff --git a/src/qaL9MatrixVerdicts.test.mjs b/src/qaL9MatrixVerdicts.test.mjs index 867e34410..2ec826e8d 100644 --- a/src/qaL9MatrixVerdicts.test.mjs +++ b/src/qaL9MatrixVerdicts.test.mjs @@ -127,9 +127,10 @@ test('an unevaluable engines range reports unknown rather than guessing', () => // A full pass run under `mise exec node@24 --` still re-execed system Node 25 // through npm; the forced gate refused ("received 25.6.1") and A3 reported it // as a product failure. A3 now invokes the binary directly and checks it. -test('only a parseable Node 24 counts as the calibrated allocation runtime', () => { +test('every supported Node major counts as a calibrated allocation runtime', () => { assert.equal(isCalibratedAllocationRuntime('v24.19.0'), true); assert.equal(isCalibratedAllocationRuntime('24.19.0'), true, 'the v prefix is optional'); + assert.equal(isCalibratedAllocationRuntime('v26.3.0'), true); assert.equal(isCalibratedAllocationRuntime('v25.6.1'), false, 'the npm re-resolution case'); assert.equal(isCalibratedAllocationRuntime('v22.21.1'), false); for (const junk of ['', ' ', 'node', undefined, null]) { diff --git a/src/unitTestRunner.test.mjs b/src/unitTestRunner.test.mjs index 80d9091e0..e206726e8 100644 --- a/src/unitTestRunner.test.mjs +++ b/src/unitTestRunner.test.mjs @@ -7,7 +7,7 @@ import { ALLOCATION_TEST_FILES, UNIT_TEST_ROOTS, allocationTestArgs, - assertNode24AllocationRuntime, + assertCalibratedAllocationRuntime, buildUnitTestPlan, discoverUnitTestFiles, isCalibratedAllocationRuntime, @@ -87,30 +87,27 @@ test('the runner discovers every test file in the repository', () => { } }); -test('allocation runtime calibration is explicit and pinned to Node 24', () => { - assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0'); +test('allocation runtime calibration covers both supported Node majors', () => { + assert.equal(assertCalibratedAllocationRuntime('24.19.0'), '24.19.0'); assert.throws( - () => assertNode24AllocationRuntime('22.23.1'), - /calibrated Node 24 runtime/, - ); - assert.throws( - () => assertNode24AllocationRuntime('26.0.0'), - /calibrated Node 24 runtime/, + () => assertCalibratedAllocationRuntime('22.23.1'), + /calibrated Node 24 or 26/, ); + assert.equal(assertCalibratedAllocationRuntime('26.0.0'), '26.0.0'); assert.equal(isCalibratedAllocationRuntime('24.19.0'), true); assert.equal(isCalibratedAllocationRuntime('22.23.1'), false); - assert.equal(isCalibratedAllocationRuntime('26.3.0'), false); + assert.equal(isCalibratedAllocationRuntime('26.3.0'), true); }); -test('npm test stays green on every supported engine, not only the calibrated one', () => { +test('npm test calibrates both supported engines while retaining off-range warnings', () => { // package.json wiring: `npm test` must invoke this runner, and the engines // range it advertises must not be narrower than what the runner tolerates. const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); assert.equal(pkg.scripts.test, 'node scripts/run-unit-tests.mjs'); const enginesNode = String(pkg.engines?.node || ''); assert.ok(enginesNode, 'engines.node must be declared'); - // The runner throws for uncalibrated runtimes ONLY behind the explicit - // opt-in env; by default it skips, so a supported non-24 engine cannot fail. + // Unsupported runtimes only skip probes without the explicit opt-in hard gate. + // Node 26 is calibrated and runs those probes with the same budgets. const runner = readFileSync(new URL('../scripts/run-unit-tests.mjs', import.meta.url), 'utf8'); assert.match(runner, /GEV_REQUIRE_ALLOCATION_GATE/); assert.match(runner, /SKIPPED .*allocation microbenchmarks/); From 84c1fef8d34dfd4dd556b6dca6bda9e3d2cf6b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:15:34 -0300 Subject: [PATCH 03/20] fix(firms): stop requesting the retired Suomi NPP source NASA stopped delivering Suomi NPP data on 2026-11-02 (FIRMS news page). With a live MAP_KEY on 2026-11-04, VIIRS_SNPP_NRT came back ok with zero rows while NOAA-20, NOAA-21 and MODIS returned 57k, 49k and 11k, so the source only spent one transaction per refresh. The proxy now requests NOAA-20, NOAA-21 and MODIS NRT. The "N" satellite label stays, so cached or archived Suomi NPP rows still read SNPP. Tests count sources from the list instead of assuming four, and a regression test keeps the retired source out of the production list. Refs #849 Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 7 ++++--- docs/CURRENT-STATE.md | 5 +++++ server/providers/firms.js | 14 ++++++-------- src/data/firmsProxy.test.mjs | 18 ++++++++++++++---- src/tooling/environmentProviders.test.mjs | 4 ++-- 5 files changed, 31 insertions(+), 17 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index cac0b5ab7..efee6b9ff 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -273,11 +273,12 @@ Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons > We acknowledge the use of data and/or imagery from NASA's Fire Information for Resource Management System (FIRMS) (https://earthdata.nasa.gov/firms), part of NASA's Earth Observing System Data and Information System (EOSDIS). FIRMS active fires are **fetched live at runtime** (CC0 / U.S. public domain data): the -`/api/firms` server-side proxy merges the three VIIRS NRT sources (NOAA-20, NOAA-21, -Suomi-NPP) and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY +`/api/firms` server-side proxy merges the two VIIRS NRT sources (NOAA-20, NOAA-21) +and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY transaction quota. Requires a free `FIRMS_MAP_KEY` (https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it. -The former bundled 2026-05-25 snapshot was removed 2026-07-16. +The former bundled 2026-05-25 snapshot was removed 2026-07-16. Suomi NPP (`VIIRS_SNPP_NRT`) +is no longer requested: NASA stopped delivering its data on 2026-11-02. ### Natural Earth physical regions (`natural_earth/`) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e868dfc95..102efb997 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2325,6 +2325,11 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## FIRMS source status +The FIRMS proxy requests VIIRS NOAA-20, VIIRS NOAA-21 and MODIS NRT (Terra and +Aqua). It no longer requests VIIRS Suomi NPP: NASA stopped delivering Suomi NPP +data on 2026-11-02, and the source returned no rows while still costing one +MAP_KEY transaction per refresh. + The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. The `get_active_fires` tool reports acquisition times as UTC ISO timestamps, including zero-padded hours and minutes; missing or invalid dates and times diff --git a/server/providers/firms.js b/server/providers/firms.js index 20a2f4bab..51ea4fc56 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -131,8 +131,8 @@ export async function fetchFirms( * NASA FIRMS live active-fire proxy with a memory + disk cache. * Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2 * - * Merges three VIIRS NRT sources (NOAA-20, NOAA-21, Suomi-NPP — independent - * satellites, no cross-source dedup) plus MODIS_NRT (combined Terra+Aqua, + * Merges two VIIRS NRT sources (NOAA-20, NOAA-21 — independent satellites, no + * cross-source dedup) plus MODIS_NRT (combined Terra+Aqua, * ~1 km, numeric 0-100 confidence), fetched sequentially (quota courtesy) * with `days=2` (`days=1` means "current UTC day", nearly empty just after * 00:00Z) and clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS @@ -141,6 +141,9 @@ export async function fetchFirms( * fresh-enough disk cache (.gev-cache/firms.json) prevents ANY upstream * fetch across dev-server restarts. Pattern mirrors celestrakProxy. * + * VIIRS_SNPP_NRT is not requested: NASA stopped delivering Suomi NPP data on + * 2026-11-02, so that source only spent a transaction per refresh (#849). + * * Routes: * GET /api/firms → {fetchedAt, stale, ttlMs, sources, count, fires} * GET /api/firms/status → {hasKey, lastFetch, count, stale, ttlMs, transactions} @@ -153,12 +156,7 @@ export async function fetchFirms( export function firmsProxy() { const TTL_MS = 30 * 60_000; const STATUS_TTL_MS = 5 * 60_000; - const SOURCES = [ - 'VIIRS_NOAA20_NRT', - 'VIIRS_NOAA21_NRT', - 'VIIRS_SNPP_NRT', - 'MODIS_NRT', - ]; + const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'MODIS_NRT']; /** * Per-source body cap. A VIIRS world/2 pull is ~130k rows x ~100 B, about * 13 MB per satellite in heavy fire season (MODIS runs smaller), so this is a diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs index 14668c825..135002ca7 100644 --- a/src/data/firmsProxy.test.mjs +++ b/src/data/firmsProxy.test.mjs @@ -21,7 +21,7 @@ assert.notEqual(start, -1, 'FIRMS refresh function exists'); const end = config.indexOf('\n }', start); assert.notEqual(end, -1, 'FIRMS refresh function closes'); const refreshSource = config.slice(start, end + 4); -const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT', 'MODIS_NRT']; +const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'MODIS_NRT']; const NOW = Date.UTC(2026, 8, 11, 12); const recent = { acqDate: '2026-09-11', acqTime: '1100' }; @@ -63,7 +63,7 @@ test('FIRMS keeps successful sources when another upstream fails', async () => { if (source === SOURCES[1]) throw new Error('upstream unavailable'); return [recent]; })('fixture'); - assert.equal(result.fires.length, 3); + assert.equal(result.fires.length, SOURCES.length - 1); assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ source, count: index === 1 ? 0 : 1, ok: index !== 1, }))); @@ -77,7 +77,7 @@ test('FIRMS reports one failure if consuming a source throws before append', asy // Fault injection for aggregation; ordinary parsed CSV returns an array. return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } }; })('fixture'); - assert.equal(result.fires.length, 3); + assert.equal(result.fires.length, SOURCES.length - 1); assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ source, count: index === 0 ? 0 : 1, ok: index !== 0, }))); @@ -107,7 +107,7 @@ function wrappedAddressSelectionFailure() { { name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner }); return new TypeError('fetch failed', { cause }); } -const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2'; +const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_NOAA20_NRT/world/2'; test('FIRMS failures log the socket cause, never the keyed URL', () => { const described = describeFetchError(addressSelectionFailure()); @@ -307,3 +307,13 @@ test('every FIRMS upstream request goes through the IPv4 fallback', async () => assert.ok(calls[0][2] instanceof AbortSignal); assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline'); }); + +test('FIRMS requests only the satellites NASA still delivers', () => { + const listed = config.match(/const SOURCES = \[([^\]]*)\];/); + assert.ok(listed, 'FIRMS source list exists'); + const production = [...listed[1].matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1]); + assert.deepEqual(production, SOURCES, 'the test mirrors the production source list'); + // NASA stopped delivering Suomi NPP data on 2026-11-02 (#849): requesting it + // returns no rows and still spends a MAP_KEY transaction on every refresh. + assert.ok(!production.includes('VIIRS_SNPP_NRT')); +}); diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index da3cfc335..feca9432d 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -313,9 +313,9 @@ test('FIRMS retains a large successful source during partial failure and filters const first = json(await request()); assert.equal(first.count, 130001); assert.equal(first.sources.filter((s) => s.ok).length, 1); - assert.equal(calls, 4); + assert.equal(calls, first.sources.length); assert.equal(json(await request()).count, 130001); - assert.equal(calls, 4); + assert.equal(calls, first.sources.length); assert.deepEqual(json(await request('/status')).transactions, { used: 3, limit: 5000, From b342e405d7514505861ab26244f38496a59350cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:20:09 -0300 Subject: [PATCH 04/20] docs(changelog): add fragment for #1552 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1552-firms-drop-snpp.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1552-firms-drop-snpp.md diff --git a/changelog.d/1552-firms-drop-snpp.md b/changelog.d/1552-firms-drop-snpp.md new file mode 100644 index 000000000..fcb375bda --- /dev/null +++ b/changelog.d/1552-firms-drop-snpp.md @@ -0,0 +1,4 @@ +- Stop requesting Suomi NPP in Active Fires. NASA stopped delivering its data + on November 2, 2026, so `VIIRS_SNPP_NRT` returned no rows while still costing + a MAP_KEY transaction on every refresh; the layer now merges VIIRS NOAA-20, + VIIRS NOAA-21 and MODIS (IMBrandao, #1552). From b37025414d90d6b696dcecd252c222972c00c557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:57:27 -0300 Subject: [PATCH 05/20] test(firms): pin the refresh call count and read sources from production Review follow-up for #1552: - environmentProviders: assert exactly 3 upstream calls per refresh again, so a returning fourth source would fail the test. - firmsProxy: build the test's SOURCES from server/providers/firms.js, so there is one list instead of a mirrored copy. - firmsProxy: drop the unused fourth count entry. Co-Authored-By: Claude Opus 5.5 --- src/data/firmsProxy.test.mjs | 13 ++++++------- src/tooling/environmentProviders.test.mjs | 4 ++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs index 135002ca7..dc9ed8144 100644 --- a/src/data/firmsProxy.test.mjs +++ b/src/data/firmsProxy.test.mjs @@ -21,7 +21,10 @@ assert.notEqual(start, -1, 'FIRMS refresh function exists'); const end = config.indexOf('\n }', start); assert.notEqual(end, -1, 'FIRMS refresh function closes'); const refreshSource = config.slice(start, end + 4); -const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'MODIS_NRT']; +// Read the source list from production so these tests share one source of truth. +const listed = config.match(/const SOURCES = \[([^\]]*)\];/); +assert.ok(listed, 'FIRMS source list exists'); +const SOURCES = [...listed[1].matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1]); const NOW = Date.UTC(2026, 8, 11, 12); const recent = { acqDate: '2026-09-11', acqTime: '1100' }; @@ -54,7 +57,7 @@ test('FIRMS retains large sources in order and filters expired rows', async () = assert.equal(result.fires[199_999], large.at(-1)); assert.equal(result.fires.at(-1), last); assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ - source, count: [200_000, 1, 0, 0][index], ok: true, + source, count: [200_000, 1, 0][index], ok: true, }))); }); @@ -309,11 +312,7 @@ test('every FIRMS upstream request goes through the IPv4 fallback', async () => }); test('FIRMS requests only the satellites NASA still delivers', () => { - const listed = config.match(/const SOURCES = \[([^\]]*)\];/); - assert.ok(listed, 'FIRMS source list exists'); - const production = [...listed[1].matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1]); - assert.deepEqual(production, SOURCES, 'the test mirrors the production source list'); // NASA stopped delivering Suomi NPP data on 2026-11-02 (#849): requesting it // returns no rows and still spends a MAP_KEY transaction on every refresh. - assert.ok(!production.includes('VIIRS_SNPP_NRT')); + assert.ok(!SOURCES.includes('VIIRS_SNPP_NRT')); }); diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index feca9432d..22eb39ad3 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -313,9 +313,9 @@ test('FIRMS retains a large successful source during partial failure and filters const first = json(await request()); assert.equal(first.count, 130001); assert.equal(first.sources.filter((s) => s.ok).length, 1); - assert.equal(calls, first.sources.length); + assert.equal(calls, 3); assert.equal(json(await request()).count, 130001); - assert.equal(calls, first.sources.length); + assert.equal(calls, 3); assert.deepEqual(json(await request('/status')).transactions, { used: 3, limit: 5000, From 9f2dfdf8208efdd831b0f0743edb12f76774c809 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Valdez?= <762946+avaldini@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:34:33 +0000 Subject: [PATCH 06/20] fix(voice): send OpenAI Realtime tool schemas the API accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI Realtime now answers 400 invalid_function_parameters for a whole session whose tool schema uses Unicode property classes, as resolve_area's area-id pattern does, so voice never connects ("Failed to create Realtime token"). The session sends its tools through chatSchema, the filter typed chat adopted in #1616, which leaves out only \p{…} patterns; area ids are still checked against the action's own contract before the tool runs. Fixes #1618 --- server/providers/openai/chat.js | 2 +- server/providers/openai/realtime.js | 17 +++++++++++- src/tooling/localServices.test.mjs | 41 ++++++++++++++++++++++------- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/server/providers/openai/chat.js b/server/providers/openai/chat.js index 6ca4648d6..b0f7fe211 100644 --- a/server/providers/openai/chat.js +++ b/server/providers/openai/chat.js @@ -69,7 +69,7 @@ function validMessages(messages) { * 400 for a whole request whose tool uses Unicode property classes (`\p{L}`), * as `resolve_area`'s area ids do. Every argument is still checked against * the action's own contract before the tool runs, so the model loses a hint, - * not a rule. Realtime voice accepts these patterns and keeps them. + * not a rule. Realtime voice refuses them too now (#1618) and uses this as well. */ export function chatSchema(schema) { if (Array.isArray(schema)) return schema.map(chatSchema); diff --git a/server/providers/openai/realtime.js b/server/providers/openai/realtime.js index 73ac8d2cf..4412eb331 100644 --- a/server/providers/openai/realtime.js +++ b/server/providers/openai/realtime.js @@ -14,6 +14,7 @@ import { } from './constants.js'; import { realtimeInstructions } from './instructions.js'; import { GEV_REALTIME_TOOLS, offeredTools } from './tools.js'; +import { chatSchema } from './chat.js'; import { resolveOverpassUpstreams } from '../overpass/constants.js'; import { preferCodexOAuth, @@ -128,6 +129,19 @@ function createRealtimeOAuthLoginHandler({ }; } +/** + * The tools one Realtime session is offered, as OpenAI accepts them: it now + * refuses a whole session whose tool schema uses `\p{…}` patterns (#1618), as + * Chat Completions did (#1616), so voice drops the same pattern hints chat does. + */ +function realtimeSessionTools(tools, { overpass = false } = {}) { + return offeredTools(tools, { overpass }).map((tool) => + tool.parameters + ? { ...tool, parameters: chatSchema(tool.parameters) } + : tool, + ); +} + function createRealtimeTokenHandler({ annotationGuidance, endpoint = 'https://api.openai.com/v1/realtime/client_secrets', @@ -303,7 +317,7 @@ function createRealtimeTokenHandler({ output: { voice }, }, instructions: realtimeInstructions(annotationGuidance, { overpass }), - tools: offeredTools(tools, { overpass }), + tools: realtimeSessionTools(tools, { overpass }), tool_choice: 'auto', }, }; @@ -370,4 +384,5 @@ export { createRealtimeOAuthLoginHandler, createRealtimeOAuthStatusHandler, createRealtimeTokenHandler, + realtimeSessionTools, }; diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index 84a2f6926..7a69b5e7f 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -20,12 +20,9 @@ import { import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai'; import { keySetupEndpoint } from 'gods-eye-view/server/standalone/key-setup'; import { realtimeInstructions } from '../../server/providers/openai/instructions.js'; -import { - GEV_REALTIME_TOOLS, - offeredTools, - realtimeTools, -} from '../../server/providers/openai/tools.js'; +import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js'; import { createDebugLogHandler } from '../../server/providers/openai/debug-log.js'; +import { realtimeSessionTools } from '../../server/providers/openai/realtime.js'; import { attachVoiceResult } from '../voice/speech.js'; import { sanitizeDebugValue } from '../voice/realtimeDiagnostics.js'; import { standaloneVoiceTools } from '../../server/standalone/voiceTools.js'; @@ -199,7 +196,10 @@ test('Realtime handler preserves tools and default instructions, isolates suppli sent.at(-1).session.instructions, realtimeInstructions(guidance), ); - assert.deepEqual(sent.at(-1).session.tools, realtimeTools()); + assert.deepEqual( + sent.at(-1).session.tools, + realtimeSessionTools(GEV_REALTIME_TOOLS), + ); } assert.notEqual(sent[0].session.instructions, sent[1].session.instructions); assert.equal(sent[0].session.instructions, sent[2].session.instructions); @@ -607,7 +607,10 @@ test('osm_query is offered only when an operator configured Overpass', async (t) assert.doesNotMatch(plain.instructions, /osm_query/); assert.ok(names(plain).includes('resolve_area')); assert.ok(names(plain).includes('find_imagery')); - assert.deepEqual(configured.tools, GEV_REALTIME_TOOLS); + assert.deepEqual( + configured.tools, + realtimeSessionTools(GEV_REALTIME_TOOLS, { overpass: true }), + ); assert.match(configured.instructions, /osm_query/); assert.equal( configured.instructions, @@ -656,7 +659,7 @@ test('Realtime sessions carry supplied tools, and the standalone voice adds the ); assert.equal(response.status, 200); // Without an operator Overpass, osm_query is left out of the session. - assert.deepEqual(sent.at(-1).session.tools, offeredTools(tools)); + assert.deepEqual(sent.at(-1).session.tools, realtimeSessionTools(tools)); assert.ok( !sent.at(-1).session.tools.some(({ name }) => name === 'osm_query'), ); @@ -740,6 +743,26 @@ test('key setup writes only the supplied application root, retains request guard assert.equal(existsSync(path.join(untouched, '.env')), false); }); +test('Realtime sessions leave out the Unicode property patterns OpenAI refuses (#1618)', async (t) => { + // OpenAI answers 400 invalid_function_parameters for a whole Realtime session + // whose tool schema uses `\p{…}`, as resolve_area's area ids do, so voice + // never connects. The session drops only those patterns, as typed chat does. + env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret'); + env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined); + const sent = []; + t.mock.method(globalThis, 'fetch', async (_url, options) => { + sent.push(JSON.parse(options.body).session); + return Response.json({ value: 'fixture-ephemeral' }); + }); + const response = await request( + install(openAiRealtimeProxy({})).get('/api/realtime/token'), + ); + assert.equal(response.status, 200); + const { tools } = sent.at(-1); + assert.ok(tools.some(({ name }) => name === 'resolve_area')); + assert.doesNotMatch(JSON.stringify(tools), /\\\\[pP]\{/); +}); + test('Realtime service configuration selects compatible endpoint/model without forwarding request model IDs or keys', async () => { const handler = install( openAiRealtimeProxy({ @@ -757,7 +780,7 @@ test('Realtime service configuration selects compatible endpoint/model without f assert.equal(payload.session.model, 'configured-model'); assert.deepEqual( payload.session.tools, - realtimeTools({ overpass: false }), + realtimeSessionTools(GEV_REALTIME_TOOLS), ); return Response.json({ value: 'short-lived-fixture' }); }, From d52c719ef645657aea470b8c6aa3b2f2bd791c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Valdez?= <762946+avaldini@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:52:24 +0000 Subject: [PATCH 07/20] docs(changelog): OpenAI Realtime voice tool patterns (#1638) --- changelog.d/1638-openai-realtime-tool-patterns.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1638-openai-realtime-tool-patterns.md diff --git a/changelog.d/1638-openai-realtime-tool-patterns.md b/changelog.d/1638-openai-realtime-tool-patterns.md new file mode 100644 index 000000000..20e167112 --- /dev/null +++ b/changelog.d/1638-openai-realtime-tool-patterns.md @@ -0,0 +1,5 @@ +- Voice on OpenAI connects again: OpenAI Realtime refused every session, so + the mic failed with "Failed to create Realtime token", because + `resolve_area`'s area-id pattern uses Unicode classes it no longer accepts. + Voice leaves those patterns out, as typed chat does; area ids are still + checked before the tool runs (avaldini, #1638). From 4da477a654e64333930bb74d2cf5b047d7c464f1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 9 Nov 2026 07:16:14 -0800 Subject: [PATCH 08/20] feat(draw): hand one drawn area or line back to the application createApplicationTools returns drawShape({ shape, signal }): it opens Draw for one area or line and resolves with its Polygon or LineString without placing a mark, or null when draw mode is left, the signal aborts, a newer request replaces it, the pointer is held elsewhere or the tool goes. The shape controls cannot change the asked-for shape, and a shape across the antimeridian is refused as a row's actions refuse it and drawn again. It also returns pointerFree, so an application's own click handlers yield to a tool holding the pointer, as the layers' do. --- CHANGELOG.md | 5 + docs/CURRENT-STATE.md | 1 + src/annotations/drawTool.js | 66 ++++++++- src/annotations/drawToolBehaviour.test.mjs | 152 +++++++++++++++++++++ src/app/tools.js | 15 +- 5 files changed, 237 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 356289841..89ae0e1e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +- Applications can ask Draw for one area or line and get its geometry back + without placing a mark (`drawShape` from `createApplicationTools`), and their + own click handlers can yield while a tool holds the pointer, as the layers' + do (`pointerFree`) (samehkhamis). + ## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language - Show a Street Level view facing 359.5° or more as 0°. The viewer caption diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 08dc69d1f..b70dc1d92 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6311,6 +6311,7 @@ request viewbox and continues searching without that geographic preference. - UTILITIES ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (icon buttons for Area, Line and Pin) and a colour swatch for the next shape, click the vertices on the real world, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone. - **Drawn shapes are listed under Draw like Import's files** (`src/ui/views/panels/DrawMarkList.vue`, rows worded by `presentDrawMarks` in `src/annotations/drawMarks.js`): one row per hand-drawn mark (`manual: true`; voice marks are not listed) with its colour swatch, its name, and show/hide, fly-to and remove. The draw tool publishes the rows with the rest of its controls and answers the row actions (`renameDrawMark`, `recolorDrawMark`, `toggleDrawMark`, `flyToDrawMark`, `removeDrawMark`) through the display store; it republishes on the engine's `onChange`, so a voice clear or an expiry updates the list. Rows are keyed and updated in place: a board change never takes a name being typed (the field keeps its draft while focused) or the button under a click. The name is saved on Enter or on leaving the field, once; Esc restores it, before anything else and with the row's colours open too. Both keep focus in the field, so neither drops it to the page or leaves Draw. The swatch opens the five colours right after it (shown at the end of the row), with the current colour focused; the arrows, Home and End try another, and a click or Esc closes them and returns focus to the swatch. Both colour groups (the next shape's and a row's) are one `DrawSwatches` radio group with a single tab stop, coloured from `DRAW_COLOR_CSS`; on touch screens each dot answers a 24 px target. An unnamed shape shows its kind, and an unnamed line its length; a renamed line keeps its length (`Ridge walk — 950 m`). **Hidden** keeps a mark on the board at alpha 0, and the world renderer takes every one of its entities out (`show = false`), so no material that keeps its own colour (the line's flowing stroke) still paints it; the screen renderer does not project a hidden callout, so it cannot push a visible label aside; `fly_route` skips a hidden route; and with every mark hidden the annotations render hold is released. **Fly to** is an immediate navigation (`runImmediateNavigation('drawing', …)`), like Import's: Cockpit refuses it and a running orbit, follow or track lets go first. A line is framed by its whole path, across the antimeridian too. - Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect, markActions })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam. +- **One shape, handed back.** `createApplicationTools` also returns `drawShape({ shape, signal })` (`drawTool.drawShape`): it opens draw mode for one `area` or `line` and resolves with its geometry, a closed `[lon, lat]` `Polygon` or a `LineString`, without placing a mark; the shape chosen before comes back and draw mode ends. While it is open the controls cannot change the shape, and one across the antimeridian is refused as a row's actions refuse it ("That shape could not be placed.") and drawn again. It resolves `null` when the drawing is cancelled (the second Escape), draw mode is left, `signal` aborts, another request replaces it, the pointer is held elsewhere, the tool is destroyed, or the page has no Draw controls. It returns `pointerFree` (`data/inputOwnership.js` `isPointerFree`) too, so an application's own click handlers yield to the tool holding the pointer, as the layers' do. - **Row actions are an application's own.** `createApplicationTools({ drawMarkActions })` hands `initDrawTool` buttons for drawn areas' and lines' rows; the standalone shell passes none, so the open-source build shows no new button and makes no new request. Each action (`drawMarkActions` in `src/annotations/drawMarks.js`) is `{ id, icon, label(name), shapes: ['area', 'line'], accepts?(geometry, shape), run({ id, shape, name, title, geometry }) }`: the icon a Material Symbols name, the label worded by the application, `name` the person's own and `title` the row's (the name, else `Area` or `Line · 950 m`), the geometry `drawMarkGeometry`'s `[lon, lat]` `Polygon` (closed; a `MultiPolygon` for an area handle with several parts) or `LineString`, longitudes wrapped into [-180, 180]. A pin, and a shape that crosses the antimeridian or has a part that cannot be given whole, has no geometry and is offered nothing. Malformed actions and repeated ids are dropped once, at construction. What `run` returns or resolves to is a note under the row (`role=status`; past 200 characters it is cut at a word with an ellipsis); the row is busy (`aria-disabled`, no second press) until it settles, a rejection or a throw clears the note and is logged (so an action words its own refusals and resolves with them), and a shape's note goes with the shape, so a late answer never lands on a removed one. - Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, appear in `.list()`, and clear with `clear_annotations` / `.clear()`. A drawn shape is never merged with another as a duplicate: every one is drawn on purpose, and every one is unnamed until its row names it, so two pins dropped close together are two pins. - **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it. diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index f44d30753..14a5929eb 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -54,6 +54,7 @@ import { DRAW_COLORS, DRAW_COLOR_CSS, drawMarkActions, + drawMarkGeometry, drawMarkActionsFor, drawMarkFallbackName, presentDrawMarks, @@ -120,6 +121,9 @@ export function initDrawTool({ let savedDoubleClick = null; let savedSingleClick = null; let cursor = null; // last mouse position on the canvas, for the rubber band + // One shape asked for by `drawShape`: `{ resolve, restoreShape, off }`. Its + // finish hands the geometry back instead of placing a mark. + let request = null; // Bumped by anything that supersedes an in-flight finish: cancelling, clearing // the board, leaving draw mode, teardown. An `annotate()` that resolves after // one of those must not write its outcome over the newer state. @@ -280,6 +284,20 @@ export function initDrawTool({ // Named afterwards, in the shape's row. const spec = finishSpec(session, { label: '', color }); if (!spec) return null; + if (request) { + // As a row's actions get it: a shape across the antimeridian has none, + // and is drawn again rather than handed back wrapped round the world. + const geometry = drawMarkGeometry(spec); + if (!geometry) { + session = createDrawSession(shape); + cursor = null; + syncPreview(); + setHint(() => t('That shape could not be placed.')); + return null; + } + endRequest(geometry); + return geometry; + } // Start a fresh session BEFORE awaiting, so a second Enter or the second // half of a double-click cannot submit the same shape twice. session = createDrawSession(shape); @@ -407,6 +425,7 @@ export function initDrawTool({ generation += 1; session = null; cursor = null; + endRequest(null); releaseSceneHandler(); releasePointer(lease); lease = null; @@ -532,6 +551,46 @@ export function initDrawTool({ return true; } + /** + * Close the asked-for shape with `geometry` (null when there is none): the + * shape chosen before it comes back, and draw mode ends. + */ + function endRequest(geometry) { + const open = request; + if (!open) return; + request = null; + open.off(); + chooseShape(open.restoreShape); + open.resolve(geometry); + if (active) setActive(false); + } + + /** + * Draw one area or line and hand back its geometry (a Polygon or a + * LineString) without placing a mark. Escape works as in Draw: the first + * clears a shape in progress, and one on an empty shape leaves. Resolves null + * when draw mode is left, `signal` aborts, or the pointer is taken. + * A second request ends the first with null. + * @param {{shape?: 'area'|'line', signal?: AbortSignal}} [options] + * @returns {Promise<{type: 'Polygon', coordinates: number[][][]}|{type: 'LineString', coordinates: number[][]}|null>} + */ + function drawShape({ shape: wanted = 'area', signal } = {}) { + if (destroyed || signal?.aborted) return Promise.resolve(null); + endRequest(null); + const restoreShape = shape; + return new Promise((resolve) => { + const abort = () => endRequest(null); + signal?.addEventListener('abort', abort, { once: true }); + request = { + resolve, + restoreShape, + off: () => signal?.removeEventListener('abort', abort), + }; + chooseShape(normalizeShape(wanted) === 'line' ? 'line' : 'area'); + if (!active && !setActive(true)) endRequest(null); + }); + } + /** Draw `next` from now on; an open shape starts over in it. */ function chooseShape(next) { shape = normalizeShape(next); @@ -545,7 +604,10 @@ export function initDrawTool({ } const disconnect = connect({ toggleDraw: () => setActive(!active), - setDrawShape: chooseShape, + // An asked-for shape is the one asked for. + setDrawShape: (next) => { + if (!request) chooseShape(next); + }, setDrawColor: (value) => { color = DRAW_COLORS.includes(value) ? value : 'primary'; present(); @@ -602,6 +664,7 @@ export function initDrawTool({ finish, cancel, clearAll, + drawShape, shapes: DRAW_SHAPES, /** What this tool currently holds — for teardown and leak assertions. */ diagnostics() { @@ -638,6 +701,7 @@ export function initDrawTool({ if (destroyed) return attaching; // Supersede any finish still in flight before anything is torn down. generation += 1; + endRequest(null); if (active) setActive(false); destroyed = true; offLocale(); diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index 4cef4d4fe..276852248 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -704,6 +704,158 @@ test('what a row action answers is its row\'s note; the row is busy until then, } }); +test('drawShape hands back the drawn area as a polygon and places no mark', async () => { + const h = harness(); + try { + h.act('setDrawShape', 'line'); + const drawn = h.tool.drawShape({ shape: 'area' }); + assert.equal(h.tool.active, true, 'it opens draw mode'); + assert.equal(h.tool.shape, 'area'); + h.tool.addVertex(-97.75, 30.26); + h.tool.addVertex(-97.74, 30.26); + h.tool.addVertex(-97.74, 30.27); + h.documentDouble.emit('keydown', { + key: 'Enter', + target: h.documentDouble.body, + }); + const geometry = await drawn; + assert.deepEqual(geometry, { + type: 'Polygon', + coordinates: [ + [ + [-97.75, 30.26], + [-97.74, 30.26], + [-97.74, 30.27], + [-97.75, 30.26], + ], + ], + }); + assert.equal(h.annotations.calls.annotate.length, 0, 'no mark is placed'); + assert.equal(h.tool.active, false, 'draw mode ends'); + assert.equal(isPointerFree(), true); + assert.equal(h.tool.shape, 'line', 'the shape chosen before comes back'); + } finally { + h.restore(); + } +}); + +test('drawShape hands back a line as a LineString', async () => { + const h = harness(); + try { + const drawn = h.tool.drawShape({ shape: 'line' }); + h.tool.addVertex(-97.75, 30.26); + h.tool.addVertex(-97.74, 30.26); + h.documentDouble.emit('keydown', { + key: 'Enter', + target: h.documentDouble.body, + }); + assert.deepEqual(await drawn, { + type: 'LineString', + coordinates: [ + [-97.75, 30.26], + [-97.74, 30.26], + ], + }); + } finally { + h.restore(); + } +}); + +test('drawShape resolves null on Escape, on abort, on a newer request and when the pointer is taken', async () => { + const h = harness(); + try { + // Escape with a shape started clears it; a second Escape leaves. + const escaped = h.tool.drawShape({ shape: 'area' }); + h.tool.addVertex(-97.75, 30.26); + h.documentDouble.emit('keydown', { + key: 'Escape', + target: h.documentDouble.body, + }); + assert.equal(h.tool.active, true, 'the first Escape only clears the shape'); + h.documentDouble.emit('keydown', { + key: 'Escape', + target: h.documentDouble.body, + }); + assert.equal(await escaped, null); + assert.equal(h.tool.active, false); + + const controller = new AbortController(); + const aborted = h.tool.drawShape({ + shape: 'area', + signal: controller.signal, + }); + controller.abort(); + assert.equal(await aborted, null); + assert.equal(isPointerFree(), true, 'an abort gives the pointer back'); + + const first = h.tool.drawShape({ shape: 'area' }); + const second = h.tool.drawShape({ shape: 'line' }); + assert.equal(await first, null, 'a newer request ends the older one'); + h.act('toggleDraw'); + assert.equal(await second, null, 'leaving draw mode ends it'); + + const done = new AbortController(); + done.abort(); + assert.equal(await h.tool.drawShape({ signal: done.signal }), null); + + const other = claimPointer('directions'); + assert.equal( + await h.tool.drawShape({ shape: 'area' }), + null, + 'a held pointer refuses it', + ); + releasePointer(other); + + // Drawing by hand afterwards places marks as before. + h.act('toggleDraw'); + h.tool.addVertex(-97.75, 30.26); + h.tool.addVertex(-97.74, 30.26); + h.tool.addVertex(-97.74, 30.27); + h.documentDouble.emit('keydown', { + key: 'Enter', + target: h.documentDouble.body, + }); + await Promise.resolve(); + assert.equal(h.annotations.calls.annotate.length, 1); + } finally { + h.restore(); + } +}); + +test('drawShape keeps its shape, and a shape across the antimeridian is drawn again', async () => { + const h = harness(); + try { + const drawn = h.tool.drawShape({ shape: 'line' }); + h.act('setDrawShape', 'pin'); + assert.equal(h.tool.shape, 'line', 'the controls cannot change the asked-for shape'); + h.tool.addVertex(179.5, 10); + h.tool.addVertex(-179.5, 10); + h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body }); + assert.equal(h.tool.active, true, 'still drawing'); + assert.equal(h.tool.session.vertices.length, 0, 'drawn again from the start'); + assert.equal(h.shown.view.hint, 'That shape could not be placed.'); + h.tool.addVertex(29, 41); + h.tool.addVertex(29.1, 41); + h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body }); + assert.equal((await drawn).type, 'LineString'); + h.act('setDrawShape', 'pin'); + assert.equal(h.tool.shape, 'pin', 'after it, the controls choose again'); + } finally { + h.restore(); + } +}); + +test('destroy ends a drawShape request with null', async () => { + const h = harness(); + try { + const drawn = h.tool.drawShape({ shape: 'area' }); + h.tool.destroy(); + assert.equal(await drawn, null); + } finally { + h.restore(); + } +}); + test('Backspace on the page removes the last vertex', () => { const h = harness(); try { diff --git a/src/app/tools.js b/src/app/tools.js index 8925151ad..9ecb3559a 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -1,6 +1,7 @@ import { SceneDirector } from '../scenes/director.js'; import { initAnnotations } from '../annotations/index.js'; import { initDrawTool } from '../annotations/drawTool.js'; +import { isPointerFree } from '../data/inputOwnership.js'; import { initGeoFileImport } from '../ui/geoFileImport.js'; import { useDisplayStore } from '../ui/stores/display.js'; import { applicationPinia } from '../ui/vueRuntime.js'; @@ -245,5 +246,17 @@ export function createApplicationTools({ signal, }), ); - return { sceneDirector, annotations, voiceCommands }; + // Draw one area or line and get its geometry back, without placing a mark + // (annotations/drawTool.js `drawShape`); null without the Draw controls. + const drawShape = (options) => + drawTool ? drawTool.drawShape(options) : Promise.resolve(null); + // An application's own click handlers yield while a tool holds the pointer, + // as the layers' do (data/inputOwnership.js). + return { + sceneDirector, + annotations, + voiceCommands, + drawShape, + pointerFree: isPointerFree, + }; } From 0600d8658401bbafdd945344da18e3debbf4e460 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 9 Nov 2026 07:16:52 -0800 Subject: [PATCH 09/20] docs(changelog): Draw hands one shape back (#1641) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89ae0e1e5..7c425d0b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - Applications can ask Draw for one area or line and get its geometry back without placing a mark (`drawShape` from `createApplicationTools`), and their own click handlers can yield while a tool holds the pointer, as the layers' - do (`pointerFree`) (samehkhamis). + do (`pointerFree`) (samehkhamis, #1641). ## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language From 75e8e7ddbdb68c01616ed074b00b80bfd1734274 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 9 Nov 2026 19:41:51 +0400 Subject: [PATCH 10/20] refactor(data): describe tile budgets per period, drop a duplicate test, add the changelog entry --- changelog.d/762-tile-budget-module.md | 4 ++++ src/data/tileBudget.js | 22 +++++++++++----------- src/data/tileBudget.test.mjs | 16 ---------------- src/data/tomtomTiles.js | 5 +++-- 4 files changed, 18 insertions(+), 29 deletions(-) create mode 100644 changelog.d/762-tile-budget-module.md diff --git a/changelog.d/762-tile-budget-module.md b/changelog.d/762-tile-budget-module.md new file mode 100644 index 000000000..6d000d357 --- /dev/null +++ b/changelog.d/762-tile-budget-module.md @@ -0,0 +1,4 @@ +- Move the tile budget helpers `utcDayKey`, `normalizeBudget` and + `isOverBudget` out of the TomTom module into `src/data/tileBudget.js`, so + other keyed tile proxies can share them. `src/data/tomtomTiles.js` + re-exports them; behaviour does not change (monwiss, #762). diff --git a/src/data/tileBudget.js b/src/data/tileBudget.js index dcc44a28c..83cf9b693 100644 --- a/src/data/tileBudget.js +++ b/src/data/tileBudget.js @@ -22,29 +22,29 @@ export function utcDayKey(epochMs = Date.now()) { } /** - * Normalize a persisted budget state against today's UTC day key. - * Rolls the counter to zero on day change; replaces missing/corrupt state. - * Returns the SAME object when it is already valid for `dayKey` (cheap to - * call on every request). + * Normalize a persisted budget state against the current period key. + * Rolls the counter to zero when the period changes; replaces missing/corrupt + * state. Returns the SAME object when it is already valid for `periodKey` + * (cheap to call on every request). * * @param {{date:string, count:number}|null|undefined} state - Persisted state. - * @param {string} dayKey - Today's UTC day key (from `utcDayKey`). - * @returns {{date:string, count:number}} Valid state for `dayKey`. + * @param {string} periodKey - Current period key, e.g. from `utcDayKey`. + * @returns {{date:string, count:number}} Valid state for `periodKey`. */ -export function normalizeBudget(state, dayKey) { +export function normalizeBudget(state, periodKey) { const valid = Boolean(state) && - state.date === dayKey && + state.date === periodKey && Number.isFinite(state.count) && state.count >= 0; - return valid ? state : { date: dayKey, count: 0 }; + return valid ? state : { date: periodKey, count: 0 }; } /** - * Whether the daily soft cap has been reached. + * Whether the soft cap for the current period has been reached. * * @param {{count:number}} state - Normalized budget state. - * @param {number} limit - Daily tile budget; non-positive/invalid never blocks. + * @param {number} limit - Budget per period; non-positive/invalid never blocks. * @returns {boolean} True when `count >= limit`. */ export function isOverBudget(state, limit) { diff --git a/src/data/tileBudget.test.mjs b/src/data/tileBudget.test.mjs index fa48440bb..c1baa4235 100644 --- a/src/data/tileBudget.test.mjs +++ b/src/data/tileBudget.test.mjs @@ -8,19 +8,3 @@ test('tile budget helpers live in one module and TomTom re-exports them', () => assert.equal(tomtom.normalizeBudget, normalizeBudget); assert.equal(tomtom.isOverBudget, isOverBudget); }); - -test('normalizeBudget keeps a valid state and resets another period', () => { - const state = { date: '2026-09-24', count: 3 }; - assert.equal(normalizeBudget(state, '2026-09-24'), state); - assert.deepEqual(normalizeBudget(state, '2026-09-25'), { - date: '2026-09-25', - count: 0, - }); - assert.deepEqual( - normalizeBudget({ date: '2026-09-24', count: -1 }, '2026-09-24'), - { - date: '2026-09-24', - count: 0, - }, - ); -}); diff --git a/src/data/tomtomTiles.js b/src/data/tomtomTiles.js index 9f3b1f0b9..14101c831 100644 --- a/src/data/tomtomTiles.js +++ b/src/data/tomtomTiles.js @@ -1,9 +1,10 @@ /** - * @file Pure tile math + budget accounting for the TomTom traffic-flow proxy. + * @file Pure tile math for the TomTom traffic-flow proxy; budget helpers are + * re-exported from `./tileBudget.js`. * * Shared by the `/api/tomtom` vite plugin (server-side: coordinate validation, * daily budget governor) and `src/layers/traffic/flowSource.js` (client-side: which tiles - * cover the current traffic fetch bounds). Zero dependencies, Cesium-free, so + * cover the current traffic fetch bounds). No external dependencies, Cesium-free, so * both sides can unit-test against it with node:test. * * Slippy scheme: standard Web Mercator XYZ, y grows southward From b04fcf191cbaf781cde1db1ff08072dd819f1c5f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 9 Nov 2026 21:38:07 +0400 Subject: [PATCH 11/20] docs(firms): count two VIIRS sources in the layer table and test note --- docs/CURRENT-STATE.md | 2 +- src/data/firmsInteraction.test.mjs | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 102efb997..6346c1db6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4152,7 +4152,7 @@ its criteria cannot be silently ignored. | Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static | | Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static | | Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | -| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | +| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×2 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | | Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) | | Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | | GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) | diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index d735df3f8..9931c8b32 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -119,9 +119,10 @@ test('fire detection key survives a refetch that renumbers the index', () => { }); test('two satellites over the same pixel at the same time stay distinct', () => { - // The proxy merges SNPP + NOAA-20 + NOAA-21 without dedup and their orbits - // overlap, so position and time alone are NOT unique. Collapsing them let a - // restored selection land on the other satellite's record. + // The proxy merges NOAA-20 + NOAA-21 without dedup and their orbits overlap, + // so position and time alone are NOT unique. Collapsing them let a restored + // selection land on the other satellite's record. Archived Suomi NPP (`N`) + // rows must stay distinct too. const base = makeFire({ satellite: 'N20' }); const twin = makeFire({ satellite: 'N21' }); const suomi = makeFire({ satellite: 'N' }); From fce5ee6381b948db2f263e1090a19b77d74dd164 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 9 Nov 2026 21:44:09 +0400 Subject: [PATCH 12/20] docs(firms): tighten the Suomi NPP notes --- DATA_SOURCES.md | 2 +- docs/CURRENT-STATE.md | 4 +--- server/providers/firms.js | 7 +++---- src/data/firmsProxy.test.mjs | 6 +++--- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 8ceb94e84..ba231df31 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -329,7 +329,7 @@ and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to res transaction quota. Requires a free `FIRMS_MAP_KEY` (https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it. The former bundled 2026-05-25 snapshot was removed 2026-07-16. Suomi NPP (`VIIRS_SNPP_NRT`) -is no longer requested: NASA stopped delivering its data on 2026-11-02. +is not requested: NASA stopped delivering it on 2026-11-02. ### UCS Satellite Database (`ucs_satellites/`) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 417e81551..05240d922 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2860,9 +2860,7 @@ reporting are unchanged. ## FIRMS source status The FIRMS proxy requests VIIRS NOAA-20, VIIRS NOAA-21 and MODIS NRT (Terra and -Aqua). It no longer requests VIIRS Suomi NPP: NASA stopped delivering Suomi NPP -data on 2026-11-02, and the source returned no rows while still costing one -MAP_KEY transaction per refresh. +Aqua). It skips VIIRS Suomi NPP, which NASA stopped delivering on 2026-11-02. The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. The `get_active_fires` tool reports acquisition times as UTC ISO timestamps, diff --git a/server/providers/firms.js b/server/providers/firms.js index 51ea4fc56..d21dc296e 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -132,8 +132,8 @@ export async function fetchFirms( * Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2 * * Merges two VIIRS NRT sources (NOAA-20, NOAA-21 — independent satellites, no - * cross-source dedup) plus MODIS_NRT (combined Terra+Aqua, - * ~1 km, numeric 0-100 confidence), fetched sequentially (quota courtesy) + * cross-source dedup) plus MODIS_NRT (combined Terra+Aqua, ~1 km, numeric + * 0-100 confidence), fetched sequentially (quota courtesy) * with `days=2` (`days=1` means "current UTC day", nearly empty just after * 00:00Z) and clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS * quota is 5,000 transactions / 10 min per MAP_KEY, so the cache is the point: @@ -141,8 +141,7 @@ export async function fetchFirms( * fresh-enough disk cache (.gev-cache/firms.json) prevents ANY upstream * fetch across dev-server restarts. Pattern mirrors celestrakProxy. * - * VIIRS_SNPP_NRT is not requested: NASA stopped delivering Suomi NPP data on - * 2026-11-02, so that source only spent a transaction per refresh (#849). + * VIIRS_SNPP_NRT is left out: NASA stopped delivering Suomi NPP on 2026-11-02. * * Routes: * GET /api/firms → {fetchedAt, stale, ttlMs, sources, count, fires} diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs index dc9ed8144..71c0af4d6 100644 --- a/src/data/firmsProxy.test.mjs +++ b/src/data/firmsProxy.test.mjs @@ -21,7 +21,7 @@ assert.notEqual(start, -1, 'FIRMS refresh function exists'); const end = config.indexOf('\n }', start); assert.notEqual(end, -1, 'FIRMS refresh function closes'); const refreshSource = config.slice(start, end + 4); -// Read the source list from production so these tests share one source of truth. +// Read the list from production so these tests follow it. const listed = config.match(/const SOURCES = \[([^\]]*)\];/); assert.ok(listed, 'FIRMS source list exists'); const SOURCES = [...listed[1].matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1]); @@ -312,7 +312,7 @@ test('every FIRMS upstream request goes through the IPv4 fallback', async () => }); test('FIRMS requests only the satellites NASA still delivers', () => { - // NASA stopped delivering Suomi NPP data on 2026-11-02 (#849): requesting it - // returns no rows and still spends a MAP_KEY transaction on every refresh. + // NASA stopped delivering Suomi NPP on 2026-11-02; asking for it only spends + // a MAP_KEY transaction. assert.ok(!SOURCES.includes('VIIRS_SNPP_NRT')); }); From 04b8c856ad8d155cc1f60bfbf61f23ebd6fe22fb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 9 Nov 2026 01:25:43 -0800 Subject: [PATCH 13/20] Keep vehicles off rooftops and bridge decks: probe the mesh where the vehicle is On the photoreal stack the rendered-mesh floor took one probe at each coarse cell's centre (0.001 degrees) and kept it for the session. A centre on a roof passed the +80 m DEM window and lifted every vehicle in the cell to roof height (a Haymarket bus drew 23.5 m above the street, on the garage roof); a centre in the river put Red Line trains on the Longfellow Bridge 6-9 m under the deck. The sampler now probes at the caller's point. A cell with a reading is probed again only at spots about 17 m apart from earlier ones, eight at most, and a spot refused only because the cell's DEM prior was cold is tried again later. The floor keeps each reading that passes the gate at the point it was taken (two within about 5 m keep the lower; sixteen at most per cell) and answers a read from the reading nearest the asked point, so a viaduct and the street beside it in one cell keep their own heights. --- docs/CURRENT-STATE.md | 23 ++- src/data/groundFloor.js | 1 + src/data/meshFloorSampler.test.mjs | 179 +++++++++++++++++++++++- src/layers/cctv/geometry.js | 2 +- src/layers/flights/snapshotRenderer.js | 4 +- src/layers/military/snapshotRenderer.js | 4 +- src/services/groundFloor.js | 81 +++++++++-- src/services/groundSnap.js | 2 +- src/services/meshFloorSampler.js | 61 ++++++-- 9 files changed, 329 insertions(+), 28 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 05240d922..5e7c36be2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1347,6 +1347,27 @@ and snapshot source. The standalone adapter keeps the existing USGS daily feed, M2.5+ filtering, static discs, magnitude labels and analyst records. Disabling or destroying the layer cancels pending work and ignores late results. +## Rendered-mesh floor readings — November 9, 2026 + +On the photoreal stack the shared floor (`cachedGroundFloor`) answers from +rendered-mesh readings per coarse cell (0.001°, about 110 × 80 m), sampled by +`src/services/meshFloorSampler.js` and gated against the DEM by +`reportValidatedMeshFloorCell` in `src/services/groundFloor.js`. The probe goes +where the caller's point is (a vehicle or contact), not to the cell's centre; +the sampler reads a cell at up to eight spots about 12–17 m or more apart, and +each reading that passes the gate is kept at its point (ground snaps add theirs +the same way; a cell keeps at most sixteen). A read answers from the cell's +reading nearest the asked point, so a viaduct and the street beside it in one +cell keep their own heights; two readings within about 5 m of each other keep +the lower. A spot refused only because the cell's DEM prior was cold is tried +again later. Before this, one probe at the cell's centre latched for the +session, so a centre on a roof lifted everything in the cell (a Haymarket bus +drew 23.5 m above the street, on the garage roof) and a centre in the river put +Red Line trains on the Longfellow Bridge 6–9 m under the deck. Residual: the +probe is vertical, so a vehicle directly under a deck or a roof overhang reads +the structure above it, and a vehicle between readings takes the nearest one, so +its floor steps where the nearest reading changes. + ## Vessel components and sources `src/data/aisLiveVessels.js` assembles `createVesselLayer` from the @@ -3148,7 +3169,7 @@ This is the current runtime/source-of-truth snapshot for the project. > `HELD_SNAP_CONTRADICTION_M` (5 m) ABOVE the held value drops the hold and the > contact is COLD again, back to the floored 2D billboard. The rule is > ONE-SIDED on purpose — it targets BURIAL. A cell reading BELOW a real sample -> is the floor chain's expected under-read (one-shot latch over ~111 m, +> is the floor chain's expected under-read (a few point readings over ~111 m, > neighbours lean lowest, `displayFloorHeightM` only ever raises), not evidence > of anything, and a two-sided cut was disproved on the track rig by a planted > cell 66.7 m below a real sample at the same spot. Accepted residual, stated diff --git a/src/data/groundFloor.js b/src/data/groundFloor.js index fed7a6cd6..978f30218 100644 --- a/src/data/groundFloor.js +++ b/src/data/groundFloor.js @@ -22,6 +22,7 @@ export const { reportMeshFloorCell, meshFloorSampleWithinPrior, reportValidatedMeshFloorCell, + meshFloorPriorReady, cachedMeshFloor, _clearMeshFloorCellsForTest, cachedGroundFloor, diff --git a/src/data/meshFloorSampler.test.mjs b/src/data/meshFloorSampler.test.mjs index f651d72f8..d33c4873d 100644 --- a/src/data/meshFloorSampler.test.mjs +++ b/src/data/meshFloorSampler.test.mjs @@ -24,7 +24,7 @@ import { sampleMeshFloorCells } from './meshFloorSampler.js'; import { resolveEllipsoidalGround } from './terrainHeights.js'; import { cachedGroundFloor, cachedMeshFloor, setMeshFloorPreferred, - _clearMeshFloorCellsForTest, + _clearMeshFloorCellsForTest, reportValidatedMeshFloorCell, } from './groundFloor.js'; /** Distinct site per test — terrainHeights' cache is module-global and warm @@ -137,3 +137,180 @@ test('sampler: a cell just across the antimeridian is still near the viewer', () sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 }); assert.equal(calls, 2, 'about 200 m apart, so both cells are probed'); }); + +test('sampler: the probe goes where the point is, not to the cell centre', async () => { + reset(); + const cell = nextSite(); + await seedDem(cell, 120.0); + const asked = []; + const scene = fakeScene((carto) => { + asked.push(Cesium.Math.toDegrees(carto.longitude)); + return 122.0; + }); + const point = { lat: cell.lat + 0.0003, lon: cell.lon + 0.0004 }; + sampleMeshFloorCells(scene, [point], { viewerLat: cell.lat, viewerLon: cell.lon }); + assert.equal(asked.length, 1); + assert.ok(Math.abs(asked[0] - point.lon) < 1e-9, `probed at ${asked[0]}, not ${point.lon}`); +}); + +test('sampler: a viaduct and the street beside it in one cell keep their own heights', async () => { + reset(); + const cell = nextSite(); + await seedDem(cell, 120.0); + // A viaduct deck 9 m up along one line of the cell, the street elsewhere. + const deckLat = cell.lat + 0.0003; + let calls = 0; + const scene = fakeScene((carto) => { + calls += 1; + return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0; + }); + const opts = { viewerLat: cell.lat, viewerLon: cell.lon }; + const onDeck = { lat: deckLat, lon: cell.lon }; + const street = { lat: cell.lat - 0.0003, lon: cell.lon }; + sampleMeshFloorCells(scene, [onDeck], opts); + sampleMeshFloorCells(scene, [street], opts); + assert.equal(calls, 2); + assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon + 0.0001), 129.0, 'the train stays on the deck'); + assert.equal(cachedMeshFloor(street.lat, street.lon - 0.0001), 121.0, 'the bus stays on the street'); + assert.equal(cachedGroundFloor(street.lat, street.lon), 121.0); + sampleMeshFloorCells(scene, [{ lat: deckLat, lon: cell.lon + 0.00001 }], opts); + assert.equal(calls, 2, 'a point next to one already probed is not probed again'); +}); + +test('sampler: a roof over the cell centre does not lift a bus on the street', async () => { + reset(); + const cell = nextSite(); + await seedDem(cell, 120.0); + // A roof 22 m up covering the cell's centre; the street 30 m west of it. + const scene = fakeScene((carto) => + Math.abs(Cesium.Math.toDegrees(carto.longitude) - cell.lon) < 0.0001 ? 142.0 : 121.0); + const bus = { lat: cell.lat, lon: cell.lon - 0.0003 }; + sampleMeshFloorCells(scene, [bus], { viewerLat: cell.lat, viewerLon: cell.lon }); + assert.equal(cachedGroundFloor(bus.lat, bus.lon), 121.0); +}); + +test('sampler: a second reading at the same spot keeps the lower one', async () => { + reset(); + const cell = nextSite(); + await seedDem(cell, 120.0); + assert.equal(reportValidatedMeshFloorCell(cell.lat, cell.lon, 131.0), true); + assert.equal(reportValidatedMeshFloorCell(cell.lat + 0.00002, cell.lon, 122.0), true); + assert.equal(cachedMeshFloor(cell.lat, cell.lon), 122.0); + assert.equal(reportValidatedMeshFloorCell(cell.lat, cell.lon + 0.00002, 140.0), true); + assert.equal(cachedMeshFloor(cell.lat, cell.lon), 122.0, 'a higher one never raises it'); +}); + +test('sampler: a cell with a reading is probed a few times at most', async () => { + reset(); + const cell = nextSite(); + await seedDem(cell, 120.0); + let calls = 0; + const scene = fakeScene(() => { + calls += 1; + return 121.0; + }); + const opts = { viewerLat: cell.lat, viewerLon: cell.lon }; + for (let i = 0; i < 20; i++) + sampleMeshFloorCells( + scene, + [{ lat: cell.lat - 0.0004 + (i % 5) * 0.0002, lon: cell.lon - 0.00045 + Math.floor(i / 5) * 0.0003 }], + opts, + ); + assert.equal(calls, 8); +}); + +test('sampler: a cell with no accepted reading is probed again on every call', async () => { + reset(); + const cell = nextSite(); + let calls = 0; + const scene = fakeScene(() => { + calls += 1; + return 138.0; + }); + const opts = { viewerLat: cell.lat, viewerLon: cell.lon }; + for (let i = 0; i < 3; i++) sampleMeshFloorCells(scene, [cell], opts); + assert.equal(calls, 3, 'no DEM prior yet, so nothing is kept and the cell is retried'); + await seedDem(cell, 120.0); + sampleMeshFloorCells(scene, [cell], opts); + assert.equal(cachedMeshFloor(cell.lat, cell.lon), 138.0, 'and lands once the prior is warm'); +}); + +test('sampler: a spot whose reading is refused does not use up the cell', async () => { + reset(); + const cell = nextSite(); + await seedDem(cell, 120.0); + // A tower 260 m up where the first vehicle waits; the street elsewhere and + // a deck 9 m up along one line of the cell. + const towerLon = cell.lon + 0.0004; + const deckLat = cell.lat + 0.0003; + const scene = fakeScene((carto) => { + if (Math.abs(Cesium.Math.toDegrees(carto.longitude) - towerLon) < 1e-7) return 380.0; + return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0; + }); + const opts = { viewerLat: cell.lat, viewerLon: cell.lon }; + for (let i = 0; i < 10; i++) sampleMeshFloorCells(scene, [{ lat: cell.lat, lon: towerLon }], opts); + assert.equal(cachedMeshFloor(cell.lat, cell.lon), null, 'the tower is refused every time'); + sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts); + sampleMeshFloorCells(scene, [{ lat: deckLat, lon: cell.lon - 0.0003 }], opts); + assert.equal(cachedMeshFloor(deckLat, cell.lon - 0.0003), 129.0, 'the deck is still probed'); +}); + +test('ground floor: a cell keeps at most sixteen readings', async () => { + reset(); + const cell = nextSite(); + await seedDem(cell, 120.0); + for (let i = 0; i < 40; i++) { + const lat = cell.lat - 0.00045 + (i % 8) * 0.0001; + const lon = cell.lon - 0.00045 + Math.floor(i / 8) * 0.0002; + reportValidatedMeshFloorCell(lat, lon, 121.0 + i); + } + // Readings 0..15 are kept; reading 39 (at the far corner) is not, so that + // spot answers from the nearest kept one. + assert.equal(cachedMeshFloor(cell.lat - 0.00045, cell.lon - 0.00045), 121.0); + assert.equal(cachedMeshFloor(cell.lat + 0.00025, cell.lon + 0.00035), 121.0 + 15); +}); + +test('sampler: a spot refused while the DEM was cold is probed again once it is warm', async () => { + reset(); + const cell = nextSite(); + const deckLat = cell.lat + 0.0003; + let calls = 0; + const scene = fakeScene((carto) => { + calls += 1; + return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0; + }); + const opts = { viewerLat: cell.lat, viewerLon: cell.lon }; + const onDeck = { lat: deckLat, lon: cell.lon }; + sampleMeshFloorCells(scene, [onDeck], opts); + assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon), null, 'no prior, nothing kept'); + await seedDem(cell, 120.0); + sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts); + sampleMeshFloorCells(scene, [onDeck], opts); + assert.equal(calls, 3); + assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon), 129.0); +}); + +test('sampler: a spot refused while the DEM prior was evicted is probed again', async () => { + const { createGroundFloor } = await import('../services/groundFloor.js'); + const { createMeshFloorSampler } = await import('../services/meshFloorSampler.js'); + let prior = 120.0; + const terrain = { + cachedRealEllipsoidalGround: () => prior, + resolveEllipsoidalGround: async () => {}, + }; + const groundFloor = createGroundFloor({ terrain }); + const sampler = createMeshFloorSampler({ groundFloor }); + const cell = { lat: 41.5, lon: -97.66 }; + const deckLat = cell.lat + 0.0003; + const scene = fakeScene((carto) => + Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0); + const opts = { viewerLat: cell.lat, viewerLon: cell.lon }; + const onDeck = { lat: deckLat, lon: cell.lon }; + sampler.sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts); + prior = null; // the cell's DEM entry is evicted; the street reading stays + sampler.sampleMeshFloorCells(scene, [onDeck], opts); + assert.equal(groundFloor.cachedMeshFloor(onDeck.lat, onDeck.lon), 121.0); + prior = 120.0; + sampler.sampleMeshFloorCells(scene, [onDeck], opts); + assert.equal(groundFloor.cachedMeshFloor(onDeck.lat, onDeck.lon), 129.0); +}); diff --git a/src/layers/cctv/geometry.js b/src/layers/cctv/geometry.js index 362ad4789..a1402c6c2 100644 --- a/src/layers/cctv/geometry.js +++ b/src/layers/cctv/geometry.js @@ -448,7 +448,7 @@ export function createGeometry({ state: layerState, services, parts, source }) { } // Photoreal regime. Sampling is delegated to the shared coarse-cell - // sampler. It remains event-driven, one-shot per cell, and keeps its + // sampler. It remains event-driven, a few probes per cell, and keeps its // existing acceptance window; CCTV adds no rooftop rejection policy. if (sampleGround && parts.model.projectionTilesReady()) { record.groundMeshSampleRequestCount = diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index ec6c2933b..d48d12774 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -300,8 +300,8 @@ export function createFlightSnapshotRenderer({ // floor cells collected in the loop (fire-and-forget, single-flight; // read synchronously by NEXT poll's clamp — the military-layer pattern). warmGroundFloor(floorWarmPoints); - // Round 4: sample the RENDERED mesh for those same cells (one-shot per - // cell, budget-capped, viewer-proximate, google-3d regime only). Own + // Round 4: sample the RENDERED mesh for those same cells (a few probes + // per cell, budget-capped, viewer-proximate, google-3d regime only). Own // billboards/models are excluded so a vertical probe can't land on an // aircraft instead of the pavement. sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, { diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 7ed2b1c1f..eb3a341f8 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -228,8 +228,8 @@ export function createMilitarySnapshotRenderer({ const bbStale = flightState._billboards.get(icao24); if (bbStale) bbStale.position = liftedPos; } - // Round 4: sample the RENDERED mesh for those cells (one-shot per cell, - // budget-capped, viewer-proximate, google-3d regime only), excluding + // Round 4: sample the RENDERED mesh for those cells (a few probes per + // cell, budget-capped, viewer-proximate, google-3d regime only), excluding // this layer's own billboards/models from the probe. { const viewerCarto = diff --git a/src/services/groundFloor.js b/src/services/groundFloor.js index 27b7dc5e4..3b4939fc5 100644 --- a/src/services/groundFloor.js +++ b/src/services/groundFloor.js @@ -418,13 +418,26 @@ export function createGroundFloor({ terrain, signal }) { // is the photogrammetric MESH, which sits above it (measured ~17 m at the // Austin airport apron). DEM-flooring therefore still buried sprites/trails // inside the mesh. These cells hold the RENDERED surface height, sampled - // one-shot per coarse cell near the viewer (scene.sampleHeight — the same + // at the callers' points, a few per coarse cell near the viewer, each kept + // where it was taken and read back from the nearest (scene.sampleHeight — the same // source groundSnap uses, which is why 3D models always looked right) and // reported here by meshFloorSampler.js. Design principle (owner): NEVER // below the visible surface; slightly above is always fine. The DEM stays // the instant global prior/fallback and the sanity gate for samples. - /** @type {Map} coarse cell key -> rendered-surface height (m, ellipsoidal). */ + /** @type {Map>} + * coarse cell key -> rendered-surface readings (m, ellipsoidal), each kept + * at the point it was probed. A read answers from the reading nearest the + * asked point, so a viaduct and the street beside it in one cell keep their + * own heights. */ const _meshCells = new Map(); + /** @constant {number} Readings closer than this (degrees, about 5 m) are + * one spot: the lower one is kept there. */ + const MESH_READING_SAME_SPOT_DEG = 0.00005; + /** @constant {number} Readings kept per cell. The sampler probes a cell at + * most eight times, but ground snaps report at each aircraft's position; + * past this many, a reading away from the others is not kept, so a busy + * apron cell stays a short list to scan. */ + const MESH_READINGS_PER_CELL = 16; /** @type {boolean} Whether mesh cells apply — true only in the google-3d * regime (photoreal stack). On globe stacks the rendered surface IS the * terrain provider (Re:Earth on keyless), so the DEM is already correct @@ -444,7 +457,8 @@ export function createGroundFloor({ terrain, signal }) { /** * Records a sampled rendered-surface height for the cell containing the - * coordinate (one-shot latch — first accepted sample wins). + * coordinate, unchecked, when the cell has no reading yet. The sampler goes + * through `reportValidatedMeshFloorCell`, which keeps a reading per point. * @param {number} lat @param {number} lon @param {number} heightM */ function reportMeshFloorCell(lat, lon, heightM) { @@ -456,7 +470,7 @@ export function createGroundFloor({ terrain, signal }) { return; const c = coarseFloorCoord(lat, lon); const key = `${c.lat},${c.lon}`; - if (!_meshCells.has(key)) _meshCells.set(key, heightM); + if (!_meshCells.has(key)) _meshCells.set(key, [{ lat, lon, h: heightM }]); } /** Return whether a rendered-mesh sample is plausible against a real DEM prior. */ @@ -471,9 +485,11 @@ export function createGroundFloor({ terrain, signal }) { /** * Validate and record a rendered-mesh floor through the single shared gate. - * Callers must not bypass this with raw `reportMeshFloorCell`: a coarse-LOD, - * rooftop, or aircraft hit would otherwise win the session-long first-write - * latch and poison every floor consumer in the cell. + * Callers must not bypass this with raw `reportMeshFloorCell`: a coarse-LOD + * or aircraft hit would otherwise poison every floor consumer in the cell. + * The reading is kept at the point it was taken; a second reading at the + * same spot (within about 5 m) keeps the lower of the two. A cell keeps at + * most MESH_READINGS_PER_CELL readings. * @returns {boolean} Whether the sample passed and was reported. */ function reportValidatedMeshFloorCell(lat, lon, heightM) { @@ -481,13 +497,41 @@ export function createGroundFloor({ terrain, signal }) { const cell = coarseFloorCoord(lat, lon); const prior = cachedRealEllipsoidalGround(cell.lat, cell.lon); if (!meshFloorSampleWithinPrior(heightM, prior)) return false; - reportMeshFloorCell(cell.lat, cell.lon, heightM); + const key = `${cell.lat},${cell.lon}`; + const readings = _meshCells.get(key); + if (!readings) { + _meshCells.set(key, [{ lat, lon, h: heightM }]); + return true; + } + const same = readings.find( + (r) => + Math.abs(r.lat - lat) < MESH_READING_SAME_SPOT_DEG && + Math.abs(r.lon - lon) < MESH_READING_SAME_SPOT_DEG, + ); + if (same) { + if (heightM < same.h) same.h = heightM; + } else if (readings.length < MESH_READINGS_PER_CELL) { + readings.push({ lat, lon, h: heightM }); + } return true; } /** - * Synchronous mesh-cell read — null when the cell is unsampled OR the - * google-3d regime is inactive (globe stacks must floor on the DEM). + * Whether the cell holding the point has the real DEM prior that + * `reportValidatedMeshFloorCell` judges a reading against. + * @param {number} lat @param {number} lon + * @returns {boolean} + */ + function meshFloorPriorReady(lat, lon) { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; + const c = coarseFloorCoord(lat, lon); + return cachedRealEllipsoidalGround(c.lat, c.lon) != null; + } + + /** + * Synchronous mesh-cell read — the cell's reading nearest the point, or + * null when the cell is unsampled OR the google-3d regime is inactive (globe + * stacks must floor on the DEM). * @param {number} lat @param {number} lon * @returns {number|null} */ @@ -495,8 +539,20 @@ export function createGroundFloor({ terrain, signal }) { if (!_meshPreferred) return null; if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; const c = coarseFloorCoord(lat, lon); - const h = _meshCells.get(`${c.lat},${c.lon}`); - return h != null ? h : null; + const readings = _meshCells.get(`${c.lat},${c.lon}`); + if (!readings) return null; + if (readings.length === 1) return readings[0].h; + const k = Math.cos((lat * Math.PI) / 180) ** 2; + let best = readings[0], + bestD = Infinity; + for (const r of readings) { + const d = (r.lat - lat) ** 2 + k * (r.lon - lon) ** 2; + if (d < bestD) { + bestD = d; + best = r; + } + } + return best.h; } /** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */ @@ -662,6 +718,7 @@ export function createGroundFloor({ terrain, signal }) { reportMeshFloorCell, meshFloorSampleWithinPrior, reportValidatedMeshFloorCell, + meshFloorPriorReady, cachedMeshFloor, _clearMeshFloorCellsForTest, cachedGroundFloor, diff --git a/src/services/groundSnap.js b/src/services/groundSnap.js index eecb1fa34..b1d75cfbb 100644 --- a/src/services/groundSnap.js +++ b/src/services/groundSnap.js @@ -101,7 +101,7 @@ const HELD_SNAP_MAX_DRIFT_M = 250; * alone, and the asymmetry is not tidiness: * * - A cell reading LOW against a real sample is the EXPECTED error here, not a - * contradiction. Cells are a one-shot first-write latch over ~111 m, the + * contradiction. Cells hold a few point readings over ~111 m, the * borrowed-neighbour path leans to the LOWEST on purpose (`neighborFloorM`), * and `displayFloorHeightM` only ever RAISES — the whole floor chain is * built to under-read rather than to invent. Releasing on that sign would diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js index c967f80c8..083789ea9 100644 --- a/src/services/meshFloorSampler.js +++ b/src/services/meshFloorSampler.js @@ -11,6 +11,7 @@ export function createMeshFloorSampler({ coarseFloorCoord, cachedMeshFloor, reportValidatedMeshFloorCell, + meshFloorPriorReady, setMeshFloorPreferred, meshFloorPreferred, cachedGroundFloor, @@ -23,13 +24,16 @@ export function createMeshFloorSampler({ // which sits above the Re:Earth bare-earth DEM (measured ~17 m at the Austin // airport apron) — so DEM-floored sprites/trails still buried themselves in // the mesh while 3D models (which groundSnap against the mesh) looked right. - // This module samples the mesh height ONE-SHOT per coarse cell and reports it - // to groundFloor.js, whose cachedGroundFloor() then answers mesh-first. + // This module samples the mesh height at the callers' points, a few probes + // per coarse cell, and reports them to groundFloor.js, which keeps each + // reading that passes its gate at its own point; cachedGroundFloor() then + // answers mesh-first, from the reading nearest the asked point. // // Rationing (the CCTV loading-jitter lesson): scene.sampleHeight forces // tile-load prioritization at the probe point, so sampling is (a) gated to // cells NEAR the viewer — where tiles are streamed and where an error is - // visible at all, (b) capped per call, (c) one-shot per cell for the session, + // visible at all, (b) capped per call, (c) at most MAX_PROBES_PER_CELL + // readings per cell for the session, each at a point apart from the others, // (d) skipped entirely outside the google-3d regime. A returned-undefined // probe (tiles not streamed yet) is NOT latched — it retries on a later call. // @@ -49,6 +53,16 @@ export function createMeshFloorSampler({ * streamed LOD under a high camera is coarse everywhere, so every probe * would latch junk. */ const MAX_CAMERA_HEIGHT_M = 25_000; + /** @constant {number} Readings per cell: a cell is probed at most this + * many times, each at a point apart from the others, and groundFloor keeps + * each reading at its point (a read answers from the nearest one). */ + const MAX_PROBES_PER_CELL = 8; + /** @constant {number} How far apart (degrees, about 17 m) a new probe must + * be from the cell's earlier ones to be worth taking. */ + const PROBE_APART_DEG = 0.00015; + /** @type {Map>} Points probed + * per cell that returned a reading. */ + const _probedPoints = new Map(); /** @type {Cesium.Cartographic} Scratch for probe coordinates. */ const _scratchProbe = new Cesium.Cartographic(); @@ -82,7 +96,7 @@ export function createMeshFloorSampler({ /** * Samples the rendered mesh height for the coarse cells containing `points`, - * one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per + * a few probes per cell, budget-capped, viewer-proximity-gated. Call ONCE per * layer poll (never per contact, never per frame). Synchronous — Cesium's * sampleHeight is a CPU-side query against loaded tiles. * @@ -119,8 +133,21 @@ export function createMeshFloorSampler({ const cell = coarseFloorCoord(p.lat, p.lon); const key = `${cell.lat},${cell.lon}`; if (attempted.has(key)) continue; + // A cell with a reading is probed again only at a point well apart + // from the ones already probed, a few times at most, so the cell's + // readings follow where its vehicles actually are. + const probes = _probedPoints.get(key); + if ( + cachedMeshFloor(cell.lat, cell.lon) != null && + (probes?.length >= MAX_PROBES_PER_CELL || + probes?.some( + (q) => + Math.abs(q.lat - p.lat) < PROBE_APART_DEG && + Math.abs(q.lon - p.lon) < PROBE_APART_DEG, + )) + ) + continue; attempted.add(key); - if (cachedMeshFloor(cell.lat, cell.lon) != null) continue; // one-shot latch if ( Number.isFinite(viewerLat) && Number.isFinite(viewerLon) && @@ -131,9 +158,11 @@ export function createMeshFloorSampler({ } let height; try { + // Probe where the caller's point is, not at the cell's centre: a + // centre that falls on a roof would lift everything in the cell. const carto = Cesium.Cartographic.fromDegrees( - cell.lon, - cell.lat, + p.lon, + p.lat, 0, _scratchProbe, ); @@ -171,7 +200,23 @@ export function createMeshFloorSampler({ // feasible and deliberately deferred — see the P2 in // the project roadmap, which bundles it with the fly_route probe so // one post-launch pass settles rendered-mesh sampling everywhere. - reportValidatedMeshFloorCell(cell.lat, cell.lon, height); + const accepted = reportValidatedMeshFloorCell(p.lat, p.lon, height); + // A spot counts as probed once the gate has judged it against a real + // prior: accepted, or refused with the cell's DEM warm. A spot refused + // only because the DEM was still cold is tried again later. Each spot is + // recorded once, so a cell retried while it has no reading does not fill + // its probe allowance with the same spot. + if (!accepted && !meshFloorPriorReady(cell.lat, cell.lon)) continue; + if (!probes) _probedPoints.set(key, [{ lat: p.lat, lon: p.lon }]); + else if ( + probes.length < MAX_PROBES_PER_CELL && + !probes.some( + (q) => + Math.abs(q.lat - p.lat) < PROBE_APART_DEG && + Math.abs(q.lon - p.lon) < PROBE_APART_DEG, + ) + ) + probes.push({ lat: p.lat, lon: p.lon }); } } From 9bce41d3c6a83125ac496ad3b6abeea6ca200f3b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 9 Nov 2026 01:26:01 -0800 Subject: [PATCH 14/20] Read the mesh floor where transit vehicles and grounded flights actually are With readings kept per point, the floor a vehicle gets depends on which point is probed and where it is read. Transit offered the sampler the first point of each vehicle's path in a cell, usually its oldest fix there, so a vehicle that stayed in one cell never had its current spot probed. It now offers the latest point of the path in each cell, and the newest fix in the vehicle's own cell (the selected vehicle's history is walked newest first). The grounded-flight display clamp read the floor at its sticky cell's centre, which now answers from the reading nearest the centre, not the contact. It reads at the contact's own position, held inside the sticky cell so boundary hysteresis still applies. --- docs/CURRENT-STATE.md | 17 ++-- src/data/flights.test.mjs | 35 +++++++++ src/layers/flights/motion.js | 29 +++++-- src/layers/transit/height.js | 16 +++- src/layers/transit/lifecycle.test.mjs | 108 ++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 13 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5e7c36be2..0739cf5e1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1360,13 +1360,16 @@ the same way; a cell keeps at most sixteen). A read answers from the cell's reading nearest the asked point, so a viaduct and the street beside it in one cell keep their own heights; two readings within about 5 m of each other keep the lower. A spot refused only because the cell's DEM prior was cold is tried -again later. Before this, one probe at the cell's centre latched for the -session, so a centre on a roof lifted everything in the cell (a Haymarket bus -drew 23.5 m above the street, on the garage roof) and a centre in the river put -Red Line trains on the Longfellow Bridge 6–9 m under the deck. Residual: the -probe is vertical, so a vehicle directly under a deck or a roof overhang reads -the structure above it, and a vehicle between readings takes the nearest one, so -its floor steps where the nearest reading changes. +again later. Transit offers the sampler the latest point of each vehicle's path +in a cell (its newest fix in its own cell), and the grounded-flight display +clamp reads at the contact, held inside its sticky cell. Before this, one probe +at the cell's centre latched for the session, so a centre on a roof lifted +everything in the cell (a Haymarket bus drew 23.5 m above the street, on the +garage roof) and a centre in the river put Red Line trains on the Longfellow +Bridge 6–9 m under the deck. Residual: the probe is vertical, so a vehicle +directly under a deck or a roof overhang reads the structure above it, and a +vehicle between readings takes the nearest one, so its floor steps where the +nearest reading changes. ## Vessel components and sources diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index 7fe197c30..1d6007e8f 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -20,9 +20,11 @@ import { setMilitaryLayerActive } from './militaryRegistry.js'; import { GROUND_FLOOR_LIFT_M, reportMeshFloorCell, + reportValidatedMeshFloorCell, setMeshFloorPreferred, _clearMeshFloorCellsForTest, } from './groundFloor.js'; +import { resolveEllipsoidalGround } from './terrainHeights.js'; import { _setTrackedOverlayHostForTest, destroyTrackedReadout, @@ -1181,6 +1183,39 @@ test('display floor: a grounded contact that drifted onto a higher cell is lifte assert.ok(Math.abs(Cesium.Math.toDegrees(c.longitude) + 97.6604) < 1e-6); }); +test('display floor: reads the reading nearest the contact, not the cell centre', async () => { + _clearDisplayFloorStateForTest(); + _clearMeshFloorCellsForTest(); + setMeshFloorPreferred(true); + const realFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify({ results: [{ ellipsoid: 120 }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + try { + await resolveEllipsoidalGround([{ lat: 30.317, lon: -97.717 }]); + } finally { + globalThis.fetch = realFetch; + } + // A hangar roof over the cell's centre; the apron where the contact is. + assert.equal(reportValidatedMeshFloorCell(30.317, -97.717, 142), true); + assert.equal(reportValidatedMeshFloorCell(30.3173, -97.7173, 121), true); + const pos = Cesium.Cartesian3.fromDegrees(-97.7173, 30.3173, 100); + const out = _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + ); + const c = _floorCarto(out); + assert.ok( + Math.abs(c.height - (121 + GROUND_FLOOR_LIFT_M)) < 0.05, + `height ${c.height} should be the apron's 121 + lift`, + ); + _clearMeshFloorCellsForTest(); + _clearDisplayFloorStateForTest(); +}); + test('display floor: an AIRBORNE contact over the same cell is never lifted', () => { _clearDisplayFloorStateForTest(); _clearMeshFloorCellsForTest(); diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index a14c8c4ac..c09522153 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -37,6 +37,22 @@ import { DISPLAY_CORRIDOR_FAIR_SHARE, } from './policy.js'; +/** Half a coarse floor cell (0.001°), less a margin so rounding keeps a + * clamped coordinate inside the cell. */ +const HALF_CELL_DEG = 0.00049; + +/** + * A coordinate held within half a cell of the cell's centre (the short way + * round for longitude), so a read lands in the given cell. + * @param {number} deg @param {number} centreDeg + * @returns {number} + */ +function clampToCell(deg, centreDeg) { + let d = deg - centreDeg; + d -= 360 * Math.round(d / 360); + return centreDeg + Math.max(-HALF_CELL_DEG, Math.min(HALF_CELL_DEG, d)); +} + export function createMotion({ flightState, services, @@ -865,12 +881,15 @@ export function createMotion({ ); // Boundary hysteresis: a position jittering across a cell edge would flip // floors at fleet-tick rate (see stickyFloorCell). - const cell = stickyFloorCell( - Cesium.Math.toDegrees(carto.latitude), - Cesium.Math.toDegrees(carto.longitude), - state?.cell, + const latDeg = Cesium.Math.toDegrees(carto.latitude); + const lonDeg = Cesium.Math.toDegrees(carto.longitude); + const cell = stickyFloorCell(latDeg, lonDeg, state?.cell); + // A cell holds rendered-surface readings at the points they were taken, + // so the floor is read where the contact is, held inside its sticky cell. + const floor = cachedGroundFloor( + clampToCell(latDeg, cell.lat), + clampToCell(lonDeg, cell.lon), ); - const floor = cachedGroundFloor(cell.lat, cell.lon); const next = state || { cell, in: new Cesium.Cartesian3(), diff --git a/src/layers/transit/height.js b/src/layers/transit/height.js index 2e2945a88..027dc7d41 100644 --- a/src/layers/transit/height.js +++ b/src/layers/transit/height.js @@ -124,7 +124,14 @@ export function createHeight({ state, services, parts }) { const cells = new Map(); const collect = (point, answer) => { if (!onGround) return; - if (!cells.has(answer.cell)) + // A cell keeps the last point offered in it: the path is walked + // oldest first, so that is where the vehicle is now or about to be, + // and the mesh sampler reads the surface there. + const held = cells.get(answer.cell); + if (held) { + held.lat = point.lat; + held.lon = point.lon; + } else cells.set(answer.cell, { lat: point.lat, lon: point.lon, @@ -136,6 +143,13 @@ export function createHeight({ state, services, parts }) { const newest = entry.fixes.at(-1); const answer = requestHeight(newest.lat, newest.lon, entry); entry.heightCell = answer.cell; + // The selected vehicle's path also offers its history, newest segment + // first; the vehicle's own cell is read where its newest fix is. + const own = cells.get(answer.cell); + if (own) { + own.lat = newest.lat; + own.lon = newest.lon; + } if (answer.height !== null) { entry.heightM = answer.height; entry.heightResolved = true; diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index e36d87de8..fee9a60ed 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -2938,6 +2938,114 @@ test('mesh refinement reaches all 100 retained DEM cells within five poll budget assert.equal(probed.size, 100); }); +test('mesh refinement probes the latest point the path offers in a cell', async (t) => { + const { createHeight } = await import('./height.js'); + // Three points along one cell's stretch of path, oldest first. + const path = [ + { lat: 42.3611, lon: -71.0751 }, + { lat: 42.3612, lon: -71.0753 }, + { lat: 42.3614, lon: -71.0754 }, + ]; + const entry = { key: 'train', to: path[0], fixes: [path.at(-1)] }; + const probed = []; + const state = { + _enabled: true, + _selectedKey: null, + _visible: new Set([entry]), + _vehicles: new Map([[entry.key, entry]]), + _floorCursor: 0, + _heightDirty: new Set(), + _viewer: { camera: { positionCartographic: { height: 100 } } }, + }; + const height = createHeight({ + state, + services: { + render: { governorRequestRender() {} }, + ground: { + coarseFloorCoord: (lat, lon) => ({ + lat: Number(lat.toFixed(3)), + lon: Number(lon.toFixed(3)), + }), + cachedGroundFloor: () => 10, + GROUND_FLOOR_LIFT_M: 1.5, + }, + mesh: { + sampleMeshFloorCells(scene, points) { + probed.push(...points.map((p) => [p.lat, p.lon])); + }, + }, + }, + parts: { + trails: { + prepareEntry(e, collect) { + if (collect) + for (const point of path) + collect(point, height.requestHeight(point.lat, point.lon)); + }, + }, + rendering: { requestVisibility() {} }, + }, + }); + t.after(() => height.clear()); + height.anchorFloors(); + assert.deepEqual(probed, [[42.3614, -71.0754]]); +}); + +test('the selected vehicle is probed at its newest fix in its own cell', async (t) => { + const { createHeight } = await import('./height.js'); + // The selected path offers its active corridor first, then its history + // newest segment first, so the last offer in the cell is an older point. + const newest = { lat: 42.3614, lon: -71.0754 }; + const offers = [ + newest, + { lat: 42.3612, lon: -71.0753 }, + { lat: 42.3611, lon: -71.0751 }, + ]; + const entry = { key: 'train', to: offers[0], fixes: [newest] }; + const probed = []; + const state = { + _enabled: true, + _selectedKey: entry.key, + _visible: new Set([entry]), + _vehicles: new Map([[entry.key, entry]]), + _floorCursor: 0, + _heightDirty: new Set(), + _viewer: { camera: { positionCartographic: { height: 100 } } }, + }; + const height = createHeight({ + state, + services: { + render: { governorRequestRender() {} }, + ground: { + coarseFloorCoord: (lat, lon) => ({ + lat: Number(lat.toFixed(3)), + lon: Number(lon.toFixed(3)), + }), + cachedGroundFloor: () => 10, + GROUND_FLOOR_LIFT_M: 1.5, + }, + mesh: { + sampleMeshFloorCells(scene, points) { + probed.push(...points.map((p) => [p.lat, p.lon])); + }, + }, + }, + parts: { + trails: { + prepareEntry(e, collect) { + if (collect) + for (const point of offers) + collect(point, height.requestHeight(point.lat, point.lon)); + }, + }, + rendering: { requestVisibility() {} }, + }, + }); + t.after(() => height.clear()); + height.anchorFloors(); + assert.deepEqual(probed, [[42.3614, -71.0754]]); +}); + test('selection churn releases past geometry on every retained vehicle', async (t) => { const app = harness(t, { floorAt: () => 10 }); const { mergeHistory, seek } = await import('../../data/contactPlayback.js'); From 09520bc34ce262ad208295fc9843146f6dce3330 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 9 Nov 2026 14:54:51 -0800 Subject: [PATCH 15/20] Changelog fragment for #1642 --- changelog.d/1642-transit-floor-per-point.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/1642-transit-floor-per-point.md diff --git a/changelog.d/1642-transit-floor-per-point.md b/changelog.d/1642-transit-floor-per-point.md new file mode 100644 index 000000000..04dd59760 --- /dev/null +++ b/changelog.d/1642-transit-floor-per-point.md @@ -0,0 +1,7 @@ +- Keep buses and trains off rooftops and out of bridge decks on the photoreal + 3D tiles: the ground floor probes the rendered mesh where each vehicle is, + keeps each reading at the point it was taken (up to sixteen a cell) and + answers from the reading nearest the vehicle, instead of one probe at each + 110 m cell's centre that lifted every vehicle in the cell onto whatever lay + there. Transit probes each vehicle's latest point, and grounded flights read + the floor at their own position (Sameh Khamis, @samehkhamis, #1642). From a7f96313e92105e96180d02bfdbf712d79816ff3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 9 Nov 2026 16:56:59 -0800 Subject: [PATCH 16/20] feat(pointer): an application's own tool can take the pointer createApplicationTools returns pointer: claim(owner) takes the pointer as Draw does and returns its lease, or null while another tool holds it; release(lease) gives it back; isFree() lets the application's own click handlers yield, as the layers' do. This replaces drawShape, which this branch added: Draw is as it was. --- CHANGELOG.md | 7 +- docs/CURRENT-STATE.md | 2 +- src/annotations/drawTool.js | 66 +-------- src/annotations/drawToolBehaviour.test.mjs | 152 --------------------- src/app/tools.js | 22 +-- 5 files changed, 18 insertions(+), 231 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c425d0b6..f49dad02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,9 @@ ## [Unreleased] -- Applications can ask Draw for one area or line and get its geometry back - without placing a mark (`drawShape` from `createApplicationTools`), and their - own click handlers can yield while a tool holds the pointer, as the layers' - do (`pointerFree`) (samehkhamis, #1641). +- An application's own tool can take the pointer as Draw does, and its click + handlers can yield while a tool holds it, as the layers' do (`pointer` from + `createApplicationTools`) (samehkhamis, #1641). ## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b70dc1d92..f8914f19f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6311,7 +6311,7 @@ request viewbox and continues searching without that geographic preference. - UTILITIES ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (icon buttons for Area, Line and Pin) and a colour swatch for the next shape, click the vertices on the real world, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone. - **Drawn shapes are listed under Draw like Import's files** (`src/ui/views/panels/DrawMarkList.vue`, rows worded by `presentDrawMarks` in `src/annotations/drawMarks.js`): one row per hand-drawn mark (`manual: true`; voice marks are not listed) with its colour swatch, its name, and show/hide, fly-to and remove. The draw tool publishes the rows with the rest of its controls and answers the row actions (`renameDrawMark`, `recolorDrawMark`, `toggleDrawMark`, `flyToDrawMark`, `removeDrawMark`) through the display store; it republishes on the engine's `onChange`, so a voice clear or an expiry updates the list. Rows are keyed and updated in place: a board change never takes a name being typed (the field keeps its draft while focused) or the button under a click. The name is saved on Enter or on leaving the field, once; Esc restores it, before anything else and with the row's colours open too. Both keep focus in the field, so neither drops it to the page or leaves Draw. The swatch opens the five colours right after it (shown at the end of the row), with the current colour focused; the arrows, Home and End try another, and a click or Esc closes them and returns focus to the swatch. Both colour groups (the next shape's and a row's) are one `DrawSwatches` radio group with a single tab stop, coloured from `DRAW_COLOR_CSS`; on touch screens each dot answers a 24 px target. An unnamed shape shows its kind, and an unnamed line its length; a renamed line keeps its length (`Ridge walk — 950 m`). **Hidden** keeps a mark on the board at alpha 0, and the world renderer takes every one of its entities out (`show = false`), so no material that keeps its own colour (the line's flowing stroke) still paints it; the screen renderer does not project a hidden callout, so it cannot push a visible label aside; `fly_route` skips a hidden route; and with every mark hidden the annotations render hold is released. **Fly to** is an immediate navigation (`runImmediateNavigation('drawing', …)`), like Import's: Cockpit refuses it and a running orbit, follow or track lets go first. A line is framed by its whole path, across the antimeridian too. - Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect, markActions })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam. -- **One shape, handed back.** `createApplicationTools` also returns `drawShape({ shape, signal })` (`drawTool.drawShape`): it opens draw mode for one `area` or `line` and resolves with its geometry, a closed `[lon, lat]` `Polygon` or a `LineString`, without placing a mark; the shape chosen before comes back and draw mode ends. While it is open the controls cannot change the shape, and one across the antimeridian is refused as a row's actions refuse it ("That shape could not be placed.") and drawn again. It resolves `null` when the drawing is cancelled (the second Escape), draw mode is left, `signal` aborts, another request replaces it, the pointer is held elsewhere, the tool is destroyed, or the page has no Draw controls. It returns `pointerFree` (`data/inputOwnership.js` `isPointerFree`) too, so an application's own click handlers yield to the tool holding the pointer, as the layers' do. +- **The pointer, for an application's own tool.** `createApplicationTools` also returns `pointer` (`data/inputOwnership.js`): `claim(owner)` takes the pointer as Draw does and returns its lease, or `null` while another tool holds it; `release(lease)` gives it back; `isFree()` lets the application's own click handlers yield, as the layers' do. An owner without a name in `pointerOwnerNames.js` is named by its id when another tool reports who holds the pointer. - **Row actions are an application's own.** `createApplicationTools({ drawMarkActions })` hands `initDrawTool` buttons for drawn areas' and lines' rows; the standalone shell passes none, so the open-source build shows no new button and makes no new request. Each action (`drawMarkActions` in `src/annotations/drawMarks.js`) is `{ id, icon, label(name), shapes: ['area', 'line'], accepts?(geometry, shape), run({ id, shape, name, title, geometry }) }`: the icon a Material Symbols name, the label worded by the application, `name` the person's own and `title` the row's (the name, else `Area` or `Line · 950 m`), the geometry `drawMarkGeometry`'s `[lon, lat]` `Polygon` (closed; a `MultiPolygon` for an area handle with several parts) or `LineString`, longitudes wrapped into [-180, 180]. A pin, and a shape that crosses the antimeridian or has a part that cannot be given whole, has no geometry and is offered nothing. Malformed actions and repeated ids are dropped once, at construction. What `run` returns or resolves to is a note under the row (`role=status`; past 200 characters it is cut at a word with an ellipsis); the row is busy (`aria-disabled`, no second press) until it settles, a rejection or a throw clears the note and is logged (so an action words its own refusals and resolves with them), and a shape's note goes with the shape, so a late answer never lands on a removed one. - Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, appear in `.list()`, and clear with `clear_annotations` / `.clear()`. A drawn shape is never merged with another as a duplicate: every one is drawn on purpose, and every one is unnamed until its row names it, so two pins dropped close together are two pins. - **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it. diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index 14a5929eb..f44d30753 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -54,7 +54,6 @@ import { DRAW_COLORS, DRAW_COLOR_CSS, drawMarkActions, - drawMarkGeometry, drawMarkActionsFor, drawMarkFallbackName, presentDrawMarks, @@ -121,9 +120,6 @@ export function initDrawTool({ let savedDoubleClick = null; let savedSingleClick = null; let cursor = null; // last mouse position on the canvas, for the rubber band - // One shape asked for by `drawShape`: `{ resolve, restoreShape, off }`. Its - // finish hands the geometry back instead of placing a mark. - let request = null; // Bumped by anything that supersedes an in-flight finish: cancelling, clearing // the board, leaving draw mode, teardown. An `annotate()` that resolves after // one of those must not write its outcome over the newer state. @@ -284,20 +280,6 @@ export function initDrawTool({ // Named afterwards, in the shape's row. const spec = finishSpec(session, { label: '', color }); if (!spec) return null; - if (request) { - // As a row's actions get it: a shape across the antimeridian has none, - // and is drawn again rather than handed back wrapped round the world. - const geometry = drawMarkGeometry(spec); - if (!geometry) { - session = createDrawSession(shape); - cursor = null; - syncPreview(); - setHint(() => t('That shape could not be placed.')); - return null; - } - endRequest(geometry); - return geometry; - } // Start a fresh session BEFORE awaiting, so a second Enter or the second // half of a double-click cannot submit the same shape twice. session = createDrawSession(shape); @@ -425,7 +407,6 @@ export function initDrawTool({ generation += 1; session = null; cursor = null; - endRequest(null); releaseSceneHandler(); releasePointer(lease); lease = null; @@ -551,46 +532,6 @@ export function initDrawTool({ return true; } - /** - * Close the asked-for shape with `geometry` (null when there is none): the - * shape chosen before it comes back, and draw mode ends. - */ - function endRequest(geometry) { - const open = request; - if (!open) return; - request = null; - open.off(); - chooseShape(open.restoreShape); - open.resolve(geometry); - if (active) setActive(false); - } - - /** - * Draw one area or line and hand back its geometry (a Polygon or a - * LineString) without placing a mark. Escape works as in Draw: the first - * clears a shape in progress, and one on an empty shape leaves. Resolves null - * when draw mode is left, `signal` aborts, or the pointer is taken. - * A second request ends the first with null. - * @param {{shape?: 'area'|'line', signal?: AbortSignal}} [options] - * @returns {Promise<{type: 'Polygon', coordinates: number[][][]}|{type: 'LineString', coordinates: number[][]}|null>} - */ - function drawShape({ shape: wanted = 'area', signal } = {}) { - if (destroyed || signal?.aborted) return Promise.resolve(null); - endRequest(null); - const restoreShape = shape; - return new Promise((resolve) => { - const abort = () => endRequest(null); - signal?.addEventListener('abort', abort, { once: true }); - request = { - resolve, - restoreShape, - off: () => signal?.removeEventListener('abort', abort), - }; - chooseShape(normalizeShape(wanted) === 'line' ? 'line' : 'area'); - if (!active && !setActive(true)) endRequest(null); - }); - } - /** Draw `next` from now on; an open shape starts over in it. */ function chooseShape(next) { shape = normalizeShape(next); @@ -604,10 +545,7 @@ export function initDrawTool({ } const disconnect = connect({ toggleDraw: () => setActive(!active), - // An asked-for shape is the one asked for. - setDrawShape: (next) => { - if (!request) chooseShape(next); - }, + setDrawShape: chooseShape, setDrawColor: (value) => { color = DRAW_COLORS.includes(value) ? value : 'primary'; present(); @@ -664,7 +602,6 @@ export function initDrawTool({ finish, cancel, clearAll, - drawShape, shapes: DRAW_SHAPES, /** What this tool currently holds — for teardown and leak assertions. */ diagnostics() { @@ -701,7 +638,6 @@ export function initDrawTool({ if (destroyed) return attaching; // Supersede any finish still in flight before anything is torn down. generation += 1; - endRequest(null); if (active) setActive(false); destroyed = true; offLocale(); diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index 276852248..4cef4d4fe 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -704,158 +704,6 @@ test('what a row action answers is its row\'s note; the row is busy until then, } }); -test('drawShape hands back the drawn area as a polygon and places no mark', async () => { - const h = harness(); - try { - h.act('setDrawShape', 'line'); - const drawn = h.tool.drawShape({ shape: 'area' }); - assert.equal(h.tool.active, true, 'it opens draw mode'); - assert.equal(h.tool.shape, 'area'); - h.tool.addVertex(-97.75, 30.26); - h.tool.addVertex(-97.74, 30.26); - h.tool.addVertex(-97.74, 30.27); - h.documentDouble.emit('keydown', { - key: 'Enter', - target: h.documentDouble.body, - }); - const geometry = await drawn; - assert.deepEqual(geometry, { - type: 'Polygon', - coordinates: [ - [ - [-97.75, 30.26], - [-97.74, 30.26], - [-97.74, 30.27], - [-97.75, 30.26], - ], - ], - }); - assert.equal(h.annotations.calls.annotate.length, 0, 'no mark is placed'); - assert.equal(h.tool.active, false, 'draw mode ends'); - assert.equal(isPointerFree(), true); - assert.equal(h.tool.shape, 'line', 'the shape chosen before comes back'); - } finally { - h.restore(); - } -}); - -test('drawShape hands back a line as a LineString', async () => { - const h = harness(); - try { - const drawn = h.tool.drawShape({ shape: 'line' }); - h.tool.addVertex(-97.75, 30.26); - h.tool.addVertex(-97.74, 30.26); - h.documentDouble.emit('keydown', { - key: 'Enter', - target: h.documentDouble.body, - }); - assert.deepEqual(await drawn, { - type: 'LineString', - coordinates: [ - [-97.75, 30.26], - [-97.74, 30.26], - ], - }); - } finally { - h.restore(); - } -}); - -test('drawShape resolves null on Escape, on abort, on a newer request and when the pointer is taken', async () => { - const h = harness(); - try { - // Escape with a shape started clears it; a second Escape leaves. - const escaped = h.tool.drawShape({ shape: 'area' }); - h.tool.addVertex(-97.75, 30.26); - h.documentDouble.emit('keydown', { - key: 'Escape', - target: h.documentDouble.body, - }); - assert.equal(h.tool.active, true, 'the first Escape only clears the shape'); - h.documentDouble.emit('keydown', { - key: 'Escape', - target: h.documentDouble.body, - }); - assert.equal(await escaped, null); - assert.equal(h.tool.active, false); - - const controller = new AbortController(); - const aborted = h.tool.drawShape({ - shape: 'area', - signal: controller.signal, - }); - controller.abort(); - assert.equal(await aborted, null); - assert.equal(isPointerFree(), true, 'an abort gives the pointer back'); - - const first = h.tool.drawShape({ shape: 'area' }); - const second = h.tool.drawShape({ shape: 'line' }); - assert.equal(await first, null, 'a newer request ends the older one'); - h.act('toggleDraw'); - assert.equal(await second, null, 'leaving draw mode ends it'); - - const done = new AbortController(); - done.abort(); - assert.equal(await h.tool.drawShape({ signal: done.signal }), null); - - const other = claimPointer('directions'); - assert.equal( - await h.tool.drawShape({ shape: 'area' }), - null, - 'a held pointer refuses it', - ); - releasePointer(other); - - // Drawing by hand afterwards places marks as before. - h.act('toggleDraw'); - h.tool.addVertex(-97.75, 30.26); - h.tool.addVertex(-97.74, 30.26); - h.tool.addVertex(-97.74, 30.27); - h.documentDouble.emit('keydown', { - key: 'Enter', - target: h.documentDouble.body, - }); - await Promise.resolve(); - assert.equal(h.annotations.calls.annotate.length, 1); - } finally { - h.restore(); - } -}); - -test('drawShape keeps its shape, and a shape across the antimeridian is drawn again', async () => { - const h = harness(); - try { - const drawn = h.tool.drawShape({ shape: 'line' }); - h.act('setDrawShape', 'pin'); - assert.equal(h.tool.shape, 'line', 'the controls cannot change the asked-for shape'); - h.tool.addVertex(179.5, 10); - h.tool.addVertex(-179.5, 10); - h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body }); - assert.equal(h.tool.active, true, 'still drawing'); - assert.equal(h.tool.session.vertices.length, 0, 'drawn again from the start'); - assert.equal(h.shown.view.hint, 'That shape could not be placed.'); - h.tool.addVertex(29, 41); - h.tool.addVertex(29.1, 41); - h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body }); - assert.equal((await drawn).type, 'LineString'); - h.act('setDrawShape', 'pin'); - assert.equal(h.tool.shape, 'pin', 'after it, the controls choose again'); - } finally { - h.restore(); - } -}); - -test('destroy ends a drawShape request with null', async () => { - const h = harness(); - try { - const drawn = h.tool.drawShape({ shape: 'area' }); - h.tool.destroy(); - assert.equal(await drawn, null); - } finally { - h.restore(); - } -}); - test('Backspace on the page removes the last vertex', () => { const h = harness(); try { diff --git a/src/app/tools.js b/src/app/tools.js index 9ecb3559a..9da848c78 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -1,7 +1,11 @@ import { SceneDirector } from '../scenes/director.js'; import { initAnnotations } from '../annotations/index.js'; import { initDrawTool } from '../annotations/drawTool.js'; -import { isPointerFree } from '../data/inputOwnership.js'; +import { + claimPointer, + isPointerFree, + releasePointer, +} from '../data/inputOwnership.js'; import { initGeoFileImport } from '../ui/geoFileImport.js'; import { useDisplayStore } from '../ui/stores/display.js'; import { applicationPinia } from '../ui/vueRuntime.js'; @@ -246,17 +250,17 @@ export function createApplicationTools({ signal, }), ); - // Draw one area or line and get its geometry back, without placing a mark - // (annotations/drawTool.js `drawShape`); null without the Draw controls. - const drawShape = (options) => - drawTool ? drawTool.drawShape(options) : Promise.resolve(null); - // An application's own click handlers yield while a tool holds the pointer, - // as the layers' do (data/inputOwnership.js). + // An application's own tool takes the pointer as Draw does, and its own + // click handlers yield while a tool holds it, as the layers' do + // (data/inputOwnership.js). return { sceneDirector, annotations, voiceCommands, - drawShape, - pointerFree: isPointerFree, + pointer: { + claim: claimPointer, + release: releasePointer, + isFree: isPointerFree, + }, }; } From 4dc51927bc55e88a9a09bd442c8c18fac3d072af Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 9 Nov 2026 18:04:16 -0800 Subject: [PATCH 17/20] perf(traffic): build road lines one set at a time With Lines on, every progressive paint of a loading city rebuilt all road lines: up to three ground-line primitives for as many as 1,500 roads, built asynchronously. Builds started faster than they finished, and each kept the set it replaced in the scene until it was ready, so sets piled up. One set now builds at a time. A rebuild asked for while lines are building replaces the one waiting, and the newest starts when the build finishes. Lines replaced by a finished build are removed before the next one starts. Turning Lines off removes the lines at once and drops the waiting rebuild. --- docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 1 + src/layers/traffic/lineRebuild.test.mjs | 219 ++++++++++++++++++++++++ src/layers/traffic/rendering.js | 52 ++++-- src/layers/traffic/state.js | 4 + 5 files changed, 264 insertions(+), 14 deletions(-) create mode 100644 src/layers/traffic/lineRebuild.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 91b7a56a4..03d4efc94 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -134,7 +134,7 @@ existing batch size and one bounded upstream chunk. Larger requests return HTTP 413 before disk-cache loading or upstream work; malformed coordinates remain 400. The browser continues to split larger workloads into sequential 64-point requests. -Traffic holds continuous rendering only while enabled with shown animated dots, a pulsing jam heat-line, or road lines whose geometry is still building (replaced lines stay drawn until their successors are ready, so a rebuild never blinks); hidden dots (`dots` off) and settled flow lines hold nothing. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading. +Traffic holds continuous rendering only while enabled with shown animated dots, a pulsing jam heat-line, or road lines whose geometry is still building (replaced lines stay drawn until their successors are ready, so a rebuild never blinks; one set builds at a time, and of the rebuilds asked for meanwhile only the newest runs, when that build finishes; turning lines off removes them at once); hidden dots (`dots` off) and settled flow lines hold nothing. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading. Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7dbd9d2b7..c82c5ebb3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1077,6 +1077,7 @@ "src/layers/traffic/roadModes.test.mjs", "scripts/qa-traffic-motion.mjs", "src/layers/traffic/motion.test.mjs", + "src/layers/traffic/lineRebuild.test.mjs", "scripts/qa-journey-recorder.mjs", "scripts/qa-alpr-journey.mjs", "scripts/qa-context-arrivals.mjs", diff --git a/src/layers/traffic/lineRebuild.test.mjs b/src/layers/traffic/lineRebuild.test.mjs new file mode 100644 index 000000000..b4533b63d --- /dev/null +++ b/src/layers/traffic/lineRebuild.test.mjs @@ -0,0 +1,219 @@ +// Street Traffic road lines build one set at a time: rebuilds asked for while +// a set is building wait, and only the newest of them runs. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createRendering } from './rendering.js'; + +// Cesium tests a material's uniforms against these browser classes. +for (const name of [ + 'HTMLCanvasElement', + 'HTMLImageElement', + 'ImageBitmap', + 'OffscreenCanvas', +]) + globalThis[name] ??= class {}; +// A line primitive's render state is checked against the context's limits, +// which only a WebGL context sets. +Cesium.ContextLimits._minimumAliasedLineWidth = 1; +Cesium.ContextLimits._maximumAliasedLineWidth = 1; + +/** A road with flow; `level` picks its bucket (0.1 jam, 0.6 slow, 1 free). */ +function road(level = 1) { + return { + flow: { level, closure: false }, + segmentDist: [100], + waypoints: Cesium.Cartesian3.fromDegreesArray([ + -97.744, 30.267, -97.742, 30.267, + ]), + }; +} + +/** + * Rendering over a scene that records its ground primitives. Each added + * primitive is replaced by a stand-in whose `ready` the test sets, as Cesium + * sets it once the asynchronous geometry is built. + */ +function harness() { + const scene = []; + const added = []; + let linesOn = true; + const state = { + _viewer: { + scene: { + globe: { show: true }, + groundPrimitives: { + add(primitive) { + const line = { + ready: false, + roads: primitive.geometryInstances.length, + }; + scene.push(line); + added.push(line); + return line; + }, + remove(line) { + const index = scene.indexOf(line); + if (index >= 0) scene.splice(index, 1); + }, + }, + }, + }, + _liveMode: true, + _showLines: true, + _heatSupported: true, + _heatJamPrim: null, + _heatSlowPrim: null, + _heatFreePrim: null, + _heatRetired: [], + _heatPendingRoads: null, + _heatLineCount: 0, + }; + const rendering = createRendering({ + state, + services: {}, + source: {}, + parts: { + style: { heatlineOn: () => linesOn }, + animation: { syncRenderDemand() {} }, + }, + }); + return { + state, + rendering, + scene, + added, + setLines(on) { + linesOn = on; + state._showLines = on; + }, + /** Finish every build in the scene, then run a frame. */ + frame({ built = true } = {}) { + if (built) for (const line of scene) line.ready = true; + rendering.retireHeatLines(); + }, + }; +} + +const roads = (count) => Array.from({ length: count }, () => road()); + +test('rebuilds asked for while lines build do not start more builds', () => { + const { rendering, scene, added, frame } = harness(); + rendering.rebuildHeatLines(roads(1)); + assert.equal(added.length, 1); + // Progressive paints while the first set builds. + for (const count of [2, 3, 4]) rendering.rebuildHeatLines(roads(count)); + frame({ built: false }); + assert.equal(added.length, 1, 'no build started during a build'); + assert.equal(scene.length, 1); + assert.deepEqual(scene[0].roads, 1); +}); + +test('the newest rebuild asked for during a build is the one that runs', () => { + const { state, rendering, scene, added, frame } = harness(); + rendering.rebuildHeatLines(roads(1)); + for (const count of [2, 3, 4]) rendering.rebuildHeatLines(roads(count)); + frame(); + // One build for the three requests, of the last one's roads. + assert.deepEqual( + added.map((line) => line.roads), + [1, 4], + ); + assert.equal(state._heatLineCount, 4); + assert.equal(state._heatPendingRoads, null); + // The set it replaces stays drawn while it builds, then goes. + assert.deepEqual( + scene.map((line) => line.roads), + [1, 4], + ); + frame(); + assert.deepEqual( + scene.map((line) => line.roads), + [4], + ); + // Nothing is waiting, so later frames start nothing. + frame(); + assert.equal(added.length, 2); +}); + +test('a rebuild with nothing building starts at once', () => { + const { rendering, added, frame } = harness(); + rendering.rebuildHeatLines(roads(1)); + frame(); + rendering.rebuildHeatLines(roads(2)); + assert.deepEqual( + added.map((line) => line.roads), + [1, 2], + ); +}); + +test('turning lines off during a build removes them at once and drops the waiting rebuild', () => { + const { state, rendering, scene, added, setLines, frame } = harness(); + rendering.rebuildHeatLines(roads(1)); + frame(); + rendering.rebuildHeatLines(roads(2)); + rendering.rebuildHeatLines(roads(3)); + assert.equal(scene.length, 2, 'the drawn set and the building one'); + setLines(false); + rendering.rebuildHeatLines(roads(3)); + assert.deepEqual(scene, [], 'not deferred behind the build'); + assert.equal(state._heatPendingRoads, null); + assert.equal(state._heatLineCount, 0); + frame(); + assert.equal(added.length, 2, 'the waiting rebuild never runs'); + assert.deepEqual(scene, []); +}); + +test('a rebuild right after a build finishes does not keep the lines that build replaced', () => { + const { rendering, scene } = harness(); + rendering.rebuildHeatLines(roads(1)); + for (const line of scene) line.ready = true; + rendering.rebuildHeatLines(roads(2)); + // The second build finishes, and a third is asked for before a frame ran. + for (const line of scene) line.ready = true; + rendering.rebuildHeatLines(roads(3)); + assert.deepEqual( + scene.map((line) => line.roads), + [2, 3], + ); +}); + +test('removing the lines drops the waiting rebuild', () => { + const { state, rendering, scene, added, frame } = harness(); + rendering.rebuildHeatLines(roads(1)); + rendering.rebuildHeatLines(roads(2)); + rendering.removeHeatLines(); + assert.deepEqual(scene, []); + assert.equal(state._heatPendingRoads, null); + frame(); + assert.equal(added.length, 1); +}); + +test('a stream of rebuilds never leaves more than two sets in the scene, and ends with one', () => { + const { rendering, scene, added, frame } = harness(); + let most = 0; + const lines = [road(0.1), road(0.6), road(1)]; + // Three primitives per set: one per flow bucket. + for (let paint = 0; paint < 40; paint += 1) { + rendering.rebuildHeatLines(lines); + most = Math.max(most, scene.length); + // A build takes several paints. + if (paint % 5 === 4) frame(); + else frame({ built: false }); + most = Math.max(most, scene.length); + } + frame(); + frame(); + assert.equal(most <= 6, true, `${most} primitives at once`); + assert.equal(scene.length, 3, 'only the current set remains'); + assert.equal( + scene.every((line) => line.ready), + true, + ); + // Without coalescing every paint builds: 40 sets, 120 primitives. + assert.equal( + added.length <= 3 * 10, + true, + `${added.length} primitives built`, + ); +}); diff --git a/src/layers/traffic/rendering.js b/src/layers/traffic/rendering.js index 402cd08b6..49f5af8e9 100644 --- a/src/layers/traffic/rendering.js +++ b/src/layers/traffic/rendering.js @@ -59,24 +59,37 @@ export function createRendering({ /** Remove the heat-line / flow-line ground primitives from the scene. */ function removeHeatLines() { + layerState._heatPendingRoads = null; removePrimitives(takeHeatLines()); removePrimitives(layerState._heatRetired || []); layerState._heatRetired = []; parts.animation.syncRenderDemand?.(); } + /** @returns {boolean} Whether a line primitive's geometry is still building. */ + + function linesBuilding() { + return LINE_KEYS.some((key) => layerState[key] && !layerState[key].ready); + } + /** * Replaced lines stay drawn until their successors finish building * (GroundPolylinePrimitive geometry is asynchronous), so a rebuild never - * blinks. Called every held frame. + * blinks. Once they have, the replaced lines go and the newest rebuild + * asked for in the meantime starts. Called every held frame. */ function retireHeatLines() { - if (!layerState._heatRetired?.length) return; - if (LINE_KEYS.some((key) => layerState[key] && !layerState[key].ready)) - return; - removePrimitives(layerState._heatRetired); - layerState._heatRetired = []; + if (linesBuilding()) return; + if (layerState._heatRetired?.length) { + removePrimitives(layerState._heatRetired); + layerState._heatRetired = []; + } + const pending = layerState._heatPendingRoads; + if (pending) { + layerState._heatPendingRoads = null; + rebuildHeatLines(pending); + } } /** @@ -95,24 +108,37 @@ export function createRendering({ * the globe is hidden (photoreal) and on terrain or tiles otherwise. No-op in * sim mode, when both looks are off, or without ground-primitive support. * + * One build runs at a time. A city loads in progressive paints, each asking + * for a rebuild, and every build drapes up to the cap of polylines + * asynchronously. A rebuild asked for while lines are building only replaces + * the one waiting, which retireHeatLines starts when the build finishes, so + * the scene never holds more than the lines drawn and the lines building. + * Turning the lines off is never deferred. + * * @param {Array} roads - Road objects visible in the current render. */ function rebuildHeatLines(roads) { + const wanted = + layerState._viewer && layerState._liveMode && parts.style.heatlineOn(); + if (wanted && linesBuilding()) { + layerState._heatPendingRoads = roads; + return; + } + layerState._heatPendingRoads = null; + // No build is left for replaced lines to wait for (it finished, or lines + // are going off), so they go first and never pile up behind this rebuild. + removePrimitives(layerState._heatRetired || []); + layerState._heatRetired = []; const previous = takeHeatLines(); const finish = () => { - // Nothing new to wait for: the old lines go now. if (LINE_KEYS.some((key) => layerState[key])) (layerState._heatRetired ||= []).push(...previous); + // Nothing new to wait for: the old lines go now. else removePrimitives(previous); parts.animation.syncRenderDemand?.(); }; - if ( - !layerState._viewer || - !layerState._liveMode || - !parts.style.heatlineOn() - ) - return finish(); + if (!wanted) return finish(); if (layerState._heatSupported === null) { layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported( layerState._viewer.scene, diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js index 8c2fd9f94..e82cd6191 100644 --- a/src/layers/traffic/state.js +++ b/src/layers/traffic/state.js @@ -200,6 +200,10 @@ export function createState({ services, source }) { layerState._heatRetired = []; + /** @type {Array|null} Roads of the newest rebuild asked for while the lines were building. */ + + layerState._heatPendingRoads = null; + /** @type {number} Heat-lines in the current render (stats). */ layerState._heatLineCount = 0; From b996e35b0ce86dd62f16d7547a2e0c57c2f5fa64 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 9 Nov 2026 20:31:53 -0800 Subject: [PATCH 18/20] docs(changelog): traffic lines build one set at a time --- changelog.d/1645-traffic-lines-one-build.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1645-traffic-lines-one-build.md diff --git a/changelog.d/1645-traffic-lines-one-build.md b/changelog.d/1645-traffic-lines-one-build.md new file mode 100644 index 000000000..da1d00be2 --- /dev/null +++ b/changelog.d/1645-traffic-lines-one-build.md @@ -0,0 +1,3 @@ +- Street Traffic Lines build one set at a time while a city loads, instead of + one per paint, so panning with Lines on no longer piles up line builds + (Sameh Khamis, @samehkhamis, #1645). From 1a8defbfc880a53597410f61679a156cbbfe8b9e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 9 Nov 2026 20:36:03 -0800 Subject: [PATCH 19/20] feat(flights): show the tracked aircraft's Planespotters photo in Contacts Tracking a civil flight asks Planespotters for that aircraft's photo, once per aircraft, straight from the browser. The CONTEXT panel's CONTACTS view shows it under the flight's name with the photographer's credit, as a plain link to the photo's page. Only images on plnspttrs.net and pages on www.planespotters.net are ever shown or linked. Military aircraft and a converted TR-3B are never looked up. Flight lookups (type, route, photo) whose source failed temporarily are asked again after a 60-second cool-down; an answer, found or not, and a permanent refusal stay final for the session. A route belongs to the callsign it was looked up for, so a callsign change drops the old route. Browser QA scripts that track a flight answer the photo lookup themselves, so they never call the third party. --- DATA_SOURCES.md | 6 +- docs/CODE-BOUNDARIES.md | 3 + docs/CURRENT-STATE.md | 11 + package.json | 1 + scripts/package-boundaries.json | 20 +- scripts/qa-cockpit-utility.mjs | 3 + scripts/qa-context-arrivals.mjs | 2 + scripts/qa-emergency-squawk.mjs | 2 + scripts/qa-focus-evidence.mjs | 2 + scripts/qa-heading-b3.mjs | 2 + scripts/qa-photo-stub.mjs | 78 ++++++ scripts/qa-sprites-b5.mjs | 2 + scripts/track-regression.mjs | 3 + src/data/dataCredits.js | 7 + src/data/dataCredits.test.mjs | 7 + src/data/flights.js | 2 + src/data/flights.test.mjs | 260 +++++++++++++++++- src/data/militaryAwareness.test.mjs | 88 +++++- src/data/planespottersPhoto.js | 121 +++++++++ src/data/planespottersPhoto.test.mjs | 200 ++++++++++++++ src/i18n/locales/ar.json | 3 +- src/i18n/locales/de.json | 3 +- src/i18n/locales/es.json | 3 +- src/i18n/locales/fr.json | 3 +- src/i18n/locales/it.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/pt.json | 3 +- src/i18n/locales/ru.json | 3 +- src/i18n/locales/uk.json | 3 +- src/i18n/locales/zh-CN.json | 3 +- src/i18n/locales/zh-Hant.json | 3 +- src/i18n/same-as-english.json | 3 +- src/layers/awareness/panel.js | 23 ++ src/layers/awareness/panel.test.mjs | 117 ++++++++ src/layers/awareness/state.js | 2 + src/layers/awareness/subject.js | 60 ++++- src/layers/flights/enrichment.js | 118 ++++++++- src/layers/flights/lifecycle.js | 1 + src/layers/flights/ownership.test.mjs | 354 ++++++++++++++++++++++++- src/layers/flights/policy.js | 5 + src/layers/flights/queries.js | 16 +- src/layers/flights/records.js | 15 +- src/layers/flights/records.test.mjs | 24 ++ src/layers/flights/snapshotRenderer.js | 7 + src/layers/flights/state.js | 4 + src/layers/flights/testing.js | 15 +- src/layers/flights/tracking.js | 1 + src/sources/live/contract.test.mjs | 74 ++++++ src/sources/live/standalone.js | 29 ++ src/ui/awarenessPanel.test.mjs | 70 +++++ src/ui/styles/radio.css | 25 ++ src/ui/views/panels/AwarenessPanel.vue | 11 + 52 files changed, 1781 insertions(+), 46 deletions(-) create mode 100644 scripts/qa-photo-stub.mjs create mode 100644 src/data/planespottersPhoto.js create mode 100644 src/data/planespottersPhoto.test.mjs diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index ba231df31..03a37ee5a 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -30,6 +30,7 @@ How to read this: | **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Integrity layer (aircraft-reported NIC/NACp and GPS-loss integrity facts, via `/api/gnss-integrity`, 60 s server cache) | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` | | **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) | | **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow | +| **Planespotters.net** (`api.planespotters.net/pub/photos/hex/{hex}`) | One thumbnail photo of the tracked aircraft, by ICAO hex, in the CONTEXT panel's CONTACTS view under the tracked flight's name | [Planespotters photo API terms](https://www.planespotters.net/photo/api); each photo remains the copyright of its photographer. Thumbnails may be hotlinked on the condition that the photographer is named next to the photo and the photo links to its Planespotters page with a followable link | "Photo © {photographer}" beside every photo, linked to its Planespotters page; "Planespotters.net" in Data attribution | | **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) | | **CelesTrak** | Satellite TLEs (SGP4) and SATCAT records (object type, operational status, owner) that file each satellite under a class | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" | | **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) | @@ -200,7 +201,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei contributors, ODbL 1.0; GPSJAM for aggregation only. -- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it. +- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session (for the flights layer's enrichment, a lookup that failed temporarily, such as an outage, a timeout, a rate limit or an authorization refusal that may clear, may be asked again after a 60-second cool-down; an answer, found or not, and a permanent refusal such as a 404 are final; Local ADS-B lookups are asked once), with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it. +- **Planespotters photos.** When you track an aircraft, the browser asks `https://api.planespotters.net/pub/photos/hex/{hex}` directly (the API answers cross-origin, so no key and no server proxy are involved) for that aircraft only, once per hex per session, through the same enrichment queue as adsbdb, with an 8-second timeout. A miss is remembered for the session; a request that failed temporarily (an outage, the timeout or a rate limit) may be asked again after a 60-second cool-down while the aircraft stays tracked, as for the flights layer's other enrichment. Either way no photo shows meanwhile. The first photo whose image is an `https://` URL on `*.plnspttrs.net`, whose page is an `https://www.planespotters.net` URL, and which names its photographer is shown in the CONTEXT panel's CONTACTS view, under the tracked flight's name (`src/ui/views/panels/AwarenessPanel.vue`), and nowhere else; a vessel, an installation, a military aircraft or a converted TR-3B has none, and a military aircraft or TR-3B is never looked up. Anything else is dropped, so the app never shows or links outside Planespotters (`src/data/planespottersPhoto.js`, also exported as `gods-eye-view/sources/planespotters` for servers that cache the lookup). The photo follows the attribution rules: the thumbnail is hotlinked from the URL the API gives (never copied or re-hosted), the photographer's name is visible text next to it ("Photo © {photographer}"), and the photo and credit are a plain link to the photo's Planespotters page with `target="_blank" rel="noopener"` and never `rel="nofollow"`. If the image fails to load, the photo and its credit are hidden. - **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state. - **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover. - **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does. @@ -538,4 +540,4 @@ NOAA density imagery with the raw GLM product. ## In-app attribution -The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app. +The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, Planespotters.net, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index e5b337a51..591db02dc 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -136,6 +136,9 @@ socket/watchdog on server close and re-reads configuration after restart. The portable `gods-eye-view/sources/adsb-lol` export normalizes existing aircraft records without importing Node middleware or a renderer. Browser layer/controller separation is outside this server extraction. +The portable `gods-eye-view/sources/planespotters` export reads a Planespotters +photo answer (upstream or a server's cached shape) into one host-checked, +credited thumbnail; it has no fetch, DOM or Node dependency. ## Place-search and routing providers diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 91b7a56a4..5a92f2663 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2812,6 +2812,17 @@ in `ais-store.js`. Common response caps, request coalescing and query parsing have their own modules. `server/providers/local.js` composes these with the remaining providers and retains existing named compatibility exports. +The tracked flight also gets a photo. The browser asks Planespotters for it +directly (`src/sources/live/standalone.js`, read by +`src/data/planespottersPhoto.js`), once per aircraft, and the CONTEXT panel's +CONTACTS view shows it under the flight's name with the photographer's credit +and a link to the photo's page. Military aircraft and a converted TR-3B are +never looked up. A flight lookup (type, route or photo) whose source failed +temporarily is asked again after a 60-second cool-down while the aircraft +stays in view or tracked; an answer, found or not, and a permanent refusal +stay final for the session. A route belongs to the callsign it was looked up +for, so a callsign change drops the old route. See `DATA_SOURCES.md`. + `gods-eye-view/server/providers/live` is a Node-only entry for the existing plugins and shared request helpers. Importing it starts no sockets or timers. The existing aircraft normalizer is separately available through the portable diff --git a/package.json b/package.json index 76808c084..84b34d528 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "node": "./server/providers/live.js" }, "./sources/adsb-lol": "./src/data/adsbLolFallback.js", + "./sources/planespotters": "./src/data/planespottersPhoto.js", "./server/providers/places": { "node": "./server/providers/places.js" }, diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 703aca438..85ca44bdd 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -242,6 +242,11 @@ "modules": ["src/data/adsbLolFallback.js", "src/sources/adsbFields.js"], "external": [] }, + "planespotters-source": { + "exports": ["./sources/planespotters"], + "modules": ["src/data/planespottersPhoto.js"], + "external": [] + }, "place-providers": { "runtime": "node", "exports": ["./server/providers/places"], @@ -1395,7 +1400,8 @@ "src/sources/live/contract.js", "src/sources/live/index.js", "src/sources/live/standalone.js", - "src/sources/live/vessels.js" + "src/sources/live/vessels.js", + "src/data/planespottersPhoto.js" ], "external": [] }, @@ -1462,7 +1468,8 @@ "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/time/worldClock.js", - "src/time/julian.js" + "src/time/julian.js", + "src/data/planespottersPhoto.js" ], "external": ["cesium"] }, @@ -3438,7 +3445,8 @@ "src/layers/satellites/classes.js", "src/layers/satellites/sensors.js", "src/data/ucsSatellites.js", - "src/data/imagingSensors.js" + "src/data/imagingSensors.js", + "src/data/planespottersPhoto.js" ], "external": [ "hls.js", @@ -4098,7 +4106,8 @@ "src/layers/satellites/classes.js", "src/layers/satellites/sensors.js", "src/data/ucsSatellites.js", - "src/data/imagingSensors.js" + "src/data/imagingSensors.js", + "src/data/planespottersPhoto.js" ], "external": [ "hls.js", @@ -5244,7 +5253,8 @@ "src/layers/satellites/classes.js", "src/layers/satellites/sensors.js", "src/data/ucsSatellites.js", - "src/data/imagingSensors.js" + "src/data/imagingSensors.js", + "src/data/planespottersPhoto.js" ], "external": ["@mapbox/vector-tile", "pbf"] }, diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index c5a94018f..a342a5cdf 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -4,6 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import puppeteer from 'puppeteer'; +import { stubPlanespotters } from './qa-photo-stub.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const shotsDir = path.join(repoRoot, 'qa-shots', 'cockpit-utility'); @@ -29,6 +30,7 @@ const browser = await puppeteer.launch({ ], }); const page = await browser.newPage(); +await stubPlanespotters(page); const failures = []; const consoleErrors = []; const localHttpErrors = []; @@ -825,6 +827,7 @@ try { // a legitimate retained snapshot cannot satisfy or invalidate a FIRST-fetch // assertion when camera motion supersedes the manager's initial update. const readinessPage = await browser.newPage(); + await stubPlanespotters(readinessPage); let deferredInstallationReadiness; try { await readinessPage.setViewport({ width: 1440, height: 900 }); diff --git a/scripts/qa-context-arrivals.mjs b/scripts/qa-context-arrivals.mjs index 72a6ffab8..704d34a6b 100644 --- a/scripts/qa-context-arrivals.mjs +++ b/scripts/qa-context-arrivals.mjs @@ -28,6 +28,7 @@ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; +import { stubPlanespotters } from './qa-photo-stub.mjs'; import { createScreencast, moveCamera } from './qa-journey-recorder.mjs'; const argv = process.argv.slice(2); @@ -77,6 +78,7 @@ const browser = await puppeteer.launch({ async function openPage({ aircraft = false } = {}) { const page = await browser.newPage(); + await stubPlanespotters(page); await page.setViewport({ width: 1440, height: 900 }); page.on('console', (message) => { if (message.type() === 'error') result.consoleErrors.push(message.text()); diff --git a/scripts/qa-emergency-squawk.mjs b/scripts/qa-emergency-squawk.mjs index f877ea659..aeedd6125 100644 --- a/scripts/qa-emergency-squawk.mjs +++ b/scripts/qa-emergency-squawk.mjs @@ -27,6 +27,7 @@ */ import puppeteer from 'puppeteer'; +import { stubPlanespotters } from './qa-photo-stub.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -223,6 +224,7 @@ async function main() { const errors = []; try { const page = await browser.newPage(); + await stubPlanespotters(page); await page.setViewport({ width: 1440, height: 900 }); page.on('pageerror', (e) => errors.push(e.message)); const { lon, lat } = CENTER; diff --git a/scripts/qa-focus-evidence.mjs b/scripts/qa-focus-evidence.mjs index 1baaf6083..cae1c5c72 100644 --- a/scripts/qa-focus-evidence.mjs +++ b/scripts/qa-focus-evidence.mjs @@ -21,6 +21,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import puppeteer from 'puppeteer'; +import { stubPlanespotters } from './qa-photo-stub.mjs'; const argv = process.argv.slice(2); const getOpt = (name, fallback = null) => { @@ -651,6 +652,7 @@ async function main() { ], }); const page = await browser.newPage(); + await stubPlanespotters(page); await page.setViewport(VIEWPORT); const consoleMessages = []; page.on('console', (message) => { diff --git a/scripts/qa-heading-b3.mjs b/scripts/qa-heading-b3.mjs index cbba1b88e..fb33654b1 100644 --- a/scripts/qa-heading-b3.mjs +++ b/scripts/qa-heading-b3.mjs @@ -99,6 +99,7 @@ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; +import { stubPlanespotters } from './qa-photo-stub.mjs'; // --------------------------------------------------------------------------- // Args (same shape as track-regression.mjs) @@ -466,6 +467,7 @@ async function main() { const failedResponses = []; try { const page = await browser.newPage(); + await stubPlanespotters(page); await page.setViewport({ width: 1280, height: 800 }); page.on('console', (msg) => { if (msg.type() === 'error') { diff --git a/scripts/qa-photo-stub.mjs b/scripts/qa-photo-stub.mjs new file mode 100644 index 000000000..43dd2becb --- /dev/null +++ b/scripts/qa-photo-stub.mjs @@ -0,0 +1,78 @@ +/** + * Keep browser QA off third-party aircraft photo services. + * + * Tracking a civil flight asks Planespotters for the aircraft's photo. QA + * runs track synthetic hexes, so a real request only adds a third-party + * dependency and, when the service refuses a headless browser, a console + * error that fails the "no console errors" checks. Every photo lookup is + * answered with no photos instead, without leaving the browser. + */ + +/** Origin of the aircraft photo lookup. */ +export const PLANESPOTTERS_ORIGIN = 'https://api.planespotters.net'; + +/** The answer every stubbed lookup gets: no photos for this aircraft. */ +export const PLANESPOTTERS_EMPTY_BODY = JSON.stringify({ photos: [] }); + +/** Whether a URL is a photo lookup the stub answers. */ +export function isPlanespottersRequest(url) { + try { + return new URL(String(url)).origin === PLANESPOTTERS_ORIGIN; + } catch { + return false; + } +} + +/** + * Answer an intercepted photo lookup from a page that already uses request + * interception. Returns false for any other request, which the caller + * handles as before. + * @param {import('puppeteer').HTTPRequest} request + * @returns {boolean} Whether the request was answered. + */ +export function respondToPlanespotters(request) { + if (!isPlanespottersRequest(request.url())) return false; + request.respond({ + status: 200, + contentType: 'application/json', + headers: { 'Access-Control-Allow-Origin': '*' }, + body: PLANESPOTTERS_EMPTY_BODY, + }); + return true; +} + +/** + * Stub photo lookups in a page that does not intercept requests: wraps + * `fetch` before any app code runs, so a lookup never reaches the network. + * Composes with other `fetch` shims in either order. + * @param {import('puppeteer').Page} page + * @returns {Promise} + */ +export async function stubPlanespotters(page) { + await page.evaluateOnNewDocument( + (origin, body) => { + const original = window.fetch.bind(window); + window.fetch = (input, init) => { + const href = + typeof input === 'string' ? input : input?.url || String(input); + let target = ''; + try { + target = new URL(href, location.href).origin; + } catch { + /* not a URL */ + } + if (target === origin) { + return Promise.resolve( + new Response(body, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + return original(input, init); + }; + }, + PLANESPOTTERS_ORIGIN, + PLANESPOTTERS_EMPTY_BODY, + ); +} diff --git a/scripts/qa-sprites-b5.mjs b/scripts/qa-sprites-b5.mjs index a70eb1b2d..7e5b824c0 100644 --- a/scripts/qa-sprites-b5.mjs +++ b/scripts/qa-sprites-b5.mjs @@ -32,6 +32,7 @@ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; +import { stubPlanespotters } from './qa-photo-stub.mjs'; import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js'; // Hangar fleet (2026-08-16): real per-class GLBs render at scale 1; military // heavies (airliner/quadjet/glider) render airplane.glb at 1 x class; only @@ -186,6 +187,7 @@ async function main() { const httpErrors = []; try { const page = await browser.newPage(); + await stubPlanespotters(page); await page.setViewport({ width: 1280, height: 800 }); page.on('console', (msg) => { if (msg.type() === 'error') { diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index 16c345db5..d2e04609b 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -94,6 +94,7 @@ import fs from 'node:fs'; import puppeteer from 'puppeteer'; +import { respondToPlanespotters } from './qa-photo-stub.mjs'; import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs'; import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js'; import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js'; @@ -287,6 +288,8 @@ async function main() { }); return; } + // Aircraft photo lookups never reach the third party: no photos. + if (respondToPlanespotters(request)) return; if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') { request.respond({ status: 200, diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 6b972fc28..7413f1bab 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -134,6 +134,13 @@ export const DATA_CREDITS = [ 'copied, published, or incorporated into other databases without the ' + 'explicit permission of David J Taylor, Edinburgh', }, + { + key: 'planespotters', + html: + 'Aircraft photos: ' + + 'Planespotters.net ' + + '· each photo is © its photographer, credited beside it', + }, { key: 'aisstream', html: diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs index ef20250fc..e0c85623a 100644 --- a/src/data/dataCredits.test.mjs +++ b/src/data/dataCredits.test.mjs @@ -43,6 +43,13 @@ test('adsbdb is credited and carries its published route-data restriction', () = assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/); }); +test('Planespotters is credited with a plain followable link', () => { + const credit = DATA_CREDITS.find((entry) => entry.key === 'planespotters'); + assert.ok(credit, 'the tracked aircraft photos come from Planespotters'); + assert.match(credit.html, /href="https:\/\/www\.planespotters\.net"/); + assert.doesNotMatch(credit.html, /nofollow/); +}); + test('the UCS Satellite Database is acknowledged with its version and webpage', () => { // UCS database manual, section 3: references name the version (the Excel // file's name) and link www.ucsusa.org/satellite_database. diff --git a/src/data/flights.js b/src/data/flights.js index 6e2d35dbc..815ae41ed 100644 --- a/src/data/flights.js +++ b/src/data/flights.js @@ -40,5 +40,7 @@ export const _driveFleetModelHandoffForTest = flightsLayer.testing._driveFleetModelHandoffForTest; export const _ensureFleetModelForTest = flightsLayer.testing._ensureFleetModelForTest; +export const _resetEnrichmentForTest = + flightsLayer.testing._resetEnrichmentForTest; export const mapAnalystRecord = flightsLayer.mapAnalystRecord; export default flightsLayer; diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index 1d6007e8f..5005723d9 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -1,7 +1,7 @@ // src/data/flights.test.mjs // Focused tests for the pure analyst-record mapper (analyst query engine seam). // Pure function — no viewer/DOM needed; imported directly. -import { test } from 'node:test'; +import { after, afterEach, before, test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import flightsLayer, { @@ -14,9 +14,14 @@ import flightsLayer, { _setTrackedFlightRefreshStateForTest, _floorGroundedDisplayPositionForTest, _clearDisplayFloorStateForTest, + _resetEnrichmentForTest, mapAnalystRecord, } from './flights.js'; -import { setMilitaryLayerActive } from './militaryRegistry.js'; +import { + isMilitaryLayerActive, + registerMilitaryIcaos, + setMilitaryLayerActive, +} from './militaryRegistry.js'; import { GROUND_FLOOR_LIFT_M, reportMeshFloorCell, @@ -31,6 +36,31 @@ import { initTrackedReadout, } from './trackedReadout.js'; +// Unit tests never reach the network. A test that tracks a plane queues its +// enrichment (type, route, photo), so every test ends on an empty queue, and a +// fetch of any absolute URL fails the test it happened in. A test that needs +// answers installs its own fetch and restores this one. +const nodeFetch = globalThis.fetch; +const networkRequests = []; +before(() => { + globalThis.fetch = async (url) => { + const href = String(url?.url ?? url); + if (/^https?:\/\//i.test(href)) networkRequests.push(href); + throw new TypeError(`Failed to parse URL from ${href}`); + }; +}); +afterEach(() => { + _resetEnrichmentForTest(); + assert.deepEqual( + networkRequests.splice(0), + [], + 'no test reaches the network', + ); +}); +after(() => { + globalThis.fetch = nodeFetch; +}); + test('share-Follow absence requires an accepted OpenSky snapshot', async () => { _setFlightTrackingRefreshOutcomeForTest({ status: 'source-unavailable' }); assert.equal( @@ -404,6 +434,232 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA } }); +test('a tracked plane whose photo lookup failed asks again on the first poll after the cool-down', async (t) => { + const icao24 = 'b2c3d4'; + const realNow = Date.now.bind(Date); + let offsetMs = 0; + t.mock.method(Date, 'now', () => realNow() + offsetMs); + const entity = { gevLabelModel: { title: 'OLD', details: [] } }; + const viewer = { camera: { positionCartographic: null }, scene: {} }; + _setTrackedFlightRefreshStateForTest({ + icao24, + entity, + billboard: { + position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 9_000), + color: Cesium.Color.WHITE, + show: false, + }, + billboardCollection: { show: false, remove() {} }, + viewer, + meta: { + callsign: 'UAL9', + altitude: 9_000, + renderAltitudeM: 9_050, + velocity: 180, + true_track: 80, + klass: 'airliner', + onGround: false, + wasAirborne: true, + turnRateDps: 0, + rawLat: 30.2, + rawLon: -97.7, + }, + }); + let callsign = 'UAL9 '; + const photoRequests = []; + const routeRequests = []; + const guardFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + url = String(url); + if (url.startsWith('https://api.planespotters.net/')) { + photoRequests.push(url); + return { ok: false, status: 503, json: async () => ({}) }; + } + if (url.startsWith('/api/adsbdb/route/UAL')) routeRequests.push(url); + if (!url.startsWith('/api/flights')) + return { ok: true, status: 200, json: async () => ({ found: false }) }; + const nowSec = Math.floor(Date.now() / 1000); + return { + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({ + time: nowSec, + states: [ + [ + icao24, + callsign, + 'United States', + nowSec, + nowSec, + -97.6, + 30.3, + 10_668, + false, + 250, + 95, + 5, + null, + 10_700, + null, + null, + null, + 5, + ], + ], + }), + }; + }; + // Lookups drip out one per 200 ms. Wait for what must arrive (with a + // generous deadline, so a loaded machine cannot fail it), then a little + // longer so anything that must NOT arrive would have. + const until = async (ready) => { + const deadline = realNow() + 5_000; + while (!ready() && realNow() < deadline) + await new Promise((done) => setTimeout(done, 20)); + await new Promise((done) => setTimeout(done, 450)); + }; + try { + await flightsLayer.update(viewer); + await until(() => photoRequests.length >= 1 && routeRequests.length >= 1); + assert.equal(photoRequests.length, 1, 'the first poll asks for the photo'); + assert.deepEqual(routeRequests, ['/api/adsbdb/route/UAL9']); + + // Well inside the 60 s cool-down, and well past it below, so real time + // spent between polls cannot move either side across the line. + offsetMs += 30_000; + callsign = 'UAL10 '; + await flightsLayer.update(viewer); + await until(() => routeRequests.length >= 2); + assert.equal(photoRequests.length, 1, 'not within the cool-down'); + assert.deepEqual( + routeRequests, + ['/api/adsbdb/route/UAL9', '/api/adsbdb/route/UAL10'], + 'the route follows the callsign the poll just recorded', + ); + + offsetMs += 60_000; + await flightsLayer.update(viewer); + await until(() => photoRequests.length >= 2); + assert.deepEqual( + photoRequests, + [ + 'https://api.planespotters.net/pub/photos/hex/b2c3d4', + 'https://api.planespotters.net/pub/photos/hex/b2c3d4', + ], + 'asked again on the first poll after the cool-down', + ); + } finally { + globalThis.fetch = guardFetch; + // The moved clock dated this layer's dispatch and cool-downs into the + // future; reset before the real clock comes back. + _resetEnrichmentForTest(); + t.mock.restoreAll(); + } +}); + +test('a known-military aircraft tracked in the flights layer is never looked up and shows no photo', async () => { + const icao24 = 'ae5e01'; + // The registry has no way to forget one hex (only a whole-registry reset, + // which would also drop its source), so the test uses a hex no other test + // reads and restores the layer-active flag it changes. + const militaryWasActive = isMilitaryLayerActive(); + registerMilitaryIcaos([icao24]); + setMilitaryLayerActive(false); + const viewer = { camera: { positionCartographic: null }, scene: {} }; + _setTrackedFlightRefreshStateForTest({ + icao24, + entity: { gevLabelModel: { title: 'OLD', details: [] } }, + billboard: { + position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 9_000), + color: Cesium.Color.WHITE, + show: false, + }, + billboardCollection: { show: false, remove() {} }, + viewer, + meta: { + callsign: 'RCH123', + altitude: 9_000, + renderAltitudeM: 9_050, + velocity: 180, + true_track: 80, + klass: 'airliner', + onGround: false, + wasAirborne: true, + turnRateDps: 0, + rawLat: 30.2, + rawLon: -97.7, + // Even a photo already on the record is never shown. + photo: { + src: 'https://t.plnspttrs.net/1/ae5e01_280.jpg', + width: 280, + height: 186, + photographer: 'Jane Doe', + link: 'https://www.planespotters.net/photo/1/x', + }, + }, + }); + const photoRequests = []; + const routeRequests = []; + const guardFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + url = String(url); + if (url.startsWith('https://api.planespotters.net/')) { + photoRequests.push(url); + return { ok: false, status: 503, json: async () => ({}) }; + } + if (url.startsWith('/api/adsbdb/route/')) routeRequests.push(url); + if (!url.startsWith('/api/flights')) + return { ok: true, status: 200, json: async () => ({ found: false }) }; + const nowSec = Math.floor(Date.now() / 1000); + return { + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({ + time: nowSec, + states: [ + [ + icao24, + 'RCH123 ', + 'United States', + nowSec, + nowSec, + -97.6, + 30.3, + 10_668, + false, + 250, + 95, + 5, + null, + 10_700, + null, + null, + null, + 5, + ], + ], + }), + }; + }; + try { + await flightsLayer.update(viewer); + // A photo lookup would go out ahead of the route; wait for the route. + const deadline = Date.now() + 5_000; + while (!routeRequests.length && Date.now() < deadline) + await new Promise((done) => setTimeout(done, 20)); + assert.deepEqual(routeRequests, ['/api/adsbdb/route/RCH123']); + assert.deepEqual(photoRequests, [], 'no photo lookup for a military hex'); + assert.equal(flightsLayer.getTrackedInfo()?.icao24, icao24); + assert.equal(flightsLayer.getTrackedInfo()?.photo, null); + assert.equal(flightsLayer.getTrackedSubject()?.photo, null); + } finally { + globalThis.fetch = guardFetch; + setMilitaryLayerActive(militaryWasActive); + } +}); + test('real civil track path creates no native label and publishes every cached host line', async () => { const icao24 = 'civ001'; const position = Cesium.Cartesian3.fromDegrees(-97.6699, 30.1945, 10_668); diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 98743b03c..430ca3362 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -425,7 +425,7 @@ test('a dependency the user adopts stays on when Contacts turns off', async () = assert.deepEqual( released, AWARENESS_DEPENDENCIES.filter((layerId) => layerId !== 'flights').sort(), - 'the adopted layer is the user\'s now; the rest are still released', + "the adopted layer is the user's now; the rest are still released", ); } finally { runtime.restore(); @@ -1118,6 +1118,92 @@ test('production military tracked-subject label falls back to registration befor } }); +test('the tracked airliner’s photo reaches the Contacts panel when it lands after selection', async () => { + const id = 'a1f2c3'; + const position = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668); + const sourceViewer = { camera: { positionCartographic: null }, scene: {} }; + const runtime = installAwarenessRuntime(); + const restores = []; + const meta = { + callsign: 'AAL100', + altitude: 10_668, + renderAltitudeM: 10_700, + velocity: 250, + true_track: 95, + klass: 'airliner', + onGround: false, + wasAirborne: true, + turnRateDps: 0, + rawLat: 30.21, + rawLon: -97.71, + photo: null, + }; + + try { + _setTrackedFlightRefreshStateForTest({ + icao24: id, + entity: null, + billboard: { position, color: Cesium.Color.WHITE, show: true }, + billboardCollection: { show: true, remove() {} }, + viewer: sourceViewer, + meta, + }); + runtime.viewer.trackedEntity = { gevTrackedId: `flights:${id}` }; + replaceMethod( + militaryFlightsLayer, + 'getTrackedSubject', + () => null, + restores, + ); + replaceMethod(flightsLayer, 'trackById', () => true, restores); + replaceMethod(militaryFlightsLayer, 'trackById', () => true, restores); + + militaryAwarenessLayer.setParams({ passive: false }); + await nextTurn(); + assert.equal(runtime.panel().view?.kind, 'results'); + assert.equal(runtime.panel().view.photo, null, 'no photo before it lands'); + + // An unchanged refresh stays quiet. + const settledPublishes = runtime.panel().publishes; + await militaryAwarenessLayer.update(); + assert.equal(runtime.panel().publishes, settledPublishes); + + // The photo lands while the plane holds still: render-worthy on its own. + meta.photo = { + src: 'https://t.plnspttrs.net/1/a1f2c3_280.jpg', + width: 280, + height: 186, + photographer: 'Jane Doe', + link: 'https://www.planespotters.net/photo/1/n100aa', + }; + await militaryAwarenessLayer.update(); + assert.deepEqual(runtime.panel().view.photo, { + src: 'https://t.plnspttrs.net/1/a1f2c3_280.jpg', + width: 280, + height: 186, + link: 'https://www.planespotters.net/photo/1/n100aa', + credit: 'Photo © Jane Doe', + }); + + // And it repaints once, not on every refresh. + const shownPublishes = runtime.panel().publishes; + await militaryAwarenessLayer.update(); + assert.equal(runtime.panel().publishes, shownPublishes); + } finally { + restores.reverse().forEach((restore) => restore()); + _setTrackedFlightRefreshStateForTest({ + icao24: id, + entity: null, + billboard: { position, show: true }, + billboardCollection: { show: false, remove() {} }, + viewer: sourceViewer, + meta: {}, + tracked: false, + }); + runtime.restore(); + } +}); + test('a cached Context subject re-reads its label when enrichment lands after selection', async () => { // The subject snapshot is captured once at selection. Selecting a // callsign-less aircraft BEFORE adsbdb enrichment answers used to freeze the diff --git a/src/data/planespottersPhoto.js b/src/data/planespottersPhoto.js new file mode 100644 index 000000000..8346df49f --- /dev/null +++ b/src/data/planespottersPhoto.js @@ -0,0 +1,121 @@ +/** + * @module planespottersPhoto + * @description Reads a Planespotters photo answer into the one credited + * thumbnail every surface shows. Planespotters lets the thumbnail be hotlinked + * on the condition that the photographer is credited next to it and the image + * links back to the photo's page, so a photo without a photographer, or whose + * image or page is not on Planespotters' own hosts, is "no photo": nothing + * arbitrary is ever shown or linked. + * + * Accepts the upstream answer (`GET https://api.planespotters.net/pub/photos/hex/{hex}`, + * `{photos: [...]}`), the cached server shape + * (`{found, thumbnail_large?, thumbnail: {src, width, height}, photographer, + * link}`), and an + * already-read photo (`{found, src, width, height, photographer, link}`). + * Pure: no fetch, no DOM. + */ + +/** The image host: Planespotters' thumbnail CDN and its subdomains. */ +const IMAGE_HOST = 'plnspttrs.net'; +/** The only page host a photo may link to. */ +const PAGE_HOST = 'www.planespotters.net'; + +const NO_PHOTO = Object.freeze({ found: false }); + +const isObject = (value) => Boolean(value) && typeof value === 'object'; + +/** The parsed `https:` URL, or null for anything else or with userinfo/port. */ +function httpsUrl(value) { + if (typeof value !== 'string' || !value) return null; + let url; + try { + url = new URL(value); + } catch { + return null; + } + if (url.protocol !== 'https:' || url.username || url.password || url.port) + return null; + return url; +} + +function imageSrc(value) { + const url = httpsUrl(value); + return url && + (url.hostname === IMAGE_HOST || url.hostname.endsWith(`.${IMAGE_HOST}`)) + ? url.href + : null; +} + +function pageLink(value) { + const url = httpsUrl(value); + return url && url.hostname === PAGE_HOST ? url.href : null; +} + +function dimension(value) { + const number = Number(value); + return value != null && Number.isFinite(number) && number > 0 + ? Math.round(number) + : null; +} + +/** One candidate `{src, width, height, photographer, link}`, checked. */ +function checked({ src, width, height, photographer, link }) { + const credit = typeof photographer === 'string' ? photographer.trim() : ''; + const image = imageSrc(src); + const page = pageLink(link); + if (!credit || !image || !page) return null; + return { + found: true, + src: image, + width: dimension(width), + height: dimension(height), + photographer: credit, + link: page, + }; +} + +/** The upstream photo's candidates, large thumbnail first. */ +function upstreamCandidates(photo) { + if (!photo || typeof photo !== 'object') return []; + return [photo.thumbnail_large, photo.thumbnail] + .filter((thumb) => thumb && typeof thumb === 'object') + .map((thumb) => ({ + src: thumb.src, + width: thumb.size?.width, + height: thumb.size?.height, + photographer: photo.photographer, + link: photo.link, + })); +} + +/** + * Read a Planespotters answer into the first photo that passes the checks. + * @param {*} payload Upstream, cached-server, or already-read photo. + * @returns {{found: true, src: string, width: ?number, height: ?number, + * photographer: string, link: string} | {found: false}} + */ +export function parsePlanespottersPhoto(payload) { + if (!payload || typeof payload !== 'object') return NO_PHOTO; + let candidates; + if (Array.isArray(payload.photos)) { + candidates = payload.photos.flatMap(upstreamCandidates); + } else if (payload.found !== true) { + return NO_PHOTO; + } else if (isObject(payload.thumbnail) || isObject(payload.thumbnail_large)) { + // Large first, as for the upstream answer; each must pass the checks. + candidates = [payload.thumbnail_large, payload.thumbnail] + .filter(isObject) + .map((thumb) => ({ + ...thumb, + photographer: payload.photographer, + link: payload.link, + })); + } else { + candidates = [payload]; + } + for (const candidate of candidates) { + const photo = checked(candidate); + if (photo) return photo; + } + return NO_PHOTO; +} diff --git a/src/data/planespottersPhoto.test.mjs b/src/data/planespottersPhoto.test.mjs new file mode 100644 index 000000000..e051367f2 --- /dev/null +++ b/src/data/planespottersPhoto.test.mjs @@ -0,0 +1,200 @@ +// src/data/planespottersPhoto.test.mjs — the Planespotters photo answer is read +// into one credited thumbnail, and nothing outside Planespotters' own hosts is +// ever shown or linked. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parsePlanespottersPhoto } from './planespottersPhoto.js'; + +const UPSTREAM = Object.freeze({ + photos: [ + { + id: '1745410', + thumbnail: { + src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg', + size: { width: 200, height: 133 }, + }, + thumbnail_large: { + src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg', + size: { width: 420, height: 280 }, + }, + link: 'https://www.planespotters.net/photo/1745410/n160an-american-airlines-airbus-a321-231-wl?utm_source=api', + photographer: 'Jan Seler', + }, + ], +}); + +const PHOTO = Object.freeze({ + found: true, + src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg', + width: 420, + height: 280, + photographer: 'Jan Seler', + link: 'https://www.planespotters.net/photo/1745410/n160an-american-airlines-airbus-a321-231-wl?utm_source=api', +}); + +const photo = (overrides) => ({ + photos: [{ ...UPSTREAM.photos[0], ...overrides }], +}); + +test('the upstream answer maps to the large thumbnail, its size, credit and link', () => { + assert.deepEqual(parsePlanespottersPhoto(UPSTREAM), PHOTO); +}); + +test('the small thumbnail stands in when the large one is missing', () => { + assert.deepEqual(parsePlanespottersPhoto(photo({ thumbnail_large: null })), { + ...PHOTO, + src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg', + width: 200, + height: 133, + }); +}); + +test('a server’s cached shape reads the same way', () => { + assert.deepEqual( + parsePlanespottersPhoto({ + found: true, + thumbnail: { src: PHOTO.src, width: 420, height: 280 }, + photographer: 'Jan Seler', + link: PHOTO.link, + }), + PHOTO, + ); + assert.deepEqual(parsePlanespottersPhoto({ found: false }), { + found: false, + }); +}); + +test('a server’s cached shape prefers its large thumbnail', () => { + assert.deepEqual( + parsePlanespottersPhoto({ + found: true, + thumbnail: { + src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg', + width: 200, + height: 133, + }, + thumbnail_large: { src: PHOTO.src, width: 420, height: 280 }, + photographer: 'Jan Seler', + link: PHOTO.link, + }), + PHOTO, + ); +}); + +test('a server’s cached shape falls back to its small thumbnail when the large one fails the checks', () => { + const small = { + src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg', + width: 200, + height: 133, + }; + for (const thumbnail_large of [ + { src: 'https://evil.example/a.jpg', width: 420, height: 280 }, + null, + ]) { + assert.deepEqual( + parsePlanespottersPhoto({ + found: true, + thumbnail: small, + thumbnail_large, + photographer: 'Jan Seler', + link: PHOTO.link, + }), + { ...PHOTO, ...small }, + JSON.stringify(thumbnail_large), + ); + } +}); + +test('no photo, junk, and missing fields are "no photo"', () => { + for (const payload of [ + { photos: [] }, + {}, + null, + undefined, + 'nope', + { photos: 'nope' }, + { photos: [null] }, + photo({ photographer: '' }), + photo({ photographer: ' ' }), + photo({ photographer: 42 }), + photo({ link: '' }), + photo({ thumbnail: null, thumbnail_large: null }), + ]) { + assert.deepEqual( + parsePlanespottersPhoto(payload), + { found: false }, + JSON.stringify(payload), + ); + } +}); + +test('a src outside https *.plnspttrs.net is dropped', () => { + for (const src of [ + 'http://t.plnspttrs.net/a.jpg', + 'https://plnspttrs.net.evil.example/a.jpg', + 'https://evil.example/t.plnspttrs.net/a.jpg', + 'https://t.plnspttrs.net.evil.example/a.jpg', + 'https://user@t.plnspttrs.net/a.jpg', + 'https://t.plnspttrs.net:8443/a.jpg', + 'javascript:alert(1)', + '//t.plnspttrs.net/a.jpg', + 'not a url', + ]) { + assert.deepEqual( + parsePlanespottersPhoto( + photo({ + thumbnail_large: { src, size: { width: 420, height: 280 } }, + thumbnail: { src, size: { width: 200, height: 133 } }, + }), + ), + { found: false }, + src, + ); + } +}); + +test('a link anywhere but https://www.planespotters.net is dropped', () => { + for (const link of [ + 'http://www.planespotters.net/photo/1', + 'https://planespotters.net/photo/1', + 'https://www.planespotters.net.evil.example/photo/1', + 'https://evil.example/?u=https://www.planespotters.net/', + 'https://user:pw@www.planespotters.net/photo/1', + 'https://www.planespotters.net:444/photo/1', + 'javascript:alert(1)', + ]) { + assert.deepEqual( + parsePlanespottersPhoto(photo({ link })), + { found: false }, + link, + ); + } +}); + +test('the first photo that passes the checks is the one shown', () => { + const second = { + ...UPSTREAM.photos[0], + photographer: 'Second Spotter', + }; + assert.deepEqual( + parsePlanespottersPhoto({ + photos: [ + { ...UPSTREAM.photos[0], link: 'https://evil.example/' }, + second, + ], + }), + { ...PHOTO, photographer: 'Second Spotter' }, + ); +}); + +test('a missing size is null and the credit is trimmed', () => { + assert.deepEqual( + parsePlanespottersPhoto( + photo({ + photographer: ' Jan Seler ', + thumbnail_large: { src: PHOTO.src }, + }), + ), + { ...PHOTO, width: null, height: null }, + ); +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 83e163cde..59a18415f 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3619,5 +3619,6 @@ "1 wk/s": "أسبوع/ث", "Play speed": "سرعة التشغيل", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل", - "1 min/s": "دقيقة/ث" + "1 min/s": "دقيقة/ث", + "Photo © {photographer}": "صورة © {photographer}" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index c21a9f936..02d6fdb73 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3311,5 +3311,6 @@ "1 wk/s": "1 Wo./s", "Play speed": "Wiedergabegeschwindigkeit", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles", - "1 min/s": "1 Min./s" + "1 min/s": "1 Min./s", + "Photo © {photographer}": "Foto © {photographer}" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index c060630e2..a007bec4e 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3388,5 +3388,6 @@ "1 wk/s": "1 sem/s", "Play speed": "Velocidad de reproducción", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo", - "1 min/s": "1 min/s" + "1 min/s": "1 min/s", + "Photo © {photographer}": "Foto © {photographer}" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index e0731d11a..f394eaf98 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3388,5 +3388,6 @@ "1 wk/s": "1 sem/s", "Play speed": "Vitesse de lecture", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir", - "1 min/s": "1 min/s" + "1 min/s": "1 min/s", + "Photo © {photographer}": "Photo © {photographer}" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 3ff2a8313..8122e2c47 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3388,5 +3388,6 @@ "1 wk/s": "1 sett/s", "Play speed": "Velocità di riproduzione", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scorri o pizzica per lo zoom, trascina per spostarti · doppio clic per vedere tutto", - "1 min/s": "1 min/s" + "1 min/s": "1 min/s", + "Photo © {photographer}": "Foto © {photographer}" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index a4bd4522b..9c716ec48 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3234,5 +3234,6 @@ "1 wk/s": "1週間/秒", "Play speed": "再生速度", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示", - "1 min/s": "1分/秒" + "1 min/s": "1分/秒", + "Photo © {photographer}": "写真 © {photographer}" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index e1578d66d..1afd6d382 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3388,5 +3388,6 @@ "1 wk/s": "1 sem/s", "Play speed": "Velocidade de reprodução", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo", - "1 min/s": "1 min/s" + "1 min/s": "1 min/s", + "Photo © {photographer}": "Foto © {photographer}" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 43639b06f..d6af529c0 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3465,5 +3465,6 @@ "1 wk/s": "1 нед/с", "Play speed": "Скорость воспроизведения", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё", - "1 min/s": "1 мин/с" + "1 min/s": "1 мин/с", + "Photo © {photographer}": "Фото © {photographer}" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index a99148853..6826eb2fa 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3465,5 +3465,6 @@ "1 wk/s": "1 тиж/с", "Play speed": "Швидкість відтворення", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все", - "1 min/s": "1 хв/с" + "1 min/s": "1 хв/с", + "Photo © {photographer}": "Фото © {photographer}" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e7d9ca384..d0af7ee5e 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3234,5 +3234,6 @@ "1 wk/s": "1 周/秒", "Play speed": "播放速度", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部", - "1 min/s": "1 分钟/秒" + "1 min/s": "1 分钟/秒", + "Photo © {photographer}": "照片 © {photographer}" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 1042a0d35..c0e22141b 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3234,5 +3234,6 @@ "1 wk/s": "1 週/秒", "Play speed": "播放速度", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部", - "1 min/s": "1 分鐘/秒" + "1 min/s": "1 分鐘/秒", + "Photo © {photographer}": "照片 © {photographer}" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 8c2a2bd75..458354169 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -586,7 +586,8 @@ "Ⅱ PAUSE", "≥ {value} MW", "▲ {frp} MW", - "SATELLITE" + "SATELLITE", + "Photo © {photographer}" ], "it": [ "1 min/s", diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index 5396a0390..fbc9725c9 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -182,6 +182,28 @@ export function createPanel({ state: layerState, parts }) { : null; } + /** The tracked airliner's photo with its credit, or null. The subject + * refresh resolves it; only the subject it was resolved for shows it. */ + function presentPhoto(subject) { + const held = layerState.subjectPhoto; + if ( + !held || + held.layerId !== subject.layerId || + String(held.id) !== String(subject.id) + ) + return null; + const { photo } = held; + return { + src: photo.src, + width: photo.width ?? null, + height: photo.height ?? null, + link: photo.link, + credit: t('Photo © {photographer}', { + photographer: photo.photographer, + }), + }; + } + function renderResults() { if (!layerState.enabled || !layerState.results) return hidePanel(); ensurePanel(); @@ -191,6 +213,7 @@ export function createPanel({ state: layerState, parts }) { kind: 'results', subject: `${subject.label} · ${t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) })}`, subjectNames: presentNames(subject.memberNames), + photo: presentPhoto(subject), navigation: presentNavigation(), rows: cohorts.map(presentRow), note: t( diff --git a/src/layers/awareness/panel.test.mjs b/src/layers/awareness/panel.test.mjs index eaaac70e9..4fa2c448c 100644 --- a/src/layers/awareness/panel.test.mjs +++ b/src/layers/awareness/panel.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createPanel } from './panel.js'; +import { createSubject } from './subject.js'; test('a Contacts row names its provider as the Layers panel does, with its feed state', () => { const cohort = (id, source, sourceStatus) => ({ @@ -36,3 +37,119 @@ test('a Contacts row names its provider as the Layers panel does, with its feed ); state.offPanelLocale?.(); }); + +const PHOTO = Object.freeze({ + src: 'https://t.plnspttrs.net/1/abc123_280.jpg', + width: 280, + height: 186, + photographer: 'Jane Doe', + link: 'https://www.planespotters.net/photo/1/n123ab', +}); + +/** The published Contacts view for one subject and the refresh's photo. */ +function photoView(subject, subjectPhoto) { + const state = { + enabled: true, + results: { subject, cohorts: [] }, + subjectPhoto, + cohortPages: new Map(), + panelListeners: new Set(), + panelSignature: '', + navigationIndex: 0, + }; + const parts = { + navigation: { canNavigateNext: () => false }, + rendering: { hideDirectionOverlay() {} }, + }; + createPanel({ state, parts }).renderResults(); + state.offPanelLocale?.(); + return state.panelView; +} + +const FLIGHT = Object.freeze({ + layerId: 'flights', + id: 'abc123', + label: 'N123AB', +}); + +test('the Contacts view carries the tracked flight’s photo with its credit', () => { + const view = photoView(FLIGHT, { + layerId: 'flights', + id: 'abc123', + photo: PHOTO, + }); + assert.deepEqual(view.photo, { + src: PHOTO.src, + width: 280, + height: 186, + link: PHOTO.link, + credit: 'Photo © Jane Doe', + }); +}); + +test('the Contacts view has no photo without one, or with another subject’s', () => { + assert.equal(photoView(FLIGHT, null).photo, null); + assert.equal( + photoView(FLIGHT, { layerId: 'flights', id: 'def456', photo: PHOTO }).photo, + null, + ); + assert.equal( + photoView( + { layerId: 'ais-live-vessels', id: 'abc123', label: 'VESSEL' }, + { layerId: 'flights', id: 'abc123', photo: PHOTO }, + ).photo, + null, + ); +}); + +test('only the tracked flight has a photo: not a vessel, an installation, a military aircraft or another flight', () => { + const tracked = { + layerId: 'flights', + id: 'abc123', + label: 'N123AB', + photo: PHOTO, + }; + let current = tracked; + let lookups = 0; + const flights = { + getTrackedSubject: () => { + lookups += 1; + return current; + }, + }; + const military = { + getTrackedSubject: () => ({ ...tracked, layerId: 'military' }), + }; + const { resolveSubjectPhoto } = createSubject({ + state: {}, + services: { flights, military, vessels: {}, installations: {} }, + parts: { + navigation: { + isFlightLayer: (id) => id === 'flights' || id === 'military', + }, + }, + }); + assert.deepEqual( + resolveSubjectPhoto({ layerId: 'flights', id: 'abc123' }), + PHOTO, + ); + assert.equal(lookups, 1); + assert.equal(resolveSubjectPhoto({ layerId: 'flights', id: 'def456' }), null); + assert.equal( + resolveSubjectPhoto({ layerId: 'ais-live-vessels', id: 'abc123' }), + null, + ); + assert.equal( + resolveSubjectPhoto({ layerId: 'military-installations', id: 'abc123' }), + null, + ); + assert.equal( + resolveSubjectPhoto({ layerId: 'military', id: 'abc123' }), + null, + ); + assert.equal(resolveSubjectPhoto(null), null); + current = null; + assert.equal(resolveSubjectPhoto({ layerId: 'flights', id: 'abc123' }), null); + current = { ...tracked, photo: null }; + assert.equal(resolveSubjectPhoto({ layerId: 'flights', id: 'abc123' }), null); +}); diff --git a/src/layers/awareness/state.js b/src/layers/awareness/state.js index adc896943..d950e7a84 100644 --- a/src/layers/awareness/state.js +++ b/src/layers/awareness/state.js @@ -29,6 +29,8 @@ export function createState({ services }) { /** Stops following the manager's adoptions (see watchAdoptions). */ unwatchAdoptions: null, subject: null, + /** The tracked flight's photo with the subject it belongs to, or null. */ + subjectPhoto: null, results: null, visual: null, /** Whether layer views replace the panel's initial markup yet. */ diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 60f622e29..050d144c2 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -378,19 +378,48 @@ export function createSubject({ state: layerState, services, parts, source }) { * * Identity (`subject.id`) is NEVER re-derived — only the rendered string. * @param {{layerId: string, id: string, label: string}} subject Current subject. + * @param {?object} [tracked] The subject's tracked descriptor, when the + * caller already has it (see trackedSubjectOf). * @returns {string} The current label, or the cached one when the owning layer * has no tracked subject (e.g. a cross-layer handoff is in flight). */ - function resolveSubjectLabel(subject) { - if (!parts.navigation.isFlightLayer(subject?.layerId)) - return subject?.label; + function resolveSubjectLabel(subject, tracked = trackedSubjectOf(subject)) { + return tracked?.label || subject?.label; + } + + /** + * The owning flight layer's tracked-subject descriptor when it describes + * this subject, else null. Built once per refresh and shared by the label + * and the photo. + * @param {{layerId: string, id: string}|null} subject Current subject. + * @returns {?{id: string, label: string, photo?: ?object}} + */ + function trackedSubjectOf(subject) { + if (!parts.navigation.isFlightLayer(subject?.layerId)) return null; const layer = subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer; const tracked = layer.getTrackedSubject?.(); - if (tracked?.label && String(tracked.id) === String(subject.id)) - return tracked.label; - return subject.label; + return tracked && String(tracked.id) === String(subject.id) + ? tracked + : null; + } + + /** + * The tracked airliner's Planespotters photo, for the Contacts panel. + * + * Only the flights layer looks photos up, and only for the plane it tracks; + * a vessel, an installation, a military aircraft or an untracked flight has + * none. + * @param {{layerId: string, id: string}|null} subject Current subject. + * @param {?object} [tracked] The subject's tracked descriptor, when the + * caller already has it. + * @returns {?{src: string, width: ?number, height: ?number, photographer: string, link: string}} + */ + function resolveSubjectPhoto(subject, tracked = trackedSubjectOf(subject)) { + return subject?.layerId === 'flights' && tracked?.photo + ? tracked.photo + : null; } /** Refresh proximity counts/distances against the subject's current live position. */ @@ -417,8 +446,21 @@ export function createSubject({ state: layerState, services, parts, source }) { if (presence === SUBJECT_PRESENCE.LIVE) layerState.subjectMissing = false; else if (presence === SUBJECT_PRESENCE.MISSING) layerState.subjectMissing = true; - const nextLabel = resolveSubjectLabel(layerState.subject); + const tracked = trackedSubjectOf(layerState.subject); + const nextLabel = resolveSubjectLabel(layerState.subject, tracked); const labelChanged = nextLabel !== layerState.subject.label; + // The panel reads the photo from here, with the subject it belongs to. + const photo = resolveSubjectPhoto(layerState.subject, tracked); + layerState.subjectPhoto = photo + ? { + layerId: layerState.subject.layerId, + id: layerState.subject.id, + photo, + } + : null; + // A photo arriving after selection is render-worthy on its own, as a label. + const photoChanged = + (photo?.src ?? null) !== (layerState.panelView?.photo?.src ?? null); layerState.subject = { ...layerState.subject, position, label: nextLabel }; const movementM = layerState.lastEvaluatedPosition ? Cesium.Cartesian3.distance(layerState.lastEvaluatedPosition, position) @@ -439,7 +481,7 @@ export function createSubject({ state: layerState, services, parts, source }) { // kept the ICAO hex until unrelated movement, a source-revision bump, or // page rotation happened to repaint it. Gated on an ACTUAL change so the // common no-op refresh (every 750 ms) still costs no repaint. - if (labelChanged) parts.panel.renderResults(); + if (labelChanged || photoChanged) parts.panel.renderResults(); parts.rendering.scheduleDirectionOverlayUpdate(force); return; } @@ -502,6 +544,7 @@ export function createSubject({ state: layerState, services, parts, source }) { militaryInstallationsLayer.setContextAnchor?.(null); layerState.autoFocusRetryPending = false; layerState.subject = null; + layerState.subjectPhoto = null; layerState.subjectMissing = false; layerState.results = null; layerState.navigationHistory = []; @@ -528,6 +571,7 @@ export function createSubject({ state: layerState, services, parts, source }) { resolveSubjectPosition, collectionSubjectPosition, resolveSubjectLabel, + resolveSubjectPhoto, refreshSelectedSubject, subjectFromContext, markSubjectEvicted, diff --git a/src/layers/flights/enrichment.js b/src/layers/flights/enrichment.js index aaf8ceb38..1132c36c8 100644 --- a/src/layers/flights/enrichment.js +++ b/src/layers/flights/enrichment.js @@ -1,5 +1,6 @@ import { classifyAircraft } from '../../data/aircraftClass.js'; import { horizonOccluder } from '../../data/iconOrientation.js'; +import { parsePlanespottersPhoto } from '../../data/planespottersPhoto.js'; import * as Cesium from 'cesium'; import { ENRICH_MAX_INFLIGHT, @@ -8,6 +9,7 @@ import { ENRICH_AMBIENT_REFILL_TOKENS, ENRICH_AMBIENT_REFILL_WINDOW_MS, ENRICH_AMBIENT_PER_SWEEP, + ENRICH_RETRY_COOLDOWN_MS, } from './policy.js'; export function createEnrichment({ @@ -17,11 +19,62 @@ export function createEnrichment({ layer, resolveAsset, }) { - function _enqueueEnrich(key, query, onData, priority = false) { + const { isMilitaryIcao } = services.militaryRegistry; + const { isTr3b } = services.aircraftPresentation; + /** + * Whether `key` is answered, queued, or cooling down after a failure. A + * key past its cool-down is released here so it can be asked again. + * @param {string} key Lookup key. + * @returns {boolean} + */ + function _enrichHeld(key) { + if (flightState._enrichSeen.has(key)) return true; + const retryAfter = flightState._enrichRetryAfter.get(key); + if (retryAfter === undefined) return false; + if (Date.now() < retryAfter) return true; + flightState._enrichRetryAfter.delete(key); + return false; + } + + /** + * Whether a source failure is permanent: an unsupported lookup, or an HTTP + * 4xx other than 401/403 (an authorization refusal that may clear), 408 + * (timeout) and 429 (rate limited). Anything else, including an error without a + * status, is temporary. + * @param {*} error The rejection, as `LiveSourceError` / `httpError` carry it. + * @returns {boolean} + */ + function _enrichFailureIsFinal(error) { + if (error?.code === 'unsupported') return true; + const status = Number(error?.status); + return ( + Number.isInteger(status) && + status >= 400 && + status < 500 && + status !== 401 && + status !== 403 && + status !== 408 && + status !== 429 + ); + } + + /** + * Ask each key at most once per session, except that a lookup whose source + * failed temporarily (rejected or threw) may be asked again after + * ENRICH_RETRY_COOLDOWN_MS; an answer, found or not, and a permanent + * refusal (see _enrichFailureIsFinal) are final. + * @param {string} key Session-unique lookup key. + * @param {object} query The source's enrichment query. + * @param {function(object):void} onData Receives a `found` answer. + * @param {boolean} [priority] Front of the queue. + * @param {function(*):?object} [read] Reads the source's answer first, for + * a lookup whose answer may arrive in more than one shape. + */ + function _enqueueEnrich(key, query, onData, priority = false, read = null) { if (flightState.lifetime.signal.aborted) return; - if (flightState._enrichSeen.has(key)) return; + if (_enrichHeld(key)) return; flightState._enrichSeen.add(key); - const job = { query, onData }; + const job = { key, query, onData, read }; // Priority (tracked / model-eligible) goes to the FRONT so a deep ambient // backlog can never delay the plane the user just clicked or zoomed into. if (priority) flightState._enrichQueue.unshift(job); @@ -61,9 +114,24 @@ export function createEnrichment({ signal: lifetime.signal, }); }) - .then((data) => { - if (!lifetime.signal.aborted && data && data.found) job.onData(data); - }) + .then( + (answer) => { + const data = job.read ? job.read(answer) : answer; + if (!lifetime.signal.aborted && data && data.found) + job.onData(data); + }, + (error) => { + // The source failed: release the key after a cool-down. A layer + // teardown is not a failure and schedules nothing, and a + // permanent refusal is as final as an answer. + if (lifetime.signal.aborted || _enrichFailureIsFinal(error)) return; + flightState._enrichSeen.delete(job.key); + flightState._enrichRetryAfter.set( + job.key, + Date.now() + ENRICH_RETRY_COOLDOWN_MS, + ); + }, + ) .catch(() => { /* enrichment never surfaces errors */ }) @@ -119,6 +187,14 @@ export function createEnrichment({ (data) => { const meta = flightState.records.data.get(icao24); if (!meta) return; + // A late answer for a callsign the plane no longer flies is dropped. + if ( + String(meta.callsign || '') + .trim() + .toUpperCase() !== cs + ) + return; + meta.routeCallsign = cs; meta.airline = data.airline || meta.airline; if (data.origin && data.destination) meta.route = { origin: data.origin, destination: data.destination }; @@ -129,6 +205,31 @@ export function createEnrichment({ ); // route lookups only fire for the TRACKED plane — front of the queue } + /** The tracked plane's photo from Planespotters, asked once per hex at the + * front of the queue like its route. A miss is final; a temporary failure + * is asked again after the cool-down. A military aircraft or a converted + * TR-3B is never looked up. The answer is read with + * `parsePlanespottersPhoto` whatever shape the source returns (the upstream + * answer, a server's cached shape, or an already-read photo), so only a + * checked photo is ever stored. */ + function _requestPhotoEnrichment(icao24) { + if (!/^[0-9a-f]{6}$/i.test(icao24)) return; + const id = icao24.toLowerCase(); + if (isMilitaryIcao(id) || isTr3b(id)) return; + _enqueueEnrich( + `p:${id}`, + { kind: 'photo', id }, + (data) => { + const meta = flightState.records.data.get(id); + if (!meta) return; // evicted while the lookup was in flight + const { src, width, height, photographer, link } = data; + meta.photo = { src, width, height, photographer, link }; + }, + true, + parsePlanespottersPhoto, + ); + } + /** QA seam: headless harnesses (scripts/qa-enrich-ambient.mjs) shrink the * bucket knobs via window.__GEV_ENRICH_AMBIENT_QA = {ceil, refillTokens, * windowMs} — they cannot wait out a real 5-minute window. Read lazily each @@ -196,7 +297,9 @@ export function createEnrichment({ ); const cand = []; for (const [icao24, bb] of flightState._billboards) { - if (flightState._enrichSeen.has(`t:${icao24}`)) continue; // answered / queued / negative this session + // Answered, queued, negative this session, or cooling down after a + // failure: never spend a token on it. + if (_enrichHeld(`t:${icao24}`)) continue; if (!/^[0-9a-f]{6}$/i.test(icao24)) continue; // adsbdb keys are 6-char hex only if (flightState.records.data.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works) if (!bb.position || !occluder.isPointVisible(bb.position)) continue; // beyond the limb @@ -233,6 +336,7 @@ export function createEnrichment({ _drainEnrich, _requestTypeEnrichment, _requestRouteEnrichment, + _requestPhotoEnrichment, _ambientBudgetKnobs, _refillAmbientBudget, _sweepAmbientEnrichment, diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index 0ca944647..87e3e356a 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -312,6 +312,7 @@ export function createLifecycle({ flightState._displayFloorState.clear(); flightState._enrichQueue.length = 0; flightState._enrichSeen.clear(); + flightState._enrichRetryAfter.clear(); if (flightState._enrichDripTimer) { clearTimeout(flightState._enrichDripTimer); flightState._enrichDripTimer = null; diff --git a/src/layers/flights/ownership.test.mjs b/src/layers/flights/ownership.test.mjs index 16c1a9cc6..9b0c1a606 100644 --- a/src/layers/flights/ownership.test.mjs +++ b/src/layers/flights/ownership.test.mjs @@ -4,6 +4,7 @@ import { createCivilFlightLayer } from './index.js'; import { createFlightState } from './state.js'; import { createEnrichment } from './enrichment.js'; import { createTracking } from './tracking.js'; +import { LiveSourceError } from '../../sources/live/contract.js'; function services() { const names = [ @@ -29,6 +30,14 @@ function services() { }; } +/** The services enrichment reads: which hexes are military or a TR-3B. */ +function enrichServices({ military = [], tr3b = [] } = {}) { + return { + militaryRegistry: { isMilitaryIcao: (id) => military.includes(id) }, + aircraftPresentation: { isTr3b: (id) => tr3b.includes(id) }, + }; +} + test('constructing independent flight layers performs no source request and isolates selection', () => { let requests = 0; const source = { @@ -90,7 +99,11 @@ test('aborted enrichment cannot write into a later lifecycle or decrement its ac }, }; const flightState = createFlightState({ source, services: services() }); - const enrichment = createEnrichment({ flightState, services: {}, parts: {} }); + const enrichment = createEnrichment({ + flightState, + services: enrichServices(), + parts: {}, + }); let writes = 0; enrichment._enqueueEnrich( 'test', @@ -118,7 +131,11 @@ test('a synchronous enrichment failure stays bounded and releases its slot', asy }, }, }); - const enrichment = createEnrichment({ flightState, services: {}, parts: {} }); + const enrichment = createEnrichment({ + flightState, + services: enrichServices(), + parts: {}, + }); assert.doesNotThrow(() => enrichment._enqueueEnrich('test', {}, () => assert.fail()), ); @@ -146,3 +163,336 @@ test('voice context reads a track that rounds to north as 0°, not 360°', () => '0°', ); }); + +test('the tracked plane’s photo is asked once per hex and lands on its record', async () => { + const queries = []; + let answer = { + found: true, + src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg', + width: 420, + height: 280, + photographer: 'Jan Seler', + link: 'https://www.planespotters.net/photo/1745410/x?utm_source=api', + }; + const flightState = createFlightState({ + services: services(), + source: { + async getEnrichment(query) { + queries.push(query); + if (answer instanceof Error) throw answer; + return answer; + }, + }, + }); + const enrichment = createEnrichment({ + flightState, + services: enrichServices(), + parts: {}, + }); + flightState.records.data.set('a0f1bb', { callsign: 'AAL1' }); + flightState._trackedIcao = 'a0f1bb'; + flightState._trackedEntity = {}; + enrichment._requestPhotoEnrichment('A0F1BB'); + enrichment._requestPhotoEnrichment('a0f1bb'); + await new Promise((done) => setImmediate(done)); + assert.deepEqual(queries, [{ kind: 'photo', id: 'a0f1bb' }]); + assert.deepEqual(flightState.records.data.get('a0f1bb').photo, { + src: answer.src, + width: 420, + height: 280, + photographer: 'Jan Seler', + link: answer.link, + }); + + // A failure is not asked again within its cool-down. + answer = new Error('outage'); + flightState.records.data.set('abc123', {}); + enrichment._requestPhotoEnrichment('abc123'); + await new Promise((done) => setTimeout(done, 300)); + enrichment._requestPhotoEnrichment('abc123'); + await new Promise((done) => setImmediate(done)); + assert.equal(queries.length, 2); + assert.equal(flightState.records.data.get('abc123').photo, undefined); + enrichment._requestPhotoEnrichment('not-hex'); + assert.equal(queries.length, 2); + flightState.lifetime.abort(); +}); + +test('a military aircraft or a converted TR-3B is never looked up for a photo', async () => { + const queries = []; + const flightState = createFlightState({ + services: services(), + source: { + async getEnrichment(query) { + queries.push(query); + return { found: false }; + }, + }, + }); + const enrichment = createEnrichment({ + flightState, + services: enrichServices({ military: ['ae1234'], tr3b: ['a0f1bb'] }), + parts: {}, + }); + for (const id of ['ae1234', 'a0f1bb']) { + flightState.records.data.set(id, {}); + enrichment._requestPhotoEnrichment(id); + } + enrichment._requestPhotoEnrichment('AE1234'); + await new Promise((done) => setImmediate(done)); + assert.deepEqual(queries, []); + assert.equal(flightState._enrichSeen.size, 0); + flightState.lifetime.abort(); +}); + +test('a photo answer in the upstream shape or a server’s cached shape lands checked', async () => { + const SRC = 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg'; + const LINK = 'https://www.planespotters.net/photo/1745410/x?utm_source=api'; + const answers = { + a0f1bb: { + photos: [ + { + thumbnail_large: { src: SRC, size: { width: 420, height: 280 } }, + link: LINK, + photographer: 'Jan Seler', + }, + ], + }, + abc123: { + found: true, + thumbnail: { src: SRC, width: 420, height: 280 }, + photographer: 'Jan Seler', + link: LINK, + }, + def456: { + found: true, + thumbnail: { src: 'https://evil.example/a.jpg', width: 1, height: 1 }, + photographer: 'Jan Seler', + link: LINK, + }, + fed789: { photos: [] }, + }; + const flightState = createFlightState({ + services: services(), + source: { getEnrichment: async ({ id }) => answers[id] }, + }); + const enrichment = createEnrichment({ + flightState, + services: enrichServices(), + parts: {}, + }); + const ids = Object.keys(answers); + for (const id of ids) flightState.records.data.set(id, {}); + for (const id of ids) { + enrichment._requestPhotoEnrichment(id); + // One dispatch per drip gap. + await new Promise((done) => setTimeout(done, 250)); + } + const photo = { + src: SRC, + width: 420, + height: 280, + photographer: 'Jan Seler', + link: LINK, + }; + assert.deepEqual(flightState.records.data.get('a0f1bb').photo, photo); + assert.deepEqual(flightState.records.data.get('abc123').photo, photo); + assert.equal(flightState.records.data.get('def456').photo, undefined); + assert.equal(flightState.records.data.get('fed789').photo, undefined); + flightState.lifetime.abort(); +}); + +/** An enrichment queue over `answer(query)` with a hand-driven clock. */ +function enrichmentClock(t, answer) { + let now = 1_000_000; + t.mock.method(Date, 'now', () => now); + const queries = []; + const flightState = createFlightState({ + services: services(), + source: { + getEnrichment(query) { + queries.push(query.id); + return answer(query); + }, + }, + }); + const enrichment = createEnrichment({ + flightState, + services: enrichServices(), + parts: { tracking: { _updateTrackedLabelModel() {} } }, + }); + const settle = () => new Promise((done) => setImmediate(done)); + const advance = (ms) => { + now += ms; + }; + return { flightState, enrichment, queries, settle, advance }; +} + +test('a failed lookup is asked again only after its cool-down', async (t) => { + let fail = true; + const { flightState, enrichment, queries, settle, advance } = enrichmentClock( + t, + async () => { + if (fail) throw new Error('503'); + return { found: true, typeCode: 'A321' }; + }, + ); + flightState.records.data.set('a0f1bb', {}); + enrichment._requestTypeEnrichment('a0f1bb'); + await settle(); + assert.deepEqual(queries, ['a0f1bb']); + advance(59_000); + enrichment._requestTypeEnrichment('a0f1bb'); + await settle(); + assert.deepEqual(queries, ['a0f1bb'], 'not within the cool-down'); + fail = false; + advance(1_000); + enrichment._requestTypeEnrichment('a0f1bb'); + await settle(); + assert.deepEqual(queries, ['a0f1bb', 'a0f1bb'], 'asked again after it'); + assert.equal(flightState.records.data.get('a0f1bb').typeCode, 'A321'); + advance(120_000); + enrichment._requestTypeEnrichment('a0f1bb'); + await settle(); + assert.equal(queries.length, 2, 'an answer is final'); + assert.equal(flightState._enrichActive, 0); + flightState.lifetime.abort(); +}); + +test('a synchronous source failure cools down like a rejected one', async (t) => { + const { flightState, enrichment, queries, settle, advance } = enrichmentClock( + t, + () => { + throw new Error('boom'); + }, + ); + enrichment._requestPhotoEnrichment('a0f1bb'); + await settle(); + advance(60_000); + enrichment._requestPhotoEnrichment('a0f1bb'); + await settle(); + assert.deepEqual(queries, ['a0f1bb', 'a0f1bb']); + flightState.lifetime.abort(); +}); + +test('a "not found" answer is never asked again', async (t) => { + const { flightState, enrichment, queries, settle, advance } = enrichmentClock( + t, + async ({ kind }) => (kind === 'photo' ? { photos: [] } : { found: false }), + ); + enrichment._requestTypeEnrichment('a0f1bb'); + await settle(); + advance(1_000); // past the dispatch drip + enrichment._requestPhotoEnrichment('a0f1bb'); + await settle(); + assert.equal(queries.length, 2); + advance(3_600_000); + enrichment._requestTypeEnrichment('a0f1bb'); + enrichment._requestPhotoEnrichment('a0f1bb'); + await settle(); + assert.equal(queries.length, 2); + assert.equal(flightState._enrichRetryAfter.size, 0); + flightState.lifetime.abort(); +}); + +test('a lookup cut off by the layer’s teardown schedules no retry', async (t) => { + let release; + const { flightState, enrichment, settle } = enrichmentClock( + t, + (query) => + new Promise((_resolve, reject) => { + release = () => reject(new DOMException('aborted', 'AbortError')); + }), + ); + enrichment._requestTypeEnrichment('a0f1bb'); + await settle(); + flightState.lifetime.abort(); + release(); + await settle(); + assert.equal(flightState._enrichRetryAfter.size, 0); + assert.equal(flightState._enrichSeen.has('t:a0f1bb'), true); +}); + +test('a permanent refusal is final; a timeout, rate limit, outage or authorization refusal cools down', async (t) => { + const errors = { + aaaaa1: new LiveSourceError('denied', 'adsbdb HTTP 404', { status: 404 }), + aaaaa2: new LiveSourceError('unsupported', 'Enrichment unavailable'), + aaaaa3: new LiveSourceError('limited', 'adsbdb rate limited', { + status: 429, + }), + aaaaa4: new LiveSourceError('unavailable', 'adsbdb HTTP 503', { + status: 503, + }), + aaaaa5: new LiveSourceError('unavailable', 'adsbdb HTTP 408', { + status: 408, + }), + aaaaa6: new Error('network'), + aaaaa7: new LiveSourceError('denied', 'Unauthorized', { status: 401 }), + aaaaa8: new LiveSourceError('denied', 'Forbidden', { status: 403 }), + }; + const { flightState, enrichment, queries, settle, advance } = enrichmentClock( + t, + async ({ id }) => { + throw errors[id]; + }, + ); + const ids = Object.keys(errors); + for (const id of ids) { + enrichment._requestTypeEnrichment(id); + await settle(); + advance(1_000); // past the dispatch drip + } + assert.deepEqual(queries, ids); + advance(60_000); + for (const id of ids) { + enrichment._requestTypeEnrichment(id); + await settle(); + advance(1_000); + } + assert.deepEqual( + queries.slice(ids.length), + ['aaaaa3', 'aaaaa4', 'aaaaa5', 'aaaaa6', 'aaaaa7', 'aaaaa8'], + 'a 404 and an unsupported lookup are never asked again', + ); + assert.equal(flightState._enrichSeen.has('t:aaaaa1'), true); + assert.equal(flightState._enrichRetryAfter.has('t:aaaaa1'), false); + flightState.lifetime.abort(); +}); + +test('a route answer for a callsign the plane no longer flies is dropped', async (t) => { + const pending = []; + const { flightState, enrichment, settle, advance } = enrichmentClock( + t, + (query) => new Promise((resolve) => pending.push({ query, resolve })), + ); + const meta = { callsign: 'UAL9' }; + flightState.records.data.set('a0f1bb', meta); + enrichment._requestRouteEnrichment('a0f1bb'); + await settle(); + meta.callsign = 'UAL10'; // the next poll's callsign + advance(1_000); + enrichment._requestRouteEnrichment('a0f1bb'); + await settle(); + assert.deepEqual( + pending.map(({ query }) => query.id), + ['UAL9', 'UAL10'], + ); + const route = (code) => ({ + found: true, + airline: `${code} Air`, + origin: { code: `${code}O` }, + destination: { code: `${code}D` }, + }); + pending[0].resolve(route('OLD')); + await settle(); + assert.equal(meta.route, undefined, 'the late UAL9 answer is ignored'); + assert.equal(meta.airline, undefined); + pending[1].resolve(route('NEW')); + await settle(); + assert.deepEqual(meta.route, { + origin: { code: 'NEWO' }, + destination: { code: 'NEWD' }, + }); + assert.equal(meta.routeCallsign, 'UAL10'); + flightState.lifetime.abort(); +}); diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index 9f0e810bd..de44b806a 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -254,6 +254,11 @@ export const ENRICH_MAX_INFLIGHT = 4; export const ENRICH_DISPATCH_GAP_MS = 200; +/** A lookup whose source failed (an outage, a timeout, a restarting server) may + * be asked again after this long; a real "not found" answer never is. */ + +export const ENRICH_RETRY_COOLDOWN_MS = 60_000; + // --------------------------------------------------------------------------- // Ambient fleet type enrichment (2026-07-02 field data: OpenSky's live // category field is 0/"no info" for ~94% of planes, so ambient classification diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 89b91f125..a696238dc 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -24,7 +24,8 @@ export function createQueries({ layer, resolveAsset, }) { - const { tr3bTypeLabel, tr3bAircraftClass } = services.aircraftPresentation; + const { isTr3b, tr3bTypeLabel, tr3bAircraftClass } = + services.aircraftPresentation; const { formatFlightLevel } = services.labels; const { isMilitaryIcao } = services.militaryRegistry; const { applyTrackedCameraFrame } = services.camera; @@ -208,6 +209,12 @@ export function createQueries({ destination: { ...info.route.destination }, } : null, + // The airframe's Planespotters photo for the Context panel; a military + // aircraft or a converted TR-3B (it would name the real one) shows none. + photo: + info?.photo && !isTr3b(icao24) && !isMilitaryIcao(icao24) + ? { ...info.photo } + : null, }; } @@ -875,8 +882,9 @@ export function createQueries({ /** * Describe the currently tracked aircraft at its dead-reckoned position. - * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} - * Tracked aircraft info, or null when nothing is tracked. + * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null, photo: {src: string, width: number|null, height: number|null, photographer: string, link: string}|null}|null} + * Tracked aircraft info, or null when nothing is tracked. `photo` is the + * airframe's credited Planespotters photo, or null. */ getTrackedInfo() { if (!flightState._trackedIcao) return null; @@ -906,6 +914,8 @@ export function createQueries({ _toCleanText(described.registration) || described.icao24, position: Cesium.Cartesian3.clone(described.position), + // The airframe's photo for the Context panel, or null. + photo: described.photo, }; }, diff --git a/src/layers/flights/records.js b/src/layers/flights/records.js index 709cd69ed..fdd2d67c8 100644 --- a/src/layers/flights/records.js +++ b/src/layers/flights/records.js @@ -198,10 +198,16 @@ export class FlightRecords { !!prevMeta && (prevMeta.onGround === true) !== onGround; // Store flight metadata for click-to-track labels const cat = stickyNumber(category, prevMeta?.category, null); + const nextCallsign = stickyText(callsign, prevMeta?.callsign); + // A route (and its airline) belongs to the callsign it was looked up for; + // a new callsign drops it, so an old flight's route never shows under it. + const routeHeld = + Boolean(prevMeta?.routeCallsign) && + prevMeta.routeCallsign === nextCallsign.toUpperCase(); const meta = { sourceReference: observation.reference, observedReceiptMs: Date.now(), - callsign: stickyText(callsign, prevMeta?.callsign), + callsign: nextCallsign, altitude: alt, // geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the // untouched aviation `altitude` — never rename/replace it (labels, @@ -256,8 +262,11 @@ export class FlightRecords { typeCode: prevMeta?.typeCode ?? null, typeName: prevMeta?.typeName ?? null, registration: prevMeta?.registration ?? null, - airline: prevMeta?.airline ?? null, - route: prevMeta?.route ?? null, + airline: routeHeld ? (prevMeta.airline ?? null) : null, + route: routeHeld ? (prevMeta.route ?? null) : null, + routeCallsign: routeHeld ? prevMeta.routeCallsign : null, + // Planespotters photo of the tracked airframe (Core asks it directly). + photo: prevMeta?.photo ?? null, // The RAW poll fix lat/lon (this tick's OpenSky state-vector // coords, pre-dead-reckon) — kept distinct from the continuously // dead-reckoned billboard position for any consumer that needs the diff --git a/src/layers/flights/records.test.mjs b/src/layers/flights/records.test.mjs index 638c6f843..f74574e96 100644 --- a/src/layers/flights/records.test.mjs +++ b/src/layers/flights/records.test.mjs @@ -39,6 +39,7 @@ test('civil records keep aviation units separate from render height and survive assert.equal(first.meta.renderAltitudeM, 1040); assert.equal(first.fixEpochMs, 1700000000000); first.meta.typeCode = 'B738'; + first.meta.photo = { src: 'https://t.plnspttrs.net/1/2_280.jpg' }; const second = store.receive( observation({ callsign: '', @@ -55,6 +56,7 @@ test('civil records keep aviation units separate from render height and survive assert.equal(second.meta.velocity, 120); assert.equal(second.meta.true_track, 90); assert.equal(second.meta.typeCode, 'B738'); + assert.equal(second.meta.photo, first.meta.photo, 'the photo survives polls'); assert.equal(Object.hasOwn(second.meta, 'cullPosition'), false); assert.deepEqual(JSON.parse(JSON.stringify(second.meta)), second.meta); }); @@ -140,3 +142,25 @@ test('civil emergency status follows the latest poll instead of sticking', () => assert.equal(next.meta.squawk, '2000'); assert.equal(next.meta.emergency, null); }); + +test('a route belongs to its callsign: a new callsign drops it, a blank one keeps it', () => { + const store = records(); + store.geoidReady = true; + const first = store.receive(observation({ callsign: 'UAL9' }), view()); + first.meta.route = { origin: { code: 'AUS' }, destination: { code: 'DEN' } }; + first.meta.airline = 'United'; + first.meta.routeCallsign = 'UAL9'; + const same = store.receive(observation({ callsign: 'UAL9 ' }), view()); + assert.equal(same.meta.route, first.meta.route); + const blank = store.receive(observation({ callsign: '' }), view()); + assert.equal( + blank.meta.route, + first.meta.route, + 'a dropped callsign is sticky', + ); + assert.equal(blank.meta.airline, 'United'); + const next = store.receive(observation({ callsign: 'UAL10' }), view()); + assert.equal(next.meta.route, null, 'the old route never shows under UAL10'); + assert.equal(next.meta.airline, null); + assert.equal(next.meta.routeCallsign, null); +}); diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index d48d12774..414e6a0f6 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -230,6 +230,13 @@ export function createFlightSnapshotRenderer({ // (position updates automatically via dead-reckoning CallbackProperty) if (isTracked && flightState._trackedEntity) { tracking._updateTrackedLabelModel(icao24); + // Re-ask the tracked plane's lookups each poll. Answered and queued + // keys return at once; a lookup whose source failed runs again once + // its cool-down has passed, and the route uses the callsign this + // snapshot just recorded. + enrichment._requestTypeEnrichment(icao24, true); + enrichment._requestRouteEnrichment(icao24); + enrichment._requestPhotoEnrichment(icao24); } } diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index c259c44f1..81b849f4b 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -342,6 +342,10 @@ export function createFlightState({ source, services }) { flightState._enrichSeen = new Set(); + /** Lookup key -> epoch ms before which a failed lookup is not asked again. */ + + flightState._enrichRetryAfter = new Map(); + flightState._enrichAmbientBudget = ENRICH_AMBIENT_BUDGET_CEIL; /** Epoch ms the bucket last accounted a refill window from (0 = unset). */ diff --git a/src/layers/flights/testing.js b/src/layers/flights/testing.js index ccb109ae1..60c5de10c 100644 --- a/src/layers/flights/testing.js +++ b/src/layers/flights/testing.js @@ -190,8 +190,20 @@ export function createTesting({ ); } - /** Exercise the exact asynchronous fleet loader and return its admitted model. */ + /** Forget every enrichment lookup and the dispatch clock, so a test that + * moved `Date.now` leaves no future-dated drip or cool-down behind. */ + function _resetEnrichmentForTest() { + flightState._enrichQueue.length = 0; + flightState._enrichSeen.clear(); + flightState._enrichRetryAfter.clear(); + flightState._enrichLastDispatchMs = 0; + if (flightState._enrichDripTimer) { + clearTimeout(flightState._enrichDripTimer); + flightState._enrichDripTimer = null; + } + } + /** Exercise the exact asynchronous fleet loader and return its admitted model. */ async function _ensureFleetModelForTest(icao24) { await parts.rendering._ensureModel(icao24); return flightState._models.get(icao24) || null; @@ -212,5 +224,6 @@ export function createTesting({ _trackedBillboardColorForTest, _driveFleetModelHandoffForTest, _ensureFleetModelForTest, + _resetEnrichmentForTest, }; } diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index f81249e88..9fbde6a3f 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -1060,6 +1060,7 @@ export function createTracking({ // starts one. parts.enrichment._requestTypeEnrichment(icao24, true); // tracked plane — front of the enrichment queue parts.enrichment._requestRouteEnrichment(icao24); + parts.enrichment._requestPhotoEnrichment(icao24); // Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing // aircraft's history is retrievable on select. Grounded flights positions // are already surface-clamped (the surfaceM chain), so seeds/appends drape. diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index f42a00ec3..416b42a37 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -412,3 +412,77 @@ test('squawks are validated and omitted OpenSky status differs from an explicit assert.equal(quiet.squawk, null); assert.equal(quiet.emergency, null); }); + +test('a photo enrichment asks Planespotters directly and returns the checked photo', async () => { + const requests = []; + const source = createFlightSource({ + fetchImpl: async (url, init) => { + requests.push({ url, init }); + return response({ + photos: [ + { + thumbnail_large: { + src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg', + size: { width: 420, height: 280 }, + }, + link: 'https://www.planespotters.net/photo/1745410/x?utm_source=api', + photographer: 'Jan Seler', + }, + ], + }); + }, + }); + const signal = new AbortController().signal; + const photo = await source.getEnrichment( + { kind: 'photo', id: 'a0f1bb' }, + { signal }, + ); + assert.equal( + requests[0].url, + 'https://api.planespotters.net/pub/photos/hex/a0f1bb', + ); + assert.ok(requests[0].init.signal, 'the request is cancellable'); + assert.equal(requests[0].init.credentials, 'omit'); + assert.deepEqual(photo, { + found: true, + src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg', + width: 420, + height: 280, + photographer: 'Jan Seler', + link: 'https://www.planespotters.net/photo/1745410/x?utm_source=api', + }); +}); + +test('a photo enrichment reads an empty answer as no photo and refuses a bad hex', async () => { + let calls = 0; + const source = createFlightSource({ + fetchImpl: async (_url, _init) => { + calls++; + return response({ photos: [] }); + }, + }); + assert.deepEqual( + await source.getEnrichment({ kind: 'photo', id: 'a0f1bb' }), + { found: false }, + ); + await assert.rejects(source.getEnrichment({ kind: 'photo', id: '../x' }), { + code: 'unsupported', + }); + assert.equal(calls, 1); +}); + +test('a photo enrichment outage rejects without echoing the body', async () => { + for (const status of [403, 429, 502]) { + const source = createFlightSource({ + fetchImpl: async () => response({ error: 'sensitive' }, {}, status), + }); + await assert.rejects( + source.getEnrichment({ kind: 'photo', id: 'a0f1bb' }), + (error) => { + assert.equal(error.status, status); + assert.equal(error.message.includes('sensitive'), false); + return true; + }, + ); + } +}); diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index d76ecf3fa..b244266b0 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -11,10 +11,37 @@ import { readsbIdentities, } from './aircraft.js'; import { normalizeVesselTrack, vesselSnapshot } from './vessels.js'; +import { parsePlanespottersPhoto } from '../../data/planespottersPhoto.js'; const defaultFetch = (...args) => globalThis.fetch(...args); const header = (response, name) => response.headers?.get?.(name); +/** Planespotters' public photo lookup, called straight from the browser (it + * answers with `access-control-allow-origin: *`). */ +const PLANESPOTTERS_PHOTOS = 'https://api.planespotters.net/pub/photos/hex/'; +/** A photo is decoration: a slow answer gives up rather than hold an + * enrichment slot. */ +const PHOTO_TIMEOUT_MS = 8000; + +/** The tracked plane's Planespotters photo, read and host-checked. */ +async function planespottersPhoto(fetchImpl, hex, signal) { + const id = String(hex || '').toLowerCase(); + if (!/^[0-9a-f]{6}$/.test(id)) + throw new LiveSourceError('unsupported', 'Enrichment unavailable'); + const timeout = AbortSignal.timeout(PHOTO_TIMEOUT_MS); + const { response, payload } = await readResponse( + fetchImpl, + PLANESPOTTERS_PHOTOS + id, + { + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + credentials: 'omit', + }, + 'Planespotters', + ); + if (!response.ok) throw httpError(response, 'Planespotters'); + return parsePlanespottersPhoto(payload); +} + function openSkyError(response) { const error = httpError(response, 'Flights'); const mode = String( @@ -106,6 +133,8 @@ export function createFlightSource({ }; }, async getEnrichment(query, { signal } = {}) { + if (query.kind === 'photo') + return planespottersPhoto(fetchImpl, query.id, signal); if (!['type', 'route'].includes(query.kind)) throw new LiveSourceError('unsupported', 'Enrichment unavailable'); const { response, payload } = await readResponse( diff --git a/src/ui/awarenessPanel.test.mjs b/src/ui/awarenessPanel.test.mjs index 101711c66..53cf0ad94 100644 --- a/src/ui/awarenessPanel.test.mjs +++ b/src/ui/awarenessPanel.test.mjs @@ -245,3 +245,73 @@ test('a repaint that leaves focus outside the panel does not pull it back', asyn 'an empty cohort renders no list', ); }); + +const photo = (extra = {}) => ({ + src: 'https://t.plnspttrs.net/1/abc123_280.jpg', + width: 280, + height: 186, + link: 'https://www.planespotters.net/photo/1/n123ab', + credit: 'Photo © Jane Doe', + ...extra, +}); + +test('a tracked flight’s photo shows under its name, credited and linked to its page', async (t) => { + const { panel, publish } = fixture(t); + await publish({ ...results(), photo: photo() }); + const root = panel(); + const link = root.querySelector('a.military-awareness-photo'); + assert.ok(link, 'the photo renders'); + assert.equal( + root.querySelector('.military-awareness-subject').nextElementSibling, + link, + 'directly under the subject line', + ); + assert.equal(link.getAttribute('href'), photo().link); + assert.equal(link.getAttribute('target'), '_blank'); + assert.equal(link.getAttribute('rel'), 'noopener'); + const img = link.querySelector('img'); + assert.equal(img.getAttribute('src'), photo().src); + assert.equal(img.getAttribute('alt'), ''); + assert.equal(img.getAttribute('width'), '280'); + assert.equal(img.getAttribute('height'), '186'); + assert.equal(link.querySelector('small').textContent, 'Photo © Jane Doe'); +}); + +test('no photo renders without one', async (t) => { + const { panel, publish } = fixture(t); + await publish({ ...results(), photo: null }); + assert.equal(panel().querySelector('.military-awareness-photo'), null); +}); + +test('a photo whose image fails to load is hidden until another arrives', async (t) => { + const { panel, publish } = fixture(t); + await publish({ ...results(), photo: photo() }); + panel() + .querySelector('.military-awareness-photo img') + .dispatchEvent(new Event('error')); + await nextTick(); + assert.equal(panel().querySelector('.military-awareness-photo'), null); + // A repaint with the same photo keeps it hidden. + await publish({ ...results({ previousDisabled: false }), photo: photo() }); + assert.equal(panel().querySelector('.military-awareness-photo'), null); + // Another plane's photo shows. + const next = photo({ src: 'https://t.plnspttrs.net/2/def456_280.jpg' }); + await publish({ ...results(), photo: next }); + assert.equal( + panel().querySelector('.military-awareness-photo img').getAttribute('src'), + next.src, + ); +}); + +test('a late error from a previous photo’s image does not hide the current one', async (t) => { + const { panel, publish } = fixture(t); + const previous = photo(); + const current = photo({ src: 'https://t.plnspttrs.net/2/def456_280.jpg' }); + await publish({ ...results(), photo: current }); + // The error belongs to the image that failed: here, one still on the old src. + const img = panel().querySelector('.military-awareness-photo img'); + img.setAttribute('src', previous.src); + img.dispatchEvent(new Event('error')); + await nextTick(); + assert.ok(panel().querySelector('.military-awareness-photo')); +}); diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 91611bf4a..e9af111f1 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -360,6 +360,31 @@ html:not([lang='en']) .radio-transport button { margin: 0 0 7px; color: #fff; } +.military-awareness-photo { + display: block; + margin: 0 0 8px; + color: #a9b7c7; + text-decoration: none; +} +.military-awareness-photo img { + display: block; + width: 100%; + height: auto; + border: 1px solid rgba(98, 181, 255, 0.34); + border-radius: 4px; +} +.military-awareness-photo small { + display: block; + margin-top: 3px; +} +.military-awareness-photo:hover small, +.military-awareness-photo:focus-visible small { + color: #fff; +} +.military-awareness-photo:focus-visible { + outline: 1px solid rgba(127, 200, 255, 0.85); + outline-offset: 2px; +} .military-awareness-controls { display: grid; grid-template-columns: 1fr 1fr 1fr; diff --git a/src/ui/views/panels/AwarenessPanel.vue b/src/ui/views/panels/AwarenessPanel.vue index cb4fa7f62..7179cb251 100644 --- a/src/ui/views/panels/AwarenessPanel.vue +++ b/src/ui/views/panels/AwarenessPanel.vue @@ -11,6 +11,13 @@ import AwarenessNames from './AwarenessNames.vue'; const { awareness: view, awarenessLayer: layer } = storeToRefs(useContextStore()); const user = { origin: 'user' }; const root = ref(null); +// A photo whose image failed to load stays hidden until another one arrives. +// The failing image's own src is recorded, so a late error from a previous +// photo never hides the current one. +const failedPhoto = ref(''); +const fail = (event) => { + failedPhoto.value = event.target.getAttribute('src'); +}; // A live repaint keeps focus on the same control by identity: browsers drop // focus from a node Vue moves, removes or disables, so the control is found @@ -52,6 +59,10 @@ onUpdated(() => {