From 0da4d390eaf5b54cac0efeb269879d92e6a562bd Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 26 Sep 2026 17:36:59 +0700 Subject: [PATCH 01/86] docs: add the realtime backend to the voice ownership table The ownership table lists every owner except the backend, though realtimeBackend.js owns the two requests, their separate transports, the abort/timeout scope around both, and client-secret expiry. The method signatures are already documented with application construction, so link there rather than repeat them, and state only the ownership facts written nowhere: the connection is the backend's only consumer in the application, the resolved credential is opaque to it and is handed to negotiate whole, and client-secret expiry is refused inside the backend. --- docs/VOICE-OWNERSHIP.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/VOICE-OWNERSHIP.md b/docs/VOICE-OWNERSHIP.md index d5553df00..be7a6c26b 100644 --- a/docs/VOICE-OWNERSHIP.md +++ b/docs/VOICE-OWNERSHIP.md @@ -8,6 +8,7 @@ The session adapter, action runner and backend interfaces remain unchanged. | Owner | State and responsibility | | --- | --- | | `realtimeConnection.js` | Start generation, request cancellation, peer/data channel, acquired microphone stream and playback element | +| `realtimeBackend.js` | Token and SDP requests, their separate transports, the abort/timeout scope around both, and client-secret expiry | | `realtimeTurns.js` | Active and superseded responses, pending confirmations, deduplicated calls and action cancellation | | `realtimeRadio.js` | Speaker ducking, prepared playback, stronger-action reservations and playback observers | | `realtimeInput.js` | Physical Space gesture, microphone mute, input/output meters and their frame/listener lifetime | @@ -15,6 +16,15 @@ The session adapter, action runner and backend interfaces remain unchanged. | `realtimeViewport.js` | One retained image, bounded deletion identities and capture generation | | `realtimeDiagnostics.js` | Bounded error history and sanitized optional diagnostics | +The backend is replaceable per deployment: `realtimeController.js` accepts it as a +`backend` option and falls back to `createRealtimeBackend()`. Its two method +signatures live with [application construction](APPLICATION.md); what belongs here +is the ownership. The connection is the backend's only consumer in the application. +The resolved credential is opaque to the connection and is handed to `negotiate` +whole. Client-secret expiry is refused inside the backend, not by the connection. +`realtimeOwners.test.mjs` drives whole sessions through a substitute that +implements only those two methods. + Pure input policy, preferences and protocol-response policy have separate modules. Owners receive named readers, operations or focused collaborators. Cross-owner cancellation uses operations; response handling does not own Radio's reservations From 993b1bcdb1d7871a1f39b7cabc7c16387d93dd5d Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:29:19 -0500 Subject: [PATCH 02/86] docs: add Pinokio browser performance workaround --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 88690d52f..0ff6c32e1 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,14 @@ locked dependencies, finds a free local port, and opens the app. Version 8.2 fixes the launcher installation issue; [details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g). +**Pinokio opens but the globe is very choppy?** Before treating that as a GPU +compatibility or minimum-hardware problem, copy the local server URL shown by +Pinokio and open the same URL in your normal hardware-accelerated browser. A +Pinokio 8.2.0 Linux report reproduced severe stutter in Pinokio's embedded view +while the same running server was smooth in an external browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). +That isolates the browser/compositor path from the app server and your machine; +it does not establish a minimum GPU requirement. + ### Path 2 — Terminal / coding agent Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about From 4ab530b8a83c6f56b34612c1ea5c9bc07c34e3ea Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:29:21 -0500 Subject: [PATCH 03/86] docs: distinguish embedded browser performance --- docs/PERFORMANCE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 34b207f79..873ef8768 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -128,6 +128,12 @@ Use the same controls before attributing a difference to the application: 6. Record live object counts before attributing a difference to the client. 7. Treat a live-source outage as missing coverage, not as evidence of low client rendering cost. +8. Record whether the app is running in Pinokio's embedded view or in an + external browser. The same Pinokio 8.2.0 Linux server was reported as + severely stuttery in the embedded view and smooth when its local URL was + opened directly in a normal browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). + Treat that as a browser/compositor-path difference until the GPU renderer + proves otherwise. ## What is not established yet From c87f007e34a627bc9554c4bfe8070267f9c5e0b5 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 11:39:19 +0700 Subject: [PATCH 04/86] fix(build): discover unit tests under server/, not only src/ npm test is the only suite CI runs, and its discovery walked src/ alone, so server/providers/transitHistory.test.mjs has never executed since it was added. Its 8 tests pass, so this was silent coverage loss rather than a hidden failure: the file reads like covered code in review and never ran anywhere. Add server/ to the searched roots, and pin the property with a test that walks the repository itself and compares the two sets, so a tree added later without updating the roots fails loudly instead of going quiet. Suite goes from 5090 to 5099 tests, 0 failures: the 8 tests in that file plus the regression test added here. --- scripts/run-unit-tests.mjs | 14 ++++++++-- src/unitTestRunner.test.mjs | 51 ++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 9bd7e70e0..7024ea38c 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -21,9 +21,19 @@ export function assertNode24AllocationRuntime(version = process.versions.node) { return version; } +/** + * Directories this runner searches for `*.test.mjs`, in stable order. + * + * `server/` belongs here because provider modules own tests of their own: a + * suite placed beside the module it covers must run, and `npm test` is the only + * thing CI invokes, so a directory missing from this list is a suite that never + * runs anywhere. `unitTestRunner.test.mjs` asserts the list covers every test + * file in the repository, so adding a tree without adding it here fails. + */ +export const UNIT_TEST_ROOTS = Object.freeze(['src', 'server']); + /** Discover repository unit tests in stable path order. */ export function discoverUnitTestFiles(root = process.cwd()) { - const sourceRoot = path.join(root, 'src'); const files = []; const visit = (directory) => { for (const entry of readdirSync(directory, { withFileTypes: true })) { @@ -34,7 +44,7 @@ export function discoverUnitTestFiles(root = process.cwd()) { } } }; - visit(sourceRoot); + for (const testRoot of UNIT_TEST_ROOTS) visit(path.join(root, testRoot)); return files.sort(); } diff --git a/src/unitTestRunner.test.mjs b/src/unitTestRunner.test.mjs index 002dcb796..201f6ed8e 100644 --- a/src/unitTestRunner.test.mjs +++ b/src/unitTestRunner.test.mjs @@ -1,14 +1,40 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { ALLOCATION_TEST_FILES, + UNIT_TEST_ROOTS, allocationTestArgs, assertNode24AllocationRuntime, buildUnitTestPlan, + discoverUnitTestFiles, isCalibratedAllocationRuntime, } from '../scripts/run-unit-tests.mjs'; +const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); + +/** Every `*.test.mjs` a contributor could add, found without consulting the runner. */ +function testFilesOnDisk(root) { + // Build outputs and dependencies are not places a contributor writes a test, + // and walking them would be slow enough to matter. Everything else counts. + const skip = new Set(['node_modules', 'dist', 'build', 'public']); + const found = []; + const visit = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.name.startsWith('.') || skip.has(entry.name)) continue; + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) visit(absolute); + else if (entry.isFile() && entry.name.endsWith('.test.mjs')) { + found.push(path.relative(root, absolute).split(path.sep).join('/')); + } + } + }; + visit(root); + return found.sort(); +} + test('unit runner serializes only GC-bracketed allocation microbenchmarks', () => { const ordinary = [ 'src/data/manager.test.mjs', @@ -37,6 +63,29 @@ test('unit runner serializes only GC-bracketed allocation microbenchmarks', () = ); }); +test('the runner discovers every test file in the repository', () => { + // `npm test` is the only suite CI runs, and it runs exactly what this + // function returns. A `*.test.mjs` outside the searched roots therefore never + // executes anywhere, while still reading — in review and to its author — like + // covered code. Compare the two sets rather than their sizes, so the failure + // names the files instead of only disagreeing about a count. + const discovered = discoverUnitTestFiles(repositoryRoot); + const onDisk = testFilesOnDisk(repositoryRoot); + const undiscovered = onDisk.filter((file) => !discovered.includes(file)); + assert.deepEqual( + undiscovered, + [], + `test files the runner never executes: ${undiscovered.join(', ')}`, + ); + assert.deepEqual(discovered, onDisk); + for (const file of discovered) { + assert.ok( + UNIT_TEST_ROOTS.some((root) => file.startsWith(`${root}/`)), + `${file} should sit under one of ${UNIT_TEST_ROOTS.join(', ')}`, + ); + } +}); + test('allocation runtime calibration is explicit and pinned to Node 24', () => { assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0'); assert.throws( From de4c8343ddd3ce3a164e0d44e63cf3c7e805f477 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:33:46 -0500 Subject: [PATCH 05/86] test: make Recent Imagery live gate panel-relative --- scripts/qa-recent-imagery.mjs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 1460ed3b5..f9601432f 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -308,15 +308,21 @@ const CONTROLS = { }; const measure = () => page.evaluate((controls) => { + const panel = document.getElementById('recent-imagery-panel'); const body = document.getElementById('recent-imagery-panel-body'); + const panelRect = panel?.getBoundingClientRect(); const out = { bodyScroll: body?.scrollTop ?? null, rects: {} }; for (const [name, selector] of Object.entries(controls)) { const rect = document.querySelector(selector)?.getBoundingClientRect(); - out.rects[name] = rect - ? [rect.left, rect.top, rect.width, rect.height].map( - (v) => Math.round(v * 10) / 10, - ) - : null; + out.rects[name] = + rect && panelRect + ? [ + rect.left - panelRect.left, + rect.top - panelRect.top, + rect.width, + rect.height, + ].map((v) => Math.round(v * 10) / 10) + : null; } return out; }, CONTROLS); @@ -1042,13 +1048,13 @@ try { ) <= target * 0.01, fitHeight, - 2_000, + 4_000, ); const zoomedHeight = await page.evaluate( () => window.__godsEyeView.viewer.camera.positionCartographic.height, ); check( - `ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within 2 s`, + `ZOOM IN lands at ${Math.round(fitHeight / 1000)} km`, zoomed, `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`, ); @@ -1258,7 +1264,7 @@ try { ); await wait(100); } - const tail = (key) => new Set(samples.slice(8).map((s) => s[key])); + const tail = (key) => new Set(samples.slice(-8).map((s) => s[key])); return { settled: tail('imagery').size === 1 && tail('cctv').size === 1, detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`, From 8d1f69a8731b6cab3fb95f1bf6d5cd05d89f7e85 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:34:28 -0500 Subject: [PATCH 06/86] ci: prove Recent Imagery gate repair --- .../recent-imagery-gate-repair-proof.yml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/recent-imagery-gate-repair-proof.yml diff --git a/.github/workflows/recent-imagery-gate-repair-proof.yml b/.github/workflows/recent-imagery-gate-repair-proof.yml new file mode 100644 index 000000000..b613d9561 --- /dev/null +++ b/.github/workflows/recent-imagery-gate-repair-proof.yml @@ -0,0 +1,37 @@ +name: Recent Imagery gate repair proof + +on: + push: + branches: + - fix/recent-imagery-qa-relative-layout-20261003 + +permissions: + contents: read + +jobs: + browser-proof: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + - name: Start GEV + run: | + npm run dev -- --host 127.0.0.1 --port 4173 > /tmp/gev-dev.log 2>&1 & + for i in {1..90}; do + if curl -fsS http://127.0.0.1:4173/ >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/gev-dev.log + exit 1 + - name: Recent Imagery live gate + run: node scripts/qa-recent-imagery.mjs + - name: Dev log on failure + if: failure() + run: cat /tmp/gev-dev.log From f93755af488585bd745f75c7baa43bebbed5dc3e Mon Sep 17 00:00:00 2001 From: Bruno Gomes Date: Tue, 6 Oct 2026 16:13:45 -0300 Subject: [PATCH 07/86] fix(cctv): start live HLS on plain-HTTP LAN pages The client lease id came from crypto.randomUUID, which browsers only expose in secure contexts. Opened over http://:4173 (the HOST=0.0.0.0 opt-in), starting a live camera feed threw before playback. Fall back to a v4 UUID from crypto.getRandomValues, which the server's lease check accepts. --- CHANGELOG.md | 6 ++++ docs/CURRENT-STATE.md | 4 ++- src/layers/cctv/videoPlayback.js | 28 +++++++++++++++++- src/layers/cctv/videoPlayback.test.mjs | 39 +++++++++++++++++++++++++- 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b433f8c06..9fc367d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed under NLOD 2.0. +- Live HLS cameras play on a page opened over plain HTTP from another machine + (`HOST=0.0.0.0`). The client lease id came from `crypto.randomUUID`, which + browsers only expose in secure contexts, so starting a live feed threw + before playback. The id now falls back to a v4 UUID built from + `crypto.getRandomValues`, which the server's lease check accepts. + ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) - Ship each bundled data pack once. The region, marine, admin-boundary, diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d4fe022a8..fde1373af 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4814,7 +4814,9 @@ its paint loop. Camera deactivation destroys the decoder, and fatal playback errors revert to the labeled frame fallback. Camera changes clear the old panel frame. hls.js is imported only when an HLS camera starts. Two live sessions may coexist with at most eight independent consumer leases each; no-access lease -expiry is 15 seconds. Closing one consumer leaves other consumers running. Encrypted/fMP4/byte-range playlists fail +expiry is 15 seconds. Closing one consumer leaves other consumers running. Lease +ids use `crypto.randomUUID` where the page is a secure context and an equivalent +v4 id from `crypto.getRandomValues` on plain-HTTP LAN pages. Encrypted/fMP4/byte-range playlists fail closed. Performance and live agency reliability require recorded validation. ## Wind forecast models diff --git a/src/layers/cctv/videoPlayback.js b/src/layers/cctv/videoPlayback.js index d59143b5a..48988c17c 100644 --- a/src/layers/cctv/videoPlayback.js +++ b/src/layers/cctv/videoPlayback.js @@ -1,3 +1,28 @@ +/** + * A v4 UUID for the client lease. `crypto.randomUUID` only exists in secure + * contexts, so a page opened over plain HTTP from another machine (the + * HOST=0.0.0.0 LAN opt-in) has none; `getRandomValues` exists in every context + * and yields the same shape the server's lease check accepts. + */ +export function createLeaseId(cryptoImpl = globalThis.crypto) { + if (typeof cryptoImpl.randomUUID === 'function') { + return cryptoImpl.randomUUID(); + } + const bytes = cryptoImpl.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')); + return [ + hex.slice(0, 4), + hex.slice(4, 6), + hex.slice(6, 8), + hex.slice(8, 10), + hex.slice(10), + ] + .map((group) => group.join('')) + .join('-'); +} + /** One decoder per active camera; both surfaces consume this video element. */ export function attachCctvVideo( video, @@ -7,13 +32,14 @@ export function attachCctvVideo( loadHls = () => import('hls.js'), onFailure = () => {}, fetchImpl = globalThis.fetch, + cryptoImpl = globalThis.crypto, } = {}, ) { // Preserve replay for existing finite video feeds; live HLS must not loop. video.loop = feedType !== 'hls'; let disposed = false; let hls = null; - const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null; + const leaseId = feedType === 'hls' ? createLeaseId(cryptoImpl) : null; const mediaUrl = feedType === 'hls' ? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}` diff --git a/src/layers/cctv/videoPlayback.test.mjs b/src/layers/cctv/videoPlayback.test.mjs index 1f8c1c0bb..d1ef02539 100644 --- a/src/layers/cctv/videoPlayback.test.mjs +++ b/src/layers/cctv/videoPlayback.test.mjs @@ -1,6 +1,15 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { attachCctvVideo } from './videoPlayback.js'; +import { webcrypto } from 'node:crypto'; +import { attachCctvVideo, createLeaseId } from './videoPlayback.js'; + +const LEASE_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +/** What an insecure context exposes: getRandomValues but no randomUUID. */ +const insecureCrypto = { + getRandomValues: (array) => webcrypto.getRandomValues(array), +}; function video() { const v = new EventTarget(); Object.assign(v, { @@ -69,6 +78,34 @@ test('native HLS releases its client lease without response-header access', asyn assert.equal(releases[0].init.method, 'DELETE'); }); +test('lease ids are v4 UUIDs with or without crypto.randomUUID', () => { + assert.match(createLeaseId(), LEASE_V4); + const ids = new Set(); + for (let i = 0; i < 64; i++) ids.add(createLeaseId(insecureCrypto)); + assert.equal(ids.size, 64); + for (const id of ids) assert.match(id, LEASE_V4); +}); + +test('HLS starts on a plain-HTTP LAN page without crypto.randomUUID', async () => { + const source = video(); + source.canPlayType = () => 'probably'; + const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', { + loadHls: async () => ({ default: { isSupported: () => false } }), + fetchImpl: async () => {}, + cryptoImpl: insecureCrypto, + }); + try { + await playback.ready; + const lease = new URL( + source.src, + 'http://192.168.1.10:4173/', + ).searchParams.get('lease'); + assert.match(lease, LEASE_V4); + } finally { + playback.dispose(); + } +}); + test('finite video feeds retain looping while live HLS does not loop', async () => { for (const feedType of ['mp4', 'webm', 'hls']) { const source = video(); From 6cc05a7fcbf89a6a581e8a94cd5b42b87baad8be Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:14:22 -0500 Subject: [PATCH 08/86] fix(ui): recut #918 src/ui/panelRails.test.mjs on current main --- src/ui/panelRails.test.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 016de58fa..de5b3997b 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -704,6 +704,25 @@ test('narrow-screen rails pin every hosted panel glow inside its panel box', () } }); +test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity', () => { + const f = fixture('right'); + const weather = element('weather-panel', { height: 300 }); + weather.computed.display = 'none'; + f.stack.children.push(weather); + weather.parentElement = f.stack; + + f.run(); + + assert.equal(f.stack.classList.contains('layout-exclusive'), false); + assert.equal(f.stack.dataset.expandedCount, '0'); + assert.equal(f.first.getAttribute('aria-hidden'), undefined); + assert.equal(f.second.getAttribute('aria-hidden'), undefined); + assert.equal( + weather.style.getPropertyValue('--right-panel-allocated-height'), + '', + ); +}); + test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => { const f = fixture('right'); const imagery = element('recent-imagery-panel', { height: 0 }); From 21a90b41442af3a1f7c56d86c95c376a174e92dd Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:14:25 -0500 Subject: [PATCH 09/86] fix(ui): recut #918 src/ui/rightPanelRail.js on current main --- src/ui/rightPanelRail.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 3b85bb896..b62be511c 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -72,7 +72,9 @@ export function layoutRightPanelRail({ // A floating (lifted-out) panel is not laid out by the rail. const panels = [...stack.children].filter( (panel) => - panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden, + panel.matches('[data-panel-id]:not(.panel-floating)') && + !panel.hidden && + getComputedStyle(panel).display !== 'none', ); if (!hud.visible || hud.variant !== 'tactical') { for (const panel of panels.filter((item) => From 123bf55f39a7dc12cad6215b362da0acc91c6c23 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:52:30 -0700 Subject: [PATCH 10/86] fix(qa): repoint stale mutation anchors after the September refactors The three mutation gates matched source text that has since moved or been reflowed, so 37 of their 108 reverts reported STALE and could no longer prove anything: - qa-floorhold-mutations (2): the neighbour-floor code moved from src/data/groundFloor.js into src/services/groundFloor.js (ac977bd). - qa-flyroute-mutations (8): src/cameraVerbs.js was reflowed by the formatter (2aca7ae); the anchors now match the formatted lines. - qa-firstrun-mutations (27): the launcher markup, styles and startup moved to src/ui/templates/welcome.html, src/ui/styles/first-run.css and src/app/startupChrome.js; the rail default to panelPositionControls.js and the radio ESC handling to radioBindings.js (a67bc60 and follow-ups). The tool-schema pin now edits a tool description, since the context-mode enum it used to edit no longer lives in tools.js. Every revert still reintroduces the same defect. Results: floor-hold 22/22, fly_route 19/19, first-run 67/67 caught, with all source files restored. Co-Authored-By: Claude Opus 5.5 --- scripts/qa-firstrun-mutations.mjs | 68 ++++++++++++++++-------------- scripts/qa-floorhold-mutations.mjs | 10 ++--- scripts/qa-flyroute-mutations.mjs | 20 ++++----- 3 files changed, 51 insertions(+), 47 deletions(-) diff --git a/scripts/qa-firstrun-mutations.mjs b/scripts/qa-firstrun-mutations.mjs index b8b7c9f5e..3ef331abd 100644 --- a/scripts/qa-firstrun-mutations.mjs +++ b/scripts/qa-firstrun-mutations.mjs @@ -30,12 +30,16 @@ const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome. const FILES = { keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'), module: path.join(ROOT, 'src', 'firstRunExperience.js'), - html: path.join(ROOT, 'index.html'), - css: path.join(ROOT, 'style.css'), + html: path.join(ROOT, 'src', 'ui', 'templates', 'welcome.html'), + css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'), voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'), - voiceTools: path.join(ROOT, 'server/providers/openai/tools.js'), - main: path.join(ROOT, 'src', 'standalone', 'startupChrome.js'), - ui: path.join(ROOT, 'src', 'ui.js'), + voiceDescriptions: path.join( + ROOT, + 'server/providers/openai/toolDescriptions.js', + ), + main: path.join(ROOT, 'src', 'app', 'startupChrome.js'), + ui: path.join(ROOT, 'src', 'ui', 'panelPositionControls.js'), + radio: path.join(ROOT, 'src', 'ui', 'radioBindings.js'), docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'), }; @@ -63,14 +67,14 @@ const MUTATIONS = [ { defect: 'the session flag is never read, so it re-nags on every reload', file: 'module', - from: "if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;", - to: '// session check removed', + from: " if (\n readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===\n 'dismissed'\n )\n return false;", + to: ' // session check removed', }, { defect: 'the durable flag is never read, so the checkbox does nothing', file: 'module', - from: "if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;", - to: '// durable check removed', + from: " if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')\n return false;", + to: ' // durable check removed', }, { defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it', @@ -105,7 +109,7 @@ const MUTATIONS = [ { defect: 'the guarded resolver stops catching, so a hostile getter escapes', file: 'module', - from: " try {\n return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }", + from: " try {\n return kind === 'session'\n ? globalThis.sessionStorage\n : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }", to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;", }, { @@ -131,8 +135,8 @@ const MUTATIONS = [ { defect: 'nothing watches for another surface taking the screen', file: 'module', - from: "surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });", - to: '/* observer never attached */', + from: " surfaceObserver?.observe(documentRef.body, {\n attributes: true,\n attributeFilter: ['class'],\n });", + to: ' /* observer never attached */', }, { defect: 'cockpit drops off the exclusive list and can stack with the launcher', @@ -155,8 +159,8 @@ const MUTATIONS = [ { defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again', file: 'module', - from: ' && root.getClientRects().length > 0\n && !coveredByOverlay();', - to: ' && root.getClientRects().length > 0;', + from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();', + to: ' root.getClientRects().length > 0;', }, { defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working', @@ -174,9 +178,9 @@ const MUTATIONS = [ // Anchored past the call so it cannot land on the cockpit disclosure's own // stopImmediatePropagation() a few lines below and prove nothing about this. defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things', - file: 'ui', - from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn', - to: ' event.stopPropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn', + file: 'radio', + from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||', + to: ' event.stopPropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||', }, { defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind', @@ -234,7 +238,7 @@ const MUTATIONS = [ { defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump', file: 'module', - from: " environmental: Object.freeze({", + from: ' environmental: Object.freeze({', to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({", }, { @@ -326,8 +330,8 @@ const MUTATIONS = [ { defect: 'the Context panel is left collapsed, hiding the mode that just started', file: 'module', - from: "styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });", - to: '// panel reveal removed', + from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });", + to: ' // panel reveal removed', }, { defect: 'the decision table is deleted, so the next editor re-litigates it blind', @@ -340,8 +344,8 @@ const MUTATIONS = [ { defect: 'disabling the focused tile drops the keyboard to mid-flight', file: 'module', - from: "for (const button of buttons) button.setAttribute('aria-disabled', String(next));", - to: 'for (const button of buttons) button.disabled = next;', + from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));", + to: ' for (const button of buttons) button.disabled = next;', }, { defect: 'Tab escapes into an app the visitor has not seen yet', @@ -370,8 +374,8 @@ const MUTATIONS = [ { defect: 'the card stays clickable through its fade-out (double-launch)', file: 'css', - from: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;', - to: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);', + from: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;', + to: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);', }, { defect: 'the launcher survives Clean-UI and recording mode', @@ -403,8 +407,8 @@ const MUTATIONS = [ // those instead and prove nothing about this feature. defect: "the launcher's reduced-motion opt-out is dropped", file: 'css', - from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow { transition: none; }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices { scroll-behavior: auto; }', - to: ' .first-run-nothing { transition: none; }', + from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow {\n transition: none;\n }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices {\n scroll-behavior: auto;\n }', + to: ' .first-run-nothing {\n transition: none;\n }', }, // ── Markup and startup ordering ─────────────────────────────────────────── @@ -442,21 +446,21 @@ const MUTATIONS = [ defect: 'the launcher is revealed before the loading cover yields', file: 'main', from: " loadingScreen.classList.add('hidden');", - to: ' initFirstRunExperience({ styleManager, dataManager });\n loadingScreen.classList.add(\'hidden\');', + to: " revealFirstRun();\n loadingScreen.classList.add('hidden');", }, { defect: 'the globe missions lose their DataManager and cannot enable layers', file: 'main', - from: 'initFirstRunExperience({ styleManager, dataManager });', - to: 'initFirstRunExperience({ styleManager });', + from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });', + to: 'firstRun = initializeWelcome?.({ styleManager });', }, // ── Voice: schema must not drift ────────────────────────────────────────── { defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)', - file: 'voiceTools', - from: " 'earthquakes',\n 'satellites',", - to: " 'earthquakes',\n 'infrastructure-mode',\n 'satellites',", + file: 'voiceDescriptions', + from: "'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers", + to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers", }, { defect: 'the instruction mapping is dropped, so voice cannot reach the modes', diff --git a/scripts/qa-floorhold-mutations.mjs b/scripts/qa-floorhold-mutations.mjs index 0e2ac9d62..b193ff169 100644 --- a/scripts/qa-floorhold-mutations.mjs +++ b/scripts/qa-floorhold-mutations.mjs @@ -26,7 +26,7 @@ const TESTS = [ ]; const FLIGHTS = 'src/layers/flights/motion.js'; -const FLOOR = 'src/data/groundFloor.js'; +const FLOOR = 'src/services/groundFloor.js'; const ALT = 'src/data/renderAltitude.js'; /** @type {Array<{defect: string, edits: Array<{file: string, from: string, to: string}>}>} */ @@ -133,8 +133,8 @@ const MUTATIONS = [ edits: [ { file: FLOOR, - from: ' if (lowest == null || h < lowest) lowest = h;', - to: ' if (lowest == null || h > lowest) lowest = h;', + from: ' if (lowest == null || h < lowest) lowest = h;', + to: ' if (lowest == null || h > lowest) lowest = h;', }, ], }, @@ -143,8 +143,8 @@ const MUTATIONS = [ edits: [ { file: FLOOR, - from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;', - to: ' return resolved >= 1 ? lowest : null;', + from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;', + to: ' return resolved >= 1 ? lowest : null;', }, ], }, diff --git a/scripts/qa-flyroute-mutations.mjs b/scripts/qa-flyroute-mutations.mjs index dbece6dba..d7d3d3325 100644 --- a/scripts/qa-flyroute-mutations.mjs +++ b/scripts/qa-flyroute-mutations.mjs @@ -35,17 +35,17 @@ const MUTATIONS = [ }, { defect: 'heading lerps in Cartesian space and cannot cross a U-turn', - from: ' const turned = Cesium.Matrix3.multiplyByVector(rotation, state.headingDir, _frameDir);', + from: ' const turned = Cesium.Matrix3.multiplyByVector(\n rotation,\n state.headingDir,\n _frameDir,\n );', to: ' const turned = Cesium.Cartesian3.lerp(state.headingDir, gaze, k, _frameDir);', }, { defect: 'the exact-180° bank direction becomes a floating-point coin toss', - from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS) return Math.PI;', + from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS)\n return Math.PI;', to: ' // branch removed', }, { defect: 'a cold cell is read as sea level (the 1.3 km underground bug)', - from: ' state.floorM = Number.isFinite(state.floorM) ? state.floorM : state.coldSeedFloorM;', + from: ' state.floorM = Number.isFinite(state.floorM)\n ? state.floorM\n : state.coldSeedFloorM;', to: ' state.floorM = carto.height;', }, { @@ -55,12 +55,12 @@ const MUTATIONS = [ }, { defect: 'the dolly races the fire-and-forget warm instead of arming for it', - from: ' if (canAnswer && !state.floorKnown && !state.departed && state.armS < ROUTE_ARM_S) {', + from: ' if (\n canAnswer &&\n !state.floorKnown &&\n !state.departed &&\n state.armS < ROUTE_ARM_S\n ) {', to: ' if (false) {', }, { defect: 'no mesh probe, so a permanently cold DEM is flown blind', - from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {', + from: ' if (\n cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {', to: ' if (false) {', }, { @@ -90,8 +90,8 @@ const MUTATIONS = [ }, { defect: 'the mesh probe fires every arming frame instead of once per route', - from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;', - to: ' if (cold.length && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;', + from: ' cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;', + to: ' cold.length &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;', }, { defect: 'one warm cell resolves a corridor whose other cells are unknown', @@ -105,13 +105,13 @@ const MUTATIONS = [ }, { defect: 'the hard clearance clamp reads the SMOOTHED floor, not the raw sample', - from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) + ROUTE_MIN_CLEARANCE_M,', + from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) +\n ROUTE_MIN_CLEARANCE_M,', to: ' state.floorM + ROUTE_MIN_CLEARANCE_M,', }, { defect: 'levelling also RE-FRAMES the camera (destination passed with the HPR)', - from: ' cam.setView({ orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });', - to: ' cam.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000), orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });', + from: ' cam.setView({\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });', + to: ' cam.setView({\n destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000),\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });', }, { defect: 'the OS reduced-motion preference is never consulted', From fa53eec53dd81622e68244d47c29471565e3d349 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:33:25 -0400 Subject: [PATCH 11/86] fix(search): refuse empty coordinate components instead of silently dropping them --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++++ src/search/coordinate-empty-components.test.mjs | 12 ++++++++++++ src/search/coordinateParser.js | 4 ++-- 4 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 src/search/coordinate-empty-components.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..598588ffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- Coordinate searches refuse empty latitude or longitude components instead of navigating to an unintended location. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..b490d308f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5127,3 +5127,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(search): refuse empty coordinate components instead of silently dropping them + +Require exactly two nonempty components while preserving comma, semicolon and whitespace pairs. Malformed separator input falls through to geocoding. diff --git a/src/search/coordinate-empty-components.test.mjs b/src/search/coordinate-empty-components.test.mjs new file mode 100644 index 000000000..48bca54ed --- /dev/null +++ b/src/search/coordinate-empty-components.test.mjs @@ -0,0 +1,12 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseCoordinateQuery } from './coordinateParser.js'; +test('search refuses omitted components and repeated separators', () => { + for (const q of ['30,, -97', ',30,-97', '30,-97,', '30;; -97', '30,; -97']) + assert.equal(parseCoordinateQuery(q), null, q); + for (const q of ['30,-97', '30; -97', '30 -97', 'N 30, W 97']) + assert.deepEqual( + [parseCoordinateQuery(q).lat, parseCoordinateQuery(q).lon], + [30, -97], + ); +}); diff --git a/src/search/coordinateParser.js b/src/search/coordinateParser.js index 261e2394c..cb8c85524 100644 --- a/src/search/coordinateParser.js +++ b/src/search/coordinateParser.js @@ -49,8 +49,8 @@ function splitComponents(query) { const parts = /[,;]/.test(text) ? text.split(/[,;]/) : text.split(/\s+(?=[+-]?[.\d]|[NSEW]\s*[+-]?[.\d])/i); - const trimmed = parts.map((part) => part.trim()).filter(Boolean); - return trimmed.length === 2 ? trimmed : null; + const trimmed = parts.map((part) => part.trim()); + return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null; } /** Present a parsed pair the way a coordinate is normally written. */ From 7bbf0aa55af84a6382283eff4640f2ddecde5f2e Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:41 -0400 Subject: [PATCH 12/86] fix: address review feedback for PR #1022 --- docs/CURRENT-STATE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b490d308f..8e4f58af1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4143,6 +4143,8 @@ and unreachable upstream (502/504) separately from road geometry. ### Place-search providers +Coordinate input requires exactly two nonempty components; extra comma or semicolon separators decline to geocoding rather than silently choosing a location. + Location search/fly-to, annotations and Radio location lookup receive one `placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes two offline providers first — a strict decimal-degree coordinate reader and an @@ -5127,8 +5129,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(search): refuse empty coordinate components instead of silently dropping them - -Require exactly two nonempty components while preserving comma, semicolon and whitespace pairs. Malformed separator input falls through to geocoding. From 9501dc121bce1552c4ce50dfa4d448064165f97e Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:33:01 -0500 Subject: [PATCH 13/86] test: preserve strict default timing with opt-in slow CI gate --- .../recent-imagery-gate-repair-proof.yml | 2 ++ scripts/qa-recent-imagery.mjs | 15 ++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/recent-imagery-gate-repair-proof.yml b/.github/workflows/recent-imagery-gate-repair-proof.yml index b613d9561..d93925e8c 100644 --- a/.github/workflows/recent-imagery-gate-repair-proof.yml +++ b/.github/workflows/recent-imagery-gate-repair-proof.yml @@ -31,6 +31,8 @@ jobs: cat /tmp/gev-dev.log exit 1 - name: Recent Imagery live gate + env: + QA_RECENT_IMAGERY_SLOW_RENDER: '1' run: node scripts/qa-recent-imagery.mjs - name: Dev log on failure if: failure() diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index f9601432f..f81209b9f 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -12,8 +12,10 @@ * Run: start the app (`./scripts/dev-fresh.sh`) and * node scripts/qa-recent-imagery.mjs * Env: QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR (default - * qa-shots/recent-imagery), QA_HEADFUL=1. Every wait is bounded. Without - * network access to NASA the run skips with exit code 0. + * qa-shots/recent-imagery), QA_HEADFUL=1. + * QA_RECENT_IMAGERY_SLOW_RENDER=1 permits loaded SwiftShader CI to use a + * 4 s ZOOM IN wait and 0.8 s height-stability tail (defaults: 2 s / 1.2 s). + * Every wait is bounded. Without NASA access the run skips with code 0. */ import fs from 'node:fs'; import path from 'node:path'; @@ -22,6 +24,9 @@ import puppeteer from 'puppeteer'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery'; const HEADFUL = process.env.QA_HEADFUL === '1'; +const SLOW_RENDER = process.env.QA_RECENT_IMAGERY_SLOW_RENDER === '1'; +const ZOOM_TIMEOUT_MS = SLOW_RENDER ? 4_000 : 2_000; +const HEIGHT_TAIL_SAMPLES = SLOW_RENDER ? 8 : 12; const STEP_TIMEOUT = 20_000; const AUSTIN = { lon: -97.74, lat: 30.27, height: 12_000 }; const PANEL = '#recent-imagery-panel'; @@ -1048,13 +1053,13 @@ try { ) <= target * 0.01, fitHeight, - 4_000, + ZOOM_TIMEOUT_MS, ); const zoomedHeight = await page.evaluate( () => window.__godsEyeView.viewer.camera.positionCartographic.height, ); check( - `ZOOM IN lands at ${Math.round(fitHeight / 1000)} km`, + `ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within ${ZOOM_TIMEOUT_MS / 1_000} s`, zoomed, `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`, ); @@ -1264,7 +1269,7 @@ try { ); await wait(100); } - const tail = (key) => new Set(samples.slice(-8).map((s) => s[key])); + const tail = (key) => new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key])); return { settled: tail('imagery').size === 1 && tail('cctv').size === 1, detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`, From 9d59e555c2b1bd2053296561f9dc01ba64ed551b Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:34:47 -0500 Subject: [PATCH 14/86] style: format slow-CI timing assertions --- scripts/qa-recent-imagery.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index f81209b9f..9c1d6700e 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -1059,7 +1059,7 @@ try { () => window.__godsEyeView.viewer.camera.positionCartographic.height, ); check( - `ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within ${ZOOM_TIMEOUT_MS / 1_000} s`, + `ZOOM IN reaches target within ${ZOOM_TIMEOUT_MS / 1_000} s`, zoomed, `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`, ); @@ -1269,7 +1269,8 @@ try { ); await wait(100); } - const tail = (key) => new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key])); + const tail = (key) => + new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key])); return { settled: tail('imagery').size === 1 && tail('cctv').size === 1, detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`, From 2357a1140907720f6bfc7037a992196e83057dfb Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:36:02 -0500 Subject: [PATCH 15/86] ci: remove fork-only Recent Imagery proof workflow per review --- .../recent-imagery-gate-repair-proof.yml | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/recent-imagery-gate-repair-proof.yml diff --git a/.github/workflows/recent-imagery-gate-repair-proof.yml b/.github/workflows/recent-imagery-gate-repair-proof.yml deleted file mode 100644 index d93925e8c..000000000 --- a/.github/workflows/recent-imagery-gate-repair-proof.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Recent Imagery gate repair proof - -on: - push: - branches: - - fix/recent-imagery-qa-relative-layout-20261003 - -permissions: - contents: read - -jobs: - browser-proof: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24.14.0 - cache: npm - - run: npm ci - - name: Start GEV - run: | - npm run dev -- --host 127.0.0.1 --port 4173 > /tmp/gev-dev.log 2>&1 & - for i in {1..90}; do - if curl -fsS http://127.0.0.1:4173/ >/dev/null; then - exit 0 - fi - sleep 1 - done - cat /tmp/gev-dev.log - exit 1 - - name: Recent Imagery live gate - env: - QA_RECENT_IMAGERY_SLOW_RENDER: '1' - run: node scripts/qa-recent-imagery.mjs - - name: Dev log on failure - if: failure() - run: cat /tmp/gev-dev.log From e5eac0c7f898864090211ebd096924cf13743bc5 Mon Sep 17 00:00:00 2001 From: hifiguy <34317110+hifiguy@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:57:15 -0400 Subject: [PATCH 16/86] fix: request HTTPS tiles for ion-backed Bing imagery --- CHANGELOG.md | 4 + docs/CURRENT-STATE.md | 6 ++ src/maps/imagery.js | 37 +++++++ src/maps/sourceFactories.test.mjs | 158 +++++++++++++++++++++++++++++- 4 files changed, 204 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..6c59600a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Bing Aerial and Bing Labels request HTTPS tiles on HTTP localhost through + their ion-backed providers, retaining ion and per-tile attribution without + widening the Content Security Policy. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..f68d8a194 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5018,6 +5018,12 @@ photoreal globe is hidden. Map credentials are passed to each source constructor rather than changing SDK-wide Google or ion defaults. +Bing Aerial and Bing Labels resolve their ion imagery endpoint with the supplied +token, then construct the Bing provider with `tileProtocol: 'https'`. Both styles +request HTTPS tiles even on HTTP localhost, with the existing Content Security +Policy unchanged. Ion attribution and Bing's per-tile credits are preserved; +other ion imagery assets continue through the SDK's standard ion factory. + The controller accepts other source registries without adding provider branches. Each instance caches source construction, ignores superseded scene changes and releases imagery layers/listeners on replacement. Destroy invalidates pending diff --git a/src/maps/imagery.js b/src/maps/imagery.js index 32fd3f375..d151b6ce6 100644 --- a/src/maps/imagery.js +++ b/src/maps/imagery.js @@ -25,5 +25,42 @@ export function createEsriImagery() { export function createIonImagery(style, accessToken) { accessToken = String(accessToken || '').trim(); if (!accessToken) throw new Error('Ion imagery requires an explicit token'); + if ( + style === Cesium.IonWorldImageryStyle.AERIAL || + style === Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS + ) { + return createBingIonImagery(style, accessToken); + } return Cesium.IonImageryProvider.fromAssetId(style, { accessToken }); } + +async function createBingIonImagery(style, accessToken) { + const endpoint = await new Cesium.Resource({ + url: `https://api.cesium.com/v1/assets/${style}/endpoint`, + queryParameters: { access_token: accessToken }, + }).fetchJson(); + if ( + endpoint?.type !== 'IMAGERY' || + endpoint.externalType !== 'BING' || + typeof endpoint.options?.url !== 'string' + ) { + throw new Error('Ion world imagery did not return a Bing imagery endpoint'); + } + // Bing otherwise follows the page's HTTP scheme on localhost. Resolve its + // ion-issued credentials through public APIs so tiles use HTTPS without + // weakening CSP, changing SDK defaults or touching provider internals. + const { url, ...options } = endpoint.options; + const provider = await Cesium.BingMapsImageryProvider.fromUrl(url, { + ...options, + tileProtocol: 'https', + }); + const ionCredits = (endpoint.attributions ?? []).map( + ({ html, collapsible }) => new Cesium.Credit(html, collapsible === false), + ); + const getTileCredits = provider.getTileCredits.bind(provider); + provider.getTileCredits = (x, y, level) => [ + ...ionCredits, + ...(getTileCredits(x, y, level) ?? []), + ]; + return provider; +} diff --git a/src/maps/sourceFactories.test.mjs b/src/maps/sourceFactories.test.mjs index 15571666d..d3aaa5068 100644 --- a/src/maps/sourceFactories.test.mjs +++ b/src/maps/sourceFactories.test.mjs @@ -89,7 +89,7 @@ test('imagery and terrain pass their own ion token without relying on SDK defaul calls.push({ kind: 'terrain', resource, options }); return { id: 'terrain' }; }; - await createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, 'imagery-token'); + await createIonImagery(12345, 'imagery-token'); const result = await createWorldTerrain('terrain-token'); assert.equal(calls[0].options.accessToken, 'imagery-token'); assert.equal(calls[1].options.accessToken, 'terrain-token'); @@ -104,6 +104,162 @@ test('imagery and terrain pass their own ion token without relying on SDK defaul } }); +test('both Bing ion stacks use HTTPS and retain ion and per-tile attribution', async (t) => { + const defaultToken = Cesium.Ion.defaultAccessToken; + const metadataCalls = []; + const bingCalls = []; + const perTileCalls = []; + const bingCredit = new Cesium.Credit('Bing tile credit', true); + const endpoint = { + type: 'IMAGERY', + externalType: 'BING', + options: Object.freeze({ + url: 'https://dev.virtualearth.net', + key: 'ion-issued-bing-fixture', + mapStyle: 'Aerial', + tileProtocol: 'http', + }), + attributions: [ + { html: 'Ion required credit', collapsible: false }, + { html: 'Ion optional credit', collapsible: true }, + ], + }; + t.mock.method(Cesium.Resource.prototype, 'fetchJson', async function () { + metadataCalls.push({ + url: this.url, + token: this.queryParameters.access_token, + }); + const mapStyle = this.url.includes( + `/assets/${Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS}/`, + ) + ? 'AerialWithLabels' + : 'Aerial'; + return { + ...endpoint, + options: Object.freeze({ ...endpoint.options, mapStyle }), + }; + }); + t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', () => + assert.fail('Bing must not inherit the local HTTP page protocol'), + ); + t.mock.method( + Cesium.BingMapsImageryProvider, + 'fromUrl', + async (url, options) => { + bingCalls.push({ url, options }); + const provider = { + errorEvent: new Cesium.Event(), + getTileCredits(x, y, level) { + assert.equal(this, provider); + perTileCalls.push([x, y, level]); + return level === 0 ? undefined : [bingCredit]; + }, + }; + return provider; + }, + ); + for (const style of [ + Cesium.IonWorldImageryStyle.AERIAL, + Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS, + ]) { + const provider = await createIonImagery(style, ' own-ion-token '); + const credits = provider.getTileCredits(3, 4, 5); + assert.deepEqual( + credits.map(({ html, showOnScreen }) => ({ html, showOnScreen })), + [ + { html: 'Ion required credit', showOnScreen: true }, + { html: 'Ion optional credit', showOnScreen: false }, + { html: 'Bing tile credit', showOnScreen: true }, + ], + ); + assert.equal(credits[2], bingCredit); + assert.equal(provider.getTileCredits(0, 0, 0).length, 2); + assert.ok(provider.errorEvent instanceof Cesium.Event); + assert.equal(metadataCalls.at(-1).token, 'own-ion-token'); + const requested = new URL(metadataCalls.at(-1).url); + assert.equal(requested.origin, 'https://api.cesium.com'); + assert.equal(requested.pathname, `/v1/assets/${style}/endpoint`); + } + assert.deepEqual(perTileCalls, [ + [3, 4, 5], + [0, 0, 0], + [3, 4, 5], + [0, 0, 0], + ]); + assert.equal(bingCalls.length, 2); + for (const [index, { url, options }] of bingCalls.entries()) { + assert.equal(url, 'https://dev.virtualearth.net'); + assert.deepEqual(options, { + key: 'ion-issued-bing-fixture', + mapStyle: index === 0 ? 'Aerial' : 'AerialWithLabels', + tileProtocol: 'https', + }); + } + assert.equal(endpoint.options.tileProtocol, 'http'); + assert.equal(Cesium.Ion.defaultAccessToken, defaultToken); +}); + +test('Bing ion metadata and construction failures propagate without another source', async (t) => { + const style = Cesium.IonWorldImageryStyle.AERIAL; + const metadataError = new Error('ion metadata fixture failure'); + const providerError = new Error('Bing construction fixture failure'); + let metadata = () => { + throw metadataError; + }; + let bingCalls = 0; + t.mock.method(Cesium.Resource.prototype, 'fetchJson', async () => metadata()); + t.mock.method(Cesium.BingMapsImageryProvider, 'fromUrl', async () => { + bingCalls += 1; + throw providerError; + }); + t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', () => + assert.fail('Bing failures must not silently use an alternate provider'), + ); + await assert.rejects(createIonImagery(style, 'test-token'), metadataError); + for (const endpoint of [ + undefined, + { + type: 'TERRAIN', + externalType: 'BING', + options: { url: 'https://example.test' }, + }, + { + type: 'IMAGERY', + externalType: 'OTHER', + options: { url: 'https://example.test' }, + }, + { type: 'IMAGERY', externalType: 'BING', options: {} }, + ]) { + metadata = () => endpoint; + await assert.rejects( + createIonImagery(style, 'test-token'), + /Bing imagery endpoint/, + ); + } + assert.equal(bingCalls, 0); + metadata = () => ({ + type: 'IMAGERY', + externalType: 'BING', + options: { url: 'https://dev.virtualearth.net', key: 'bing-fixture' }, + }); + await assert.rejects(createIonImagery(style, 'test-token'), providerError); + assert.equal(bingCalls, 1); +}); + +test('other ion imagery assets keep the standard SDK factory', async (t) => { + const expected = {}; + const calls = []; + t.mock.method(Cesium.Resource.prototype, 'fetchJson', () => + assert.fail('generic assets must use the SDK ion factory'), + ); + t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', async (...args) => { + calls.push(args); + return expected; + }); + assert.equal(await createIonImagery(12345, ' custom-token '), expected); + assert.deepEqual(calls, [[12345, { accessToken: 'custom-token' }]]); +}); + test('cancellation after ion metadata prevents terrain construction', async () => { const originalResource = Cesium.IonResource.fromAssetId; const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl; From f71da95e91c9ec65227379b992d7144179875961 Mon Sep 17 00:00:00 2001 From: Crew Leo Agile dev team Date: Tue, 29 Sep 2026 15:57:58 +0200 Subject: [PATCH 17/86] chore: scaffold GNSS interference layer branch (Hex project) From eefcb338b375a03ebd1c6956b1e9b17cffa11e4a Mon Sep 17 00:00:00 2001 From: Leo Pechnicki Date: Tue, 29 Sep 2026 16:34:33 +0200 Subject: [PATCH 18/86] feat(layers): GNSS interference layer from ADS-B NIC/NACp integrity Bins airborne ADS-B v1/v2 aircraft that report degraded navigation integrity (NIC < 7, NACp < 8, or readsb's GPS-loss flag) into 0.5 degree cells over a rolling 30-minute window, coloured with the gpsjam.org bands. Keyless: reads the capped adsb.lol point snapshot around the view through /api/gnss-integrity, which returns integrity fields only, caches 60 s per whole-degree anchor, backs off on upstream 429 and serves the last snapshot on failure. Adds scripts/qa-gnss.mjs. Built by the Hex project (Crew Leo Agile dev team). Co-Authored-By: Claude --- CHANGELOG.md | 8 + DATA_SOURCES.md | 3 +- docs/CURRENT-STATE.md | 3 + package.json | 2 + scripts/format-scope.json | 9 + scripts/package-boundaries.json | 23 +++ scripts/qa-gnss.mjs | 212 ++++++++++++++++++++++ server/providers/gnssIntegrity.js | 135 ++++++++++++++ server/providers/local.js | 2 + src/app/constructCatalog.js | 2 + src/app/constructCatalog.test.mjs | 1 + src/app/layers/gnss.js | 12 ++ src/data/dataCredits.js | 2 +- src/data/gnssIntegrityProxy.test.mjs | 183 +++++++++++++++++++ src/data/layerState.js | 5 + src/data/layerState.test.mjs | 17 ++ src/data/layerStateTokenLedger.test.mjs | 2 +- src/data/layerStateTokenReservations.json | 1 + src/layers/gnss/index.js | 200 ++++++++++++++++++++ src/layers/gnss/ownership.test.mjs | 161 ++++++++++++++++ src/layers/gnss/records.js | 171 +++++++++++++++++ src/layers/gnss/records.test.mjs | 133 ++++++++++++++ src/layers/gnss/source.js | 37 ++++ src/layers/gnss/source.test.mjs | 59 ++++++ src/tooling/previewServing.test.mjs | 2 + src/ui/layerPanel.js | 8 +- 26 files changed, 1389 insertions(+), 4 deletions(-) create mode 100644 scripts/qa-gnss.mjs create mode 100644 server/providers/gnssIntegrity.js create mode 100644 src/app/layers/gnss.js create mode 100644 src/data/gnssIntegrityProxy.test.mjs create mode 100644 src/layers/gnss/index.js create mode 100644 src/layers/gnss/ownership.test.mjs create mode 100644 src/layers/gnss/records.js create mode 100644 src/layers/gnss/records.test.mjs create mode 100644 src/layers/gnss/source.js create mode 100644 src/layers/gnss/source.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c9d15d897..c0623687c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -155,6 +155,14 @@ largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed under NLOD 2.0. +- GNSS Interference layer (Events group): ADS-B aircraft that report degraded + navigation accuracy (NIC < 7 or NACp < 8, or readsb's GPS-loss flag) are + binned into 0.5° cells over a rolling 30-minute window and coloured with the + gpsjam.org bands. Keyless, worldwide wherever adsb.lol has receivers; reads + a capped 250 nm snapshot around the camera through `/api/gnss-integrity`, + which returns integrity fields only and serves the last snapshot when the + upstream fails. + ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) - Ship each bundled data pack once. The region, marine, admin-boundary, diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 040e53afc..dd7dd37d9 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -25,7 +25,7 @@ How to read this: | **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org | -| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` | +| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Interference layer (per-aircraft NIC/NACp integrity only, 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 | | **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) | @@ -78,6 +78,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. +- **GNSS interference (adsb.lol integrity fields).** The GNSS Interference layer reads the same keyless 250 nm point snapshot around the camera (anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. Cells use the gpsjam.org method (NIC < 7 or NACp < 8 marks an aircraft degraded; `100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %; at least 3 aircraft per 0.5° cell over a rolling 30-minute window). Degraded navigation accuracy indicates probable jamming or spoofing, not a confirmed source or location. Verified 2026-09-29. - **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. - **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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a9aeeddbc..b1abd9f15 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3069,8 +3069,11 @@ its criteria cannot be silently ignored. | 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) | | Street Level 📷 | Street-level imagery; Mapillary is the first provider (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, cached 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 Interference 📡 | adsb.lol point snapshot, ADS-B NIC/NACp integrity binned per 0.5° cell (gpsjam.org method, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | 60s (server cache 60 s; 30 min rolling window) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | +GNSS Interference fetches a capped adsb.lol snapshot around the view anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band. It shows probable interference, not a located emitter. + Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. Street Level is a collapsible right-rail panel (`#street-level-panel`, layer diff --git a/package.json b/package.json index 4b2829313..d5c900cbd 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ "./layers/recent-imagery": "./src/layers/recentImagery/index.js", "./layers/recent-imagery/source": "./src/layers/recentImagery/source.js", "./layers/perimeters": "./src/layers/perimeters/index.js", + "./layers/gnss": "./src/layers/gnss/index.js", "./sources/firms-csv": "./src/data/firmsCsv.js", "./layers/firms": "./src/layers/firms/index.js", "./layers/satellites": "./src/layers/satellites/index.js", @@ -238,6 +239,7 @@ "./layers/cctv/source": "./src/layers/cctv/source.js", "./layers/earthquakes/source": "./src/layers/earthquakes/source.js", "./layers/perimeters/source": "./src/layers/perimeters/source.js", + "./layers/gnss/source": "./src/layers/gnss/source.js", "./layers/firms/source": "./src/layers/firms/source.js", "./layers/installations/source": "./src/layers/installations/source.js", "./layers/launches/source": "./src/layers/launches/source.js", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 4973bd484..f87618780 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -826,6 +826,15 @@ "server/providers/firePerimeters.js", "src/data/cyclonesProxy.test.mjs", "src/data/firePerimetersProxy.test.mjs", + "server/providers/gnssIntegrity.js", + "src/data/gnssIntegrityProxy.test.mjs", + "src/app/layers/gnss.js", + "src/layers/gnss/index.js", + "src/layers/gnss/records.js", + "src/layers/gnss/source.js", + "src/layers/gnss/records.test.mjs", + "src/layers/gnss/source.test.mjs", + "src/layers/gnss/ownership.test.mjs", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index fc7308ab5..18e8b4e3c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2029,6 +2029,10 @@ "src/layers/perimeters/records.js", "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js", + "src/app/layers/gnss.js", + "src/layers/gnss/index.js", + "src/layers/gnss/source.js", + "src/layers/gnss/records.js", "src/layers/cctv/sourcePolicy.js", "src/sources/reference.js", "src/search/nominatim.js", @@ -2613,6 +2617,10 @@ "src/layers/perimeters/records.js", "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js", + "src/app/layers/gnss.js", + "src/layers/gnss/index.js", + "src/layers/gnss/source.js", + "src/layers/gnss/records.js", "src/layers/cctv/sourcePolicy.js", "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -3324,6 +3332,21 @@ ], "external": ["cesium"] }, + "gnss": { + "exports": ["./layers/gnss"], + "modules": [ + "src/layers/gnss/index.js", + "src/sources/httpBody.js", + "src/layers/gnss/source.js", + "src/layers/gnss/records.js" + ], + "external": ["cesium"] + }, + "gnss-source": { + "exports": ["./layers/gnss/source"], + "modules": ["src/sources/httpBody.js", "src/layers/gnss/source.js"], + "external": [] + }, "perimeters-source": { "exports": ["./layers/perimeters/source"], "modules": [ diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs new file mode 100644 index 000000000..81b430f13 --- /dev/null +++ b/scripts/qa-gnss.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node +/** + * qa-gnss.mjs — headless proof for the GNSS Interference layer. + * + * Drives the REAL app in headless Chromium against a dev server (keyless). + * The live section reads adsb.lol through `/api/gnss-integrity` (60 s server + * cache, one upstream read per whole-degree anchor); the other sections + * intercept that route so they are deterministic. + * + * (i) LIVE — over the southern Baltic the layer settles with cells, + * no error, and legend band counts that sum to the cell count. + * (ii) BANDS (synthetic) — a 10-aircraft cell with 5 degraded is high, + * a 3-aircraft cell with 1 degraded is low (first bad discounted), + * a 2-aircraft cell is withheld; `stale: true` reaches getStats. + * (iii) FAILURE — the route intercepted to 502: stats.error is set and the + * cells already on the globe are kept. + * (iv) DISABLE — the layer's data source is hidden. + * + * Visual proof saved to qa-shots/gnss-*.png (gitignored). + * + * Run: node scripts/qa-gnss.mjs --url http://localhost:4173 + * Exits non-zero on any FAIL. Does not commit anything. + */ + +import puppeteer from 'puppeteer'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); +const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots'); + +const argv = process.argv.slice(2); +const getOpt = (name, dflt) => { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const APP_URL = getOpt('--url', 'http://localhost:4173'); +const HEADFUL = argv.includes('--headful'); +const LAYER_ID = 'gnss-interference'; + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** Boot the app, dismiss the intro and place the camera straight down. */ +async function boot(page, { lon, lat, height }) { + await page.goto(`${APP_URL}/?welcome=0`, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForFunction( + () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + { timeout: 60000 }, + ); + await sleep(5000); + await page.keyboard.press('Escape'); + await page.evaluate((lo, la, h) => { + const gev = window.__godsEyeView; + const d2r = Math.PI / 180; + // The app's intro flyTo animation clobbers a setView issued mid-flight. + try { gev.viewer.camera.cancelFlight(); } catch { /* no flight active */ } + gev.viewer.camera.setView({ + destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: lo * d2r, latitude: la * d2r, height: h, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + }, lon, lat, height); +} + +/** Enable (or refresh) the layer and poll until it settles. */ +async function enableAndSettle(page, { timeoutS = 30, refresh = false } = {}) { + return page.evaluate(async (id, tS, again) => { + const dm = window.__godsEyeView.dataManager; + const layer = dm.layers.get(id).module; + if (again) await layer.update(); + else await dm.setEnabled(id, true); + let stats = null; + for (let i = 0; i < tS; i++) { + stats = layer.getStats(); + if (stats.count > 0 || stats.error) break; + await new Promise((r) => setTimeout(r, 1000)); + } + const legend = layer.getRowControls().legend.map(({ label, count }) => ({ label, count })); + const ds = window.__godsEyeView.viewer.dataSources.getByName(id)[0]; + return { stats, legend, entities: ds?.entities.values.length ?? -1, shown: ds?.show ?? null }; + }, LAYER_ID, timeoutS, refresh); +} + +const fixtureRow = (hex, lat, lon, degraded) => ({ + hex, lat, lon, nic: degraded ? 0 : 8, nacp: degraded ? 0 : 10, gpsLost: false, degraded, +}); + +/** Three synthetic 0.5° cells: high (5/10 bad), low (1/3 bad), withheld (2 aircraft). */ +function fixturePayload() { + const rows = []; + const hex = (prefix, i) => `${prefix}${i.toString(16).padStart(2, '0')}`; + for (let i = 0; i < 10; i++) rows.push(fixtureRow(hex('a000', i), 55.1 + i * 0.01, 19.1, i < 5)); + for (let i = 0; i < 3; i++) rows.push(fixtureRow(hex('b000', i), 54.1 + i * 0.01, 18.1, i === 0)); + for (let i = 0; i < 2; i++) rows.push(fixtureRow(hex('c000', i), 53.1 + i * 0.01, 17.1, true)); + return { fetchedAt: Date.now(), anchor: { lat: 55, lon: 19 }, radiusNm: 250, rows, stale: true }; +} + +async function main() { + console.log('\nGNSS Interference proof (qa-gnss)'); + console.log(` App URL : ${APP_URL}\n`); + try { + const res = await fetch(APP_URL); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (e) { + console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`); + process.exit(2); + } + fs.mkdirSync(SHOTS_DIR, { recursive: true }); + const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH + || (await puppeteer.executablePath().catch(() => null)) || undefined; + const browser = await puppeteer.launch({ + headless: HEADFUL ? false : 'new', + ...(executablePath && fs.existsSync(executablePath) ? { executablePath } : {}), + args: [ + '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', + '--disable-dev-shm-usage', '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', '--window-size=1440,900', + ], + }); + + let exitCode = 0; + const errors = []; + try { + // ── (i) LIVE ────────────────────────────────────────────────────────── + console.log('(i) LIVE — adsb.lol integrity snapshot over the southern Baltic...'); + const live = await browser.newPage(); + await live.setViewport({ width: 1440, height: 900 }); + live.on('pageerror', (e) => errors.push(e.message)); + await boot(live, { lon: 19.5, lat: 55.0, height: 1_600_000 }); + const l = await enableAndSettle(live); + const legendTotal = l.legend.reduce((sum, { count }) => sum + count, 0); + record('LIVE: cells drawn, no error', + l.stats.count > 0 && !l.stats.error && l.entities === l.stats.count, + `cells=${l.stats.count} entities=${l.entities} error=${JSON.stringify(l.stats.error)}`); + record('LIVE: legend band counts sum to the cell count', legendTotal === l.stats.count, + JSON.stringify(l.legend)); + await sleep(3000); + await live.screenshot({ path: path.join(SHOTS_DIR, 'gnss-live-baltic.png') }); + await live.close(); + + // ── (ii) BANDS ──────────────────────────────────────────────────────── + console.log('\n(ii) BANDS — synthetic integrity rows through the real layer...'); + const page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900 }); + page.on('pageerror', (e) => errors.push(e.message)); + await page.setRequestInterception(true); + let mode = 'fixture'; + page.on('request', (req) => { + if (!req.url().includes('/api/gnss-integrity')) { req.continue(); return; } + if (mode === 'fixture') { + req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(fixturePayload()) }); + } else { + req.respond({ status: 502, contentType: 'application/json', + body: JSON.stringify({ error: 'gnss_integrity_unavailable' }) }); + } + }); + await boot(page, { lon: 18.6, lat: 54.6, height: 450_000 }); + const f = await enableAndSettle(page); + const band = (prefix) => f.legend.find(({ label }) => label.startsWith(prefix))?.count; + record('BANDS: 2 cells shown, the 2-aircraft cell withheld', + f.stats.count === 2 && f.entities === 2, `cells=${f.stats.count} entities=${f.entities}`); + record('BANDS: 5/10 degraded is high, 1/3 degraded is low', + band('Over') === 1 && band('Under') === 1 && band('2') === 0, JSON.stringify(f.legend)); + record('BANDS: stale snapshot is reported', f.stats.stale === true, `stale=${f.stats.stale}`); + await page.waitForFunction(() => window.__godsEyeView.viewer.scene.globe.tilesLoaded, + { timeout: 60000 }).catch(() => {}); + await sleep(2000); + await page.screenshot({ path: path.join(SHOTS_DIR, 'gnss-synthetic-bands.png') }); + + // ── (iii) FAILURE ───────────────────────────────────────────────────── + console.log('\n(iii) FAILURE — /api/gnss-integrity intercepted to 502...'); + mode = 'error'; + const e = await enableAndSettle(page, { refresh: true, timeoutS: 1 }); + record('FAILURE: error surfaced, cells kept', + Boolean(e.stats.error) && e.stats.count === 2 && e.entities === 2, + `error=${JSON.stringify(e.stats.error)} cells=${e.stats.count}`); + + // ── (iv) DISABLE ────────────────────────────────────────────────────── + console.log('\n(iv) DISABLE...'); + const shown = await page.evaluate(async (id) => { + await window.__godsEyeView.dataManager.setEnabled(id, false); + return window.__godsEyeView.viewer.dataSources.getByName(id)[0]?.show; + }, LAYER_ID); + record('DISABLE: data source hidden', shown === false, `show=${shown}`); + record('no uncaught browser errors', errors.length === 0, errors.slice(0, 3).join(' | ')); + } catch (err) { + console.error('\x1b[31mHarness error:\x1b[0m', err); + exitCode = 3; + } finally { + await browser.close(); + } + + const pass = results.filter((r) => r.ok).length; + const fail = results.length - pass; + console.log('\n' + '─'.repeat(60)); + console.log(` RESULT: ${pass} passed, ${fail} failed`); + console.log(` Shots : ${SHOTS_DIR}/gnss-*.png`); + console.log('─'.repeat(60) + '\n'); + process.exit(exitCode || (fail > 0 ? 1 : 0)); +} + +main().catch((e) => { console.error(e); process.exit(3); }); diff --git a/server/providers/gnssIntegrity.js b/server/providers/gnssIntegrity.js new file mode 100644 index 000000000..a275940f2 --- /dev/null +++ b/server/providers/gnssIntegrity.js @@ -0,0 +1,135 @@ +import { normalizeGnssAircraft } from '../../src/layers/gnss/records.js'; +import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js'; +import { requiredFiniteQueryNumber } from './common/query.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; + +// adsb.lol regional snapshot (keyless, ODbL). Only the integrity fields the +// GNSS layer needs leave this proxy, so the browser never sees the full feed. +const RADIUS_NM = 250; +const CACHE_MS = 60_000; +const CACHE_MAX = 64; +const COOLDOWN_MS = 60_000; +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +const TIMEOUT_MS = 10_000; + +/** Snap a view anchor to a whole degree so nearby views share one upstream read. */ +export function gnssIntegrityAnchor(url) { + const params = new URL(url || '/', 'http://localhost').searchParams; + const lat = requiredFiniteQueryNumber(params, 'lat'); + const lon = requiredFiniteQueryNumber(params, 'lon'); + if (!Number.isFinite(lat) || lat < -90 || lat > 90) return null; + if (!Number.isFinite(lon) || lon < -180 || lon > 180) return null; + return { lat: Math.round(lat), lon: Math.round(lon) }; +} + +/** Fixed-origin, bounded adsb.lol integrity route for dev and preview. */ +export function gnssIntegrityProxy({ + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), +} = {}) { + const cache = new Map(); + const inFlight = new Map(); + const allow = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 600 }); + let cooldownUntil = 0; + + async function load({ lat, lon }) { + if (now() < cooldownUntil) + throw Object.assign(new Error('cooling_down'), { status: 429 }); + const signal = AbortSignal.timeout(TIMEOUT_MS); + const response = await fetchImpl( + `https://api.adsb.lol/v2/lat/${lat}/lon/${lon}/dist/${RADIUS_NM}`, + { + signal, + redirect: 'error', + headers: { + Accept: 'application/json', + 'User-Agent': 'gods-eye-view-gnss-integrity/1.0', + }, + }, + ); + if (!response.ok) { + await response.body?.cancel(); + if (response.status === 429) { + const retryS = Number(response.headers.get('retry-after')); + cooldownUntil = + now() + + (Number.isFinite(retryS) && retryS > 0 + ? Math.min(retryS * 1000, 10 * COOLDOWN_MS) + : COOLDOWN_MS); + } + throw new Error('upstream_unavailable'); + } + const payload = await readResponseJsonCapped( + response, + MAX_RESPONSE_BYTES, + signal, + ); + const rows = normalizeGnssAircraft(payload); + if (!rows) throw new Error('invalid_snapshot'); + return { + fetchedAt: now(), + anchor: { lat, lon }, + radiusNm: RADIUS_NM, + rows, + }; + } + + async function acquire(anchor) { + const key = `${anchor.lat},${anchor.lon}`; + const previous = cache.get(key); + if (previous && now() - previous.fetchedAt < CACHE_MS) + return { value: previous, stale: false }; + try { + const { promise } = coalesceProxyRequest(inFlight, key, async () => { + const value = await load(anchor); + cache.delete(key); + cache.set(key, value); + while (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value); + return value; + }); + return { value: await promise, stale: false }; + } catch (error) { + if (previous) return { value: previous, stale: true }; + throw error; + } + } + + async function handler(req, res) { + const json = (status, value, stale = false) => { + if (res.destroyed) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(status === 405 ? { Allow: 'GET' } : {}), + ...(status === 429 ? { 'Retry-After': '60' } : {}), + ...(stale ? { 'X-Data-Stale': 'true' } : {}), + }); + res.end(JSON.stringify(value)); + }; + if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' }); + const path = (req.url || '/').split('?')[0]; + if (path !== '/' && path !== '') + return json(404, { error: 'unknown_route' }); + const anchor = gnssIntegrityAnchor(req.url); + if (!anchor) return json(400, { error: 'invalid_anchor' }); + if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' }); + try { + const { value, stale } = await acquire(anchor); + json(200, stale ? { ...value, stale: true } : value, stale); + } catch (error) { + json(error.status === 429 ? 429 : 502, { + error: 'gnss_integrity_unavailable', + }); + } + } + + return { + name: 'gnss-integrity', + configureServer({ middlewares }) { + middlewares.use('/api/gnss-integrity', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/gnss-integrity', handler); + }, + }; +} diff --git a/server/providers/local.js b/server/providers/local.js index 9acbade31..9fb6ae9c0 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -24,6 +24,7 @@ import { mapillaryProxy } from './mapillary.js'; import { keySetupEndpoint } from '../standalone/key-setup.js'; import { weatherProxy } from './weather.js'; import { firePerimetersProxy } from './firePerimeters.js'; +import { gnssIntegrityProxy } from './gnssIntegrity.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; @@ -60,6 +61,7 @@ function localProviderPlugins({ realtime } = {}) { cycloneProxy(), mapillaryProxy(), firePerimetersProxy(), + gnssIntegrityProxy(), keySetupEndpoint(), ]; } diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 80315fe85..832f0ef9e 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -25,6 +25,7 @@ import { createApplicationAwareness } from './layers/militaryAwareness.js'; import { createApplicationFirms } from './layers/firms.js'; import { createApplicationEarthquakes } from './layers/earthquakes.js'; import { createApplicationFirePerimeters } from './layers/perimeters.js'; +import { createApplicationGnssInterference } from './layers/gnss.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; @@ -120,6 +121,7 @@ export function createApplicationCatalog({ createApplicationFirePerimeters({ source: sources['fire-perimeters'], }), + createApplicationGnssInterference(), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ surface, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index f42e82c7c..f1e4c8c4b 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -60,6 +60,7 @@ test('catalogs construct distinct layers and classification from their supplied '2026-09-21T12:00:00.000Z', ); assert.ok(first.get('fire-perimeters')); + assert.ok(first.get('gnss-interference')); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); assert.deepEqual( diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js new file mode 100644 index 000000000..49c74c268 --- /dev/null +++ b/src/app/layers/gnss.js @@ -0,0 +1,12 @@ +import { + createAdsbGnssSource, + createGnssInterferenceLayer, +} from '../../layers/gnss/index.js'; + +/** Wire the adsb.lol GNSS interference layer into the application catalog. */ +export function createApplicationGnssInterference(options = {}) { + return createGnssInterferenceLayer({ + source: createAdsbGnssSource(), + ...options, + }); +} diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 2967f0a2b..bdfbb4cde 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -104,7 +104,7 @@ export const DATA_CREDITS = [ { key: 'adsblol', html: - 'Military flights, aircraft traces & bounded regional flight fallback: ' + + 'Military flights, aircraft traces, bounded regional flight fallback & GNSS interference: ' + 'adsb.lol ' + '(ODbL 1.0)', }, diff --git a/src/data/gnssIntegrityProxy.test.mjs b/src/data/gnssIntegrityProxy.test.mjs new file mode 100644 index 000000000..17370a3f6 --- /dev/null +++ b/src/data/gnssIntegrityProxy.test.mjs @@ -0,0 +1,183 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + gnssIntegrityAnchor, + gnssIntegrityProxy, +} from '../../server/providers/gnssIntegrity.js'; + +const snapshot = { + now: 1, + ac: [ + { + hex: '4ca7b5', + type: 'adsb_icao', + version: 2, + alt_baro: 36000, + lat: 50.1, + lon: 19.9, + nic: 0, + nac_p: 0, + seen_pos: 1, + flight: 'RYR1AB ', + r: 'EI-ABC', + }, + { hex: '000001', type: 'mlat', lat: 50, lon: 20, nic: 0, nac_p: 0 }, + ], +}; + +function install(options = {}, hook = 'configureServer') { + let handler; + const plugin = gnssIntegrityProxy(options); + assert.equal(plugin.name, 'gnss-integrity'); + plugin[hook]({ + middlewares: { + use(path, callback) { + assert.equal(path, '/api/gnss-integrity'); + handler = callback; + }, + }, + }); + return async (url = '/?lat=50&lon=20', method = 'GET', peer = 'local') => { + const res = { + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(body) { + this.body = JSON.parse(body); + }, + }; + await handler({ url, method, socket: { remoteAddress: peer } }, res); + return res; + }; +} + +test('anchors snap to whole degrees and reject missing or out-of-range values', () => { + assert.deepEqual(gnssIntegrityAnchor('/?lat=50.44&lon=19.51'), { + lat: 50, + lon: 20, + }); + assert.equal(gnssIntegrityAnchor('/?lat=91&lon=0'), null); + assert.equal(gnssIntegrityAnchor('/?lat=0&lon=-181'), null); + assert.equal(gnssIntegrityAnchor('/?lat=0'), null); + assert.equal(gnssIntegrityAnchor('/?lat=abc&lon=1'), null); +}); + +for (const hook of ['configureServer', 'configurePreviewServer']) { + test(`${hook}: fetches the fixed adsb.lol origin and returns only integrity facts`, async () => { + const calls = []; + const request = install( + { + now: () => 1234, + fetchImpl: async (url, options) => { + calls.push(url); + assert.ok(options.signal instanceof AbortSignal); + assert.equal(options.redirect, 'error'); + return Response.json(snapshot); + }, + }, + hook, + ); + const res = await request( + '/?lat=50.2&lon=19.8&url=https://invalid.example', + ); + assert.equal(res.status, 200); + assert.deepEqual(calls, ['https://api.adsb.lol/v2/lat/50/lon/20/dist/250']); + assert.deepEqual(res.body, { + fetchedAt: 1234, + anchor: { lat: 50, lon: 20 }, + radiusNm: 250, + rows: [ + { + hex: '4ca7b5', + lat: 50.1, + lon: 19.9, + nic: 0, + nacp: 0, + gpsLost: false, + degraded: true, + }, + ], + }); + assert.equal(res.headers['Cache-Control'], 'no-store'); + }); +} + +test('snapshots are cached per anchor and concurrent reads share one upstream call', async () => { + let clock = 0; + let calls = 0; + const request = install({ + now: () => clock, + fetchImpl: async () => { + calls += 1; + return Response.json(snapshot); + }, + }); + await Promise.all([request(), request('/?lat=50.3&lon=20.2')]); + assert.equal(calls, 1); + clock = 59_000; + await request(); + assert.equal(calls, 1); + clock = 61_000; + await request(); + assert.equal(calls, 2); + await request('/?lat=10&lon=10'); + assert.equal(calls, 3); +}); + +test('an upstream failure serves the last snapshot as stale, else 502', async () => { + let clock = 0; + let fail = false; + const request = install({ + now: () => clock, + fetchImpl: async () => + fail ? new Response('down', { status: 503 }) : Response.json(snapshot), + }); + await request(); + fail = true; + clock = 120_000; + const stale = await request(); + assert.equal(stale.status, 200); + assert.equal(stale.body.stale, true); + assert.equal(stale.headers['X-Data-Stale'], 'true'); + const missing = await request('/?lat=-10&lon=-10'); + assert.equal(missing.status, 502); + assert.deepEqual(missing.body, { error: 'gnss_integrity_unavailable' }); +}); + +test('an upstream 429 starts a cooldown with no further upstream calls', async () => { + let clock = 0; + let calls = 0; + const request = install({ + now: () => clock, + fetchImpl: async () => { + calls += 1; + return new Response('slow down', { + status: 429, + headers: { 'retry-after': '120' }, + }); + }, + }); + assert.equal((await request()).status, 502); + const cooling = await request('/?lat=1&lon=1'); + assert.equal(cooling.status, 429); + assert.equal(calls, 1); + clock = 121_000; + await request('/?lat=2&lon=2'); + assert.equal(calls, 2); +}); + +test('the route rejects other methods, paths, bad anchors, a malformed feed and floods', async () => { + const request = install({ + fetchImpl: async () => Response.json({ nope: true }), + }); + assert.equal((await request('/', 'POST')).status, 405); + assert.equal((await request('/other?lat=1&lon=1')).status, 404); + assert.equal((await request('/?lat=1')).status, 400); + assert.equal((await request('/?lat=1&lon=1')).status, 502); + let limited = 0; + for (let i = 0; i < 40; i += 1) + if ((await request('/?lat=1&lon=1', 'GET', 'flood')).status === 429) + limited += 1; + assert.ok(limited > 0); +}); diff --git a/src/data/layerState.js b/src/data/layerState.js index 373eac20d..468e9d117 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -607,6 +607,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ disposition: 'enabled+options', optionOwner: 'flights', }), + Object.freeze({ + id: 'gnss-interference', + token: '0', + disposition: 'enabled-only', + }), Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }), Object.freeze({ id: 'local-datacenters', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 2b94d81d7..9aa0d6e1d 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -2639,6 +2639,23 @@ test('the recent-imagery split is share-link only: never stored locally, and a s ); }); +test('GNSS interference uses digit 0 without colliding with other digit tokens', () => { + const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=0.2.1')); + assert.deepEqual(decoded.enabledLayerIds, [ + 'fire-perimeters', + 'gnss-interference', + 'recent-imagery', + ]); + assert.equal( + LAYER_STATE_REGISTRY.find(({ id }) => id === 'gnss-interference').token, + '0', + ); + assert.deepEqual( + decodeLayerStateParams(new URLSearchParams(encode(decoded))), + decoded, + ); +}); + test('fire perimeters uses digit 2 without colliding with wind or recent imagery', () => { const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1')); assert.deepEqual(decoded.enabledLayerIds, [ diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 5a02d85cf..29a5d5eeb 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -131,7 +131,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29); assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, - { ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0' }, + { ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0', 'gnss-interference': '0' }, ); assert.throws( () => parseLayerStateTokenReservations(reservationRows.slice(1)), diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index eb49eed27..b859de677 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -9,6 +9,7 @@ ["earthquakes", "e"], ["fire-perimeters", "2"], ["flights", "f"], + ["gnss-interference", "0"], ["local-dams", "q"], ["local-datacenters", "d"], ["local-firms", "w"], diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js new file mode 100644 index 000000000..7dec290db --- /dev/null +++ b/src/layers/gnss/index.js @@ -0,0 +1,200 @@ +import * as Cesium from 'cesium'; +import { + GNSS_LEVEL_COLORS, + GNSS_WINDOW_MS, + accumulateGnssObservations, + binGnssCells, +} from './records.js'; +export * from './records.js'; +export { createAdsbGnssSource } from './source.js'; + +const FILL_ALPHA = Object.freeze({ low: 0.14, medium: 0.32, high: 0.45 }); +const LEVEL_LABELS = Object.freeze({ + low: 'Under 2% degraded', + medium: '2–10% degraded', + high: 'Over 10% degraded', +}); + +/** Degrees at the centre of the current view, or null before the camera settles. */ +export function defaultGnssViewAnchor(viewer) { + const rectangle = viewer?.camera?.computeViewRectangle?.( + viewer.scene?.globe?.ellipsoid, + ); + const carto = rectangle + ? Cesium.Rectangle.center(rectangle) + : viewer?.camera?.positionCartographic; + if (!carto) return null; + const latitude = Cesium.Math.toDegrees(carto.latitude); + const longitude = Cesium.Math.toDegrees(carto.longitude); + return Number.isFinite(latitude) && Number.isFinite(longitude) + ? { latitude, longitude } + : null; +} + +/** + * Own one GNSS-interference display: rolling integrity observations around + * the views the user visits, binned into cells coloured by the share of + * aircraft reporting degraded navigation integrity. + */ +export function createGnssInterferenceLayer({ + source, + viewAnchor = defaultGnssViewAnchor, + now = () => Date.now(), + windowMs = GNSS_WINDOW_MS, +} = {}) { + if (typeof source?.getSnapshot !== 'function') + throw new TypeError('GNSS interference requires a snapshot source'); + let _viewer = null; + let _dataSource = null; + let _request = null; + let _enabled = false; + let _cells = []; + let _signature = null; + let _lastUpdate = null; + let _lastError = null; + let _stale = false; + const _observations = new Map(); + + function render() { + const signature = JSON.stringify( + _cells.map(({ id, level, aircraft, degraded }) => [ + id, + level, + aircraft, + degraded, + ]), + ); + if (signature === _signature) return; + _signature = signature; + _dataSource.entities.removeAll(); + for (const cell of _cells) { + const color = Cesium.Color.fromCssColorString( + GNSS_LEVEL_COLORS[cell.level], + ); + _dataSource.entities.add( + new Cesium.Entity({ + id: `gnss-interference:${cell.id}`, + rectangle: { + coordinates: Cesium.Rectangle.fromDegrees( + cell.west, + cell.south, + cell.east, + cell.north, + ), + material: new Cesium.ColorMaterialProperty( + color.withAlpha(FILL_ALPHA[cell.level]), + ), + classificationType: Cesium.ClassificationType.BOTH, + }, + }), + ); + } + } + + const layer = { + id: 'gnss-interference', + name: 'GNSS Interference', + icon: '📡', + source: 'adsb.lol', + updateInterval: 60000, + + init(viewer) { + if (_viewer) + throw new Error('GNSS interference layer is already initialized'); + _viewer = viewer; + _dataSource = new Cesium.CustomDataSource('gnss-interference'); + _dataSource.show = false; + viewer.dataSources.add(_dataSource); + }, + + enable() { + _enabled = true; + if (_dataSource) _dataSource.show = true; + }, + + disable() { + _request?.abort(); + _request = null; + _enabled = false; + if (_dataSource) _dataSource.show = false; + }, + + async update() { + if (!_enabled || !_dataSource) return false; + const anchor = viewAnchor(_viewer); + if (!anchor) return false; + _request?.abort(); + const request = new AbortController(); + _request = request; + try { + const snapshot = await source.getSnapshot(anchor, { + signal: request.signal, + }); + if (request.signal.aborted || _request !== request || !_enabled) + return false; + accumulateGnssObservations(_observations, snapshot.rows, now(), { + windowMs, + }); + _cells = binGnssCells(_observations); + render(); + _stale = snapshot.stale; + _lastUpdate = now(); + _lastError = null; + return true; + } catch (e) { + if (request.signal.aborted || _request !== request || !_enabled) + return false; + console.warn('[Data:GNSS] Fetch error:', e); + _lastError = e?.message || 'GNSS integrity source unavailable'; + return false; + } finally { + if (_request === request) _request = null; + } + }, + + destroy(viewer = _viewer) { + _request?.abort(); + _request = null; + _enabled = false; + _observations.clear(); + _cells = []; + _signature = null; + if (_dataSource) { + viewer.dataSources.remove(_dataSource, true); + _dataSource = null; + } + _viewer = null; + _lastUpdate = null; + _lastError = null; + _stale = false; + }, + + getRowControls() { + return { + chips: [], + legend: ['high', 'medium', 'low'].map((level, index) => ({ + label: LEVEL_LABELS[level], + color: GNSS_LEVEL_COLORS[level], + count: _cells.filter((cell) => cell.level === level).length, + ...(index === 0 + ? { + blurb: + 'Share of ADS-B aircraft reporting low navigation integrity (NIC/NACp) over the last 30 minutes, around the views you visit. An inference of jamming or spoofing, not a detection.', + } + : {}), + })), + }; + }, + + getStats() { + return { + count: _cells.length, + lastUpdate: _lastUpdate, + error: _lastError, + stale: _stale, + fallback: false, + }; + }, + }; + return layer; +} diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs new file mode 100644 index 000000000..0b579089f --- /dev/null +++ b/src/layers/gnss/ownership.test.mjs @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGnssInterferenceLayer } from './index.js'; + +const row = (hex, lat, degraded) => ({ + hex, + lat, + lon: 30.2, + nic: degraded ? 0 : 8, + nacp: degraded ? 0 : 10, + gpsLost: false, + degraded, +}); + +function harness( + getSnapshot, + { anchor = { latitude: 50, longitude: 30 } } = {}, +) { + const sources = []; + const viewer = { + dataSources: { + add(value) { + sources.push(value); + }, + remove(value) { + sources.splice(sources.indexOf(value), 1); + }, + }, + }; + let clock = 1_000; + const anchors = []; + const layer = createGnssInterferenceLayer({ + source: { getSnapshot }, + viewAnchor: () => { + anchors.push(anchor); + return anchor; + }, + now: () => clock, + }); + layer.init(viewer); + return { + layer, + sources, + anchors, + advance(ms) { + clock += ms; + }, + }; +} + +test('the layer requires a snapshot source and cannot be initialized twice', () => { + assert.throws(() => createGnssInterferenceLayer({}), TypeError); + const { layer } = harness(async () => ({ rows: [] })); + assert.throws(() => layer.init({ dataSources: { add() {} } }), /already/); +}); + +test('an enabled refresh bins the snapshot into coloured ground cells', async () => { + const { layer, sources, anchors } = harness(async (anchor, { signal }) => { + assert.deepEqual(anchor, { latitude: 50, longitude: 30 }); + assert.ok(signal instanceof AbortSignal); + return { + rows: [ + row('a1', 50.1, true), + row('a2', 50.1, true), + row('a3', 50.2, false), + row('a4', 50.3, false), + row('b1', 55.1, false), + ], + stale: false, + }; + }); + assert.equal(await layer.update(), false, 'disabled layers do not fetch'); + assert.equal(anchors.length, 0); + layer.enable(); + assert.equal(sources[0].show, true); + assert.equal(await layer.update(), true); + const entities = sources[0].entities.values; + assert.equal(entities.length, 1, 'the one-aircraft cell is withheld'); + assert.equal(entities[0].id, 'gnss-interference:280:420'); + assert.ok(entities[0].rectangle); + const stats = layer.getStats(); + assert.equal(stats.count, 1); + assert.equal(stats.error, null); + assert.equal(stats.fallback, false); + const { legend } = layer.getRowControls(); + assert.deepEqual( + legend.map(({ count }) => count), + [1, 0, 0], + ); + assert.match(legend[0].blurb, /not a detection/); +}); + +test('a failed refresh keeps the last cells and reports the error', async () => { + let fail = false; + const { layer, sources } = harness(async () => { + if (fail) throw new Error('adsb.lol HTTP 502'); + return { + rows: [ + row('a1', 1.1, false), + row('a2', 1.2, false), + row('a3', 1.3, false), + ], + stale: true, + }; + }); + layer.enable(); + await layer.update(); + assert.equal(layer.getStats().stale, true); + fail = true; + assert.equal(await layer.update(), false); + assert.equal(sources[0].entities.values.length, 1); + assert.equal(layer.getStats().error, 'adsb.lol HTTP 502'); + assert.equal(layer.getStats().count, 1); +}); + +test('observations expire with the window, and disable aborts the request in flight', async () => { + let pending; + const { layer, sources, advance } = harness( + (anchor, { signal }) => + new Promise((resolve, reject) => { + pending = { resolve }; + signal.addEventListener('abort', () => reject(signal.reason)); + }), + ); + layer.enable(); + let result = layer.update(); + pending.resolve({ + rows: [row('a1', 1.1, false), row('a2', 1.2, false), row('a3', 1.3, false)], + }); + assert.equal(await result, true); + assert.equal(layer.getStats().count, 1); + + advance(31 * 60_000); + result = layer.update(); + pending.resolve({ rows: [] }); + assert.equal(await result, true); + assert.equal(layer.getStats().count, 0); + assert.equal(sources[0].entities.values.length, 0); + + result = layer.update(); + layer.disable(); + assert.equal(await result, false); + assert.equal(sources[0].show, false); +}); + +test('no view anchor means no request, and destroy releases the data source', async () => { + let calls = 0; + const { layer, sources } = harness( + async () => { + calls += 1; + return { rows: [] }; + }, + { anchor: null }, + ); + layer.enable(); + assert.equal(await layer.update(), false); + assert.equal(calls, 0); + layer.destroy(); + assert.equal(sources.length, 0); + assert.equal(layer.getStats().count, 0); +}); diff --git a/src/layers/gnss/records.js b/src/layers/gnss/records.js new file mode 100644 index 000000000..4cd6de8e8 --- /dev/null +++ b/src/layers/gnss/records.js @@ -0,0 +1,171 @@ +/** + * GNSS interference inferred from ADS-B navigation integrity. + * + * Aircraft broadcasting DO-260 version 1/2 ADS-B report how much they trust + * their own position: NIC (integrity containment radius) and NACp (position + * accuracy). Both collapse when the GNSS receiver is jammed or spoofed, so a + * cluster of aircraft reporting low values in one area is a well-known public + * proxy for interference (the method popularized by gpsjam.org and the + * readsb/tar1090 `gpsOkBefore` flag). It is an inference, not a detection: + * avionics faults and old transponders also report low values, which is why + * cells need several aircraft and the first degraded aircraft is discounted. + */ + +/** Direct 1090ES ADS-B only: MLAT, TIS-B and ADS-R positions are not self-reported. */ +const QUALIFYING_TYPES = new Set(['adsb_icao']); +/** NIC below 7 means a containment radius above 0.2 NM (RNP 0.3 and worse). */ +export const GNSS_NIC_THRESHOLD = 7; +/** NACp below 8 means an estimated position uncertainty of 93 m or more. */ +export const GNSS_NACP_THRESHOLD = 8; +/** Positions older than this are not current evidence of the local sky. */ +const MAX_POSITION_AGE_S = 60; +/** Cell edge in degrees: roughly 55 km north-south, similar in area to an H3 res-4 cell. */ +export const GNSS_CELL_DEG = 0.5; +/** Fewer aircraft than this in a cell cannot distinguish one faulty transponder from interference. */ +export const GNSS_MIN_AIRCRAFT = 3; +/** Rolling window over which aircraft seen in a cell are counted. */ +export const GNSS_WINDOW_MS = 30 * 60_000; + +const HEX_PATTERN = /^~?[0-9a-f]{6}$/; + +const finite = (value) => typeof value === 'number' && Number.isFinite(value); + +/** + * Reduce one adsb.lol / readsb `aircraft.json`-style payload to the integrity + * facts the layer needs. Returns null when the payload has no aircraft array. + * + * @param {{ac?: object[], now?: number}} payload - readsb-style snapshot. + * @returns {{hex: string, lat: number, lon: number, nic: number, nacp: number, gpsLost: boolean, degraded: boolean}[]|null} + */ +export function normalizeGnssAircraft(payload) { + if (!Array.isArray(payload?.ac)) return null; + const rows = []; + const seen = new Set(); + for (const aircraft of payload.ac) { + if (!aircraft || typeof aircraft !== 'object') continue; + const hex = typeof aircraft.hex === 'string' ? aircraft.hex : ''; + if (!HEX_PATTERN.test(hex) || seen.has(hex)) continue; + if (!QUALIFYING_TYPES.has(aircraft.type)) continue; + if (!finite(aircraft.version) || aircraft.version < 1) continue; + if (aircraft.alt_baro === 'ground' || !finite(aircraft.alt_baro)) continue; + const { lat, lon, nic, nac_p: nacp } = aircraft; + if (!finite(lat) || Math.abs(lat) > 90) continue; + if (!finite(lon) || Math.abs(lon) > 180) continue; + if (!finite(nic) || !finite(nacp)) continue; + if (finite(aircraft.seen_pos) && aircraft.seen_pos > MAX_POSITION_AGE_S) + continue; + const gpsLost = aircraft.gpsOkBefore != null; + seen.add(hex); + rows.push({ + hex, + lat, + lon, + nic, + nacp, + gpsLost, + degraded: + gpsLost || nic < GNSS_NIC_THRESHOLD || nacp < GNSS_NACP_THRESHOLD, + }); + } + return rows; +} + +/** Stable key of the cell containing a position. */ +export function gnssCellKey(lat, lon, cellDeg = GNSS_CELL_DEG) { + const row = Math.floor((Math.min(lat, 89.999999) + 90) / cellDeg); + const col = Math.floor((Math.min(lon, 179.999999) + 180) / cellDeg); + return `${row}:${col}`; +} + +/** + * Fold one snapshot into a rolling observation store. Each aircraft counts + * once per cell per window; a degraded report in the window keeps it degraded + * there, so an aircraft that loses GNSS mid-cell is not averaged away. + * + * @param {Map} store + * @param {ReturnType} rows + * @param {number} nowMs + * @param {{windowMs?: number, cellDeg?: number}} [options] + * @returns {Map} The same store, pruned to the window. + */ +export function accumulateGnssObservations( + store, + rows, + nowMs, + { windowMs = GNSS_WINDOW_MS, cellDeg = GNSS_CELL_DEG } = {}, +) { + for (const row of rows || []) { + const cell = gnssCellKey(row.lat, row.lon, cellDeg); + const key = `${row.hex}|${cell}`; + const previous = store.get(key); + store.set(key, { + cell, + degraded: row.degraded || (previous?.degraded ?? false), + seenAt: nowMs, + }); + } + for (const [key, entry] of store) { + if (nowMs - entry.seenAt > windowMs) store.delete(key); + } + return store; +} + +/** + * Interference level of a cell, using gpsjam.org's published formula and + * bands: the first degraded aircraft is discounted to limit false positives, + * then under 2% is low, 2–10% medium and over 10% high. + */ +export function gnssInterferenceLevel(total, degraded) { + const percent = total > 0 ? (100 * Math.max(0, degraded - 1)) / total : 0; + return { + percent, + level: percent > 10 ? 'high' : percent >= 2 ? 'medium' : 'low', + }; +} + +/** + * Aggregate the observation store into displayable cells. + * + * @returns {{id: string, south: number, west: number, north: number, east: number, lat: number, lon: number, aircraft: number, degraded: number, percentDegraded: number, level: 'low'|'medium'|'high'}[]} + */ +export function binGnssCells( + store, + { cellDeg = GNSS_CELL_DEG, minAircraft = GNSS_MIN_AIRCRAFT } = {}, +) { + const tallies = new Map(); + for (const { cell, degraded } of store.values()) { + const tally = tallies.get(cell) || { aircraft: 0, degraded: 0 }; + tally.aircraft += 1; + if (degraded) tally.degraded += 1; + tallies.set(cell, tally); + } + const cells = []; + for (const [id, { aircraft, degraded }] of tallies) { + if (aircraft < minAircraft) continue; + const [row, col] = id.split(':').map(Number); + const south = row * cellDeg - 90; + const west = col * cellDeg - 180; + const { percent, level } = gnssInterferenceLevel(aircraft, degraded); + cells.push({ + id, + south, + west, + north: south + cellDeg, + east: west + cellDeg, + lat: south + cellDeg / 2, + lon: west + cellDeg / 2, + aircraft, + degraded, + percentDegraded: Math.round(percent * 10) / 10, + level, + }); + } + return cells.sort((a, b) => a.id.localeCompare(b.id)); +} + +/** Legend colors: gpsjam's green / amber / red convention. */ +export const GNSS_LEVEL_COLORS = Object.freeze({ + low: '#3fb950', + medium: '#e3b341', + high: '#f85149', +}); diff --git a/src/layers/gnss/records.test.mjs b/src/layers/gnss/records.test.mjs new file mode 100644 index 000000000..b49a053d3 --- /dev/null +++ b/src/layers/gnss/records.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + accumulateGnssObservations, + binGnssCells, + gnssCellKey, + gnssInterferenceLevel, + normalizeGnssAircraft, +} from './records.js'; + +const aircraft = (overrides = {}) => ({ + hex: '4ca7b5', + type: 'adsb_icao', + version: 2, + alt_baro: 36000, + lat: 50.1, + lon: 19.9, + nic: 8, + nac_p: 10, + seen_pos: 1.2, + ...overrides, +}); + +test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', () => { + const rows = normalizeGnssAircraft({ + ac: [ + aircraft(), + aircraft({ hex: '000001', type: 'mlat', nic: 0 }), + aircraft({ hex: '000002', type: 'tisb_icao' }), + aircraft({ hex: '000003', version: 0 }), + aircraft({ hex: '000004', alt_baro: 'ground' }), + aircraft({ hex: '000005', seen_pos: 120 }), + aircraft({ hex: '000006', nic: undefined }), + aircraft({ hex: '000007', lat: 95 }), + aircraft({ hex: 'not-hex' }), + aircraft(), + null, + ], + }); + assert.deepEqual(rows, [ + { + hex: '4ca7b5', + lat: 50.1, + lon: 19.9, + nic: 8, + nacp: 10, + gpsLost: false, + degraded: false, + }, + ]); + assert.equal(normalizeGnssAircraft({}), null); + assert.equal(normalizeGnssAircraft(null), null); +}); + +test('low NIC, low NACp or a readsb GPS-loss flag each mark an aircraft degraded', () => { + const rows = normalizeGnssAircraft({ + ac: [ + aircraft({ hex: 'a00001', nic: 6 }), + aircraft({ hex: 'a00002', nac_p: 7 }), + aircraft({ hex: 'a00003', gpsOkBefore: 1790690000.1 }), + aircraft({ hex: 'a00004', nic: 7, nac_p: 8 }), + ], + }); + assert.deepEqual( + rows.map(({ hex, degraded, gpsLost }) => [hex, degraded, gpsLost]), + [ + ['a00001', true, false], + ['a00002', true, false], + ['a00003', true, true], + ['a00004', false, false], + ], + ); +}); + +test('cell keys are stable half-degree bins, clamped at the poles and antimeridian', () => { + assert.equal(gnssCellKey(50.1, 19.9), gnssCellKey(50.4, 19.6)); + assert.notEqual(gnssCellKey(50.1, 19.9), gnssCellKey(50.6, 19.9)); + assert.equal(gnssCellKey(90, 180), gnssCellKey(89.9, 179.9)); + assert.equal(gnssCellKey(-90, -180), '0:0'); +}); + +test('interference level follows the published gpsjam formula and bands', () => { + assert.deepEqual(gnssInterferenceLevel(10, 0), { percent: 0, level: 'low' }); + // One degraded aircraft is always discounted. + assert.deepEqual(gnssInterferenceLevel(10, 1), { percent: 0, level: 'low' }); + assert.equal(gnssInterferenceLevel(50, 2).level, 'medium'); + assert.equal(gnssInterferenceLevel(10, 2).level, 'medium'); + assert.equal(gnssInterferenceLevel(10, 3).level, 'high'); + assert.equal(gnssInterferenceLevel(0, 0).level, 'low'); +}); + +test('observations count each aircraft once per cell and expire after the window', () => { + const store = new Map(); + const at = (lat, hex, degraded) => ({ hex, lat, lon: 20.1, degraded }); + accumulateGnssObservations( + store, + [at(50.1, 'a1', true), at(50.2, 'a2', false), at(50.3, 'a3', false)], + 0, + { windowMs: 1000 }, + ); + // a1 recovers inside the same cell: it stays degraded for the window. + accumulateGnssObservations(store, [at(50.2, 'a1', false)], 500, { + windowMs: 1000, + }); + let [cell] = binGnssCells(store); + assert.equal(cell.aircraft, 3); + assert.equal(cell.degraded, 1); + assert.equal(cell.level, 'low'); + assert.deepEqual( + [cell.south, cell.west, cell.north, cell.east], + [50, 20, 50.5, 20.5], + ); + + accumulateGnssObservations(store, [], 1200, { windowMs: 1000 }); + assert.equal(store.size, 1); + assert.deepEqual(binGnssCells(store), []); +}); + +test('cells below the minimum aircraft count are withheld', () => { + const store = new Map(); + accumulateGnssObservations( + store, + [ + { hex: 'b1', lat: 10.1, lon: 10.1, degraded: true }, + { hex: 'b2', lat: 10.2, lon: 10.2, degraded: true }, + ], + 0, + ); + assert.deepEqual(binGnssCells(store), []); + const [cell] = binGnssCells(store, { minAircraft: 2 }); + assert.equal(cell.level, 'high'); + assert.equal(cell.percentDegraded, 50); +}); diff --git a/src/layers/gnss/source.js b/src/layers/gnss/source.js new file mode 100644 index 000000000..4feba891e --- /dev/null +++ b/src/layers/gnss/source.js @@ -0,0 +1,37 @@ +import { readResponseJsonCapped } from '../../sources/httpBody.js'; + +/** Request integrity facts around a view anchor through the same-origin adsb.lol proxy. */ +export function createAdsbGnssSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getSnapshot({ latitude, longitude } = {}, { signal } = {}) { + signal?.throwIfAborted(); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) + throw new Error('GNSS snapshot needs a view anchor'); + const params = new URLSearchParams({ + lat: latitude.toFixed(2), + lon: longitude.toFixed(2), + }); + const response = await fetchImpl(`/api/gnss-integrity?${params}`, { + signal, + }); + if (!response.ok) throw new Error(`adsb.lol HTTP ${response.status}`); + const payload = await readResponseJsonCapped( + response, + 4 * 1024 * 1024, + signal, + ); + signal?.throwIfAborted(); + if (!Array.isArray(payload?.rows)) + throw new Error('Malformed GNSS integrity snapshot'); + return { + rows: payload.rows, + fetchedAt: Number.isFinite(payload.fetchedAt) + ? payload.fetchedAt + : null, + stale: payload.stale === true, + }; + }, + }; +} diff --git a/src/layers/gnss/source.test.mjs b/src/layers/gnss/source.test.mjs new file mode 100644 index 000000000..8aad9b988 --- /dev/null +++ b/src/layers/gnss/source.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createAdsbGnssSource } from './source.js'; + +test('the source asks the same-origin proxy for the view anchor', async () => { + const calls = []; + const source = createAdsbGnssSource({ + fetchImpl: async (url, options) => { + calls.push(url); + assert.ok(options.signal instanceof AbortSignal); + return Response.json({ fetchedAt: 5, rows: [{ hex: 'abc123' }] }); + }, + }); + const snapshot = await source.getSnapshot( + { latitude: 50.06143, longitude: 19.93658 }, + { signal: new AbortController().signal }, + ); + assert.deepEqual(calls, ['/api/gnss-integrity?lat=50.06&lon=19.94']); + assert.deepEqual(snapshot, { + rows: [{ hex: 'abc123' }], + fetchedAt: 5, + stale: false, + }); +}); + +test('the source reports stale proxy data and rejects bad input or responses', async () => { + const reply = (body, init) => async () => Response.json(body, init); + const stale = await createAdsbGnssSource({ + fetchImpl: reply({ rows: [], stale: true }), + }).getSnapshot({ latitude: 1, longitude: 2 }); + assert.equal(stale.stale, true); + assert.equal(stale.fetchedAt, null); + + await assert.rejects( + createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}), + /view anchor/, + ); + await assert.rejects( + createAdsbGnssSource({ + fetchImpl: reply({ error: 'x' }, { status: 502 }), + }).getSnapshot({ latitude: 1, longitude: 2 }), + /HTTP 502/, + ); + await assert.rejects( + createAdsbGnssSource({ fetchImpl: reply({ rows: 'no' }) }).getSnapshot({ + latitude: 1, + longitude: 2, + }), + /Malformed/, + ); + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + createAdsbGnssSource({ fetchImpl: reply({ rows: [] }) }).getSnapshot( + { latitude: 1, longitude: 2 }, + { signal: controller.signal }, + ), + ); +}); diff --git a/src/tooling/previewServing.test.mjs b/src/tooling/previewServing.test.mjs index d6eb80e20..04c24b92d 100644 --- a/src/tooling/previewServing.test.mjs +++ b/src/tooling/previewServing.test.mjs @@ -10,6 +10,7 @@ import { makeFixtureRoot } from './fixtureRoot.mjs'; test('data providers have both hooks; credential editing stays development-only', () => { const plugins = localProviderPlugins(); assert.ok(plugins.some(({ name }) => name === 'fire-perimeters')); + assert.ok(plugins.some(({ name }) => name === 'gnss-integrity')); for (const plugin of plugins) { if (plugin.name === 'gev-key-setup') { assert.equal(plugin.configurePreviewServer, undefined); @@ -103,6 +104,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno ['/api/adsbdb/type/invalid', 400], ['/api/firms/status', 200], ['/api/fire-perimeters/inciweb/publication/invalid', 400], + ['/api/gnss-integrity?lat=invalid', 400], ['/api/terrain/heights?points=invalid', 400], ['/api/overpass', 405], ['/api/overpass/status', 200], diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 227cc41e1..c91771dca 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -49,7 +49,13 @@ const PANEL_GROUPS = [ }, { label: 'Events', - ids: ['rocket-launches', 'earthquakes', 'local-firms', 'fire-perimeters'], + ids: [ + 'rocket-launches', + 'earthquakes', + 'local-firms', + 'fire-perimeters', + 'gnss-interference', + ], }, { label: 'Weather', From 21201c78f053e0bdd211ab1096187e5f2c0f6eb4 Mon Sep 17 00:00:00 2001 From: Leo Pechnicki Date: Tue, 29 Sep 2026 18:22:30 +0200 Subject: [PATCH 19/86] fix(gnss): address review - reference source wiring, honest method, 429 backoff, QA render proof - Route the adsb.lol GNSS source through createReferenceSources and the constructCatalog contract; app layer module is a thin pass-through. - Describe the method honestly: gpsjam.org's published cell formula and bands; per-aircraft thresholds are the 14 CFR 91.227(c) ADS-B Out minima (NIC < 7, NACp < 8) plus readsb gpsOkBefore. - Proxy: reuse the adsb.lol anchor parser, honour upstream Retry-After (clamped 5-120 s), serve stale during cooldown, cap the body size. - Layer: redraw only when a cell id or band changes, treat a settling camera as a retry rather than a rejected enable, keep low cells visible, skip rows without finite coordinates, state the 3-aircraft rule in the legend blurb. - Voice: gnss-interference in set_layer_visibility / show_data_layers_menu plus common aliases; voice pins re-derived. - qa-gnss: stub the route in-page (request interception stalls Cesium's geometry workers), assert every cell is built, cover all bands over land and sea, screenshot every state with the Data Layers row open. Hex project - Crew Leo Agile dev team. Co-Authored-By: Claude --- CHANGELOG.md | 7 +- DATA_SOURCES.md | 2 +- docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 1 + scripts/package-boundaries.json | 3 +- scripts/qa-gnss.mjs | 367 ++++++++++++++++++++------- server/providers/gnssIntegrity.js | 64 +++-- src/app/constructCatalog.js | 4 +- src/app/layers/gnss.js | 12 +- src/data/gnssIntegrityProxy.test.mjs | 69 ++++- src/layers/gnss/index.js | 24 +- src/layers/gnss/ownership.test.mjs | 22 +- src/layers/gnss/records.js | 17 +- src/layers/gnss/records.test.mjs | 30 ++- src/sources/reference.js | 2 + src/sources/reference.test.mjs | 3 + src/voice/gevActions.test.mjs | 24 ++ 17 files changed, 514 insertions(+), 139 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0623687c..26e9c4aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -156,9 +156,10 @@ under NLOD 2.0. - GNSS Interference layer (Events group): ADS-B aircraft that report degraded - navigation accuracy (NIC < 7 or NACp < 8, or readsb's GPS-loss flag) are - binned into 0.5° cells over a rolling 30-minute window and coloured with the - gpsjam.org bands. Keyless, worldwide wherever adsb.lol has receivers; reads + navigation accuracy (below the 14 CFR 91.227 ADS-B Out minima, NIC < 7 or + NACp < 8, or readsb's GPS-loss flag) are binned into 0.5° cells over a + rolling 30-minute window and scored with gpsjam.org's published formula and + bands. Keyless, worldwide wherever adsb.lol has receivers; reads a capped 250 nm snapshot around the camera through `/api/gnss-integrity`, which returns integrity fields only and serves the last snapshot when the upstream fails. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index dd7dd37d9..217c18486 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -78,7 +78,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. -- **GNSS interference (adsb.lol integrity fields).** The GNSS Interference layer reads the same keyless 250 nm point snapshot around the camera (anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. Cells use the gpsjam.org method (NIC < 7 or NACp < 8 marks an aircraft degraded; `100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %; at least 3 aircraft per 0.5° cell over a rolling 30-minute window). Degraded navigation accuracy indicates probable jamming or spoofing, not a confirmed source or location. Verified 2026-09-29. +- **GNSS interference (adsb.lol integrity fields).** The GNSS Interference layer reads the same keyless 250 nm point snapshot around the camera (anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. An aircraft is degraded when it reports below the 14 CFR 91.227(c) ADS-B Out minima (NIC < 7 or NACp < 8) or readsb flags GPS loss; cells use gpsjam.org's published formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %; at least 3 aircraft per 0.5° cell over a rolling 30-minute window). Degraded navigation accuracy indicates probable jamming or spoofing, not a confirmed source or location. Verified 2026-09-29. - **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. - **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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b1abd9f15..f9f3e6391 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3069,7 +3069,7 @@ its criteria cannot be silently ignored. | 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) | | Street Level 📷 | Street-level imagery; Mapillary is the first provider (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, cached 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 Interference 📡 | adsb.lol point snapshot, ADS-B NIC/NACp integrity binned per 0.5° cell (gpsjam.org method, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | 60s (server cache 60 s; 30 min rolling window) | +| GNSS Interference 📡 | adsb.lol point snapshot, ADS-B NIC/NACp integrity binned per 0.5° cell (gpsjam.org formula and bands, 91.227 NIC/NACp minima, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | 60s (server cache 60 s; 30 min rolling window) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | GNSS Interference fetches a capped adsb.lol snapshot around the view anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band. It shows probable interference, not a located emitter. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index f87618780..5eaaa6d6a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -835,6 +835,7 @@ "src/layers/gnss/records.test.mjs", "src/layers/gnss/source.test.mjs", "src/layers/gnss/ownership.test.mjs", + "scripts/qa-gnss.mjs", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 18e8b4e3c..b43565597 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3121,7 +3121,8 @@ "src/sources/httpBody.js", "src/layers/perimeters/source.js", "src/layers/perimeters/records.js", - "src/layers/submarineCables/bundledSource.js" + "src/layers/submarineCables/bundledSource.js", + "src/layers/gnss/source.js" ], "external": [] }, diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index 81b430f13..08e9123b6 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -5,18 +5,24 @@ * Drives the REAL app in headless Chromium against a dev server (keyless). * The live section reads adsb.lol through `/api/gnss-integrity` (60 s server * cache, one upstream read per whole-degree anchor); the other sections - * intercept that route so they are deterministic. + * stub that route in the page so they are deterministic. * - * (i) LIVE — over the southern Baltic the layer settles with cells, - * no error, and legend band counts that sum to the cell count. + * (i) LIVE — over Poland and the southern Baltic the layer settles with no error, + * one entity per cell and legend band counts that sum to the cell + * count; cells are required only when adsb.lol actually returned + * enough aircraft (live traffic varies by hour). * (ii) BANDS (synthetic) — a 10-aircraft cell with 5 degraded is high, - * a 3-aircraft cell with 1 degraded is low (first bad discounted), - * a 2-aircraft cell is withheld; `stale: true` reaches getStats. - * (iii) FAILURE — the route intercepted to 502: stats.error is set and the + * a 30-aircraft cell with 2 degraded is medium, a 3-aircraft cell + * with 1 degraded is low (first bad discounted), a clean cell at sea + * is low, a 2-aircraft cell is withheld; `stale: true` reaches + * getStats. Every band is drawn and low is shown over land and sea. + * (iii) FAILURE — the route stubbed to 502: stats.error is set and the * cells already on the globe are kept. * (iv) DISABLE — the layer's data source is hidden. * - * Visual proof saved to qa-shots/gnss-*.png (gitignored). + * Screenshots wait until every cell's ground geometry is built and show the + * open Data Layers row with its legend. Visual proof saved to + * qa-shots/gnss-*.png (gitignored). * * Run: node scripts/qa-gnss.mjs --url http://localhost:4173 * Exits non-zero on any FAIL. Does not commit anything. @@ -39,6 +45,8 @@ const getOpt = (name, dflt) => { const APP_URL = getOpt('--url', 'http://localhost:4173'); const HEADFUL = argv.includes('--headful'); const LAYER_ID = 'gnss-interference'; +/** Live rows below this cannot reliably fill a 3-aircraft cell. */ +const LIVE_MIN_ROWS = 30; const results = []; function record(name, ok, detail) { @@ -50,59 +58,162 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** Boot the app, dismiss the intro and place the camera straight down. */ async function boot(page, { lon, lat, height }) { - await page.goto(`${APP_URL}/?welcome=0`, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.goto(`${APP_URL}/?welcome=0`, { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); await page.waitForFunction( () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, { timeout: 60000 }, ); await sleep(5000); await page.keyboard.press('Escape'); - await page.evaluate((lo, la, h) => { - const gev = window.__godsEyeView; - const d2r = Math.PI / 180; - // The app's intro flyTo animation clobbers a setView issued mid-flight. - try { gev.viewer.camera.cancelFlight(); } catch { /* no flight active */ } - gev.viewer.camera.setView({ - destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: lo * d2r, latitude: la * d2r, height: h, - }), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - gev.viewer.scene.requestRender?.(); - }, lon, lat, height); + await page.evaluate( + (lo, la, h) => { + const gev = window.__godsEyeView; + const d2r = Math.PI / 180; + // The app's intro flyTo animation clobbers a setView issued mid-flight. + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight active */ + } + gev.viewer.camera.setView({ + destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: lo * d2r, + latitude: la * d2r, + height: h, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + }, + lon, + lat, + height, + ); } /** Enable (or refresh) the layer and poll until it settles. */ async function enableAndSettle(page, { timeoutS = 30, refresh = false } = {}) { - return page.evaluate(async (id, tS, again) => { - const dm = window.__godsEyeView.dataManager; - const layer = dm.layers.get(id).module; - if (again) await layer.update(); - else await dm.setEnabled(id, true); - let stats = null; - for (let i = 0; i < tS; i++) { - stats = layer.getStats(); - if (stats.count > 0 || stats.error) break; - await new Promise((r) => setTimeout(r, 1000)); + return page.evaluate( + async (id, tS, again) => { + const dm = window.__godsEyeView.dataManager; + const layer = dm.layers.get(id).module; + if (again) await dm.refreshLayer(id); + else await dm.setEnabled(id, true); + let stats = null; + for (let i = 0; i < tS; i++) { + stats = layer.getStats(); + if (stats.count > 0 || stats.error) break; + await new Promise((r) => setTimeout(r, 1000)); + } + const legend = layer + .getRowControls() + .legend.map(({ label, count }) => ({ label, count })); + const ds = window.__godsEyeView.viewer.dataSources.getByName(id)[0]; + return { + stats, + legend, + entities: ds?.entities.values.length ?? -1, + shown: ds?.show ?? null, + }; + }, + LAYER_ID, + timeoutS, + refresh, + ); +} + +/** Open the Data Layers panel and bring the GNSS row into view. */ +async function showLayerRow(page) { + await page.evaluate((id) => { + const panel = document.getElementById('data-panel'); + if (panel.classList.contains('collapsed')) + panel.querySelector('[data-collapse-target="data-panel"]').click(); + document + .querySelector(`[data-layer-id="${id}"]`) + ?.scrollIntoView({ block: 'center' }); + }, LAYER_ID); + await sleep(800); +} + +async function shoot(page, name) { + await page + .waitForFunction( + () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, + { timeout: 60000 }, + ) + .catch(() => {}); + await showLayerRow(page); + await page.screenshot({ path: path.join(SHOTS_DIR, `gnss-${name}.png`) }); +} + +/** Bounding-sphere state of every GNSS cell: DONE = 0, PENDING = 1, FAILED = 2. */ +function cellStates(id) { + const { viewer } = window.__godsEyeView; + const ds = viewer.dataSources.getByName(id)[0]; + const sphere = {}; + return ds.entities.values.map((entity) => { + try { + return viewer.dataSourceDisplay.getBoundingSphere(entity, false, sphere); + } catch { + return 1; // not yet seen by the geometry visualizer } - const legend = layer.getRowControls().legend.map(({ label, count }) => ({ label, count })); - const ds = window.__godsEyeView.viewer.dataSources.getByName(id)[0]; - return { stats, legend, entities: ds?.entities.values.length ?? -1, shown: ds?.show ?? null }; - }, LAYER_ID, timeoutS, refresh); + }); +} + +/** Wait until every GNSS cell's ground rectangle has finished building. */ +async function waitForBuiltCells(page, timeoutMs = 60000) { + const built = await page + .waitForFunction( + `(${cellStates})(${JSON.stringify(LAYER_ID)}).every((state) => state === 0)`, + { timeout: timeoutMs, polling: 250 }, + ) + .then( + () => true, + () => false, + ); + const states = await page.evaluate( + `(${cellStates})(${JSON.stringify(LAYER_ID)})`, + ); + return { built, detail: `states=${JSON.stringify(states)}` }; } const fixtureRow = (hex, lat, lon, degraded) => ({ - hex, lat, lon, nic: degraded ? 0 : 8, nacp: degraded ? 0 : 10, gpsLost: false, degraded, + hex, + lat, + lon, + nic: degraded ? 0 : 8, + nacp: degraded ? 0 : 10, + gpsLost: false, + degraded, }); -/** Three synthetic 0.5° cells: high (5/10 bad), low (1/3 bad), withheld (2 aircraft). */ +/** + * Synthetic 0.5° cells: high at sea (5/10 bad), medium on land (2/30 bad), + * low on land (1/3 bad), low at sea (0/4 bad), withheld (2 aircraft). + */ function fixturePayload() { const rows = []; const hex = (prefix, i) => `${prefix}${i.toString(16).padStart(2, '0')}`; - for (let i = 0; i < 10; i++) rows.push(fixtureRow(hex('a000', i), 55.1 + i * 0.01, 19.1, i < 5)); - for (let i = 0; i < 3; i++) rows.push(fixtureRow(hex('b000', i), 54.1 + i * 0.01, 18.1, i === 0)); - for (let i = 0; i < 2; i++) rows.push(fixtureRow(hex('c000', i), 53.1 + i * 0.01, 17.1, true)); - return { fetchedAt: Date.now(), anchor: { lat: 55, lon: 19 }, radiusNm: 250, rows, stale: true }; + for (let i = 0; i < 10; i++) + rows.push(fixtureRow(hex('a000', i), 55.1 + i * 0.01, 19.1, i < 5)); + for (let i = 0; i < 30; i++) + rows.push(fixtureRow(hex('d000', i), 54.1 + i * 0.01, 19.6, i < 2)); + for (let i = 0; i < 3; i++) + rows.push(fixtureRow(hex('b000', i), 54.1 + i * 0.01, 18.1, i === 0)); + for (let i = 0; i < 4; i++) + rows.push(fixtureRow(hex('e000', i), 55.1 + i * 0.01, 18.1, false)); + for (let i = 0; i < 2; i++) + rows.push(fixtureRow(hex('c000', i), 53.1 + i * 0.01, 17.1, true)); + return { + fetchedAt: Date.now(), + anchor: { lat: 55, lon: 19 }, + radiusNm: 250, + rows, + stale: true, + }; } async function main() { @@ -112,19 +223,30 @@ async function main() { const res = await fetch(APP_URL); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { - console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`); + console.error( + `\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`, + ); process.exit(2); } fs.mkdirSync(SHOTS_DIR, { recursive: true }); - const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH - || (await puppeteer.executablePath().catch(() => null)) || undefined; + const executablePath = + process.env.PUPPETEER_EXECUTABLE_PATH || + (await puppeteer.executablePath().catch(() => null)) || + undefined; const browser = await puppeteer.launch({ headless: HEADFUL ? false : 'new', - ...(executablePath && fs.existsSync(executablePath) ? { executablePath } : {}), + ...(executablePath && fs.existsSync(executablePath) + ? { executablePath } + : {}), args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', - '--disable-dev-shm-usage', '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', '--window-size=1440,900', + '--no-sandbox', + '--disable-setuid-sandbox', + '--use-gl=angle', + '--use-angle=swiftshader', + '--disable-dev-shm-usage', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--window-size=1440,900', ], }); @@ -132,58 +254,123 @@ async function main() { const errors = []; try { // ── (i) LIVE ────────────────────────────────────────────────────────── - console.log('(i) LIVE — adsb.lol integrity snapshot over the southern Baltic...'); + console.log( + '(i) LIVE — adsb.lol integrity snapshot over Poland and the southern Baltic...', + ); const live = await browser.newPage(); await live.setViewport({ width: 1440, height: 900 }); live.on('pageerror', (e) => errors.push(e.message)); - await boot(live, { lon: 19.5, lat: 55.0, height: 1_600_000 }); + const liveBodies = []; + live.on('response', (res) => { + if (res.url().includes('/api/gnss-integrity') && res.ok()) + liveBodies.push(res.json().then((body) => body.rows?.length ?? 0)); + }); + await boot(live, { lon: 19.5, lat: 52.5, height: 1_100_000 }); const l = await enableAndSettle(live); const legendTotal = l.legend.reduce((sum, { count }) => sum + count, 0); - record('LIVE: cells drawn, no error', - l.stats.count > 0 && !l.stats.error && l.entities === l.stats.count, - `cells=${l.stats.count} entities=${l.entities} error=${JSON.stringify(l.stats.error)}`); - record('LIVE: legend band counts sum to the cell count', legendTotal === l.stats.count, - JSON.stringify(l.legend)); - await sleep(3000); - await live.screenshot({ path: path.join(SHOTS_DIR, 'gnss-live-baltic.png') }); + const liveRows = Math.max( + 0, + ...(await Promise.allSettled(liveBodies)) + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value), + ); + record( + 'LIVE: no error, one entity per cell', + !l.stats.error && l.entities === l.stats.count, + `cells=${l.stats.count} entities=${l.entities} error=${JSON.stringify(l.stats.error)}`, + ); + if (liveRows >= LIVE_MIN_ROWS) { + record( + 'LIVE: a busy snapshot yields cells', + l.stats.count > 0, + `rows=${liveRows} cells=${l.stats.count}`, + ); + } else { + console.log( + ` [info] only ${liveRows} live rows — cell count not asserted`, + ); + } + record( + 'LIVE: legend band counts sum to the cell count', + legendTotal === l.stats.count, + JSON.stringify(l.legend), + ); + const liveBuilt = await waitForBuiltCells(live); + record( + 'LIVE: every cell rectangle is built on a parked camera', + liveBuilt.built, + liveBuilt.detail, + ); + await shoot(live, 'live-poland-baltic'); await live.close(); // ── (ii) BANDS ──────────────────────────────────────────────────────── - console.log('\n(ii) BANDS — synthetic integrity rows through the real layer...'); + console.log( + '\n(ii) BANDS — synthetic integrity rows through the real layer...', + ); const page = await browser.newPage(); await page.setViewport({ width: 1440, height: 900 }); page.on('pageerror', (e) => errors.push(e.message)); - await page.setRequestInterception(true); - let mode = 'fixture'; - page.on('request', (req) => { - if (!req.url().includes('/api/gnss-integrity')) { req.continue(); return; } - if (mode === 'fixture') { - req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(fixturePayload()) }); - } else { - req.respond({ status: 502, contentType: 'application/json', - body: JSON.stringify({ error: 'gnss_integrity_unavailable' }) }); - } - }); + // Stub the route in-page rather than with request interception: an + // intercepted page stalls Cesium's geometry workers, so ground rectangles + // never finish building and the screenshot would not show real behaviour. + await page.evaluateOnNewDocument((payload) => { + const realFetch = window.fetch.bind(window); + window.__gnssQaMode = 'fixture'; + window.fetch = (input, init) => { + const url = typeof input === 'string' ? input : input?.url || ''; + if (!url.includes('/api/gnss-integrity')) return realFetch(input, init); + const ok = window.__gnssQaMode === 'fixture'; + const body = ok + ? { ...payload, fetchedAt: Date.now() } + : { error: 'gnss_integrity_unavailable' }; + return Promise.resolve( + new Response(JSON.stringify(body), { + status: ok ? 200 : 502, + headers: { 'Content-Type': 'application/json' }, + }), + ); + }; + }, fixturePayload()); await boot(page, { lon: 18.6, lat: 54.6, height: 450_000 }); const f = await enableAndSettle(page); - const band = (prefix) => f.legend.find(({ label }) => label.startsWith(prefix))?.count; - record('BANDS: 2 cells shown, the 2-aircraft cell withheld', - f.stats.count === 2 && f.entities === 2, `cells=${f.stats.count} entities=${f.entities}`); - record('BANDS: 5/10 degraded is high, 1/3 degraded is low', - band('Over') === 1 && band('Under') === 1 && band('2') === 0, JSON.stringify(f.legend)); - record('BANDS: stale snapshot is reported', f.stats.stale === true, `stale=${f.stats.stale}`); - await page.waitForFunction(() => window.__godsEyeView.viewer.scene.globe.tilesLoaded, - { timeout: 60000 }).catch(() => {}); - await sleep(2000); - await page.screenshot({ path: path.join(SHOTS_DIR, 'gnss-synthetic-bands.png') }); + const band = (prefix) => + f.legend.find(({ label }) => label.startsWith(prefix))?.count; + record( + 'BANDS: 4 cells shown, the 2-aircraft cell withheld', + f.stats.count === 4 && f.entities === 4, + `cells=${f.stats.count} entities=${f.entities}`, + ); + record( + 'BANDS: 5/10 is high, 2/30 is medium, 1/3 and 0/4 are low', + band('Over') === 1 && band('2') === 1 && band('Under') === 2, + JSON.stringify(f.legend), + ); + record( + 'BANDS: stale snapshot is reported', + f.stats.stale === true, + `stale=${f.stats.stale}`, + ); + const bandsBuilt = await waitForBuiltCells(page); + record( + 'BANDS: every cell rectangle is built on a parked camera', + bandsBuilt.built, + bandsBuilt.detail, + ); + await shoot(page, 'synthetic-bands'); // ── (iii) FAILURE ───────────────────────────────────────────────────── - console.log('\n(iii) FAILURE — /api/gnss-integrity intercepted to 502...'); - mode = 'error'; + console.log('\n(iii) FAILURE — /api/gnss-integrity stubbed to 502...'); + await page.evaluate(() => { + window.__gnssQaMode = 'error'; + }); const e = await enableAndSettle(page, { refresh: true, timeoutS: 1 }); - record('FAILURE: error surfaced, cells kept', - Boolean(e.stats.error) && e.stats.count === 2 && e.entities === 2, - `error=${JSON.stringify(e.stats.error)} cells=${e.stats.count}`); + record( + 'FAILURE: error surfaced, cells kept', + Boolean(e.stats.error) && e.stats.count === 4 && e.entities === 4, + `error=${JSON.stringify(e.stats.error)} cells=${e.stats.count}`, + ); + await shoot(page, 'failure'); // ── (iv) DISABLE ────────────────────────────────────────────────────── console.log('\n(iv) DISABLE...'); @@ -192,7 +379,12 @@ async function main() { return window.__godsEyeView.viewer.dataSources.getByName(id)[0]?.show; }, LAYER_ID); record('DISABLE: data source hidden', shown === false, `show=${shown}`); - record('no uncaught browser errors', errors.length === 0, errors.slice(0, 3).join(' | ')); + await shoot(page, 'disabled'); + record( + 'no uncaught browser errors', + errors.length === 0, + errors.slice(0, 3).join(' | '), + ); } catch (err) { console.error('\x1b[31mHarness error:\x1b[0m', err); exitCode = 3; @@ -209,4 +401,7 @@ async function main() { process.exit(exitCode || (fail > 0 ? 1 : 0)); } -main().catch((e) => { console.error(e); process.exit(3); }); +main().catch((e) => { + console.error(e); + process.exit(3); +}); diff --git a/server/providers/gnssIntegrity.js b/server/providers/gnssIntegrity.js index a275940f2..d0a96a98b 100644 --- a/server/providers/gnssIntegrity.js +++ b/server/providers/gnssIntegrity.js @@ -1,25 +1,46 @@ import { normalizeGnssAircraft } from '../../src/layers/gnss/records.js'; +import { adsbLolFallbackAnchor } from './aircraft/opensky.js'; import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js'; -import { requiredFiniteQueryNumber } from './common/query.js'; import { makeRateLimiter, clientKey } from './common/rate-limit.js'; // adsb.lol regional snapshot (keyless, ODbL). Only the integrity fields the // GNSS layer needs leave this proxy, so the browser never sees the full feed. +// This is a separate read from the flights fallback on purpose: that point +// cache keeps only the fields the flights layer renders (no nic / nac_p), is +// keyed on 0.25° anchors and lives for 12 s, while integrity cells need +// whole-degree anchors held for a minute. const RADIUS_NM = 250; const CACHE_MS = 60_000; const CACHE_MAX = 64; +/** Cooldown after a 429 or 5xx when upstream sends no usable Retry-After. */ const COOLDOWN_MS = 60_000; +/** Bounds for an upstream-supplied Retry-After. */ +const COOLDOWN_MIN_MS = 5_000; +const COOLDOWN_MAX_MS = 120_000; const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; const TIMEOUT_MS = 10_000; /** Snap a view anchor to a whole degree so nearby views share one upstream read. */ export function gnssIntegrityAnchor(url) { - const params = new URL(url || '/', 'http://localhost').searchParams; - const lat = requiredFiniteQueryNumber(params, 'lat'); - const lon = requiredFiniteQueryNumber(params, 'lon'); - if (!Number.isFinite(lat) || lat < -90 || lat > 90) return null; - if (!Number.isFinite(lon) || lon < -180 || lon > 180) return null; - return { lat: Math.round(lat), lon: Math.round(lon) }; + const anchor = adsbLolFallbackAnchor({ url }); + if (!anchor) return null; + return { + lat: Math.round(anchor.latitude), + lon: Math.round(anchor.longitude), + }; +} + +/** Cooldown (ms) an upstream 429 earns, honouring Retry-After when sane. */ +export function gnssRetryCooldownMs(raw, nowMs) { + const clamp = (ms) => + Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, ms)); + if (raw) { + const seconds = Number(raw); + if (Number.isFinite(seconds) && seconds > 0) return clamp(seconds * 1000); + const at = Date.parse(raw); + if (Number.isFinite(at) && at > nowMs) return clamp(at - nowMs); + } + return COOLDOWN_MS; } /** Fixed-origin, bounded adsb.lol integrity route for dev and preview. */ @@ -50,12 +71,12 @@ export function gnssIntegrityProxy({ if (!response.ok) { await response.body?.cancel(); if (response.status === 429) { - const retryS = Number(response.headers.get('retry-after')); cooldownUntil = now() + - (Number.isFinite(retryS) && retryS > 0 - ? Math.min(retryS * 1000, 10 * COOLDOWN_MS) - : COOLDOWN_MS); + gnssRetryCooldownMs(response.headers.get('retry-after'), now()); + throw Object.assign(new Error('upstream_rate_limited'), { + status: 429, + }); } throw new Error('upstream_unavailable'); } @@ -95,13 +116,13 @@ export function gnssIntegrityProxy({ } async function handler(req, res) { - const json = (status, value, stale = false) => { + const json = (status, value, stale = false, retryAfterS = 60) => { if (res.destroyed) return; res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...(status === 405 ? { Allow: 'GET' } : {}), - ...(status === 429 ? { 'Retry-After': '60' } : {}), + ...(status === 429 ? { 'Retry-After': String(retryAfterS) } : {}), ...(stale ? { 'X-Data-Stale': 'true' } : {}), }); res.end(JSON.stringify(value)); @@ -117,9 +138,20 @@ export function gnssIntegrityProxy({ const { value, stale } = await acquire(anchor); json(200, stale ? { ...value, stale: true } : value, stale); } catch (error) { - json(error.status === 429 ? 429 : 502, { - error: 'gnss_integrity_unavailable', - }); + if (error.status === 429) { + // Tell the browser how long the shared upstream cooldown has left. + const retryAfterS = Math.max( + 1, + Math.ceil((cooldownUntil - now()) / 1000), + ); + return json( + 429, + { error: 'gnss_integrity_rate_limited' }, + false, + retryAfterS, + ); + } + json(502, { error: 'gnss_integrity_unavailable' }); } } diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 832f0ef9e..a9c9ca3d1 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -121,7 +121,9 @@ export function createApplicationCatalog({ createApplicationFirePerimeters({ source: sources['fire-perimeters'], }), - createApplicationGnssInterference(), + createApplicationGnssInterference({ + source: sources['gnss-interference'], + }), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ surface, diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js index 49c74c268..5c1b38916 100644 --- a/src/app/layers/gnss.js +++ b/src/app/layers/gnss.js @@ -1,12 +1,6 @@ -import { - createAdsbGnssSource, - createGnssInterferenceLayer, -} from '../../layers/gnss/index.js'; +import { createGnssInterferenceLayer } from '../../layers/gnss/index.js'; /** Wire the adsb.lol GNSS interference layer into the application catalog. */ -export function createApplicationGnssInterference(options = {}) { - return createGnssInterferenceLayer({ - source: createAdsbGnssSource(), - ...options, - }); +export function createApplicationGnssInterference(options) { + return createGnssInterferenceLayer(options); } diff --git a/src/data/gnssIntegrityProxy.test.mjs b/src/data/gnssIntegrityProxy.test.mjs index 17370a3f6..5575e64ce 100644 --- a/src/data/gnssIntegrityProxy.test.mjs +++ b/src/data/gnssIntegrityProxy.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { gnssIntegrityAnchor, gnssIntegrityProxy, + gnssRetryCooldownMs, } from '../../server/providers/gnssIntegrity.js'; const snapshot = { @@ -154,19 +155,81 @@ test('an upstream 429 starts a cooldown with no further upstream calls', async ( calls += 1; return new Response('slow down', { status: 429, - headers: { 'retry-after': '120' }, + headers: { 'retry-after': '90' }, }); }, }); - assert.equal((await request()).status, 502); + const first = await request(); + assert.equal(first.status, 429); + assert.equal(first.headers['Retry-After'], '90'); + clock = 30_500; const cooling = await request('/?lat=1&lon=1'); assert.equal(cooling.status, 429); + // The browser is told the remaining shared cooldown, rounded up. + assert.equal(cooling.headers['Retry-After'], '60'); assert.equal(calls, 1); - clock = 121_000; + clock = 91_000; await request('/?lat=2&lon=2'); assert.equal(calls, 2); }); +test('Retry-After accepts seconds or an HTTP date, clamped to 5 s – 120 s', () => { + const nowMs = Date.parse('2026-09-29T12:00:00Z'); + assert.equal(gnssRetryCooldownMs('30', nowMs), 30_000); + assert.equal(gnssRetryCooldownMs('1', nowMs), 5_000); + assert.equal(gnssRetryCooldownMs('3600', nowMs), 120_000); + assert.equal( + gnssRetryCooldownMs('Tue, 29 Sep 2026 12:00:45 GMT', nowMs), + 45_000, + ); + // Garbage, a past date or no header fall back to the default minute. + assert.equal(gnssRetryCooldownMs('soon', nowMs), 60_000); + assert.equal( + gnssRetryCooldownMs('Tue, 29 Sep 2026 11:00:00 GMT', nowMs), + 60_000, + ); + assert.equal(gnssRetryCooldownMs(null, nowMs), 60_000); +}); + +test('a cached anchor keeps serving its last snapshot as stale during a cooldown', async () => { + let clock = 0; + let limited = false; + let calls = 0; + const request = install({ + now: () => clock, + fetchImpl: async () => { + calls += 1; + return limited + ? new Response('slow down', { status: 429 }) + : Response.json(snapshot); + }, + }); + await request(); + limited = true; + clock = 61_000; + // Another anchor trips the shared cooldown… + assert.equal((await request('/?lat=5&lon=5')).status, 429); + clock = 62_000; + // …and the cached anchor is answered from cache without an upstream call. + const stale = await request(); + assert.equal(stale.status, 200); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.rows.length, 1); + assert.equal(calls, 2); +}); + +test('an oversized upstream body is refused as 502 without being parsed', async () => { + const request = install({ + fetchImpl: async () => + new Response('{}', { + headers: { 'content-length': String(9 * 1024 * 1024) }, + }), + }); + const res = await request(); + assert.equal(res.status, 502); + assert.deepEqual(res.body, { error: 'gnss_integrity_unavailable' }); +}); + test('the route rejects other methods, paths, bad anchors, a malformed feed and floods', async () => { const request = install({ fetchImpl: async () => Response.json({ nope: true }), diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index 7dec290db..2308b0aae 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -1,6 +1,7 @@ import * as Cesium from 'cesium'; import { GNSS_LEVEL_COLORS, + GNSS_MIN_AIRCRAFT, GNSS_WINDOW_MS, accumulateGnssObservations, binGnssCells, @@ -8,7 +9,8 @@ import { export * from './records.js'; export { createAdsbGnssSource } from './source.js'; -const FILL_ALPHA = Object.freeze({ low: 0.14, medium: 0.32, high: 0.45 }); +/** Low stays visible over land so coverage reads apart from no data. */ +const FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 }); const LEVEL_LABELS = Object.freeze({ low: 'Under 2% degraded', medium: '2–10% degraded', @@ -56,13 +58,9 @@ export function createGnssInterferenceLayer({ const _observations = new Map(); function render() { + // Only the id and band change what is drawn; counts alone do not. const signature = JSON.stringify( - _cells.map(({ id, level, aircraft, degraded }) => [ - id, - level, - aircraft, - degraded, - ]), + _cells.map(({ id, level }) => [id, level]), ); if (signature === _signature) return; _signature = signature; @@ -122,7 +120,10 @@ export function createGnssInterferenceLayer({ async update() { if (!_enabled || !_dataSource) return false; const anchor = viewAnchor(_viewer); - if (!anchor) return false; + // No anchor yet (camera still settling) is not a failure: returning + // false would make the manager reject the enable. The next interval + // retries. + if (!anchor) return true; _request?.abort(); const request = new AbortController(); _request = request; @@ -137,7 +138,7 @@ export function createGnssInterferenceLayer({ }); _cells = binGnssCells(_observations); render(); - _stale = snapshot.stale; + _stale = snapshot.stale === true; _lastUpdate = now(); _lastError = null; return true; @@ -178,8 +179,7 @@ export function createGnssInterferenceLayer({ count: _cells.filter((cell) => cell.level === level).length, ...(index === 0 ? { - blurb: - 'Share of ADS-B aircraft reporting low navigation integrity (NIC/NACp) over the last 30 minutes, around the views you visit. An inference of jamming or spoofing, not a detection.', + blurb: `Share of ADS-B aircraft reporting low navigation integrity (NIC/NACp) over the last 30 minutes, around the views you visit. Cells with fewer than ${GNSS_MIN_AIRCRAFT} aircraft are not drawn, so blank areas mean no data, not clean GNSS. An inference of jamming or spoofing, not a detection.`, } : {}), })), @@ -192,6 +192,8 @@ export function createGnssInterferenceLayer({ lastUpdate: _lastUpdate, error: _lastError, stale: _stale, + // Load-bearing: without an explicit boolean, layerFeedState reads the + // adsb.lol source name as a flights fallback (src/data/feedState.js). fallback: false, }; }, diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index 0b579089f..099a470f4 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -88,6 +88,7 @@ test('an enabled refresh bins the snapshot into coloured ground cells', async () [1, 0, 0], ); assert.match(legend[0].blurb, /not a detection/); + assert.match(legend[0].blurb, /fewer than 3 aircraft are not drawn/); }); test('a failed refresh keeps the last cells and reports the error', async () => { @@ -153,9 +154,28 @@ test('no view anchor means no request, and destroy releases the data source', as { anchor: null }, ); layer.enable(); - assert.equal(await layer.update(), false); + assert.equal( + await layer.update(), + true, + 'a settling camera is not a failure', + ); assert.equal(calls, 0); + assert.equal(layer.getStats().error, null); layer.destroy(); assert.equal(sources.length, 0); assert.equal(layer.getStats().count, 0); }); + +const threeCells = () => ({ + rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)], +}); + +test('an identical snapshot leaves the drawn entities untouched', async () => { + const { layer, sources } = harness(async () => threeCells()); + layer.enable(); + await layer.update(); + const [first] = sources[0].entities.values; + await layer.update(); + assert.equal(sources[0].entities.values.length, 1); + assert.equal(sources[0].entities.values[0], first); +}); diff --git a/src/layers/gnss/records.js b/src/layers/gnss/records.js index 4cd6de8e8..25353fd28 100644 --- a/src/layers/gnss/records.js +++ b/src/layers/gnss/records.js @@ -5,17 +5,20 @@ * their own position: NIC (integrity containment radius) and NACp (position * accuracy). Both collapse when the GNSS receiver is jammed or spoofed, so a * cluster of aircraft reporting low values in one area is a well-known public - * proxy for interference (the method popularized by gpsjam.org and the - * readsb/tar1090 `gpsOkBefore` flag). It is an inference, not a detection: + * proxy for interference (popularized by gpsjam.org, whose published cell + * formula and bands this module uses, and by the readsb/tar1090 `gpsOkBefore` + * flag). gpsjam does not publish its per-aircraft thresholds; here an aircraft + * is degraded when it reports below the 14 CFR 91.227(c) ADS-B Out minima + * (NIC < 7 or NACp < 8). It is an inference, not a detection: * avionics faults and old transponders also report low values, which is why * cells need several aircraft and the first degraded aircraft is discounted. */ /** Direct 1090ES ADS-B only: MLAT, TIS-B and ADS-R positions are not self-reported. */ const QUALIFYING_TYPES = new Set(['adsb_icao']); -/** NIC below 7 means a containment radius above 0.2 NM (RNP 0.3 and worse). */ +/** NIC below 7 means a containment radius of 0.2 NM or more (below the 91.227 ADS-B Out minimum). */ export const GNSS_NIC_THRESHOLD = 7; -/** NACp below 8 means an estimated position uncertainty of 93 m or more. */ +/** NACp below 8 means an estimated position uncertainty of 93 m or more (below the 91.227 minimum). */ export const GNSS_NACP_THRESHOLD = 8; /** Positions older than this are not current evidence of the local sky. */ const MAX_POSITION_AGE_S = 60; @@ -54,6 +57,9 @@ export function normalizeGnssAircraft(payload) { if (!finite(nic) || !finite(nacp)) continue; if (finite(aircraft.seen_pos) && aircraft.seen_pos > MAX_POSITION_AGE_S) continue; + // readsb sets gpsOkBefore when an aircraft's integrity collapses and keeps + // it for roughly 15 minutes after GPS recovers, so it can outlast the + // current NIC/NACp; the rolling window treats that as recent evidence. const gpsLost = aircraft.gpsOkBefore != null; seen.add(hex); rows.push({ @@ -95,6 +101,7 @@ export function accumulateGnssObservations( { windowMs = GNSS_WINDOW_MS, cellDeg = GNSS_CELL_DEG } = {}, ) { for (const row of rows || []) { + if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) continue; const cell = gnssCellKey(row.lat, row.lon, cellDeg); const key = `${row.hex}|${cell}`; const previous = store.get(key); @@ -111,7 +118,7 @@ export function accumulateGnssObservations( } /** - * Interference level of a cell, using gpsjam.org's published formula and + * Interference level of a cell, using gpsjam.org's published cell formula and * bands: the first degraded aircraft is discounted to limit false positives, * then under 2% is low, 2–10% medium and over 10% high. */ diff --git a/src/layers/gnss/records.test.mjs b/src/layers/gnss/records.test.mjs index b49a053d3..9188fd839 100644 --- a/src/layers/gnss/records.test.mjs +++ b/src/layers/gnss/records.test.mjs @@ -32,8 +32,11 @@ test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', () aircraft({ hex: '000005', seen_pos: 120 }), aircraft({ hex: '000006', nic: undefined }), aircraft({ hex: '000007', lat: 95 }), + aircraft({ hex: '000008', version: undefined }), aircraft({ hex: 'not-hex' }), aircraft(), + // A non-ICAO (`~`) address still self-reports over 1090ES ADS-B. + aircraft({ hex: '~a1b2c3' }), null, ], }); @@ -47,6 +50,15 @@ test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', () gpsLost: false, degraded: false, }, + { + hex: '~a1b2c3', + lat: 50.1, + lon: 19.9, + nic: 8, + nacp: 10, + gpsLost: false, + degraded: false, + }, ]); assert.equal(normalizeGnssAircraft({}), null); assert.equal(normalizeGnssAircraft(null), null); @@ -77,9 +89,11 @@ test('cell keys are stable half-degree bins, clamped at the poles and antimeridi assert.notEqual(gnssCellKey(50.1, 19.9), gnssCellKey(50.6, 19.9)); assert.equal(gnssCellKey(90, 180), gnssCellKey(89.9, 179.9)); assert.equal(gnssCellKey(-90, -180), '0:0'); + // Just south-west of the origin falls in the last cell before 0°, not cell 0. + assert.equal(gnssCellKey(-0.1, -0.1), '179:359'); }); -test('interference level follows the published gpsjam formula and bands', () => { +test('interference level follows the gpsjam.org published cell formula and bands', () => { assert.deepEqual(gnssInterferenceLevel(10, 0), { percent: 0, level: 'low' }); // One degraded aircraft is always discounted. assert.deepEqual(gnssInterferenceLevel(10, 1), { percent: 0, level: 'low' }); @@ -116,6 +130,20 @@ test('observations count each aircraft once per cell and expire after the window assert.deepEqual(binGnssCells(store), []); }); +test('rows without finite coordinates never key a cell', () => { + const store = new Map(); + accumulateGnssObservations( + store, + [ + { hex: 'n1', lat: Number.NaN, lon: 10.1, degraded: true }, + { hex: 'n2', lat: 10.1, lon: '10.1', degraded: true }, + null, + ], + 0, + ); + assert.equal(store.size, 0); +}); + test('cells below the minimum aircraft count are withheld', () => { const store = new Map(); accumulateGnssObservations( diff --git a/src/sources/reference.js b/src/sources/reference.js index 4bdf7e9ac..40870c1f7 100644 --- a/src/sources/reference.js +++ b/src/sources/reference.js @@ -1,6 +1,7 @@ import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js'; import { createWfigsPerimeterSource } from '../layers/perimeters/source.js'; import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js'; +import { createAdsbGnssSource } from '../layers/gnss/source.js'; /** Construct the existing reference feeds independently of application setup. */ export function createReferenceSources() { @@ -8,5 +9,6 @@ export function createReferenceSources() { earthquakes: createUsgsEarthquakeSource(), 'fire-perimeters': createWfigsPerimeterSource(), cables: createBundledCableSource(), + 'gnss-interference': createAdsbGnssSource(), }; } diff --git a/src/sources/reference.test.mjs b/src/sources/reference.test.mjs index 902a72c83..fea1cb4f2 100644 --- a/src/sources/reference.test.mjs +++ b/src/sources/reference.test.mjs @@ -16,12 +16,15 @@ test('reference factories retain compatibility without starting acquisition or s 'earthquakes', 'fire-perimeters', 'cables', + 'gnss-interference', ]); assert.notEqual(first.earthquakes, second.earthquakes); assert.notEqual(first['fire-perimeters'], second['fire-perimeters']); assert.notEqual(first.cables, second.cables); + assert.notEqual(first['gnss-interference'], second['gnss-interference']); assert.equal(typeof first.earthquakes.getSnapshot, 'function'); assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function'); assert.equal(typeof first.cables.fetch, 'function'); + assert.equal(typeof first['gnss-interference'].getSnapshot, 'function'); assert.equal(requests, 0); }); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 3d0e35b3e..b8d911767 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3263,6 +3263,30 @@ test('ALPR common names toggle only the registered camera layer through the norm } }); +test('GNSS interference common names toggle only the GNSS layer through the normal voice action', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const viewer = { clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, + camera: { moveEnd: { addEventListener() {} } } }; + const calls = []; + let enabled = false; + const dataManager = { + layers: new Map([['gnss-interference', { module: {} }]]), + getAll: () => [{ id: 'gnss-interference', name: 'GNSS Interference' }], + isEnabled: () => enabled, + setEnabled: async (id, value) => { calls.push([id, value]); enabled = value; return true; }, + }; + const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager }); + for (const alias of ['gnss-interference', 'gnss interference', 'gps interference', 'gps jamming', 'gnss']) { + for (const value of [true, false]) { + const result = await runner('set_layer_visibility', { layerId: alias, enabled: value }); + assert.equal(result.ok, true); + assert.equal(result.layerId, 'gnss-interference'); + assert.deepEqual(calls.at(-1), ['gnss-interference', value]); + } + } +}); + test('Local ADS-B common names toggle only the receiver layer through the normal voice action', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const viewer = { clock: { onTick: { addEventListener: () => () => {} } }, From 40b06a24d4d115325c20fe1d5ac4a3006b06362b Mon Sep 17 00:00:00 2001 From: Leo Pechnicki Date: Thu, 1 Oct 2026 11:00:06 +0200 Subject: [PATCH 20/86] chore(gnss): scaffold review fixes for #855 From 6d4a2a02fe7e78cec819a5300141323bebd80a80 Mon Sep 17 00:00:00 2001 From: Leo Pechnicki Date: Thu, 1 Oct 2026 12:22:05 +0200 Subject: [PATCH 21/86] fix(gnss): separate GEV integrity classifier from GPSJAM aggregation; machine-readable provenance Review on #855: the per-aircraft rule (NIC < 7 || NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima) is GEV-defined, not gpsjam's, and gpsjam's map shows low navigation accuracy, not necessarily jamming. - Primary cell state is now `navigation-integrity-anomaly` (medium/high) or `nominal`; `suspected-gnss-interference` is a secondary `interpretation` marked validated: false. - GNSS_PROVENANCE / gnssProvenance({ windowMs }) records the classifier (gev-nic-nacp-v1, GEV, unvalidated), the gpsjam aggregation (formula, bands [0.02, 0.1], FAQ source) and the GEV 30-minute visited-view window, built from the constants the code applies. Exposed as layer.provenance and getStats().provenance; the proxy tags responses with `classifier`. - Visible name "GNSS Integrity", legend "... low accuracy", blurb leads with low navigation accuracy and states the threshold is a GEV choice. - Layer id `gnss-interference` and share token `0` unchanged. - Voice aliases add "gnss integrity", "navigation integrity", "low navigation accuracy"; the interference aliases stay. - Docs describe the method as GEV threshold + gpsjam aggregation. - Tests: provenance object, renamed state/label, proxy classifier tag; qa-gnss asserts provenance and wording in the real app. Hex project - Crew Leo Agile dev team. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 17 ++-- DATA_SOURCES.md | 4 +- docs/CURRENT-STATE.md | 4 +- scripts/qa-gnss.mjs | 45 +++++++++-- server/providers/gnssIntegrity.js | 7 +- src/app/constructCatalog.js | 4 +- src/app/layers/gnss.js | 8 +- src/data/dataCredits.js | 2 +- src/data/gnssIntegrityProxy.test.mjs | 1 + src/layers/gnss/index.js | 30 ++++--- src/layers/gnss/ownership.test.mjs | 46 ++++++++++- src/layers/gnss/records.js | 117 ++++++++++++++++++++++----- src/layers/gnss/records.test.mjs | 91 +++++++++++++++++++-- src/layers/gnss/source.js | 2 + src/layers/gnss/source.test.mjs | 8 +- src/voice/gevActions.test.mjs | 6 +- 16 files changed, 325 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e9c4aca..de7b1eb56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -155,11 +155,18 @@ largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed under NLOD 2.0. -- GNSS Interference layer (Events group): ADS-B aircraft that report degraded - navigation accuracy (below the 14 CFR 91.227 ADS-B Out minima, NIC < 7 or - NACp < 8, or readsb's GPS-loss flag) are binned into 0.5° cells over a - rolling 30-minute window and scored with gpsjam.org's published formula and - bands. Keyless, worldwide wherever adsb.lol has receivers; reads +- GNSS Integrity layer (Events group, share id `gnss-interference`): cells of + low navigation accuracy reported by ADS-B aircraft. The method is a GEV + threshold plus gpsjam.org aggregation, not "the gpsjam method": an aircraft + counts as low when it reports NIC < 7 or NACp < 8 (borrowed from the US + 14 CFR 91.227 ADS-B Out minima, not a published interference classifier) or + readsb's GPS-loss flag; aircraft are binned into 0.5° cells over a + GEV-defined rolling 30-minute window around visited views and scored with + gpsjam.org's published formula and 2% / 10% bands. Medium and high cells + are navigation-integrity anomalies; suspected GNSS interference is a + secondary, unvalidated interpretation. The layer and the proxy expose + machine-readable provenance (`provenance`, `classifier: gev-nic-nacp-v1`). + Keyless, worldwide wherever adsb.lol has receivers; reads a capped 250 nm snapshot around the camera through `/api/gnss-integrity`, which returns integrity fields only and serves the last snapshot when the upstream fails. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 217c18486..5bea2456d 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -25,7 +25,7 @@ How to read this: | **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org | -| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Interference layer (per-aircraft NIC/NACp integrity only, 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 point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Integrity layer (per-aircraft NIC/NACp integrity only, 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 | | **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) | @@ -78,7 +78,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. -- **GNSS interference (adsb.lol integrity fields).** The GNSS Interference layer reads the same keyless 250 nm point snapshot around the camera (anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. An aircraft is degraded when it reports below the 14 CFR 91.227(c) ADS-B Out minima (NIC < 7 or NACp < 8) or readsb flags GPS loss; cells use gpsjam.org's published formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %; at least 3 aircraft per 0.5° cell over a rolling 30-minute window). Degraded navigation accuracy indicates probable jamming or spoofing, not a confirmed source or location. Verified 2026-09-29. +- **GNSS integrity (adsb.lol integrity fields).** The GNSS Integrity layer (id `gnss-interference`) reads the same keyless 250 nm point snapshot around the camera (anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. The method has three separately sourced parts, exposed as machine-readable `provenance` on the layer and its stats: (1) a GEV-defined per-aircraft classifier (`gev-nic-nacp-v1`): an aircraft has low navigation accuracy when it reports NIC < 7 or NACp < 8 or readsb flags GPS loss. The numbers are borrowed from the 14 CFR 91.227(c) ADS-B Out performance minima, a US regulation, not a published interference classifier; gpsjam.org does not publish its per-aircraft threshold ([FAQ](https://gpsjam.org/faq)), and this one is not yet validated against known events. (2) gpsjam.org's published cell formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %). (3) A GEV-defined window: at least 3 aircraft per 0.5° cell over a rolling 30 minutes around the views the user visits. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is a secondary interpretation, not a confirmed source or location: avionics faults also report low values. Verified 2026-09-29. - **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. - **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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f9f3e6391..356e232b2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3069,10 +3069,10 @@ its criteria cannot be silently ignored. | 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) | | Street Level 📷 | Street-level imagery; Mapillary is the first provider (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, cached 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 Interference 📡 | adsb.lol point snapshot, ADS-B NIC/NACp integrity binned per 0.5° cell (gpsjam.org formula and bands, 91.227 NIC/NACp minima, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | 60s (server cache 60 s; 30 min rolling window) | +| GNSS Integrity 📡 (`gnss-interference`) | 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` | 60s (server cache 60 s; 30 min rolling window) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | -GNSS Interference fetches a capped adsb.lol snapshot around the view anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band. It shows probable interference, not a located emitter. +GNSS Integrity fetches a capped adsb.lol snapshot around the view anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index 08e9123b6..33e3675e3 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node /** - * qa-gnss.mjs — headless proof for the GNSS Interference layer. + * qa-gnss.mjs — headless proof for the GNSS Integrity layer + * (id `gnss-interference`): low navigation accuracy reported by ADS-B. * * Drives the REAL app in headless Chromium against a dev server (keyless). * The live section reads adsb.lol through `/api/gnss-integrity` (60 s server @@ -16,6 +17,8 @@ * with 1 degraded is low (first bad discounted), a clean cell at sea * is low, a 2-aircraft cell is withheld; `stale: true` reaches * getStats. Every band is drawn and low is shown over land and sea. + * The row reads as low navigation accuracy, and stats carry the + * machine-readable provenance (GEV classifier, gpsjam bands, window). * (iii) FAILURE — the route stubbed to 502: stats.error is set and the * cells already on the globe are kept. * (iv) DISABLE — the layer's data source is hidden. @@ -108,13 +111,19 @@ async function enableAndSettle(page, { timeoutS = 30, refresh = false } = {}) { if (stats.count > 0 || stats.error) break; await new Promise((r) => setTimeout(r, 1000)); } - const legend = layer - .getRowControls() - .legend.map(({ label, count }) => ({ label, count })); + const rowControls = layer.getRowControls().legend; + const legend = rowControls.map(({ label, count }) => ({ label, count })); + const blurb = rowControls[0]?.blurb ?? ''; + const rowName = + document.querySelector(`[data-layer-id="${id}"] .data-name`) + ?.textContent ?? ''; const ds = window.__godsEyeView.viewer.dataSources.getByName(id)[0]; return { stats, legend, + blurb, + rowName, + name: layer.name, entities: ds?.entities.values.length ?? -1, shown: ds?.show ?? null, }; @@ -217,7 +226,7 @@ function fixturePayload() { } async function main() { - console.log('\nGNSS Interference proof (qa-gnss)'); + console.log('\nGNSS Integrity proof (qa-gnss)'); console.log(` App URL : ${APP_URL}\n`); try { const res = await fetch(APP_URL); @@ -346,6 +355,32 @@ async function main() { band('Over') === 1 && band('2') === 1 && band('Under') === 2, JSON.stringify(f.legend), ); + const prov = f.stats.provenance; + record( + 'PROVENANCE: GEV classifier, gpsjam bands and visited-view window are machine-readable', + prov?.classifier?.id === 'gev-nic-nacp-v1' && + prov.classifier.definedBy === 'GEV' && + prov.classifier.validated === false && + prov.aggregation?.source === 'https://gpsjam.org/faq' && + JSON.stringify(prov.aggregation.bands) === '[0.02,0.1]' && + prov.window?.minutes === 30 && + prov.window.scope === 'visited-view' && + prov.interpretation?.validated === false, + JSON.stringify(prov?.classifier), + ); + record( + 'WORDING: the row leads with low navigation accuracy, interference only as the hedged reading', + f.name === 'GNSS Integrity' && + f.rowName.includes('GNSS Integrity') && + !/interference/i.test(f.rowName) && + f.legend.every(({ label }) => label.endsWith('low accuracy')) && + f.blurb.startsWith( + 'Share of ADS-B aircraft reporting low navigation accuracy', + ) && + /GEV threshold/.test(f.blurb) && + /Suspected jamming or spoofing is one possible cause/.test(f.blurb), + `name=${JSON.stringify(f.name)}`, + ); record( 'BANDS: stale snapshot is reported', f.stats.stale === true, diff --git a/server/providers/gnssIntegrity.js b/server/providers/gnssIntegrity.js index d0a96a98b..7cb79c68f 100644 --- a/server/providers/gnssIntegrity.js +++ b/server/providers/gnssIntegrity.js @@ -1,4 +1,7 @@ -import { normalizeGnssAircraft } from '../../src/layers/gnss/records.js'; +import { + GNSS_CLASSIFIER, + normalizeGnssAircraft, +} from '../../src/layers/gnss/records.js'; import { adsbLolFallbackAnchor } from './aircraft/opensky.js'; import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js'; import { makeRateLimiter, clientKey } from './common/rate-limit.js'; @@ -91,6 +94,8 @@ export function gnssIntegrityProxy({ fetchedAt: now(), anchor: { lat, lon }, radiusNm: RADIUS_NM, + // `degraded` on each row is this classifier's verdict, not gpsjam's. + classifier: GNSS_CLASSIFIER.id, rows, }; } diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index a9c9ca3d1..f78d4d5eb 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -25,7 +25,7 @@ import { createApplicationAwareness } from './layers/militaryAwareness.js'; import { createApplicationFirms } from './layers/firms.js'; import { createApplicationEarthquakes } from './layers/earthquakes.js'; import { createApplicationFirePerimeters } from './layers/perimeters.js'; -import { createApplicationGnssInterference } from './layers/gnss.js'; +import { createApplicationGnssIntegrity } from './layers/gnss.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; @@ -121,7 +121,7 @@ export function createApplicationCatalog({ createApplicationFirePerimeters({ source: sources['fire-perimeters'], }), - createApplicationGnssInterference({ + createApplicationGnssIntegrity({ source: sources['gnss-interference'], }), createApplicationAlpr({ surface, source: sources.alpr }), diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js index 5c1b38916..46983953b 100644 --- a/src/app/layers/gnss.js +++ b/src/app/layers/gnss.js @@ -1,6 +1,6 @@ -import { createGnssInterferenceLayer } from '../../layers/gnss/index.js'; +import { createGnssIntegrityLayer } from '../../layers/gnss/index.js'; -/** Wire the adsb.lol GNSS interference layer into the application catalog. */ -export function createApplicationGnssInterference(options) { - return createGnssInterferenceLayer(options); +/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */ +export function createApplicationGnssIntegrity(options) { + return createGnssIntegrityLayer(options); } diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index bdfbb4cde..8c8ea480e 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -104,7 +104,7 @@ export const DATA_CREDITS = [ { key: 'adsblol', html: - 'Military flights, aircraft traces, bounded regional flight fallback & GNSS interference: ' + + 'Military flights, aircraft traces, bounded regional flight fallback & GNSS navigation integrity: ' + 'adsb.lol ' + '(ODbL 1.0)', }, diff --git a/src/data/gnssIntegrityProxy.test.mjs b/src/data/gnssIntegrityProxy.test.mjs index 5575e64ce..6639af088 100644 --- a/src/data/gnssIntegrityProxy.test.mjs +++ b/src/data/gnssIntegrityProxy.test.mjs @@ -88,6 +88,7 @@ for (const hook of ['configureServer', 'configurePreviewServer']) { fetchedAt: 1234, anchor: { lat: 50, lon: 20 }, radiusNm: 250, + classifier: 'gev-nic-nacp-v1', rows: [ { hex: '4ca7b5', diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index 2308b0aae..1f5840e3f 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -2,9 +2,12 @@ import * as Cesium from 'cesium'; import { GNSS_LEVEL_COLORS, GNSS_MIN_AIRCRAFT, + GNSS_NACP_THRESHOLD, + GNSS_NIC_THRESHOLD, GNSS_WINDOW_MS, accumulateGnssObservations, binGnssCells, + gnssProvenance, } from './records.js'; export * from './records.js'; export { createAdsbGnssSource } from './source.js'; @@ -12,9 +15,9 @@ export { createAdsbGnssSource } from './source.js'; /** Low stays visible over land so coverage reads apart from no data. */ const FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 }); const LEVEL_LABELS = Object.freeze({ - low: 'Under 2% degraded', - medium: '2–10% degraded', - high: 'Over 10% degraded', + low: 'Under 2% low accuracy', + medium: '2–10% low accuracy', + high: 'Over 10% low accuracy', }); /** Degrees at the centre of the current view, or null before the camera settles. */ @@ -34,18 +37,19 @@ export function defaultGnssViewAnchor(viewer) { } /** - * Own one GNSS-interference display: rolling integrity observations around - * the views the user visits, binned into cells coloured by the share of - * aircraft reporting degraded navigation integrity. + * Own one GNSS navigation-integrity display: rolling integrity observations + * around the views the user visits, binned into cells coloured by the share + * of aircraft reporting low navigation accuracy. The layer id keeps its + * original `gnss-interference` spelling because share links depend on it. */ -export function createGnssInterferenceLayer({ +export function createGnssIntegrityLayer({ source, viewAnchor = defaultGnssViewAnchor, now = () => Date.now(), windowMs = GNSS_WINDOW_MS, } = {}) { if (typeof source?.getSnapshot !== 'function') - throw new TypeError('GNSS interference requires a snapshot source'); + throw new TypeError('GNSS integrity layer requires a snapshot source'); let _viewer = null; let _dataSource = null; let _request = null; @@ -56,6 +60,7 @@ export function createGnssInterferenceLayer({ let _lastError = null; let _stale = false; const _observations = new Map(); + const provenance = gnssProvenance({ windowMs }); function render() { // Only the id and band change what is drawn; counts alone do not. @@ -91,14 +96,16 @@ export function createGnssInterferenceLayer({ const layer = { id: 'gnss-interference', - name: 'GNSS Interference', + name: 'GNSS Integrity', icon: '📡', source: 'adsb.lol', + /** Where each part of the method comes from; see records.js. */ + provenance, updateInterval: 60000, init(viewer) { if (_viewer) - throw new Error('GNSS interference layer is already initialized'); + throw new Error('GNSS integrity layer is already initialized'); _viewer = viewer; _dataSource = new Cesium.CustomDataSource('gnss-interference'); _dataSource.show = false; @@ -179,7 +186,7 @@ export function createGnssInterferenceLayer({ count: _cells.filter((cell) => cell.level === level).length, ...(index === 0 ? { - blurb: `Share of ADS-B aircraft reporting low navigation integrity (NIC/NACp) over the last 30 minutes, around the views you visit. Cells with fewer than ${GNSS_MIN_AIRCRAFT} aircraft are not drawn, so blank areas mean no data, not clean GNSS. An inference of jamming or spoofing, not a detection.`, + blurb: `Share of ADS-B aircraft reporting low navigation accuracy over the last ${provenance.window.minutes} minutes, around the views you visit. An aircraft counts when it reports NIC < ${GNSS_NIC_THRESHOLD} or NACp < ${GNSS_NACP_THRESHOLD} or GPS loss: a GEV threshold borrowed from US ADS-B Out minima, not a published interference test. Cells use gpsjam.org's formula and 2% / 10% bands. Cells with fewer than ${GNSS_MIN_AIRCRAFT} aircraft are not drawn, so blank areas mean no data, not clean GNSS. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is one possible cause, not a detection: avionics faults also report low values.`, } : {}), })), @@ -192,6 +199,7 @@ export function createGnssInterferenceLayer({ lastUpdate: _lastUpdate, error: _lastError, stale: _stale, + provenance, // Load-bearing: without an explicit boolean, layerFeedState reads the // adsb.lol source name as a flights fallback (src/data/feedState.js). fallback: false, diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index 099a470f4..339a0b603 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createGnssInterferenceLayer } from './index.js'; +import { createGnssIntegrityLayer } from './index.js'; const row = (hex, lat, degraded) => ({ hex, @@ -29,7 +29,7 @@ function harness( }; let clock = 1_000; const anchors = []; - const layer = createGnssInterferenceLayer({ + const layer = createGnssIntegrityLayer({ source: { getSnapshot }, viewAnchor: () => { anchors.push(anchor); @@ -49,7 +49,7 @@ function harness( } test('the layer requires a snapshot source and cannot be initialized twice', () => { - assert.throws(() => createGnssInterferenceLayer({}), TypeError); + assert.throws(() => createGnssIntegrityLayer({}), TypeError); const { layer } = harness(async () => ({ rows: [] })); assert.throws(() => layer.init({ dataSources: { add() {} } }), /already/); }); @@ -91,6 +91,46 @@ test('an enabled refresh bins the snapshot into coloured ground cells', async () assert.match(legend[0].blurb, /fewer than 3 aircraft are not drawn/); }); +test('the visible layer leads with low navigation accuracy and exposes its provenance', async () => { + const { layer } = harness(async () => ({ rows: [], stale: false })); + assert.equal(layer.id, 'gnss-interference', 'the share-link id is unchanged'); + assert.equal(layer.name, 'GNSS Integrity'); + assert.doesNotMatch(layer.name, /interference|jamming/i); + const { legend } = layer.getRowControls(); + assert.deepEqual( + legend.map(({ label }) => label), + ['Over 10% low accuracy', '2–10% low accuracy', 'Under 2% low accuracy'], + ); + const { blurb } = legend[0]; + assert.match( + blurb, + /^Share of ADS-B aircraft reporting low navigation accuracy/, + ); + assert.match(blurb, /NIC < 7 or NACp < 8/); + assert.match(blurb, /GEV threshold/); + assert.match(blurb, /not a published interference test/); + assert.match(blurb, /gpsjam\.org's formula and 2% \/ 10% bands/); + assert.match(blurb, /navigation-integrity anomalies/); + // Interference appears only as the secondary, hedged reading. + assert.ok( + blurb.indexOf('navigation-integrity anomalies') < + blurb.indexOf('Suspected jamming or spoofing'), + ); + assert.equal(layer.provenance.classifier.id, 'gev-nic-nacp-v1'); + assert.equal(layer.provenance.classifier.definedBy, 'GEV'); + assert.equal(layer.provenance.classifier.validated, false); + assert.equal(layer.provenance.aggregation.source, 'https://gpsjam.org/faq'); + assert.deepEqual(layer.provenance.window, { + minutes: 30, + scope: 'visited-view', + cellDeg: 0.5, + minAircraft: 3, + definedBy: 'GEV', + }); + assert.equal(layer.provenance.interpretation.validated, false); + assert.equal(layer.getStats().provenance, layer.provenance); +}); + test('a failed refresh keeps the last cells and reports the error', async () => { let fail = false; const { layer, sources } = harness(async () => { diff --git a/src/layers/gnss/records.js b/src/layers/gnss/records.js index 25353fd28..8e57b2f9a 100644 --- a/src/layers/gnss/records.js +++ b/src/layers/gnss/records.js @@ -1,24 +1,29 @@ /** - * GNSS interference inferred from ADS-B navigation integrity. + * Low navigation accuracy reported by ADS-B aircraft, binned into cells. * * Aircraft broadcasting DO-260 version 1/2 ADS-B report how much they trust * their own position: NIC (integrity containment radius) and NACp (position - * accuracy). Both collapse when the GNSS receiver is jammed or spoofed, so a - * cluster of aircraft reporting low values in one area is a well-known public - * proxy for interference (popularized by gpsjam.org, whose published cell - * formula and bands this module uses, and by the readsb/tar1090 `gpsOkBefore` - * flag). gpsjam does not publish its per-aircraft thresholds; here an aircraft - * is degraded when it reports below the 14 CFR 91.227(c) ADS-B Out minima - * (NIC < 7 or NACp < 8). It is an inference, not a detection: - * avionics faults and old transponders also report low values, which is why - * cells need several aircraft and the first degraded aircraft is discounted. + * accuracy). A cluster of aircraft reporting low values in one area is a + * navigation-integrity anomaly. Jamming or spoofing is one common cause, so + * such clusters are a well-known public proxy for GNSS interference, but + * avionics faults and old transponders also report low values: suspected + * interference is a second, unvalidated interpretation, never the primary + * state. + * + * Three separately sourced parts make up the method (see `gnssProvenance`): + * - Per-aircraft classifier, defined by GEV: NIC < 7 or NACp < 8 (borrowed + * from the US 14 CFR 91.227(c) ADS-B Out performance minima, which are not + * a published interference classifier) or the readsb/tar1090 `gpsOkBefore` + * flag. gpsjam.org does not publish its per-aircraft threshold. + * - Cell aggregation: gpsjam.org's published formula and 2% / 10% bands. + * - Window: a GEV-defined rolling 30 minutes over the views the user visits. */ /** Direct 1090ES ADS-B only: MLAT, TIS-B and ADS-R positions are not self-reported. */ const QUALIFYING_TYPES = new Set(['adsb_icao']); -/** NIC below 7 means a containment radius of 0.2 NM or more (below the 91.227 ADS-B Out minimum). */ +/** NIC below 7: containment radius of 0.2 NM or more (below the US 91.227 ADS-B Out minimum). GEV threshold. */ export const GNSS_NIC_THRESHOLD = 7; -/** NACp below 8 means an estimated position uncertainty of 93 m or more (below the 91.227 minimum). */ +/** NACp below 8: position uncertainty of 93 m or more (below the US 91.227 minimum). GEV threshold. */ export const GNSS_NACP_THRESHOLD = 8; /** Positions older than this are not current evidence of the local sky. */ const MAX_POSITION_AGE_S = 60; @@ -28,6 +33,65 @@ export const GNSS_CELL_DEG = 0.5; export const GNSS_MIN_AIRCRAFT = 3; /** Rolling window over which aircraft seen in a cell are counted. */ export const GNSS_WINDOW_MS = 30 * 60_000; +/** gpsjam.org band edges, in percent of aircraft (after discounting one). */ +export const GNSS_MEDIUM_PERCENT = 2; +export const GNSS_HIGH_PERCENT = 10; + +/** Primary cell state: what the data shows (low navigation accuracy). */ +export const GNSS_ANOMALY_STATE = 'navigation-integrity-anomaly'; +export const GNSS_NOMINAL_STATE = 'nominal'; +/** Secondary, unvalidated reading of an anomaly cell. */ +export const GNSS_INTERPRETATION = 'suspected-gnss-interference'; + +/** The per-aircraft rule; GEV-defined and not validated against known events. */ +export const GNSS_CLASSIFIER = Object.freeze({ + id: 'gev-nic-nacp-v1', + rule: `gpsOkBefore set || NIC < ${GNSS_NIC_THRESHOLD} || NACp < ${GNSS_NACP_THRESHOLD}`, + basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)', + definedBy: 'GEV', + validated: false, +}); + +/** + * Machine-readable provenance of the cell output: which part of the method + * comes from where, and which parts are unvalidated. + * + * @param {{windowMs?: number}} [options] The window the layer actually uses. + */ +export function gnssProvenance({ windowMs = GNSS_WINDOW_MS } = {}) { + return Object.freeze({ + classifier: GNSS_CLASSIFIER, + aggregation: Object.freeze({ + id: 'gpsjam-cell-bands', + formula: '100 * max(0, bad - 1) / total', + bands: Object.freeze([ + GNSS_MEDIUM_PERCENT / 100, + GNSS_HIGH_PERCENT / 100, + ]), + source: 'https://gpsjam.org/faq', + definedBy: 'gpsjam.org', + }), + window: Object.freeze({ + minutes: windowMs / 60_000, + scope: 'visited-view', + cellDeg: GNSS_CELL_DEG, + minAircraft: GNSS_MIN_AIRCRAFT, + definedBy: 'GEV', + }), + state: Object.freeze({ + id: GNSS_ANOMALY_STATE, + meaning: 'low navigation accuracy reported by ADS-B aircraft', + }), + interpretation: Object.freeze({ + id: GNSS_INTERPRETATION, + validated: false, + note: 'one possible cause of an anomaly cell; avionics faults also report low values', + }), + }); +} + +/** Provenance at the default window. */ +export const GNSS_PROVENANCE = gnssProvenance(); const HEX_PATTERN = /^~?[0-9a-f]{6}$/; @@ -118,22 +182,32 @@ export function accumulateGnssObservations( } /** - * Interference level of a cell, using gpsjam.org's published cell formula and - * bands: the first degraded aircraft is discounted to limit false positives, - * then under 2% is low, 2–10% medium and over 10% high. + * Navigation-integrity level of a cell, using gpsjam.org's published cell + * formula and bands: the first degraded aircraft is discounted to limit false + * positives, then under 2% is low, 2–10% medium and over 10% high. Medium and + * high cells are navigation-integrity anomalies. */ -export function gnssInterferenceLevel(total, degraded) { +export function gnssIntegrityLevel(total, degraded) { const percent = total > 0 ? (100 * Math.max(0, degraded - 1)) / total : 0; + const level = + percent > GNSS_HIGH_PERCENT + ? 'high' + : percent >= GNSS_MEDIUM_PERCENT + ? 'medium' + : 'low'; + const anomaly = level !== 'low'; return { percent, - level: percent > 10 ? 'high' : percent >= 2 ? 'medium' : 'low', + level, + state: anomaly ? GNSS_ANOMALY_STATE : GNSS_NOMINAL_STATE, + interpretation: anomaly ? GNSS_INTERPRETATION : null, }; } /** * Aggregate the observation store into displayable cells. * - * @returns {{id: string, south: number, west: number, north: number, east: number, lat: number, lon: number, aircraft: number, degraded: number, percentDegraded: number, level: 'low'|'medium'|'high'}[]} + * @returns {{id: string, south: number, west: number, north: number, east: number, lat: number, lon: number, aircraft: number, degraded: number, percentDegraded: number, level: 'low'|'medium'|'high', state: string, interpretation: string|null}[]} */ export function binGnssCells( store, @@ -152,7 +226,10 @@ export function binGnssCells( const [row, col] = id.split(':').map(Number); const south = row * cellDeg - 90; const west = col * cellDeg - 180; - const { percent, level } = gnssInterferenceLevel(aircraft, degraded); + const { percent, level, state, interpretation } = gnssIntegrityLevel( + aircraft, + degraded, + ); cells.push({ id, south, @@ -165,6 +242,8 @@ export function binGnssCells( degraded, percentDegraded: Math.round(percent * 10) / 10, level, + state, + interpretation, }); } return cells.sort((a, b) => a.id.localeCompare(b.id)); diff --git a/src/layers/gnss/records.test.mjs b/src/layers/gnss/records.test.mjs index 9188fd839..20546abb7 100644 --- a/src/layers/gnss/records.test.mjs +++ b/src/layers/gnss/records.test.mjs @@ -3,8 +3,11 @@ import test from 'node:test'; import { accumulateGnssObservations, binGnssCells, + GNSS_CLASSIFIER, + GNSS_PROVENANCE, gnssCellKey, - gnssInterferenceLevel, + gnssIntegrityLevel, + gnssProvenance, normalizeGnssAircraft, } from './records.js'; @@ -93,14 +96,86 @@ test('cell keys are stable half-degree bins, clamped at the poles and antimeridi assert.equal(gnssCellKey(-0.1, -0.1), '179:359'); }); -test('interference level follows the gpsjam.org published cell formula and bands', () => { - assert.deepEqual(gnssInterferenceLevel(10, 0), { percent: 0, level: 'low' }); +test('integrity level follows the gpsjam.org published cell formula and bands', () => { + const nominal = { + percent: 0, + level: 'low', + state: 'nominal', + interpretation: null, + }; + assert.deepEqual(gnssIntegrityLevel(10, 0), nominal); // One degraded aircraft is always discounted. - assert.deepEqual(gnssInterferenceLevel(10, 1), { percent: 0, level: 'low' }); - assert.equal(gnssInterferenceLevel(50, 2).level, 'medium'); - assert.equal(gnssInterferenceLevel(10, 2).level, 'medium'); - assert.equal(gnssInterferenceLevel(10, 3).level, 'high'); - assert.equal(gnssInterferenceLevel(0, 0).level, 'low'); + assert.deepEqual(gnssIntegrityLevel(10, 1), nominal); + assert.equal(gnssIntegrityLevel(50, 2).level, 'medium'); + assert.equal(gnssIntegrityLevel(10, 2).level, 'medium'); + assert.equal(gnssIntegrityLevel(10, 3).level, 'high'); + assert.equal(gnssIntegrityLevel(0, 0).level, 'low'); +}); + +test('the primary state is a navigation-integrity anomaly; interference is only the secondary reading', () => { + for (const [total, bad] of [ + [50, 2], + [10, 3], + ]) { + const { state, interpretation } = gnssIntegrityLevel(total, bad); + assert.equal(state, 'navigation-integrity-anomaly'); + assert.equal(interpretation, 'suspected-gnss-interference'); + } + const store = new Map(); + accumulateGnssObservations( + store, + [ + { hex: 'h1', lat: 10.1, lon: 10.1, degraded: true }, + { hex: 'h2', lat: 10.2, lon: 10.2, degraded: true }, + { hex: 'h3', lat: 10.3, lon: 10.3, degraded: false }, + ], + 0, + ); + const [cell] = binGnssCells(store); + assert.equal(cell.level, 'high'); + assert.equal(cell.state, 'navigation-integrity-anomaly'); + assert.equal(cell.interpretation, 'suspected-gnss-interference'); + for (const key of Object.keys(cell)) + assert.doesNotMatch(key, /interference|jamming|spoof/i); +}); + +test('provenance separates the GEV classifier from the gpsjam aggregation and the GEV window', () => { + assert.deepEqual(GNSS_PROVENANCE.classifier, { + id: 'gev-nic-nacp-v1', + rule: 'gpsOkBefore set || NIC < 7 || NACp < 8', + basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)', + definedBy: 'GEV', + validated: false, + }); + assert.equal(GNSS_PROVENANCE.classifier, GNSS_CLASSIFIER); + assert.deepEqual(GNSS_PROVENANCE.aggregation, { + id: 'gpsjam-cell-bands', + formula: '100 * max(0, bad - 1) / total', + bands: [0.02, 0.1], + source: 'https://gpsjam.org/faq', + definedBy: 'gpsjam.org', + }); + assert.deepEqual(GNSS_PROVENANCE.window, { + minutes: 30, + scope: 'visited-view', + cellDeg: 0.5, + minAircraft: 3, + definedBy: 'GEV', + }); + assert.equal(GNSS_PROVENANCE.state.id, 'navigation-integrity-anomaly'); + assert.equal( + GNSS_PROVENANCE.interpretation.id, + 'suspected-gnss-interference', + ); + assert.equal(GNSS_PROVENANCE.interpretation.validated, false); + assert.ok(Object.isFrozen(GNSS_PROVENANCE)); + assert.ok(Object.isFrozen(GNSS_PROVENANCE.aggregation.bands)); + // The bands in provenance are the ones the level function applies. + const [medium, high] = GNSS_PROVENANCE.aggregation.bands; + assert.equal(gnssIntegrityLevel(100, 1 + 100 * medium).level, 'medium'); + assert.equal(gnssIntegrityLevel(100, 1 + 100 * high).level, 'medium'); + assert.equal(gnssIntegrityLevel(100, 2 + 100 * high).level, 'high'); + assert.equal(gnssProvenance({ windowMs: 10 * 60_000 }).window.minutes, 10); }); test('observations count each aircraft once per cell and expire after the window', () => { diff --git a/src/layers/gnss/source.js b/src/layers/gnss/source.js index 4feba891e..7d1fb0aa8 100644 --- a/src/layers/gnss/source.js +++ b/src/layers/gnss/source.js @@ -31,6 +31,8 @@ export function createAdsbGnssSource({ ? payload.fetchedAt : null, stale: payload.stale === true, + classifier: + typeof payload.classifier === 'string' ? payload.classifier : null, }; }, }; diff --git a/src/layers/gnss/source.test.mjs b/src/layers/gnss/source.test.mjs index 8aad9b988..cb8fe372f 100644 --- a/src/layers/gnss/source.test.mjs +++ b/src/layers/gnss/source.test.mjs @@ -8,7 +8,11 @@ test('the source asks the same-origin proxy for the view anchor', async () => { fetchImpl: async (url, options) => { calls.push(url); assert.ok(options.signal instanceof AbortSignal); - return Response.json({ fetchedAt: 5, rows: [{ hex: 'abc123' }] }); + return Response.json({ + fetchedAt: 5, + classifier: 'gev-nic-nacp-v1', + rows: [{ hex: 'abc123' }], + }); }, }); const snapshot = await source.getSnapshot( @@ -20,6 +24,7 @@ test('the source asks the same-origin proxy for the view anchor', async () => { rows: [{ hex: 'abc123' }], fetchedAt: 5, stale: false, + classifier: 'gev-nic-nacp-v1', }); }); @@ -30,6 +35,7 @@ test('the source reports stale proxy data and rejects bad input or responses', a }).getSnapshot({ latitude: 1, longitude: 2 }); assert.equal(stale.stale, true); assert.equal(stale.fetchedAt, null); + assert.equal(stale.classifier, null); await assert.rejects( createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}), diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index b8d911767..55daa80e9 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3263,7 +3263,7 @@ test('ALPR common names toggle only the registered camera layer through the norm } }); -test('GNSS interference common names toggle only the GNSS layer through the normal voice action', async () => { +test('GNSS integrity and interference common names toggle only the GNSS layer through the normal voice action', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const viewer = { clock: { onTick: { addEventListener: () => () => {} } }, scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, @@ -3272,12 +3272,12 @@ test('GNSS interference common names toggle only the GNSS layer through the norm let enabled = false; const dataManager = { layers: new Map([['gnss-interference', { module: {} }]]), - getAll: () => [{ id: 'gnss-interference', name: 'GNSS Interference' }], + getAll: () => [{ id: 'gnss-interference', name: 'GNSS Integrity' }], isEnabled: () => enabled, setEnabled: async (id, value) => { calls.push([id, value]); enabled = value; return true; }, }; const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager }); - for (const alias of ['gnss-interference', 'gnss interference', 'gps interference', 'gps jamming', 'gnss']) { + for (const alias of ['gnss-interference', 'gnss integrity', 'navigation integrity', 'low navigation accuracy', 'gnss interference', 'gps interference', 'gps jamming', 'gnss']) { for (const value of [true, false]) { const result = await runner('set_layer_visibility', { layerId: alias, enabled: value }); assert.equal(result.ok, true); From c8d3f44bb7b1124ff9f7ea0d8e9819bef0c0a217 Mon Sep 17 00:00:00 2001 From: Leo Pechnicki Date: Wed, 7 Oct 2026 00:28:23 +0200 Subject: [PATCH 22/86] chore(gnss): scaffold maintainer review fixes for #855 From fde8b2a64f95950c3b20700e3b860842242bbd14 Mon Sep 17 00:00:00 2001 From: Leo Pechnicki Date: Wed, 7 Oct 2026 05:49:35 +0200 Subject: [PATCH 23/86] fix(gnss): age degraded evidence and replayed snapshots on their own clocks Maintainer review on #855 (mazy1998, 2026-10-06): - records: presence (seenAt) and degraded evidence (degradedAt) age separately, so continuing healthy reports no longer keep an older degraded flag alive past 30 minutes; an aircraft returning after the window starts fresh. pruneGnssObservations ages the store with no new rows. - Rows are dated by the snapshot's fetchedAt, which the proxy keeps on cached and stale answers: a replay never restarts the window, the proxy stops serving a stale snapshot older than the window, the source refuses an undated one, and cells expire during fetch failures and while there is no view anchor. - Normalization accepts ADS-B versions 1 and 2 only; a missing, invalid or negative seen_pos is not a confirmed position from the last minute. - qa-gnss: keyboard, 390x844 viewport (fresh page: isMobile reloads) and replay-age sections; the live cell assertion bins the returned rows with the layer's own rules instead of a raw row count. --- CHANGELOG.md | 8 +- docs/CURRENT-STATE.md | 2 +- scripts/qa-gnss.mjs | 224 +++++++++++++++++++++++---- server/providers/gnssIntegrity.js | 6 +- src/data/gnssIntegrityProxy.test.mjs | 23 +++ src/layers/gnss/index.js | 20 ++- src/layers/gnss/ownership.test.mjs | 80 +++++++++- src/layers/gnss/records.js | 86 +++++++--- src/layers/gnss/records.test.mjs | 153 ++++++++++++++++++ src/layers/gnss/source.js | 8 +- src/layers/gnss/source.test.mjs | 12 +- 11 files changed, 557 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de7b1eb56..ced1d2d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -168,8 +168,12 @@ machine-readable provenance (`provenance`, `classifier: gev-nic-nacp-v1`). Keyless, worldwide wherever adsb.lol has receivers; reads a capped 250 nm snapshot around the camera through `/api/gnss-integrity`, - which returns integrity fields only and serves the last snapshot when the - upstream fails. + which returns integrity fields only and serves the last snapshot (with its + original observation time, for at most 30 minutes) when the upstream fails. + Only ADS-B versions 1 and 2 with a known position age of 0-60 s count. A + degraded report keeps an aircraft degraded for 30 minutes after that report, + not after its latest healthy one; replayed snapshots keep their age, and + cells expire on schedule during fetch failures or without a view anchor. ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 356e232b2..cb1582ce8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3072,7 +3072,7 @@ its criteria cannot be silently ignored. | GNSS Integrity 📡 (`gnss-interference`) | 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` | 60s (server cache 60 s; 30 min rolling window) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | -GNSS Integrity fetches a capped adsb.lol snapshot around the view anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. +GNSS Integrity fetches a capped adsb.lol snapshot around the view anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Only DO-260A/B (ADS-B versions 1 and 2) direct 1090ES positions with a known `seen_pos` of 0–60 s count. Presence and degraded evidence age separately: a degraded report keeps the aircraft degraded in its cell for 30 minutes after that report, however many healthy reports follow, and an aircraft unheard for 30 minutes starts fresh. Rows are dated by the snapshot's `fetchedAt`, which the proxy keeps on cached and stale answers, so a replayed snapshot never restarts the window; the proxy stops serving a stale snapshot once it is older than 30 minutes, the source refuses an undated one, and cells age out on schedule during fetch failures or while there is no view anchor. Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index 33e3675e3..ae7e47a54 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -10,8 +10,9 @@ * * (i) LIVE — over Poland and the southern Baltic the layer settles with no error, * one entity per cell and legend band counts that sum to the cell - * count; cells are required only when adsb.lol actually returned - * enough aircraft (live traffic varies by hour). + * count; cells are required only when the returned rows, binned + * with the layer's own rules, reach the per-cell minimum (live + * traffic varies by hour). * (ii) BANDS (synthetic) — a 10-aircraft cell with 5 degraded is high, * a 30-aircraft cell with 2 degraded is medium, a 3-aircraft cell * with 1 degraded is low (first bad discounted), a clean cell at sea @@ -22,12 +23,22 @@ * (iii) FAILURE — the route stubbed to 502: stats.error is set and the * cells already on the globe are kept. * (iv) DISABLE — the layer's data source is hidden. + * (v) KEYBOARD — the row's toggle button, focused, turns the layer on + * with Enter and off with Space, keeps focus and names its state. + * (vi) VIEWPORT — at a 390x844 phone viewport the open row and its legend + * fit the panel width with no horizontal overflow. + * (vii) AGE (synthetic) — a fresh page whose stubbed proxy replays the same + * stale snapshot observed 31 minutes ago draws nothing and reports no + * error; observed 29 minutes ago it draws its cells. The replay keeps + * its observation time instead of restarting the 30-minute window. * * Screenshots wait until every cell's ground geometry is built and show the * open Data Layers row with its legend. Visual proof saved to * qa-shots/gnss-*.png (gitignored). * * Run: node scripts/qa-gnss.mjs --url http://localhost:4173 + * (also against a built preview: npm run build && npm run preview, + * then pass the preview URL) * Exits non-zero on any FAIL. Does not commit anything. */ @@ -35,6 +46,10 @@ import puppeteer from 'puppeteer'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + accumulateGnssObservations, + binGnssCells, +} from '../src/layers/gnss/records.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '..'); @@ -49,7 +64,6 @@ const APP_URL = getOpt('--url', 'http://localhost:4173'); const HEADFUL = argv.includes('--headful'); const LAYER_ID = 'gnss-interference'; /** Live rows below this cannot reliably fill a 3-aircraft cell. */ -const LIVE_MIN_ROWS = 30; const results = []; function record(name, ok, detail) { @@ -225,6 +239,70 @@ function fixturePayload() { }; } +/** Answer /api/gnss-integrity in-page from `fixturePayload()`. */ +async function stubRoute(page, { ageMs = 0 } = {}) { + await page.evaluateOnNewDocument( + (payload, age) => { + window.__gnssQaAgeMs = age; + const realFetch = window.fetch.bind(window); + window.__gnssQaMode = 'fixture'; + window.fetch = (input, init) => { + const url = typeof input === 'string' ? input : input?.url || ''; + if (!url.includes('/api/gnss-integrity')) return realFetch(input, init); + const ok = window.__gnssQaMode === 'fixture'; + const body = ok + ? { ...payload, fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0) } + : { error: 'gnss_integrity_unavailable' }; + return Promise.resolve( + new Response(JSON.stringify(body), { + status: ok ? 200 : 502, + headers: { 'Content-Type': 'application/json' }, + }), + ); + }; + }, + fixturePayload(), + ageMs, + ); +} + +/** Toggle button of the GNSS row, as the panel draws it. */ +function toggleState(id) { + const button = document.querySelector( + `[data-layer-id="${id}"] .data-toggle-btn`, + ); + const layer = window.__godsEyeView.dataManager.layers.get(id); + return { + focused: document.activeElement === button, + label: button?.getAttribute('aria-label') ?? '', + enabled: Boolean(layer?.enabled), + count: layer?.module.getStats().count ?? -1, + }; +} + +/** Press a key on the focused GNSS toggle and wait for the lifecycle to settle. */ +async function pressToggle(page, key, wantEnabled) { + await page.evaluate((id) => { + document.querySelector(`[data-layer-id="${id}"] .data-toggle-btn`)?.focus(); + }, LAYER_ID); + await page.keyboard.press(key); + await page + .waitForFunction( + (id, want) => { + const layer = window.__godsEyeView.dataManager.layers.get(id); + return ( + Boolean(layer?.enabled) === want && + (!want || layer.module.getStats().count > 0) + ); + }, + { timeout: 30000, polling: 250 }, + LAYER_ID, + wantEnabled, + ) + .catch(() => {}); + return page.evaluate(`(${toggleState})(${JSON.stringify(LAYER_ID)})`); +} + async function main() { console.log('\nGNSS Integrity proof (qa-gnss)'); console.log(` App URL : ${APP_URL}\n`); @@ -272,31 +350,36 @@ async function main() { const liveBodies = []; live.on('response', (res) => { if (res.url().includes('/api/gnss-integrity') && res.ok()) - liveBodies.push(res.json().then((body) => body.rows?.length ?? 0)); + liveBodies.push(res.json().then((body) => body.rows ?? [])); }); await boot(live, { lon: 19.5, lat: 52.5, height: 1_100_000 }); const l = await enableAndSettle(live); const legendTotal = l.legend.reduce((sum, { count }) => sum + count, 0); - const liveRows = Math.max( - 0, - ...(await Promise.allSettled(liveBodies)) - .filter((r) => r.status === 'fulfilled') - .map((r) => r.value), - ); + // Bin what adsb.lol actually returned with the layer's own rules: live + // traffic varies by hour, so cells are required only when some cell + // really reached the minimum aircraft count. + const liveSnapshots = (await Promise.allSettled(liveBodies)) + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value); + const liveRows = liveSnapshots.reduce((sum, rows) => sum + rows.length, 0); + const expectedStore = new Map(); + for (const rows of liveSnapshots) + accumulateGnssObservations(expectedStore, rows, Date.now()); + const expectedCells = binGnssCells(expectedStore).length; record( 'LIVE: no error, one entity per cell', !l.stats.error && l.entities === l.stats.count, `cells=${l.stats.count} entities=${l.entities} error=${JSON.stringify(l.stats.error)}`, ); - if (liveRows >= LIVE_MIN_ROWS) { + if (expectedCells > 0) { record( - 'LIVE: a busy snapshot yields cells', + 'LIVE: a snapshot with a qualifying cell yields cells', l.stats.count > 0, - `rows=${liveRows} cells=${l.stats.count}`, + `rows=${liveRows} expected=${expectedCells} cells=${l.stats.count}`, ); } else { console.log( - ` [info] only ${liveRows} live rows — cell count not asserted`, + ` [info] ${liveRows} live rows, no cell reaches the minimum — cell count not asserted`, ); } record( @@ -323,24 +406,7 @@ async function main() { // Stub the route in-page rather than with request interception: an // intercepted page stalls Cesium's geometry workers, so ground rectangles // never finish building and the screenshot would not show real behaviour. - await page.evaluateOnNewDocument((payload) => { - const realFetch = window.fetch.bind(window); - window.__gnssQaMode = 'fixture'; - window.fetch = (input, init) => { - const url = typeof input === 'string' ? input : input?.url || ''; - if (!url.includes('/api/gnss-integrity')) return realFetch(input, init); - const ok = window.__gnssQaMode === 'fixture'; - const body = ok - ? { ...payload, fetchedAt: Date.now() } - : { error: 'gnss_integrity_unavailable' }; - return Promise.resolve( - new Response(JSON.stringify(body), { - status: ok ? 200 : 502, - headers: { 'Content-Type': 'application/json' }, - }), - ); - }; - }, fixturePayload()); + await stubRoute(page); await boot(page, { lon: 18.6, lat: 54.6, height: 450_000 }); const f = await enableAndSettle(page); const band = (prefix) => @@ -415,6 +481,100 @@ async function main() { }, LAYER_ID); record('DISABLE: data source hidden', shown === false, `show=${shown}`); await shoot(page, 'disabled'); + + // ── (v) KEYBOARD ────────────────────────────────────────────────────── + console.log('\n(v) KEYBOARD — the row toggle, from the keyboard...'); + await page.evaluate(() => { + window.__gnssQaMode = 'fixture'; + }); + await showLayerRow(page); + const on = await pressToggle(page, 'Enter', true); + record( + 'KEYBOARD: Enter on the focused toggle enables the layer and keeps focus', + on.enabled && + on.focused && + on.count === 4 && + /^GNSS Integrity: /.test(on.label) && + !/OFF$/.test(on.label), + JSON.stringify(on), + ); + const off = await pressToggle(page, ' ', false); + record( + 'KEYBOARD: Space on the focused toggle disables it and the label says OFF', + !off.enabled && off.focused && /^GNSS Integrity: OFF$/.test(off.label), + JSON.stringify(off), + ); + + // ── (vi) VIEWPORT ───────────────────────────────────────────────────── + console.log( + '\n(vi) VIEWPORT — the open row on a 390x844 phone viewport...', + ); + await page.close(); + // A fresh page: switching an open page to isMobile reloads it, which + // would measure a half-booted app instead of the open row. + const phone = await browser.newPage(); + await phone.setViewport({ width: 390, height: 844, isMobile: true }); + phone.on('pageerror', (e) => errors.push(e.message)); + await stubRoute(phone); + await boot(phone, { lon: 18.6, lat: 54.6, height: 450_000 }); + await showLayerRow(phone); + await pressToggle(phone, 'Enter', true); + await showLayerRow(phone); + const fit = await phone.evaluate((id) => { + const row = document.querySelector(`[data-layer-id="${id}"]`); + const legend = [...row.querySelectorAll('.data-toggle-legend-item')]; + const rect = row.getBoundingClientRect(); + return { + width: window.innerWidth, + rowLeft: Math.round(rect.left), + rowRight: Math.round(rect.right), + overflow: row.scrollWidth - row.clientWidth, + legendItems: legend.length, + legendInside: legend.every((item) => { + const r = item.getBoundingClientRect(); + return ( + r.width > 0 && r.left >= rect.left - 1 && r.right <= rect.right + 1 + ); + }), + }; + }, LAYER_ID); + record( + 'VIEWPORT: row and legend fit a 390 px viewport without horizontal overflow', + fit.rowLeft >= 0 && + fit.rowRight <= fit.width && + fit.overflow <= 0 && + fit.legendItems === 3 && + fit.legendInside, + JSON.stringify(fit), + ); + await phone.screenshot({ path: path.join(SHOTS_DIR, 'gnss-phone.png') }); + await phone.close(); + + // ── (vii) AGE ───────────────────────────────────────────────────────── + console.log( + '\n(vii) AGE — a replayed stale snapshot keeps its observation time...', + ); + const aged = await browser.newPage(); + await aged.setViewport({ width: 1440, height: 900 }); + aged.on('pageerror', (e) => errors.push(e.message)); + await stubRoute(aged, { ageMs: 31 * 60_000 }); + await boot(aged, { lon: 18.6, lat: 54.6, height: 450_000 }); + const old = await enableAndSettle(aged, { timeoutS: 5 }); + record( + 'AGE: a snapshot observed 31 min ago adds no cells and is not an error', + old.stats.count === 0 && old.entities === 0 && !old.stats.error, + `cells=${old.stats.count} entities=${old.entities} error=${JSON.stringify(old.stats.error)}`, + ); + await aged.evaluate(() => { + window.__gnssQaAgeMs = 29 * 60_000; + }); + const recent = await enableAndSettle(aged, { refresh: true }); + record( + 'AGE: the same snapshot observed 29 min ago is still inside the window', + recent.stats.count === 4 && recent.entities === 4, + `cells=${recent.stats.count} entities=${recent.entities}`, + ); + await aged.close(); record( 'no uncaught browser errors', errors.length === 0, diff --git a/server/providers/gnssIntegrity.js b/server/providers/gnssIntegrity.js index 7cb79c68f..77f674a50 100644 --- a/server/providers/gnssIntegrity.js +++ b/server/providers/gnssIntegrity.js @@ -1,5 +1,6 @@ import { GNSS_CLASSIFIER, + GNSS_WINDOW_MS, normalizeGnssAircraft, } from '../../src/layers/gnss/records.js'; import { adsbLolFallbackAnchor } from './aircraft/opensky.js'; @@ -115,7 +116,10 @@ export function gnssIntegrityProxy({ }); return { value: await promise, stale: false }; } catch (error) { - if (previous) return { value: previous, stale: true }; + // A stale answer keeps its original fetchedAt so the layer can age it; + // past the layer's window it is no evidence at all, so it is not served. + if (previous && now() - previous.fetchedAt <= GNSS_WINDOW_MS) + return { value: previous, stale: true }; throw error; } } diff --git a/src/data/gnssIntegrityProxy.test.mjs b/src/data/gnssIntegrityProxy.test.mjs index 6639af088..1437e3218 100644 --- a/src/data/gnssIntegrityProxy.test.mjs +++ b/src/data/gnssIntegrityProxy.test.mjs @@ -141,12 +141,35 @@ test('an upstream failure serves the last snapshot as stale, else 502', async () const stale = await request(); assert.equal(stale.status, 200); assert.equal(stale.body.stale, true); + assert.equal( + stale.body.fetchedAt, + 0, + 'a stale answer keeps the time it was observed', + ); assert.equal(stale.headers['X-Data-Stale'], 'true'); const missing = await request('/?lat=-10&lon=-10'); assert.equal(missing.status, 502); assert.deepEqual(missing.body, { error: 'gnss_integrity_unavailable' }); }); +test('a stale snapshot older than the layer window is no longer served', async () => { + let clock = 0; + let fail = false; + const request = install({ + now: () => clock, + fetchImpl: async () => + fail ? new Response('down', { status: 503 }) : Response.json(snapshot), + }); + await request(); + fail = true; + clock = 30 * 60_000; + assert.equal((await request()).status, 200); + clock = 30 * 60_000 + 1; + const expired = await request(); + assert.equal(expired.status, 502); + assert.deepEqual(expired.body, { error: 'gnss_integrity_unavailable' }); +}); + test('an upstream 429 starts a cooldown with no further upstream calls', async () => { let clock = 0; let calls = 0; diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index 1f5840e3f..52922af4d 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -8,6 +8,7 @@ import { accumulateGnssObservations, binGnssCells, gnssProvenance, + pruneGnssObservations, } from './records.js'; export * from './records.js'; export { createAdsbGnssSource } from './source.js'; @@ -62,6 +63,13 @@ export function createGnssIntegrityLayer({ const _observations = new Map(); const provenance = gnssProvenance({ windowMs }); + /** Age the store without new evidence, so expired cells leave the globe. */ + function expire() { + pruneGnssObservations(_observations, now(), { windowMs }); + _cells = binGnssCells(_observations); + render(); + } + function render() { // Only the id and band change what is drawn; counts alone do not. const signature = JSON.stringify( @@ -129,8 +137,11 @@ export function createGnssIntegrityLayer({ const anchor = viewAnchor(_viewer); // No anchor yet (camera still settling) is not a failure: returning // false would make the manager reject the enable. The next interval - // retries. - if (!anchor) return true; + // retries; cells already drawn still age out of the window. + if (!anchor) { + expire(); + return true; + } _request?.abort(); const request = new AbortController(); _request = request; @@ -140,8 +151,11 @@ export function createGnssIntegrityLayer({ }); if (request.signal.aborted || _request !== request || !_enabled) return false; + // Date the rows by when the proxy observed them: a cached or stale + // replay keeps its original age instead of restarting the window. accumulateGnssObservations(_observations, snapshot.rows, now(), { windowMs, + observedAt: snapshot.fetchedAt, }); _cells = binGnssCells(_observations); render(); @@ -154,6 +168,8 @@ export function createGnssIntegrityLayer({ return false; console.warn('[Data:GNSS] Fetch error:', e); _lastError = e?.message || 'GNSS integrity source unavailable'; + // Keep the last cells only while their evidence is inside the window. + expire(); return false; } finally { if (_request === request) _request = null; diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index 339a0b603..ba4940b10 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -14,8 +14,9 @@ const row = (hex, lat, degraded) => ({ function harness( getSnapshot, - { anchor = { latitude: 50, longitude: 30 } } = {}, + { anchor: initialAnchor = { latitude: 50, longitude: 30 } } = {}, ) { + let anchor = initialAnchor; const sources = []; const viewer = { dataSources: { @@ -45,9 +46,20 @@ function harness( advance(ms) { clock += ms; }, + now: () => clock, + setAnchor(value) { + anchor = value; + }, }; } +const MIN = 60_000; +const threeHealthy = () => [ + row('a1', 1.1, false), + row('a2', 1.2, false), + row('a3', 1.3, false), +]; + test('the layer requires a snapshot source and cannot be initialized twice', () => { assert.throws(() => createGnssIntegrityLayer({}), TypeError); const { layer } = harness(async () => ({ rows: [] })); @@ -206,6 +218,72 @@ test('no view anchor means no request, and destroy releases the data source', as assert.equal(layer.getStats().count, 0); }); +test('a replayed stale snapshot keeps its observation age and expires on time', async () => { + // The proxy keeps answering with the snapshot it observed at t=1000. + const { layer, sources, advance } = harness(async () => ({ + rows: threeHealthy(), + fetchedAt: 1_000, + stale: true, + })); + layer.enable(); + assert.equal(await layer.update(), true); + assert.equal(layer.getStats().count, 1); + advance(29 * MIN); + assert.equal(await layer.update(), true); + assert.equal(layer.getStats().count, 1); + advance(2 * MIN); + assert.equal( + await layer.update(), + true, + 'the replay is not an error, only no evidence', + ); + assert.equal( + layer.getStats().count, + 0, + 'the replay did not restart the window', + ); + assert.equal(sources[0].entities.values.length, 0); +}); + +test('cells expire during prolonged fetch failures', async () => { + let fail = false; + const { layer, sources, advance, now } = harness(async () => { + if (fail) throw new Error('adsb.lol HTTP 502'); + return { rows: threeHealthy(), fetchedAt: now() }; + }); + layer.enable(); + await layer.update(); + fail = true; + advance(20 * MIN); + assert.equal(await layer.update(), false); + assert.equal(layer.getStats().count, 1, 'still inside the window'); + advance(11 * MIN); + assert.equal(await layer.update(), false); + assert.equal(layer.getStats().count, 0); + assert.equal(sources[0].entities.values.length, 0); + assert.equal(layer.getStats().error, 'adsb.lol HTTP 502'); +}); + +test('cells expire while no view anchor is available', async () => { + let calls = 0; + const { layer, sources, advance, now, setAnchor } = harness(async () => { + calls += 1; + return { rows: threeHealthy(), fetchedAt: now() }; + }); + layer.enable(); + await layer.update(); + setAnchor(null); + advance(20 * MIN); + assert.equal(await layer.update(), true); + assert.equal(layer.getStats().count, 1); + advance(11 * MIN); + assert.equal(await layer.update(), true); + assert.equal(calls, 1, 'no anchor means no request'); + assert.equal(layer.getStats().count, 0); + assert.equal(sources[0].entities.values.length, 0); + assert.equal(layer.getStats().error, null); +}); + const threeCells = () => ({ rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)], }); diff --git a/src/layers/gnss/records.js b/src/layers/gnss/records.js index 8e57b2f9a..b76f10e5a 100644 --- a/src/layers/gnss/records.js +++ b/src/layers/gnss/records.js @@ -21,6 +21,8 @@ /** Direct 1090ES ADS-B only: MLAT, TIS-B and ADS-R positions are not self-reported. */ const QUALIFYING_TYPES = new Set(['adsb_icao']); +/** DO-260A (1) and DO-260B (2) define NIC and NACp; version 0 and unknown versions do not. */ +const QUALIFYING_VERSIONS = new Set([1, 2]); /** NIC below 7: containment radius of 0.2 NM or more (below the US 91.227 ADS-B Out minimum). GEV threshold. */ export const GNSS_NIC_THRESHOLD = 7; /** NACp below 8: position uncertainty of 93 m or more (below the US 91.227 minimum). GEV threshold. */ @@ -113,14 +115,16 @@ export function normalizeGnssAircraft(payload) { const hex = typeof aircraft.hex === 'string' ? aircraft.hex : ''; if (!HEX_PATTERN.test(hex) || seen.has(hex)) continue; if (!QUALIFYING_TYPES.has(aircraft.type)) continue; - if (!finite(aircraft.version) || aircraft.version < 1) continue; + if (!QUALIFYING_VERSIONS.has(aircraft.version)) continue; if (aircraft.alt_baro === 'ground' || !finite(aircraft.alt_baro)) continue; const { lat, lon, nic, nac_p: nacp } = aircraft; if (!finite(lat) || Math.abs(lat) > 90) continue; if (!finite(lon) || Math.abs(lon) > 180) continue; if (!finite(nic) || !finite(nacp)) continue; - if (finite(aircraft.seen_pos) && aircraft.seen_pos > MAX_POSITION_AGE_S) - continue; + // Only a position confirmed within the last minute is current evidence; a + // missing, invalid or negative age confirms nothing. + const age = aircraft.seen_pos; + if (!finite(age) || age < 0 || age > MAX_POSITION_AGE_S) continue; // readsb sets gpsOkBefore when an aircraft's integrity collapses and keeps // it for roughly 15 minutes after GPS recovers, so it can outlast the // current NIC/NACp; the rolling window treats that as recent evidence. @@ -149,34 +153,72 @@ export function gnssCellKey(lat, lon, cellDeg = GNSS_CELL_DEG) { /** * Fold one snapshot into a rolling observation store. Each aircraft counts - * once per cell per window; a degraded report in the window keeps it degraded - * there, so an aircraft that loses GNSS mid-cell is not averaged away. + * once per cell per window. Presence and degraded evidence age separately: a + * degraded report keeps the aircraft degraded in that cell for one window + * after the report itself, however many healthy reports follow, so an + * aircraft that loses GNSS mid-cell is not averaged away and a recovered one + * is not held degraded forever. * - * @param {Map} store + * Every row is dated by when the snapshot was observed, not when it arrived: + * replaying a cached or stale snapshot never makes its evidence younger, and + * a snapshot older than the window adds nothing. + * + * @param {Map} store * @param {ReturnType} rows * @param {number} nowMs - * @param {{windowMs?: number, cellDeg?: number}} [options] + * @param {{windowMs?: number, cellDeg?: number, observedAt?: number}} [options] + * `observedAt` is the snapshot's observation time (defaults to `nowMs`; + * a future time is clamped to `nowMs`, a non-finite one adds nothing). * @returns {Map} The same store, pruned to the window. */ export function accumulateGnssObservations( store, rows, nowMs, - { windowMs = GNSS_WINDOW_MS, cellDeg = GNSS_CELL_DEG } = {}, + { + windowMs = GNSS_WINDOW_MS, + cellDeg = GNSS_CELL_DEG, + observedAt = nowMs, + } = {}, ) { - for (const row of rows || []) { - if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) continue; - const cell = gnssCellKey(row.lat, row.lon, cellDeg); - const key = `${row.hex}|${cell}`; - const previous = store.get(key); - store.set(key, { - cell, - degraded: row.degraded || (previous?.degraded ?? false), - seenAt: nowMs, - }); + const at = Number.isFinite(observedAt) ? Math.min(observedAt, nowMs) : NaN; + if (Number.isFinite(at) && nowMs - at <= windowMs) { + for (const row of rows || []) { + if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) continue; + const cell = gnssCellKey(row.lat, row.lon, cellDeg); + const key = `${row.hex}|${cell}`; + const previous = store.get(key); + const previousDegradedAt = previous?.degradedAt ?? null; + store.set(key, { + cell, + seenAt: Math.max(previous?.seenAt ?? at, at), + degradedAt: row.degraded + ? Math.max(previousDegradedAt ?? at, at) + : previousDegradedAt, + }); + } } + return pruneGnssObservations(store, nowMs, { windowMs }); +} + +/** + * Drop observations older than the window and degraded evidence older than + * the window, without adding anything. Used when no fresh snapshot arrives. + * + * @param {Map} store + * @param {number} nowMs + * @param {{windowMs?: number}} [options] + * @returns {Map} The same store. + */ +export function pruneGnssObservations( + store, + nowMs, + { windowMs = GNSS_WINDOW_MS } = {}, +) { for (const [key, entry] of store) { if (nowMs - entry.seenAt > windowMs) store.delete(key); + else if (entry.degradedAt != null && nowMs - entry.degradedAt > windowMs) + store.set(key, { ...entry, degradedAt: null }); } return store; } @@ -205,7 +247,9 @@ export function gnssIntegrityLevel(total, degraded) { } /** - * Aggregate the observation store into displayable cells. + * Aggregate the observation store into displayable cells. Expects a store + * already pruned to the window by `accumulateGnssObservations` or + * `pruneGnssObservations`. * * @returns {{id: string, south: number, west: number, north: number, east: number, lat: number, lon: number, aircraft: number, degraded: number, percentDegraded: number, level: 'low'|'medium'|'high', state: string, interpretation: string|null}[]} */ @@ -214,10 +258,10 @@ export function binGnssCells( { cellDeg = GNSS_CELL_DEG, minAircraft = GNSS_MIN_AIRCRAFT } = {}, ) { const tallies = new Map(); - for (const { cell, degraded } of store.values()) { + for (const { cell, degradedAt } of store.values()) { const tally = tallies.get(cell) || { aircraft: 0, degraded: 0 }; tally.aircraft += 1; - if (degraded) tally.degraded += 1; + if (degradedAt != null) tally.degraded += 1; tallies.set(cell, tally); } const cells = []; diff --git a/src/layers/gnss/records.test.mjs b/src/layers/gnss/records.test.mjs index 20546abb7..273fde10e 100644 --- a/src/layers/gnss/records.test.mjs +++ b/src/layers/gnss/records.test.mjs @@ -5,10 +5,12 @@ import { binGnssCells, GNSS_CLASSIFIER, GNSS_PROVENANCE, + GNSS_WINDOW_MS, gnssCellKey, gnssIntegrityLevel, gnssProvenance, normalizeGnssAircraft, + pruneGnssObservations, } from './records.js'; const aircraft = (overrides = {}) => ({ @@ -36,10 +38,24 @@ test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', () aircraft({ hex: '000006', nic: undefined }), aircraft({ hex: '000007', lat: 95 }), aircraft({ hex: '000008', version: undefined }), + // Only DO-260A/B (versions 1 and 2) define NIC and NACp. + aircraft({ hex: '000009', version: 3 }), + aircraft({ hex: '00000a', version: '2' }), + aircraft({ hex: '00000b', version: 1.5 }), + // A position is current only when its age is known, finite and 0..60 s. + aircraft({ hex: '00000c', seen_pos: undefined }), + aircraft({ hex: '00000d', seen_pos: null }), + aircraft({ hex: '00000e', seen_pos: -0.5 }), + aircraft({ hex: '00000f', seen_pos: '1.2' }), + aircraft({ hex: '000010', seen_pos: Number.NaN }), + aircraft({ hex: '000011', seen_pos: 60.1 }), aircraft({ hex: 'not-hex' }), aircraft(), // A non-ICAO (`~`) address still self-reports over 1090ES ADS-B. aircraft({ hex: '~a1b2c3' }), + // Version 1, and both ends of the position-age range, qualify. + aircraft({ hex: 'b00001', version: 1, seen_pos: 0 }), + aircraft({ hex: 'b00002', seen_pos: 60 }), null, ], }); @@ -62,6 +78,15 @@ test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', () gpsLost: false, degraded: false, }, + ...['b00001', 'b00002'].map((hex) => ({ + hex, + lat: 50.1, + lon: 19.9, + nic: 8, + nacp: 10, + gpsLost: false, + degraded: false, + })), ]); assert.equal(normalizeGnssAircraft({}), null); assert.equal(normalizeGnssAircraft(null), null); @@ -205,6 +230,134 @@ test('observations count each aircraft once per cell and expire after the window assert.deepEqual(binGnssCells(store), []); }); +const MIN = 60_000; +const report = (hex, degraded, lat = 50.1) => ({ + hex, + lat, + lon: 20.1, + degraded, +}); +const entry = (store, hex) => + [...store].find(([key]) => key.startsWith(`${hex}|`))?.[1]; + +test('degraded evidence expires one window after the degraded report, despite continuing healthy reports', () => { + const store = new Map(); + accumulateGnssObservations(store, [report('a1', true)], 0); + // a1 keeps reporting healthy every minute in the same cell. + for (let minute = 1; minute <= 30; minute += 1) + accumulateGnssObservations(store, [report('a1', false)], minute * MIN); + assert.equal(entry(store, 'a1').seenAt, 30 * MIN); + assert.equal( + entry(store, 'a1').degradedAt, + 0, + 'healthy reports do not refresh the degraded evidence', + ); + accumulateGnssObservations(store, [report('a1', false)], 31 * MIN); + assert.deepEqual(entry(store, 'a1'), { + cell: gnssCellKey(50.1, 20.1), + seenAt: 31 * MIN, + degradedAt: null, + }); + // A new degraded report starts its own window. + accumulateGnssObservations(store, [report('a1', true)], 32 * MIN); + assert.equal(entry(store, 'a1').degradedAt, 32 * MIN); + accumulateGnssObservations(store, [report('a1', false)], 40 * MIN); + assert.equal(entry(store, 'a1').degradedAt, 32 * MIN); +}); + +test('a degraded cell turns healthy once its degraded evidence is older than the window', () => { + const store = new Map(); + const healthy = [report('a2', false), report('a3', false)]; + accumulateGnssObservations( + store, + [report('a1', true), report('b1', true), ...healthy], + 0, + ); + assert.equal(binGnssCells(store)[0].level, 'high'); + for (let minute = 10; minute <= 30; minute += 10) + accumulateGnssObservations( + store, + [report('a1', false), report('b1', false), ...healthy], + minute * MIN, + ); + assert.equal(binGnssCells(store)[0].degraded, 2); + accumulateGnssObservations( + store, + [report('a1', false), report('b1', false), ...healthy], + 31 * MIN, + ); + const [cell] = binGnssCells(store); + assert.equal(cell.aircraft, 4); + assert.equal(cell.degraded, 0); + assert.equal(cell.level, 'low'); +}); + +test('an aircraft returning after the window starts fresh', () => { + const store = new Map(); + accumulateGnssObservations(store, [report('a1', true)], 0); + // a1 leaves; nothing is heard from it for longer than the window. + accumulateGnssObservations(store, [], 31 * MIN); + assert.equal(store.size, 0); + accumulateGnssObservations(store, [report('a1', false)], 32 * MIN); + assert.deepEqual(entry(store, 'a1'), { + cell: gnssCellKey(50.1, 20.1), + seenAt: 32 * MIN, + degradedAt: null, + }); + // Returning degraded dates the evidence from the new report. + accumulateGnssObservations(store, [report('a9', true)], 0); + accumulateGnssObservations(store, [], 31 * MIN); + accumulateGnssObservations(store, [report('a9', true)], 33 * MIN); + assert.equal(entry(store, 'a9').degradedAt, 33 * MIN); +}); + +test('rows are dated by the snapshot observation time, so a replay never restarts the window', () => { + const store = new Map(); + const snapshot = [report('a1', true), report('a2', false)]; + accumulateGnssObservations(store, snapshot, 0, { observedAt: 0 }); + // The same snapshot replayed (cached or stale) 29 minutes later. + accumulateGnssObservations(store, snapshot, 29 * MIN, { observedAt: 0 }); + assert.equal(entry(store, 'a1').seenAt, 0); + assert.equal(entry(store, 'a1').degradedAt, 0); + accumulateGnssObservations(store, snapshot, 31 * MIN, { observedAt: 0 }); + assert.equal(store.size, 0, 'a replay older than the window adds nothing'); + + // An older replay never moves an entry's age backwards either. + accumulateGnssObservations(store, snapshot, 40 * MIN); + accumulateGnssObservations(store, [report('a2', true)], 41 * MIN, { + observedAt: 35 * MIN, + }); + assert.equal(entry(store, 'a2').seenAt, 40 * MIN); + assert.equal(entry(store, 'a2').degradedAt, 35 * MIN); + + // A future time is clamped to now; an undated snapshot adds nothing. + accumulateGnssObservations(store, [report('f1', true)], 50 * MIN, { + observedAt: 99 * MIN, + }); + assert.equal(entry(store, 'f1').seenAt, 50 * MIN); + for (const observedAt of [null, Number.NaN]) + accumulateGnssObservations(store, [report('u1', true)], 50 * MIN, { + observedAt, + }); + assert.equal(entry(store, 'u1'), undefined); +}); + +test('pruning without new rows drops expired observations and expired degraded evidence', () => { + const store = new Map(); + accumulateGnssObservations(store, [report('a1', true)], 0); + accumulateGnssObservations(store, [report('a1', false)], 20 * MIN); + accumulateGnssObservations(store, [report('a2', false)], 0); + assert.equal(pruneGnssObservations(store, 31 * MIN), store); + assert.deepEqual( + [...store.keys()].map((key) => key.split('|')[0]), + ['a1'], + ); + assert.equal(entry(store, 'a1').degradedAt, null); + pruneGnssObservations(store, 51 * MIN); + assert.equal(store.size, 0); + assert.equal(GNSS_WINDOW_MS, 30 * MIN); +}); + test('rows without finite coordinates never key a cell', () => { const store = new Map(); accumulateGnssObservations( diff --git a/src/layers/gnss/source.js b/src/layers/gnss/source.js index 7d1fb0aa8..3f129d13e 100644 --- a/src/layers/gnss/source.js +++ b/src/layers/gnss/source.js @@ -25,11 +25,13 @@ export function createAdsbGnssSource({ signal?.throwIfAborted(); if (!Array.isArray(payload?.rows)) throw new Error('Malformed GNSS integrity snapshot'); + // The observation time dates every row in the rolling window; an + // undated snapshot could only be treated as new, so it is refused. + if (!Number.isFinite(payload.fetchedAt)) + throw new Error('GNSS integrity snapshot has no observation time'); return { rows: payload.rows, - fetchedAt: Number.isFinite(payload.fetchedAt) - ? payload.fetchedAt - : null, + fetchedAt: payload.fetchedAt, stale: payload.stale === true, classifier: typeof payload.classifier === 'string' ? payload.classifier : null, diff --git a/src/layers/gnss/source.test.mjs b/src/layers/gnss/source.test.mjs index cb8fe372f..b906dce1c 100644 --- a/src/layers/gnss/source.test.mjs +++ b/src/layers/gnss/source.test.mjs @@ -31,11 +31,19 @@ test('the source asks the same-origin proxy for the view anchor', async () => { test('the source reports stale proxy data and rejects bad input or responses', async () => { const reply = (body, init) => async () => Response.json(body, init); const stale = await createAdsbGnssSource({ - fetchImpl: reply({ rows: [], stale: true }), + fetchImpl: reply({ rows: [], fetchedAt: 7, stale: true }), }).getSnapshot({ latitude: 1, longitude: 2 }); assert.equal(stale.stale, true); - assert.equal(stale.fetchedAt, null); + assert.equal(stale.fetchedAt, 7, 'a stale reply keeps its observation time'); assert.equal(stale.classifier, null); + // Without an observation time the rows could only be dated as new. + for (const fetchedAt of [undefined, null, '7']) + await assert.rejects( + createAdsbGnssSource({ + fetchImpl: reply({ rows: [], fetchedAt }), + }).getSnapshot({ latitude: 1, longitude: 2 }), + /no observation time/, + ); await assert.rejects( createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}), From 7e570a79ad0c72b34e0e4ff73c160bdddbcce944 Mon Sep 17 00:00:00 2001 From: Leo Pechnicki Date: Wed, 7 Oct 2026 05:57:17 +0200 Subject: [PATCH 24/86] fix(gnss): expire stale cells on re-enable; pin the unpruned return-after-window path Re-enabling after a disable longer than the window no longer shows the old cells until the next refresh. A healthy report arriving 31 minutes after a degraded one, with no prune in between, starts clean; exactly 30 minutes still counts. --- scripts/qa-gnss.mjs | 1 - src/layers/gnss/index.js | 6 +++++- src/layers/gnss/ownership.test.mjs | 15 +++++++++++++++ src/layers/gnss/records.test.mjs | 27 +++++++++++++++++++++++---- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index ae7e47a54..d763da134 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -63,7 +63,6 @@ const getOpt = (name, dflt) => { const APP_URL = getOpt('--url', 'http://localhost:4173'); const HEADFUL = argv.includes('--headful'); const LAYER_ID = 'gnss-interference'; -/** Live rows below this cannot reliably fill a 3-aircraft cell. */ const results = []; function record(name, ok, detail) { diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index 52922af4d..0123c4c3f 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -122,7 +122,11 @@ export function createGnssIntegrityLayer({ enable() { _enabled = true; - if (_dataSource) _dataSource.show = true; + // Cells left from before a long disable must not reappear past the window. + if (_dataSource) { + expire(); + _dataSource.show = true; + } }, disable() { diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index ba4940b10..ed7308428 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -284,6 +284,21 @@ test('cells expire while no view anchor is available', async () => { assert.equal(layer.getStats().error, null); }); +test('re-enabling after a long disable does not show cells past the window', async () => { + const { layer, sources, advance, now } = harness(async () => ({ + rows: threeHealthy(), + fetchedAt: now(), + })); + layer.enable(); + await layer.update(); + assert.equal(sources[0].entities.values.length, 1); + layer.disable(); + advance(31 * MIN); + layer.enable(); + assert.equal(layer.getStats().count, 0); + assert.equal(sources[0].entities.values.length, 0); +}); + const threeCells = () => ({ rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)], }); diff --git a/src/layers/gnss/records.test.mjs b/src/layers/gnss/records.test.mjs index 273fde10e..593f3e6f3 100644 --- a/src/layers/gnss/records.test.mjs +++ b/src/layers/gnss/records.test.mjs @@ -305,10 +305,29 @@ test('an aircraft returning after the window starts fresh', () => { degradedAt: null, }); // Returning degraded dates the evidence from the new report. - accumulateGnssObservations(store, [report('a9', true)], 0); - accumulateGnssObservations(store, [], 31 * MIN); - accumulateGnssObservations(store, [report('a9', true)], 33 * MIN); - assert.equal(entry(store, 'a9').degradedAt, 33 * MIN); + const again = new Map(); + accumulateGnssObservations(again, [report('a9', true)], 0); + accumulateGnssObservations(again, [], 31 * MIN); + accumulateGnssObservations(again, [report('a9', true)], 33 * MIN); + assert.equal(entry(again, 'a9').degradedAt, 33 * MIN); +}); + +test('an aircraft heard healthy right after the window drops its stale degraded evidence', () => { + // No prune runs in between: the old entry is still in the store when the + // healthy report arrives, so the new report alone must not carry it over. + const store = new Map(); + accumulateGnssObservations(store, [report('a1', true)], 0); + accumulateGnssObservations(store, [report('a1', false)], 31 * MIN); + assert.deepEqual(entry(store, 'a1'), { + cell: gnssCellKey(50.1, 20.1), + seenAt: 31 * MIN, + degradedAt: null, + }); + // Exactly one window after the degraded report it still counts. + const edge = new Map(); + accumulateGnssObservations(edge, [report('a1', true)], 0); + accumulateGnssObservations(edge, [report('a1', false)], 30 * MIN); + assert.equal(entry(edge, 'a1').degradedAt, 0); }); test('rows are dated by the snapshot observation time, so a replay never restarts the window', () => { From 783c5b16f4d69a3694e6111e881c7bcf4d3abaec Mon Sep 17 00:00:00 2001 From: Leo Pechnicki Date: Wed, 7 Oct 2026 10:36:04 +0200 Subject: [PATCH 25/86] fix(gnss): date replayed snapshots by the proxy-reported age, not cross-clock fetchedAt The layer compared the server's fetchedAt with the browser's clock, so a skewed client clock could shift or void the 30-minute window. The proxy now returns ageMs (its own now minus fetchedAt, at serve time, on fresh, cached and stale answers); the source refuses a snapshot without a finite, non-negative ageMs, and the layer dates rows as its now() - ageMs. fetchedAt stays in the payload for display. Tests: proxy cached/stale answers report ageMs on the server clock; the source rejects missing/invalid/negative ageMs; the layer replay test drives ageMs. qa-gnss stubs send ageMs. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- docs/CURRENT-STATE.md | 2 +- scripts/qa-gnss.mjs | 6 +++++- server/providers/gnssIntegrity.js | 5 ++++- src/data/gnssIntegrityProxy.test.mjs | 10 +++++++++- src/layers/gnss/index.js | 6 ++++-- src/layers/gnss/ownership.test.mjs | 23 ++++++++++++++--------- src/layers/gnss/source.js | 12 ++++++++---- src/layers/gnss/source.test.mjs | 13 ++++++++----- 9 files changed, 55 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ced1d2d32..8d2bcca03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -169,7 +169,8 @@ Keyless, worldwide wherever adsb.lol has receivers; reads a capped 250 nm snapshot around the camera through `/api/gnss-integrity`, which returns integrity fields only and serves the last snapshot (with its - original observation time, for at most 30 minutes) when the upstream fails. + original observation time and `ageMs`, for at most 30 minutes) when the + upstream fails. Only ADS-B versions 1 and 2 with a known position age of 0-60 s count. A degraded report keeps an aircraft degraded for 30 minutes after that report, not after its latest healthy one; replayed snapshots keep their age, and diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cb1582ce8..ccc6775da 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3072,7 +3072,7 @@ its criteria cannot be silently ignored. | GNSS Integrity 📡 (`gnss-interference`) | 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` | 60s (server cache 60 s; 30 min rolling window) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | -GNSS Integrity fetches a capped adsb.lol snapshot around the view anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Only DO-260A/B (ADS-B versions 1 and 2) direct 1090ES positions with a known `seen_pos` of 0–60 s count. Presence and degraded evidence age separately: a degraded report keeps the aircraft degraded in its cell for 30 minutes after that report, however many healthy reports follow, and an aircraft unheard for 30 minutes starts fresh. Rows are dated by the snapshot's `fetchedAt`, which the proxy keeps on cached and stale answers, so a replayed snapshot never restarts the window; the proxy stops serving a stale snapshot once it is older than 30 minutes, the source refuses an undated one, and cells age out on schedule during fetch failures or while there is no view anchor. +GNSS Integrity fetches a capped adsb.lol snapshot around the view anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Only DO-260A/B (ADS-B versions 1 and 2) direct 1090ES positions with a known `seen_pos` of 0–60 s count. Presence and degraded evidence age separately: a degraded report keeps the aircraft degraded in its cell for 30 minutes after that report, however many healthy reports follow, and an aircraft unheard for 30 minutes starts fresh. Rows are dated by the snapshot's observation time, which the proxy keeps on cached and stale answers and reports as `ageMs` on its own clock (so browser clock skew does not move the window); a replayed snapshot never restarts the window; the proxy stops serving a stale snapshot once it is older than 30 minutes, the source refuses an undated one, and cells age out on schedule during fetch failures or while there is no view anchor. Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index d763da134..e673ac224 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -250,7 +250,11 @@ async function stubRoute(page, { ageMs = 0 } = {}) { if (!url.includes('/api/gnss-integrity')) return realFetch(input, init); const ok = window.__gnssQaMode === 'fixture'; const body = ok - ? { ...payload, fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0) } + ? { + ...payload, + fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0), + ageMs: window.__gnssQaAgeMs || 0, + } : { error: 'gnss_integrity_unavailable' }; return Promise.resolve( new Response(JSON.stringify(body), { diff --git a/server/providers/gnssIntegrity.js b/server/providers/gnssIntegrity.js index 77f674a50..277fc119f 100644 --- a/server/providers/gnssIntegrity.js +++ b/server/providers/gnssIntegrity.js @@ -145,7 +145,10 @@ export function gnssIntegrityProxy({ if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' }); try { const { value, stale } = await acquire(anchor); - json(200, stale ? { ...value, stale: true } : value, stale); + // ageMs is measured on this server's clock, so the browser can date + // the rows without comparing its clock to ours. + const ageMs = Math.max(0, now() - value.fetchedAt); + json(200, { ...value, ageMs, ...(stale ? { stale: true } : {}) }, stale); } catch (error) { if (error.status === 429) { // Tell the browser how long the shared upstream cooldown has left. diff --git a/src/data/gnssIntegrityProxy.test.mjs b/src/data/gnssIntegrityProxy.test.mjs index 1437e3218..7c5ca93ee 100644 --- a/src/data/gnssIntegrityProxy.test.mjs +++ b/src/data/gnssIntegrityProxy.test.mjs @@ -86,6 +86,7 @@ for (const hook of ['configureServer', 'configurePreviewServer']) { assert.deepEqual(calls, ['https://api.adsb.lol/v2/lat/50/lon/20/dist/250']); assert.deepEqual(res.body, { fetchedAt: 1234, + ageMs: 0, anchor: { lat: 50, lon: 20 }, radiusNm: 250, classifier: 'gev-nic-nacp-v1', @@ -118,8 +119,14 @@ test('snapshots are cached per anchor and concurrent reads share one upstream ca await Promise.all([request(), request('/?lat=50.3&lon=20.2')]); assert.equal(calls, 1); clock = 59_000; - await request(); + const cached = await request(); assert.equal(calls, 1); + assert.equal(cached.body.fetchedAt, 0); + assert.equal( + cached.body.ageMs, + 59_000, + 'a cached answer reports its age on the server clock', + ); clock = 61_000; await request(); assert.equal(calls, 2); @@ -146,6 +153,7 @@ test('an upstream failure serves the last snapshot as stale, else 502', async () 0, 'a stale answer keeps the time it was observed', ); + assert.equal(stale.body.ageMs, 120_000); assert.equal(stale.headers['X-Data-Stale'], 'true'); const missing = await request('/?lat=-10&lon=-10'); assert.equal(missing.status, 502); diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index 0123c4c3f..7be808d9f 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -157,9 +157,11 @@ export function createGnssIntegrityLayer({ return false; // Date the rows by when the proxy observed them: a cached or stale // replay keeps its original age instead of restarting the window. - accumulateGnssObservations(_observations, snapshot.rows, now(), { + // The proxy reports that age, so client and server clocks never mix. + const at = now(); + accumulateGnssObservations(_observations, snapshot.rows, at, { windowMs, - observedAt: snapshot.fetchedAt, + observedAt: at - snapshot.ageMs, }); _cells = binGnssCells(_observations); render(); diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index ed7308428..122489fa2 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -31,7 +31,13 @@ function harness( let clock = 1_000; const anchors = []; const layer = createGnssIntegrityLayer({ - source: { getSnapshot }, + // The real source refuses an undated snapshot; stubs default to a fresh one. + source: { + async getSnapshot(...args) { + const snapshot = await getSnapshot(...args); + return { ageMs: 0, ...snapshot }; + }, + }, viewAnchor: () => { anchors.push(anchor); return anchor; @@ -220,9 +226,9 @@ test('no view anchor means no request, and destroy releases the data source', as test('a replayed stale snapshot keeps its observation age and expires on time', async () => { // The proxy keeps answering with the snapshot it observed at t=1000. - const { layer, sources, advance } = harness(async () => ({ + const { layer, sources, advance, now } = harness(async () => ({ rows: threeHealthy(), - fetchedAt: 1_000, + ageMs: now() - 1_000, stale: true, })); layer.enable(); @@ -247,9 +253,9 @@ test('a replayed stale snapshot keeps its observation age and expires on time', test('cells expire during prolonged fetch failures', async () => { let fail = false; - const { layer, sources, advance, now } = harness(async () => { + const { layer, sources, advance } = harness(async () => { if (fail) throw new Error('adsb.lol HTTP 502'); - return { rows: threeHealthy(), fetchedAt: now() }; + return { rows: threeHealthy() }; }); layer.enable(); await layer.update(); @@ -266,9 +272,9 @@ test('cells expire during prolonged fetch failures', async () => { test('cells expire while no view anchor is available', async () => { let calls = 0; - const { layer, sources, advance, now, setAnchor } = harness(async () => { + const { layer, sources, advance, setAnchor } = harness(async () => { calls += 1; - return { rows: threeHealthy(), fetchedAt: now() }; + return { rows: threeHealthy() }; }); layer.enable(); await layer.update(); @@ -285,9 +291,8 @@ test('cells expire while no view anchor is available', async () => { }); test('re-enabling after a long disable does not show cells past the window', async () => { - const { layer, sources, advance, now } = harness(async () => ({ + const { layer, sources, advance } = harness(async () => ({ rows: threeHealthy(), - fetchedAt: now(), })); layer.enable(); await layer.update(); diff --git a/src/layers/gnss/source.js b/src/layers/gnss/source.js index 3f129d13e..ae56e45c5 100644 --- a/src/layers/gnss/source.js +++ b/src/layers/gnss/source.js @@ -25,13 +25,17 @@ export function createAdsbGnssSource({ signal?.throwIfAborted(); if (!Array.isArray(payload?.rows)) throw new Error('Malformed GNSS integrity snapshot'); - // The observation time dates every row in the rolling window; an - // undated snapshot could only be treated as new, so it is refused. - if (!Number.isFinite(payload.fetchedAt)) + // The snapshot's age (measured on the proxy's clock) dates every row in + // the rolling window; an undated snapshot could only be treated as new, + // so it is refused. + if (!Number.isFinite(payload.ageMs) || payload.ageMs < 0) throw new Error('GNSS integrity snapshot has no observation time'); return { rows: payload.rows, - fetchedAt: payload.fetchedAt, + ageMs: payload.ageMs, + fetchedAt: Number.isFinite(payload.fetchedAt) + ? payload.fetchedAt + : null, stale: payload.stale === true, classifier: typeof payload.classifier === 'string' ? payload.classifier : null, diff --git a/src/layers/gnss/source.test.mjs b/src/layers/gnss/source.test.mjs index b906dce1c..7bab0d7d5 100644 --- a/src/layers/gnss/source.test.mjs +++ b/src/layers/gnss/source.test.mjs @@ -10,6 +10,7 @@ test('the source asks the same-origin proxy for the view anchor', async () => { assert.ok(options.signal instanceof AbortSignal); return Response.json({ fetchedAt: 5, + ageMs: 0, classifier: 'gev-nic-nacp-v1', rows: [{ hex: 'abc123' }], }); @@ -22,6 +23,7 @@ test('the source asks the same-origin proxy for the view anchor', async () => { assert.deepEqual(calls, ['/api/gnss-integrity?lat=50.06&lon=19.94']); assert.deepEqual(snapshot, { rows: [{ hex: 'abc123' }], + ageMs: 0, fetchedAt: 5, stale: false, classifier: 'gev-nic-nacp-v1', @@ -31,16 +33,17 @@ test('the source asks the same-origin proxy for the view anchor', async () => { test('the source reports stale proxy data and rejects bad input or responses', async () => { const reply = (body, init) => async () => Response.json(body, init); const stale = await createAdsbGnssSource({ - fetchImpl: reply({ rows: [], fetchedAt: 7, stale: true }), + fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs: 90_000, stale: true }), }).getSnapshot({ latitude: 1, longitude: 2 }); assert.equal(stale.stale, true); - assert.equal(stale.fetchedAt, 7, 'a stale reply keeps its observation time'); + assert.equal(stale.ageMs, 90_000, 'a stale reply keeps its observation age'); + assert.equal(stale.fetchedAt, 7); assert.equal(stale.classifier, null); - // Without an observation time the rows could only be dated as new. - for (const fetchedAt of [undefined, null, '7']) + // Without an observation age the rows could only be dated as new. + for (const ageMs of [undefined, null, '7', -1, Number.NaN]) await assert.rejects( createAdsbGnssSource({ - fetchImpl: reply({ rows: [], fetchedAt }), + fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs }), }).getSnapshot({ latitude: 1, longitude: 2 }), /no observation time/, ); From eb7f05975094ece3f7517af2a7a63a5eaf94ef4b Mon Sep 17 00:00:00 2001 From: Leo Pechnicki Date: Tue, 29 Sep 2026 19:03:50 +0200 Subject: [PATCH 26/86] feat(layers): highlight aircraft broadcasting a transponder emergency Flights and Military Flights tint aircraft red while the latest poll carries squawk 7500/7600/7700 or an adsb.lol ADS-B emergency status (general/unlawful/nordo/downed). Minimum-fuel and lifeguard priorities are reported but not tinted. The Cockpit HUD aircraft-meta line names the broadcast code in red. The status is not sticky: a cleared code stops highlighting on the next refresh. The analyst/voice query engine gains squawk, emergency and emergencyKind fields. No new source or request: OpenSky state vectors already carry the squawk and adsb.lol/readsb carries both fields. Built by the Hex project (Crew Leo Agile dev team). Co-Authored-By: Claude --- CHANGELOG.md | 11 + DATA_SOURCES.md | 1 + scripts/package-boundaries.json | 10 +- scripts/qa-emergency-squawk.mjs | 437 +++++++++++++++++++++++++++ src/cockpitMath.js | 14 + src/cockpitMath.test.mjs | 159 +++++++--- src/data/aircraftEmergency.js | 71 +++++ src/data/aircraftEmergency.test.mjs | 55 ++++ src/data/analystEngine.test.mjs | 334 +++++++++++++++++--- src/data/flights.test.mjs | 3 + src/data/militaryFlights.test.mjs | 20 ++ src/layers/flights/policy.js | 6 + src/layers/flights/queries.js | 13 +- src/layers/flights/records.js | 5 + src/layers/flights/records.test.mjs | 10 + src/layers/flights/rendering.js | 29 +- src/layers/military/policy.js | 6 + src/layers/military/queries.js | 12 +- src/layers/military/records.js | 4 + src/layers/military/records.test.mjs | 16 + src/layers/military/rendering.js | 20 +- src/sources/live/aircraft.js | 5 + src/sources/live/contract.test.mjs | 20 ++ src/ui/cockpitInstruments.js | 11 +- src/ui/styles/cockpit.css | 3 + src/ui/styles/cyber.css | 6 + 26 files changed, 1174 insertions(+), 107 deletions(-) create mode 100644 scripts/qa-emergency-squawk.mjs create mode 100644 src/data/aircraftEmergency.js create mode 100644 src/data/aircraftEmergency.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c9d15d897..50e731268 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,17 @@ ## [Unreleased] +- Flights and Military Flights highlight aircraft broadcasting a transponder + emergency. Squawk 7500 (unlawful interference), 7600 (radio failure) and + 7700 (general emergency) — or adsb.lol's ADS-B `emergency` status, which + also reports downed aircraft — tint the aircraft red on the globe, in + Cockpit contacts and in snapshots, and name the code on the Cockpit HUD. + Minimum-fuel and lifeguard statuses are reported but not tinted. The status + follows the latest poll instead of sticking, so a cleared code stops + highlighting on the next refresh. The analyst and voice query engine can + filter by `squawk`, `emergency` and `emergencyKind`. No new source or + request: OpenSky already carries the squawk and adsb.lol carries both. + - Street Level: Google Street View is the second provider. On the Google 3D map (Google's terms keep it off other maps), light its chip (off by default: every panorama is billed to the Google Maps key, which needs the diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 040e53afc..ea1113642 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -78,6 +78,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. +- **Transponder emergencies.** Emergency highlighting reads fields the flight feeds already return: OpenSky state vectors' `squawk` and adsb.lol/readsb's `squawk` and `emergency`. It makes no extra request. A broadcast code states what the transponder sends, not a verified incident; OpenSky has no ADS-B emergency field, so civil highlighting there relies on squawk 7500/7600/7700 alone. - **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. - **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. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index fc7308ab5..ad81ef5b6 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -794,6 +794,7 @@ "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", @@ -837,6 +838,7 @@ "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", @@ -1494,6 +1496,7 @@ "src/contextModePolicy.js", "src/creditKeyboard.js", "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", @@ -2276,6 +2279,7 @@ "src/celestialRing.js", "src/cockpitMath.js", "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", @@ -2897,7 +2901,8 @@ "src/layers/flights/recordPolicy.js", "src/data/aircraftMeta.js", "src/data/renderAltitude.js", - "src/data/aircraftClass.js" + "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js" ], "external": [] }, @@ -2916,7 +2921,8 @@ "src/layers/military/recordPolicy.js", "src/data/aircraftMeta.js", "src/data/renderAltitude.js", - "src/data/aircraftClass.js" + "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js" ], "external": [] }, diff --git a/scripts/qa-emergency-squawk.mjs b/scripts/qa-emergency-squawk.mjs new file mode 100644 index 000000000..bbcc98c22 --- /dev/null +++ b/scripts/qa-emergency-squawk.mjs @@ -0,0 +1,437 @@ +#!/usr/bin/env node +/** + * qa-emergency-squawk.mjs — headless proof for transponder emergency highlighting. + * + * Drives the REAL app in headless Chromium against a dev server (keyless). + * `/api/opensky` and `/api/adsblol/mil` are stubbed in the page so the codes + * are deterministic; everything downstream (normalizers, records, rendering, + * Cockpit HUD) is production code. + * + * (i) TINT — a civil 7600 and a military ADS-B `general` emergency are + * drawn red; a normal civil flight stays white, a normal military + * flight stays amber, and a military `minfuel` priority stays amber. + * (ii) HUD — tracking the military emergency and entering Cockpit names + * the broadcast code on the aircraft-meta line, in red. + * (iii) CLEAR — the next poll drops the code: the HUD line returns to its + * normal wording (the status is not sticky). + * + * Screenshots saved to qa-shots/emergency-*.png (gitignored). + * + * Run: node scripts/qa-emergency-squawk.mjs --url http://localhost:4173 + * Exits non-zero on any FAIL. Does not commit anything. + */ + +import puppeteer from 'puppeteer'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); +const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots'); + +const argv = process.argv.slice(2); +const getOpt = (name, dflt) => { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const APP_URL = getOpt('--url', 'http://localhost:4173'); +const HEADFUL = argv.includes('--headful'); +const CENTER = { lon: 4.0, lat: 53.5 }; + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** OpenSky state vector: squawk sits at index 14. */ +const civilRow = (icao24, callsign, lon, lat, squawk) => [ + icao24, + callsign, + 'Netherlands', + null, + null, + lon, + lat, + 10000, + false, + 230, + 90, + 0, + null, + 10050, + squawk, + false, + 0, +]; + +/** readsb row as adsb.lol /v2/mil returns it. */ +const milRow = (hex, flight, lon, lat, squawk, emergency) => ({ + hex, + flight, + lon, + lat, + alt_baro: 24000, + alt_geom: 24200, + gs: 380, + track: 270, + t: 'C17', + r: `QA-${hex}`, + squawk, + emergency, + seen: 1, + seen_pos: 1, +}); + +/** Page-side route stubs; `__squawkQaPhase` switches the military emergency off. */ +function installStubs() { + const realFetch = window.fetch.bind(window); + window.__squawkQaPhase = 'emergency'; + const json = (body) => + Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + window.fetch = (input, init) => { + const url = typeof input === 'string' ? input : input?.url || ''; + const now = Date.now(); + const { civil, mil } = window.__squawkQaFixtures; + if (url.includes('/api/opensky-track')) return json({ path: [] }); + if (url.includes('/api/opensky')) { + const t = Math.floor(now / 1000); + return json({ + time: t, + states: civil.map((row) => { + const copy = [...row]; + copy[3] = t - 1; + copy[4] = t - 1; + return copy; + }), + }); + } + if (url.includes('/api/adsblol/mil')) { + const cleared = window.__squawkQaPhase === 'cleared'; + return json({ + msg: 'No error', + now, + ac: mil.map((row) => + cleared && row.hex === 'ae7700' + ? { ...row, squawk: '4521', emergency: 'none' } + : row, + ), + }); + } + return realFetch(input, init); + }; +} + +/** Colour of every shown fleet billboard whose id is one of `ids`. */ +function billboardColors(ids) { + const { viewer } = window.__godsEyeView; + const found = {}; + const seen = new Set(); + const visit = (primitive) => { + if (!primitive || seen.has(primitive)) return; + seen.add(primitive); + if ( + typeof primitive.length !== 'number' || + typeof primitive.get !== 'function' + ) + return; + for (let i = 0; i < primitive.length; i++) { + const item = primitive.get(i); + if (item?.color && ids.includes(item.id) && item.show !== false) { + found[item.id] = { + r: Math.round(item.color.red * 255), + g: Math.round(item.color.green * 255), + b: Math.round(item.color.blue * 255), + }; + } else visit(item); + } + }; + visit(viewer.scene.primitives); + return found; +} + +const isRed = (c) => Boolean(c) && c.r > 200 && c.g < 110 && c.b < 110; +const isWhite = (c) => Boolean(c) && c.r > 220 && c.g > 220 && c.b > 220; +const isAmber = (c) => + Boolean(c) && c.r > 180 && c.g > 110 && c.b < 140 && !isRed(c); + +async function shoot(page, name) { + await page + .waitForFunction( + () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, + { timeout: 30000 }, + ) + .catch(() => {}); + await sleep(600); + await page.screenshot({ + path: path.join(SHOTS_DIR, `emergency-${name}.png`), + }); +} + +async function main() { + console.log('\nEmergency squawk proof (qa-emergency-squawk)'); + console.log(` App URL : ${APP_URL}\n`); + try { + const res = await fetch(APP_URL); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (e) { + console.error( + `\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`, + ); + process.exit(2); + } + fs.mkdirSync(SHOTS_DIR, { recursive: true }); + const executablePath = + process.env.PUPPETEER_EXECUTABLE_PATH || + (await puppeteer.executablePath().catch(() => null)) || + undefined; + const browser = await puppeteer.launch({ + headless: HEADFUL ? false : 'new', + protocolTimeout: 180000, + ...(executablePath && fs.existsSync(executablePath) + ? { executablePath } + : {}), + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--use-gl=angle', + '--use-angle=swiftshader', + '--disable-dev-shm-usage', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--window-size=1440,900', + ], + }); + + let exitCode = 0; + const errors = []; + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900 }); + page.on('pageerror', (e) => errors.push(e.message)); + const { lon, lat } = CENTER; + await page.evaluateOnNewDocument( + (fixtures) => { + window.__squawkQaFixtures = fixtures; + }, + { + civil: [ + civilRow('4847a1', 'KLM7600 ', lon - 0.6, lat + 0.25, '7600'), + civilRow('4847a2', 'KLM1234 ', lon - 0.6, lat - 0.25, '2000'), + ], + mil: [ + milRow('ae7700', 'RCH7700', lon + 0.6, lat + 0.25, '7700', 'general'), + milRow('ae0f01', 'RCH0F01', lon + 0.6, lat - 0.25, '1234', 'minfuel'), + milRow('ae0001', 'RCH0001', lon, lat - 0.5, '4520', 'none'), + ], + }, + ); + // Stub in-page rather than with request interception: an intercepted page + // stalls Cesium's workers (see qa-gnss.mjs). + await page.evaluateOnNewDocument(installStubs); + await page.goto(`${APP_URL}/?welcome=0`, { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + await page.waitForFunction( + () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + { timeout: 60000 }, + ); + await sleep(5000); + await page.keyboard.press('Escape'); + await page.evaluate( + (lo, la) => { + const gev = window.__godsEyeView; + const d2r = Math.PI / 180; + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight active */ + } + gev.viewer.camera.setView({ + destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian( + { + longitude: lo * d2r, + latitude: la * d2r, + height: 260000, + }, + ), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + }, + lon, + lat, + ); + + // ── (i) TINT ────────────────────────────────────────────────────────── + console.log('(i) TINT — emergency codes through the real layers...'); + await page.evaluate(async () => { + const dm = window.__godsEyeView.dataManager; + await dm.setEnabled('flights', true, { origin: 'user' }); + await dm.setEnabled('military', true, { origin: 'user' }); + }); + const ids = ['4847a1', '4847a2', 'ae7700', 'ae0f01', 'ae0001']; + const probe = `(${billboardColors})(${JSON.stringify(ids)})`; + await page + .waitForFunction(`Object.keys(${probe}).length === ${ids.length}`, { + timeout: 60000, + polling: 500, + }) + .catch(() => {}); + await sleep(1500); + const colors = await page.evaluate(probe); + const show = (id) => JSON.stringify(colors[id] ?? null); + record( + 'TINT: civil squawk 7600 is red', + isRed(colors['4847a1']), + show('4847a1'), + ); + record( + 'TINT: normal civil flight stays white', + isWhite(colors['4847a2']), + show('4847a2'), + ); + record( + 'TINT: military ADS-B general emergency is red', + isRed(colors.ae7700), + show('ae7700'), + ); + record( + 'TINT: military minimum-fuel priority stays amber', + isAmber(colors.ae0f01), + show('ae0f01'), + ); + record( + 'TINT: normal military flight stays amber', + isAmber(colors.ae0001), + show('ae0001'), + ); + await shoot(page, 'globe-tint'); + + // ── (ii) HUD ────────────────────────────────────────────────────────── + console.log('\n(ii) HUD — track the emergency and enter Cockpit...'); + const tracked = await page.evaluate(() => + Boolean( + window.__godsEyeView.dataManager.layers + .get('military') + .module.trackById('ae7700', { origin: 'user' }), + ), + ); + record( + 'HUD: emergency aircraft can be tracked', + tracked, + `tracked=${tracked}`, + ); + // Cockpit entry is gated on the Contacts (flights) context mode. + await page.evaluate(() => + window.__godsEyeView.styleManager._contextControls._selectContextMode( + 'flights', + ), + ); + await page + .waitForFunction( + () => { + const entry = document.getElementById('cockpit-entry'); + return entry && !entry.hidden && !entry.disabled; + }, + { timeout: 20000 }, + ) + .catch(() => {}); + await page + .$eval('#cockpit-entry', (entry) => entry.click()) + .catch(() => {}); + const hudReady = await page + .waitForFunction( + () => + document.body.classList.contains('cockpit-mode') && + /SQUAWK 7700/.test( + document.getElementById('cockpit-aircraft-meta')?.textContent || '', + ), + { timeout: 30000 }, + ) + .then( + () => true, + () => false, + ); + const hud = await page.evaluate(() => { + const meta = document.getElementById('cockpit-aircraft-meta'); + return { + cockpit: document.body.classList.contains('cockpit-mode'), + text: meta?.textContent || '', + emergencyClass: meta?.classList.contains('emergency') || false, + color: meta ? getComputedStyle(meta).color : null, + }; + }); + record( + 'HUD: meta line names the broadcast code', + hudReady && hud.text.includes('SQUAWK 7700 · GENERAL EMERGENCY'), + JSON.stringify(hud), + ); + record( + 'HUD: meta line is styled as an emergency', + hud.emergencyClass && /255,\s*68,\s*68/.test(hud.color || ''), + `class=${hud.emergencyClass} color=${hud.color}`, + ); + await shoot(page, 'cockpit-hud'); + + // ── (iii) CLEAR ─────────────────────────────────────────────────────── + console.log('\n(iii) CLEAR — the next poll drops the code...'); + await page.evaluate(async () => { + window.__squawkQaPhase = 'cleared'; + await window.__godsEyeView.dataManager.refreshLayer('military'); + }); + const cleared = await page + .waitForFunction( + () => + !document + .getElementById('cockpit-aircraft-meta') + ?.classList.contains('emergency'), + { timeout: 20000 }, + ) + .then( + () => true, + () => false, + ); + const after = await page.evaluate( + () => document.getElementById('cockpit-aircraft-meta')?.textContent || '', + ); + record( + 'CLEAR: HUD returns to its normal wording', + cleared && !after.includes('SQUAWK') && after.includes('COURSE ALIGNED'), + JSON.stringify(after), + ); + await shoot(page, 'cockpit-cleared'); + record( + 'no uncaught browser errors', + errors.length === 0, + errors.slice(0, 3).join(' | '), + ); + } catch (err) { + console.error('\x1b[31mHarness error:\x1b[0m', err); + exitCode = 3; + } finally { + await browser.close(); + } + + const pass = results.filter((r) => r.ok).length; + const fail = results.length - pass; + console.log('\n' + '─'.repeat(60)); + console.log(` RESULT: ${pass} passed, ${fail} failed`); + console.log(` Shots : ${SHOTS_DIR}/emergency-*.png`); + console.log('─'.repeat(60) + '\n'); + process.exit(exitCode || (fail > 0 ? 1 : 0)); +} + +main().catch((e) => { + console.error(e); + process.exit(3); +}); diff --git a/src/cockpitMath.js b/src/cockpitMath.js index a0bd83676..4c9666ee9 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -83,6 +83,20 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) { return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null; } +/** + * HUD wording for a tracked aircraft's transponder emergency, or null. + * States the broadcast code, never a conclusion about the flight. + * @param {{label?: string, squawk?: string|null}|null|undefined} emergency + * @returns {string|null} + */ +export function formatCockpitEmergency(emergency) { + const label = String(emergency?.label || '') + .trim() + .toUpperCase(); + if (!label) return null; + return emergency.squawk ? `SQUAWK ${emergency.squawk} · ${label}` : label; +} + /** Format the cockpit Context scope without overstating installation coverage. */ export function formatCockpitContextScope( subjectLabel, diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index ef1e8382b..8abbf1c3e 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -10,6 +10,7 @@ import { cockpitGroundSafeHeight, cockpitSurfaceWaitExpired, cockpitUiUpdateDue, + formatCockpitEmergency, compassDivisions, formatAltitudeRulerTick, formatCockpitContextScope, @@ -41,7 +42,11 @@ test('cockpit anchor correction cannot turn a forward step into a reversal', () const speedMps = 200; const dtSec = 0.1; const forwardStepM = speedMps * dtSec; - const backwardCorrectionM = cockpitAnchorCorrectionStep(1000, speedMps, dtSec); + const backwardCorrectionM = cockpitAnchorCorrectionStep( + 1000, + speedMps, + dtSec, + ); assert.ok(backwardCorrectionM > 0); assert.ok(backwardCorrectionM < forwardStepM); }); @@ -77,10 +82,7 @@ test('cockpit Context scope distinguishes radius-complete feeds from viewport in }); test('cockpit Context scope preserves zero and replaces missing values intentionally', () => { - assert.equal( - formatCockpitContextScope('', 0), - '— · 0 KM AIR/SEA WINDOW', - ); + assert.equal(formatCockpitContextScope('', 0), '— · 0 KM AIR/SEA WINDOW'); assert.equal( formatCockpitContextScope(undefined, Number.NaN, {}), '— · — KM AIR/SEA WINDOW', @@ -168,26 +170,29 @@ test('relative bearing stays on the shortest signed arc', () => { }); test('HUD rail layout centers between intersecting upper and lower obstacles', () => { - assert.deepEqual(resolveHudRailLayout({ - viewportHeight: 1000, - panelHeight: 200, - laneLeft: 50, - laneRight: 410, - baseTop: 280, - baseBottom: 940, - gap: 10, - obstacles: [ - { left: 40, right: 240, top: 320, bottom: 460 }, - { left: 300, right: 1000, top: 780, bottom: 930 }, - { left: 900, right: 1100, top: 300, bottom: 700 }, - ], - }), { - top: 520, - maxHeight: 300, - safeTop: 470, - safeBottom: 770, - constrained: false, - }); + assert.deepEqual( + resolveHudRailLayout({ + viewportHeight: 1000, + panelHeight: 200, + laneLeft: 50, + laneRight: 410, + baseTop: 280, + baseBottom: 940, + gap: 10, + obstacles: [ + { left: 40, right: 240, top: 320, bottom: 460 }, + { left: 300, right: 1000, top: 780, bottom: 930 }, + { left: 900, right: 1100, top: 300, bottom: 700 }, + ], + }), + { + top: 520, + maxHeight: 300, + safeTop: 470, + safeBottom: 770, + constrained: false, + }, + ); }); test('HUD rail layout constrains an oversized panel to the safe corridor', () => { @@ -241,7 +246,10 @@ function contextSnapshot(subject, extra = {}) { } test('Contact panel hides only when there is no snapshot at all', () => { - const readout = resolveCockpitContextReadout({ snapshot: null, info: TRACKED_INFO }); + const readout = resolveCockpitContextReadout({ + snapshot: null, + info: TRACKED_INFO, + }); assert.equal(readout.visible, false); assert.equal(readout.mode, 'standby'); assert.equal( @@ -257,14 +265,22 @@ test('Contact panel survives NEXT onto a vessel or installation subject', () => // camera: "click next... whole left panel disappears"). for (const subject of [ { layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' }, - { layerId: 'military-installations', id: 'fort-hood', label: 'FORT CAVAZOS' }, + { + layerId: 'military-installations', + id: 'fort-hood', + label: 'FORT CAVAZOS', + }, { layerId: 'military', id: 'ae01ce', label: 'RCH451' }, ]) { const readout = resolveCockpitContextReadout({ snapshot: contextSnapshot(subject), info: TRACKED_INFO, }); - assert.equal(readout.visible, true, `${subject.layerId} subject must keep the panel up`); + assert.equal( + readout.visible, + true, + `${subject.layerId} subject must keep the panel up`, + ); assert.equal(readout.mode, 'foreign'); assert.equal(readout.subjectMatchesTracked, false); } @@ -272,7 +288,11 @@ test('Contact panel survives NEXT onto a vessel or installation subject', () => test('a foreign subject dashes the aircraft-relative fields and keeps the rest live', () => { const readout = resolveCockpitContextReadout({ - snapshot: contextSnapshot({ layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' }), + snapshot: contextSnapshot({ + layerId: 'ais-live-vessels', + id: '353136000', + label: 'MAERSK DETROIT', + }), info: TRACKED_INFO, }); assert.equal(readout.visible, true); @@ -281,12 +301,20 @@ test('a foreign subject dashes the aircraft-relative fields and keeps the rest l // frame while every other row value is measured from the subject; rendering // both live would present one mixed-frame reading as a single measurement. assert.equal(readout.aircraftRelative, false); - assert.equal(readout.contactLost, false, 'subject-frame values still refresh'); + assert.equal( + readout.contactLost, + false, + 'subject-frame values still refresh', + ); }); test('the tracked aircraft as subject keeps every field in its own frame', () => { const readout = resolveCockpitContextReadout({ - snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077', label: 'SWA1234' }), + snapshot: contextSnapshot({ + layerId: 'flights', + id: 'aaa077', + label: 'SWA1234', + }), info: TRACKED_INFO, }); assert.equal(readout.visible, true); @@ -305,7 +333,11 @@ test('a fast-culled subject keeps the panel up as CONTACT LOST instead of collap snapshot: contextSnapshot(subject, { subjectPresent: false }), info: TRACKED_INFO, }); - assert.equal(readout.visible, true, 'a lost contact must not take the panel down'); + assert.equal( + readout.visible, + true, + 'a lost contact must not take the panel down', + ); assert.equal(readout.mode, 'lost'); assert.equal(readout.contactLost, true, 'last-known values stay on screen'); } @@ -313,7 +345,10 @@ test('a fast-culled subject keeps the panel up as CONTACT LOST instead of collap test('an explicitly present subject is never reported lost', () => { const present = resolveCockpitContextReadout({ - snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077' }, { subjectPresent: true }), + snapshot: contextSnapshot( + { layerId: 'flights', id: 'aaa077' }, + { subjectPresent: true }, + ), info: TRACKED_INFO, }); assert.equal(present.contactLost, false); @@ -332,20 +367,36 @@ test('the tracked flight layer is resolved by normalized tracked identity', () = // Both layers describe a tracked aircraft during a cross-layer handoff; // civilian-first precedence would hand the cockpit the wrong aircraft. assert.equal( - resolveTrackedAircraftInfo({ civilian, military, trackedId: 'military:ae01ce' }).layerId, + resolveTrackedAircraftInfo({ + civilian, + military, + trackedId: 'military:ae01ce', + }).layerId, 'military', ); assert.equal( - resolveTrackedAircraftInfo({ civilian, military, trackedId: 'military:ae01ce' }).icao24, + resolveTrackedAircraftInfo({ + civilian, + military, + trackedId: 'military:ae01ce', + }).icao24, 'ae01ce', ); assert.equal( - resolveTrackedAircraftInfo({ civilian, military, trackedId: 'flights:aaa077' }).layerId, + resolveTrackedAircraftInfo({ + civilian, + military, + trackedId: 'flights:aaa077', + }).layerId, 'flights', ); // Case-insensitive: layers stamp lowercase hex, callers may not. assert.equal( - resolveTrackedAircraftInfo({ civilian, military, trackedId: 'MILITARY:AE01CE' }).layerId, + resolveTrackedAircraftInfo({ + civilian, + military, + trackedId: 'MILITARY:AE01CE', + }).layerId, 'military', ); }); @@ -355,12 +406,38 @@ test('tracked-identity resolution falls back to layer precedence', () => { const military = { icao24: 'ae01ce' }; // No stamped identity (or one that matches neither layer) keeps the historic // civilian-first order so tracking paths without gevTrackedId still work. - assert.equal(resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights'); assert.equal( - resolveTrackedAircraftInfo({ civilian, military, trackedId: 'satellites:25544' }).layerId, + resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights', ); - assert.equal(resolveTrackedAircraftInfo({ military, trackedId: '' }).layerId, 'military'); - assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null); + assert.equal( + resolveTrackedAircraftInfo({ + civilian, + military, + trackedId: 'satellites:25544', + }).layerId, + 'flights', + ); + assert.equal( + resolveTrackedAircraftInfo({ military, trackedId: '' }).layerId, + 'military', + ); + assert.equal( + resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), + null, + ); assert.equal(resolveTrackedAircraftInfo(), null); }); + +test('cockpit emergency wording states the broadcast code, not a conclusion', () => { + assert.equal( + formatCockpitEmergency({ label: 'General emergency', squawk: '7700' }), + 'SQUAWK 7700 · GENERAL EMERGENCY', + ); + assert.equal( + formatCockpitEmergency({ label: 'Minimum fuel', squawk: null }), + 'MINIMUM FUEL', + ); + assert.equal(formatCockpitEmergency(null), null); + assert.equal(formatCockpitEmergency({ label: '' }), null); +}); diff --git a/src/data/aircraftEmergency.js b/src/data/aircraftEmergency.js new file mode 100644 index 000000000..e7ee3aca5 --- /dev/null +++ b/src/data/aircraftEmergency.js @@ -0,0 +1,71 @@ +// src/data/aircraftEmergency.js +/** + * Transponder emergency status for one aircraft, from the Mode A squawk and + * the ADS-B emergency/priority field. + * + * Emergency squawks are ICAO-standard worldwide: 7500 unlawful interference, + * 7600 radio failure, 7700 general emergency. readsb (adsb.lol) also reports + * `emergency`, "a superset of the 7x00 squawks": none, general, lifeguard, + * minfuel, nordo, unlawful, downed, reserved. OpenSky state vectors carry the + * squawk only. + * + * The status is what the transponder broadcast in THIS poll, never a finding: + * crews pass through 7x00 codes while dialling, and a real emergency clears + * when the code does. Callers keep it per poll (not sticky). + */ + +/** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */ +export const EMERGENCY_TINT_CSS = '#FF4444'; + +const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' }; + +const KINDS = { + general: { label: 'General emergency', severity: 'emergency' }, + unlawful: { label: 'Unlawful interference', severity: 'emergency' }, + nordo: { label: 'Radio failure', severity: 'emergency' }, + downed: { label: 'Downed aircraft', severity: 'emergency' }, + minfuel: { label: 'Minimum fuel', severity: 'priority' }, + lifeguard: { label: 'Lifeguard (medical priority)', severity: 'priority' }, +}; + +/** + * A Mode A code as its four octal digits, else null. + * @param {unknown} value + * @returns {string|null} + */ +export function normalizeSquawk(value) { + const code = String(value ?? '').trim(); + return /^[0-7]{4}$/.test(code) ? code : null; +} + +/** + * Emergency status from one observation. An explicit ADS-B status wins; a + * 7x00 squawk counts even when the ADS-B field says `none`. + * @param {{squawk?: unknown, emergency?: unknown}} [observation] + * @returns {{kind: string, label: string, severity: 'emergency'|'priority', + * squawk: string|null, source: 'ads-b'|'squawk'}|null} + */ +export function aircraftEmergency({ squawk, emergency } = {}) { + const code = normalizeSquawk(squawk); + const reported = String(emergency ?? '') + .trim() + .toLowerCase(); + const fromAdsb = Object.hasOwn(KINDS, reported); + const kind = fromAdsb ? reported : (SQUAWK_KINDS[code] ?? null); + if (!kind) return null; + return { + kind, + ...KINDS[kind], + squawk: code, + source: fromAdsb ? 'ads-b' : 'squawk', + }; +} + +/** + * True when the aircraft is broadcasting an emergency (not a priority status). + * @param {{severity?: string}|null|undefined} status + * @returns {boolean} + */ +export function isEmergency(status) { + return status?.severity === 'emergency'; +} diff --git a/src/data/aircraftEmergency.test.mjs b/src/data/aircraftEmergency.test.mjs new file mode 100644 index 000000000..cae9f6c50 --- /dev/null +++ b/src/data/aircraftEmergency.test.mjs @@ -0,0 +1,55 @@ +// src/data/aircraftEmergency.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + aircraftEmergency, + isEmergency, + normalizeSquawk, +} from './aircraftEmergency.js'; + +test('the three ICAO emergency squawks map to their kinds', () => { + assert.equal(aircraftEmergency({ squawk: '7500' }).kind, 'unlawful'); + assert.equal(aircraftEmergency({ squawk: '7600' }).kind, 'nordo'); + const general = aircraftEmergency({ squawk: ' 7700 ' }); + assert.deepEqual(general, { + kind: 'general', + label: 'General emergency', + severity: 'emergency', + squawk: '7700', + source: 'squawk', + }); +}); + +test('ordinary codes, blanks and non-octal values are not emergencies', () => { + for (const squawk of ['1200', '7000', '2000', '', null, undefined, '7800']) + assert.equal(aircraftEmergency({ squawk }), null, String(squawk)); + assert.equal(aircraftEmergency(), null); + assert.equal(normalizeSquawk('7800'), null); + assert.equal(normalizeSquawk(7700), '7700'); + assert.equal(normalizeSquawk('770'), null); +}); + +test('the ADS-B emergency field wins, and `none` defers to the squawk', () => { + const minfuel = aircraftEmergency({ squawk: '4521', emergency: 'MinFuel' }); + assert.equal(minfuel.kind, 'minfuel'); + assert.equal(minfuel.severity, 'priority'); + assert.equal(minfuel.source, 'ads-b'); + assert.equal(minfuel.squawk, '4521'); + assert.equal( + aircraftEmergency({ squawk: '7700', emergency: 'none' }).source, + 'squawk', + ); + assert.equal(aircraftEmergency({ emergency: 'downed' }).kind, 'downed'); + assert.equal(aircraftEmergency({ emergency: 'none' }), null); + assert.equal(aircraftEmergency({ emergency: 'reserved' }), null); +}); + +test('priority statuses are reported but are not emergencies', () => { + assert.equal(isEmergency(aircraftEmergency({ squawk: '7700' })), true); + assert.equal( + isEmergency(aircraftEmergency({ emergency: 'lifeguard' })), + false, + ); + assert.equal(isEmergency(aircraftEmergency({ emergency: 'minfuel' })), false); + assert.equal(isEmergency(null), false); +}); diff --git a/src/data/analystEngine.test.mjs b/src/data/analystEngine.test.mjs index 8da85a4d6..3476af7b4 100644 --- a/src/data/analystEngine.test.mjs +++ b/src/data/analystEngine.test.mjs @@ -1,18 +1,89 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js'; +import { + createAnalystEngine, + applyScope, + haversineKm, +} from './analystEngine.js'; // Stub world: a square "Texland" region, flights + ships + fires around it. -const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] }; +const TEXLAND = { + name: 'Texland', + ring: [ + [-100, 28], + [-94, 28], + [-94, 33], + [-100, 33], + ], +}; const FLIGHTS = [ - { id: 'SWA1', lat: 30.2, lon: -97.7, altitudeM: 11000, speedMps: 240, military: false, onGround: false, routeOrigin: 'AUS', routeDestination: 'LAX' }, - { id: 'RCH01', lat: 31.0, lon: -97.0, altitudeM: 13500, speedMps: 250, military: true, onGround: false, routeOrigin: null, routeDestination: null }, - { id: 'N123', lat: 45.0, lon: -122.0, altitudeM: 2000, speedMps: 80, military: false, onGround: false, routeOrigin: null, routeDestination: null }, - { id: 'GND1', lat: 30.19, lon: -97.66, altitudeM: 150, speedMps: 5, military: false, onGround: true, routeOrigin: null, routeDestination: null }, + { + id: 'SWA1', + lat: 30.2, + lon: -97.7, + altitudeM: 11000, + speedMps: 240, + military: false, + onGround: false, + routeOrigin: 'AUS', + routeDestination: 'LAX', + }, + { + id: 'RCH01', + lat: 31.0, + lon: -97.0, + altitudeM: 13500, + speedMps: 250, + military: true, + onGround: false, + routeOrigin: null, + routeDestination: null, + }, + { + id: 'N123', + lat: 45.0, + lon: -122.0, + altitudeM: 2000, + speedMps: 80, + military: false, + onGround: false, + squawk: '7700', + emergency: true, + emergencyKind: 'general', + routeOrigin: null, + routeDestination: null, + }, + { + id: 'GND1', + lat: 30.19, + lon: -97.66, + altitudeM: 150, + speedMps: 5, + military: false, + onGround: true, + routeOrigin: null, + routeDestination: null, + }, ]; const SHIPS = [ - { id: 'EVERGIVEN', lat: 29.5, lon: -94.9, speedKts: 12, shipType: 'Cargo', destination: 'OAKLAND', navStatus: 'under way' }, - { id: 'SLOWBOAT', lat: 29.6, lon: -95.0, speedKts: 0.2, shipType: 'Tanker', destination: 'HOUSTON', navStatus: 'anchored' }, + { + id: 'EVERGIVEN', + lat: 29.5, + lon: -94.9, + speedKts: 12, + shipType: 'Cargo', + destination: 'OAKLAND', + navStatus: 'under way', + }, + { + id: 'SLOWBOAT', + lat: 29.6, + lon: -95.0, + speedKts: 0.2, + shipType: 'Tanker', + destination: 'HOUSTON', + navStatus: 'anchored', + }, ]; const FIRES = [ { id: 'FIRE-1', lat: 30.5, lon: -98.2, frp: 1500 }, @@ -22,7 +93,10 @@ const FIRES = [ function makeEngine() { return createAnalystEngine({ - getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES }[key] || []), + getRecords: (key) => + ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES })[ + key + ] || [], resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null), getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }), }); @@ -31,7 +105,10 @@ function makeEngine() { /** Same world, but Contacts is up with a subject far from the parked camera. */ function makeContactsEngine(subject) { return createAnalystEngine({ - getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES }[key] || []), + getRecords: (key) => + ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES })[ + key + ] || [], resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null), // Parked far away, as a high-altitude camera often is. getViewContext: () => ({ lat: 45.0, lon: -122.0, viewRadiusKm: 150 }), @@ -52,7 +129,11 @@ test('analyst: a radius query centers on the active contact, not the parked came assert.equal(r.ok, true); // Austin-area flights, not the Oregon one the camera is parked over. assert.equal(r.count, 3); - assert.equal(r.centeredOn, 'SWA1', 'the answer names the centre it measured from'); + assert.equal( + r.centeredOn, + 'SWA1', + 'the answer names the centre it measured from', + ); assert.ok(r.coverage.scope.includes('@SWA1')); }); @@ -63,7 +144,11 @@ test('analyst: an explicit center still wins over the active contact', async () scope: { kind: 'radius', km: 250, center: { lat: 45.0, lon: -122.0 } }, limit: 50, }); - assert.equal(r.count, 1, 'only the Oregon flight is within 250 km of the given center'); + assert.equal( + r.count, + 1, + 'only the Oregon flight is within 250 km of the given center', + ); assert.equal(r.centeredOn, undefined, 'an explicit center is not relabelled'); }); @@ -73,7 +158,11 @@ test('analyst: with Contacts off, radius still centers on the view', async () => scope: { kind: 'radius', km: 250 }, limit: 50, }); - assert.equal(r.count, 1, 'view-centred behaviour is unchanged outside Contacts'); + assert.equal( + r.count, + 1, + 'view-centred behaviour is unchanged outside Contacts', + ); assert.equal(r.centeredOn, undefined); assert.equal(r.coverage.scope, 'radius:250km'); }); @@ -83,13 +172,25 @@ test('analyst: a subject without usable coordinates cannot lend its name to a ca // number. A subject present but position-less fell back to the camera and // kept the contact's name on the answer, so a camera-centred count read as // contact-centred with nothing in the payload to catch it. - const r = await makeContactsEngine({ lat: null, lon: null, label: 'SWA1' }).query({ + const r = await makeContactsEngine({ + lat: null, + lon: null, + label: 'SWA1', + }).query({ layers: ['flights'], scope: { kind: 'radius', km: 250 }, limit: 50, }); - assert.equal(r.count, 1, 'the count is the camera-centred one it actually measured'); - assert.equal(r.centeredOn, undefined, 'and it must not claim a centre it did not use'); + assert.equal( + r.count, + 1, + 'the count is the camera-centred one it actually measured', + ); + assert.equal( + r.centeredOn, + undefined, + 'and it must not claim a centre it did not use', + ); assert.equal(r.coverage.scope, 'radius:250km'); assert.equal(r.scopeLabel, 'within 250 km'); }); @@ -99,33 +200,47 @@ test('analyst: every scope names itself in words', async () => { // answers look like a contradiction, so each scope carries its own phrasing. const subject = { lat: 30.2, lon: -97.7, label: 'DYNO11' }; const centred = await makeContactsEngine(subject).query({ - layers: ['flights'], scope: { kind: 'radius', km: 250 }, limit: 1, + layers: ['flights'], + scope: { kind: 'radius', km: 250 }, + limit: 1, }); assert.equal(centred.scopeLabel, 'within 250 km of DYNO11'); const plainRadius = await makeContactsEngine(null).query({ - layers: ['flights'], scope: { kind: 'radius', km: 250 }, limit: 1, + layers: ['flights'], + scope: { kind: 'radius', km: 250 }, + limit: 1, }); assert.equal(plainRadius.scopeLabel, 'within 250 km'); const inView = await makeEngine().query({ - layers: ['flights'], scope: { kind: 'view' }, limit: 1, + layers: ['flights'], + scope: { kind: 'view' }, + limit: 1, }); assert.equal(inView.scopeLabel, 'in view'); const region = await makeEngine().query({ - layers: ['flights'], scope: { kind: 'region', name: 'Texland' }, limit: 1, + layers: ['flights'], + scope: { kind: 'region', name: 'Texland' }, + limit: 1, }); assert.equal(region.scopeLabel, 'over Texland'); const anywhere = await makeEngine().query({ - layers: ['flights'], scope: { kind: 'anywhere' }, limit: 1, + layers: ['flights'], + scope: { kind: 'anywhere' }, + limit: 1, }); assert.equal(anywhere.scopeLabel, 'anywhere in the loaded data'); }); test('analyst: count flights over a region', async () => { - const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' }, limit: 50 }); + const r = await makeEngine().query({ + layers: ['flights'], + scope: { kind: 'region', name: 'Texland' }, + limit: 50, + }); assert.equal(r.ok, true); assert.equal(r.count, 3, 'Oregon flight excluded'); assert.ok(r.coverage.scope.includes('Texland')); @@ -133,32 +248,65 @@ test('analyst: count flights over a region', async () => { test('analyst: attribute filter — above 40,000 ft (~12,192 m)', async () => { const r = await makeEngine().query({ - layers: ['flights'], scope: { kind: 'anywhere' }, + layers: ['flights'], + scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM', op: 'gt', value: 12192 }], }); - assert.deepEqual(r.items.map((i) => i.id), ['RCH01']); + assert.deepEqual( + r.items.map((i) => i.id), + ['RCH01'], + ); }); test('analyst: military flag + region compose', async () => { const r = await makeEngine().query({ - layers: ['flights'], scope: { kind: 'region', name: 'Texland' }, + layers: ['flights'], + scope: { kind: 'region', name: 'Texland' }, filters: [{ field: 'military', op: 'eq', value: true }], }); assert.equal(r.count, 1); assert.equal(r.items[0].id, 'RCH01'); }); +test('analyst: aircraft broadcasting an emergency are filterable by flag and code', async () => { + const flagged = await makeEngine().query({ + layers: ['flights'], + scope: { kind: 'anywhere' }, + filters: [{ field: 'emergency', op: 'eq', value: true }], + }); + assert.deepEqual( + flagged.items.map((i) => i.id), + ['N123'], + ); + const coded = await makeEngine().query({ + layers: ['flights'], + scope: { kind: 'anywhere' }, + filters: [{ field: 'squawk', op: 'eq', value: '7700' }], + }); + assert.deepEqual( + coded.items.map((i) => i.id), + ['N123'], + ); +}); + test('analyst: ships headed to Oakland (destination contains)', async () => { const r = await makeEngine().query({ - layers: ['ais-live-vessels'], scope: { kind: 'anywhere' }, + layers: ['ais-live-vessels'], + scope: { kind: 'anywhere' }, filters: [{ field: 'destination', op: 'contains', value: 'oakland' }], }); - assert.deepEqual(r.items.map((i) => i.id), ['EVERGIVEN']); + assert.deepEqual( + r.items.map((i) => i.id), + ['EVERGIVEN'], + ); }); test('analyst: superlative — biggest fire in view radius', async () => { const r = await makeEngine().query({ - layers: ['local-firms'], scope: { kind: 'view' }, sortBy: 'frp', limit: 1, + layers: ['local-firms'], + scope: { kind: 'view' }, + sortBy: 'frp', + limit: 1, }); assert.equal(r.items[0].id, 'FIRE-1', 'BC monster is out of view scope'); assert.equal(r.summary.frpMax, 1500); @@ -166,7 +314,10 @@ test('analyst: superlative — biggest fire in view radius', async () => { test('analyst: nearest sorting attaches distanceKm ascending', async () => { const r = await makeEngine().query({ - layers: ['flights'], scope: { kind: 'view' }, sortBy: 'distance', limit: 3, + layers: ['flights'], + scope: { kind: 'view' }, + sortBy: 'distance', + limit: 3, }); assert.ok(r.items[0].distanceKm <= r.items[1].distanceKm); assert.ok(Number.isFinite(r.items[0].distanceKm)); @@ -266,7 +417,10 @@ test('analyst: failed pre-commit work cannot replace follow-up memory', async () }); test('analyst: unresolved region is an honest failure, not empty success', async () => { - const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'region', name: 'Atlantis' } }); + const r = await makeEngine().query({ + layers: ['flights'], + scope: { kind: 'region', name: 'Atlantis' }, + }); assert.equal(r.ok, false); assert.match(r.error, /Atlantis/); }); @@ -274,10 +428,17 @@ test('analyst: unresolved region is an honest failure, not empty success', async test('analyst: a region lookup timeout is reported as region-timeout', async () => { const eng = createAnalystEngine({ getRecords: () => FLIGHTS, - resolveRegionRing: async (name) => ({ name, ring: null, error: 'region-timeout' }), + resolveRegionRing: async (name) => ({ + name, + ring: null, + error: 'region-timeout', + }), getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }), }); - const r = await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } }); + const r = await eng.query({ + layers: ['flights'], + scope: { kind: 'region', name: 'Texas' }, + }); assert.equal(r.ok, false); assert.equal(r.code, 'region-timeout'); assert.match(r.error, /Texas/); @@ -319,32 +480,84 @@ test('analyst: region lookup receives and obeys the owning action signal', async test('analyst: route fields queryable from cached enrichment only', async () => { const r = await makeEngine().query({ - layers: ['flights'], scope: { kind: 'anywhere' }, + layers: ['flights'], + scope: { kind: 'anywhere' }, filters: [{ field: 'routeDestination', op: 'eq', value: 'LAX' }], }); - assert.deepEqual(r.items.map((i) => i.id), ['SWA1'], 'null route fields never match'); + assert.deepEqual( + r.items.map((i) => i.id), + ['SWA1'], + 'null route fields never match', + ); }); test('analyst: satellites and local infrastructure are queryable layers', async () => { const SATS = [ - { id: 'ISS (ZARYA)', noradId: '25544', name: 'ISS (ZARYA)', lat: 30.3, lon: -97.7, altitudeM: 410000, satelliteClass: 'STATION · ISS', group: 'stations' }, - { id: 'GPS BIIR-2', noradId: '24876', name: 'GPS BIIR-2', lat: 51.0, lon: 0.0, altitudeM: 20200000, satelliteClass: 'NAV · GPS', group: 'gps-ops' }, + { + id: 'ISS (ZARYA)', + noradId: '25544', + name: 'ISS (ZARYA)', + lat: 30.3, + lon: -97.7, + altitudeM: 410000, + satelliteClass: 'STATION · ISS', + group: 'stations', + }, + { + id: 'GPS BIIR-2', + noradId: '24876', + name: 'GPS BIIR-2', + lat: 51.0, + lon: 0.0, + altitudeM: 20200000, + satelliteClass: 'NAV · GPS', + group: 'gps-ops', + }, ]; const DAMS = [ - { id: 'Austin Dam', name: 'Austin Dam', lat: 30.27, lon: -97.74, operator: 'LCRA', river: 'Colorado', output: '2 MW' }, - { id: 'Far Dam', name: 'Far Dam', lat: 45.0, lon: -122.0, operator: 'USACE', river: 'Columbia', output: '1000 MW' }, + { + id: 'Austin Dam', + name: 'Austin Dam', + lat: 30.27, + lon: -97.74, + operator: 'LCRA', + river: 'Colorado', + output: '2 MW', + }, + { + id: 'Far Dam', + name: 'Far Dam', + lat: 45.0, + lon: -122.0, + operator: 'USACE', + river: 'Columbia', + output: '1000 MW', + }, ]; const DCS = [ - { id: 'AUS-1', name: 'AUS-1', lat: 30.28, lon: -97.75, operator: 'Example Cloud', capacity: '27 MW' }, + { + id: 'AUS-1', + name: 'AUS-1', + lat: 30.28, + lon: -97.75, + operator: 'Example Cloud', + capacity: '27 MW', + }, ]; const eng = createAnalystEngine({ - getRecords: (key) => ({ satellites: SATS, 'local-dams': DAMS, 'local-datacenters': DCS }[key] || []), + getRecords: (key) => + ({ satellites: SATS, 'local-dams': DAMS, 'local-datacenters': DCS })[ + key + ] || [], resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null), getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }), }); const sats = await eng.query({ - layers: ['satellites'], scope: { kind: 'view' }, sortBy: 'distance', limit: 2, + layers: ['satellites'], + scope: { kind: 'view' }, + sortBy: 'distance', + limit: 2, }); assert.equal(sats.ok, true); assert.equal(sats.count, 1, 'GPS sat is out of the Austin view radius'); @@ -352,20 +565,28 @@ test('analyst: satellites and local infrastructure are queryable layers', async assert.ok(Number.isFinite(sats.items[0].distanceKm)); const nav = await eng.query({ - layers: ['satellites'], scope: { kind: 'anywhere' }, + layers: ['satellites'], + scope: { kind: 'anywhere' }, filters: [{ field: 'satelliteClass', op: 'contains', value: 'NAV' }], }); - assert.deepEqual(nav.items.map((i) => i.id), ['GPS BIIR-2']); + assert.deepEqual( + nav.items.map((i) => i.id), + ['GPS BIIR-2'], + ); const dams = await eng.query({ - layers: ['local-dams'], scope: { kind: 'view' }, sortBy: 'distance', limit: 5, + layers: ['local-dams'], + scope: { kind: 'view' }, + sortBy: 'distance', + limit: 5, }); assert.equal(dams.count, 1); assert.equal(dams.items[0].id, 'Austin Dam'); assert.equal(dams.items[0].river, 'Colorado'); const dcs = await eng.query({ - layers: ['local-datacenters'], scope: { kind: 'region', name: 'Texland' }, + layers: ['local-datacenters'], + scope: { kind: 'region', name: 'Texland' }, filters: [{ field: 'operator', op: 'contains', value: 'cloud' }], }); assert.equal(dcs.count, 1); @@ -375,7 +596,11 @@ test('analyst: satellites and local infrastructure are queryable layers', async test('helpers: haversine sanity + scope radius', () => { const km = haversineKm(30.2672, -97.7431, 29.7604, -95.3698); // Austin→Houston assert.ok(km > 200 && km < 280, `Austin-Houston ~235km, got ${km}`); - const scoped = applyScope(FLIGHTS, { kind: 'radius' }, { center: { lat: 30.27, lon: -97.74 }, km: 50 }); + const scoped = applyScope( + FLIGHTS, + { kind: 'radius' }, + { center: { lat: 30.27, lon: -97.74 }, km: 50 }, + ); assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']); }); @@ -385,7 +610,11 @@ test('capped cohorts report returned/total/truncated and keep it on follow-up', getRecordCoverage: () => ({ total: 3000, truncated: true }), getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }), }); - const result = await engine.query({ layers: ['local-dams'], scope: { kind: 'anywhere' }, sortBy: 'distance' }); + const result = await engine.query({ + layers: ['local-dams'], + scope: { kind: 'anywhere' }, + sortBy: 'distance', + }); assert.equal(result.count, 1); const layer = result.coverage.layersQueried[0]; assert.deepEqual({ returned: layer.returned, total: layer.total, truncated: layer.truncated }, { returned: 1, total: 3000, truncated: true }); @@ -510,7 +739,11 @@ test('follow-up provenance stays attached to old rows after a feed recovers', as let state = 'stale'; const engine = createAnalystEngine({ getRecords: () => [{ id: 'A', lat: 0, lon: 0 }], - getLayerSnapshot: () => ({ id: 'flights', enabled: true, feedState: state }), + getLayerSnapshot: () => ({ + id: 'flights', + enabled: true, + feedState: state, + }), getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }), }); const first = await engine.query(); @@ -520,7 +753,10 @@ test('follow-up provenance stays attached to old rows after a feed recovers', as assert.equal(followUp.coverage.feedProvenance.overall, 'stale'); engine.reset(); assert.equal(engine.hasMemory(), false); - assert.equal((await engine.query()).coverage.feedProvenance.overall, 'nominal'); + assert.equal( + (await engine.query()).coverage.feedProvenance.overall, + 'nominal', + ); }); test('a large loaded set sorts and summarizes without overflowing the stack', async () => { diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index b09dc7014..740f1afcc 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -79,6 +79,9 @@ test('flights analyst record: full record maps every contract field', () => { aircraftClass: 'airliner', originCountry: 'United States', operator: 'Southwest Airlines', + squawk: null, + emergency: false, + emergencyKind: null, routeOrigin: 'AUS', routeDestination: 'LAX', }); diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index 597211bb7..cd29c1ebd 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -71,11 +71,31 @@ test('military analyst record: full record maps every contract field', () => { aircraftClass: 'widebody', originCountry: null, operator: 'United States Air Force', + squawk: null, + emergency: false, + emergencyKind: null, routeOrigin: null, routeDestination: null, }); }); +test('military analyst record: emergency flag is only raised for emergency severity', () => { + const hijack = mapAnalystRecord('ae01ce', { + ...FULL_INFO, + squawk: '7500', + emergency: { kind: 'unlawful', severity: 'emergency', squawk: '7500' }, + }); + assert.equal(hijack.squawk, '7500'); + assert.equal(hijack.emergency, true); + assert.equal(hijack.emergencyKind, 'unlawful'); + const lifeguard = mapAnalystRecord('ae01ce', { + ...FULL_INFO, + emergency: { kind: 'lifeguard', severity: 'priority', squawk: null }, + }); + assert.equal(lifeguard.emergency, false); + assert.equal(lifeguard.emergencyKind, 'lifeguard'); +}); + test('military analyst record: military is ALWAYS true, routes/country always null', () => { const r = mapAnalystRecord('ae01ce', undefined); assert.equal(r.military, true); diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index f8358c98f..80d77dddd 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -7,6 +7,7 @@ export { ERROR_BACKOFF_INTERVAL, } from './recordPolicy.js'; import * as Cesium from 'cesium'; +import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js'; export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; @@ -14,6 +15,11 @@ export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; export const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800'); +/** Red tint for aircraft broadcasting an emergency (7500/7600/7700 or ADS-B status). */ + +export const EMERGENCY_TINT = + Cesium.Color.fromCssColorString(EMERGENCY_TINT_CSS); + // --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes // taxiing and landing") ----------------------------------------------------------- // Present-but-grounded planes are RENDERED instead of being skipped: same class diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 5b64b2247..89ea44214 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -167,6 +167,9 @@ export function createQueries({ flightState.records.missingPolls.get(icao24) || flightState.feed._backoff, ), + // This poll's transponder broadcast (not sticky; see records.js). + squawk: info?.squawk ?? null, + emergency: info?.emergency ? { ...info.emergency } : null, airline: info?.airline ?? null, // CLASS label follows the TR-3B conversion so every downstream card // (cockpit, Contacts, analyst) agrees with the triangle on screen. @@ -219,7 +222,9 @@ export function createQueries({ * lon: number|null, altitudeM: number|null, speedMps: number|null, * heading: number|null, verticalRateMps: number|null, onGround: boolean, * military: boolean, aircraftClass: string|null, originCountry: string|null, - * operator: string|null, routeOrigin: string|null, routeDestination: string|null}} + * operator: string|null, squawk: string|null, emergency: boolean, + * emergencyKind: string|null, routeOrigin: string|null, + * routeDestination: string|null}} */ function mapAnalystRecord( @@ -253,6 +258,12 @@ export function createQueries({ aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)), originCountry: text(info?.originCountry), operator: text(info?.airline), + squawk: text(info?.squawk), + // True while this poll broadcast an emergency (7500/7600/7700 or the + // ADS-B status); priority statuses (lifeguard, minfuel) report the kind + // without the flag. + emergency: info?.emergency?.severity === 'emergency', + emergencyKind: text(info?.emergency?.kind), routeOrigin: routeOk ? text(info?.route?.origin?.code) : null, routeDestination: routeOk ? text(info?.route?.destination?.code) : null, }; diff --git a/src/layers/flights/records.js b/src/layers/flights/records.js index 43809f516..4b665d773 100644 --- a/src/layers/flights/records.js +++ b/src/layers/flights/records.js @@ -4,6 +4,7 @@ import { pickRenderAltitudeM, } from '../../data/renderAltitude.js'; import { classifyAircraft } from '../../data/aircraftClass.js'; +import { aircraftEmergency } from '../../data/aircraftEmergency.js'; import { approxDistanceKm, GROUND_FLOOR_CLAMP_RADIUS_KM, @@ -226,6 +227,10 @@ export class FlightRecords { // like callsign so a transient blank row doesn't blank the field. originCountry: stickyText(origin_country, prevMeta?.originCountry) || null, + // Per poll, NOT sticky: an emergency code clears when the crew resets + // it, so a blank squawk must not hold a stale 7700 on screen. + squawk: observation.squawk ?? null, + emergency: aircraftEmergency(observation), // OpenSky distinguishes the last position epoch from the last // transponder message. The fleet coast horizon uses this actual // contact time so a temporarily old position does not hard-freeze diff --git a/src/layers/flights/records.test.mjs b/src/layers/flights/records.test.mjs index d001c7811..fff25b8b9 100644 --- a/src/layers/flights/records.test.mjs +++ b/src/layers/flights/records.test.mjs @@ -106,3 +106,13 @@ test('ground transitions preserve last render height while a surface cell is col assert.equal(next.meta.renderAltitudeM, 1040); assert.equal(next.meta.wasAirborne, true); }); + +test('civil emergency status follows the latest poll instead of sticking', () => { + const store = records(); + const first = store.receive(observation({ squawk: '7700' }), view()); + assert.equal(first.meta.squawk, '7700'); + assert.equal(first.meta.emergency.kind, 'general'); + const next = store.receive(observation({ squawk: '2000' }), view()); + assert.equal(next.meta.squawk, '2000'); + assert.equal(next.meta.emergency, null); +}); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 44e1b02c3..e84e05e01 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -3,6 +3,7 @@ import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; import { civilAircraftModelSpec } from './modelSpec.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { isEmergency } from '../../data/aircraftEmergency.js'; import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; import { @@ -13,6 +14,7 @@ import { import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; import { MIL_TINT, + EMERGENCY_TINT, GROUND_SCALE, COCKPIT_CONTACT_SIZE_PX, COCKPIT_CIVILIAN_COLOR, @@ -54,13 +56,28 @@ export function createRendering({ const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } = services.recession; - /** Fleet (untracked) billboard tint: amber for known-military, white otherwise. + /** True while this contact's latest poll broadcast an emergency code. */ + + function _isEmergency(icao24) { + return isEmergency(flightState.records.data.get(icao24)?.emergency); + } + + /** Fleet (untracked) billboard tint: red while broadcasting an emergency, + * amber for known-military, white otherwise. * Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */ function _fleetBillboardColor(icao24) { + if (_isEmergency(icao24)) return EMERGENCY_TINT; return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE; } + /** Cockpit far-contact dot tint: the emergency red outranks the category tint. */ + + function _cockpitContactColor(icao24) { + if (_isEmergency(icao24)) return EMERGENCY_TINT; + return isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR; + } + /** Fleet billboard scale: per-class scale, ×GROUND_SCALE while grounded. */ function _fleetBillboardScale(icao24, klass) { @@ -129,9 +146,7 @@ export function createRendering({ bb.height = COCKPIT_CONTACT_SIZE_PX; bb.scale = limbScale; bb.scaleByDistance = _cockpitBillboardScaleByDistance(); - bb.color = ( - isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR - ).withAlpha(freshnessAlpha); + bb.color = _cockpitContactColor(icao24).withAlpha(freshnessAlpha); bb.rotation = 0; return; } @@ -162,7 +177,7 @@ export function createRendering({ function _modelColor(icao24) { if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN; - return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE; + return _fleetBillboardColor(icao24); } /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude @@ -1009,9 +1024,7 @@ export function createRendering({ flightState._cockpitNearContacts.has(icao24); const layerBaseColor = flightState._cockpitContactMode && !isCockpitNear - ? isMilitaryIcao(icao24) - ? MIL_TINT - : COCKPIT_CIVILIAN_COLOR + ? _cockpitContactColor(icao24) : _fleetBillboardColor(icao24); const baseColor = layerBaseColor; const treatment = applyAircraftBillboardTreatment({ diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js index 36d5ebde4..f375e5419 100644 --- a/src/layers/military/policy.js +++ b/src/layers/military/policy.js @@ -6,6 +6,7 @@ export { ERROR_BACKOFF_INTERVAL, } from './recordPolicy.js'; import * as Cesium from 'cesium'; +import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js'; /** * @module militaryFlights @@ -40,6 +41,11 @@ export const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800'); export const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166'); +/** @constant {Cesium.Color} Red tint for aircraft broadcasting an emergency (mirror of flights) */ + +export const EMERGENCY_ICON_COLOR = + Cesium.Color.fromCssColorString(EMERGENCY_TINT_CSS); + // --- Ground traffic (owner reversal 2026-07-03; mirror of flights.js) --------------- // adsb.lol/readsb flags ground traffic with alt_baro === "ground" (no separate // boolean). Such aircraft are RENDERED instead of floating at the 3 km altitude diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 421e9804d..eab06458c 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -133,6 +133,9 @@ export function createQueries({ flightState.records.missingPolls.get(icao24) || flightState.feed._backoff, ), + // This poll's transponder broadcast (not sticky; see records.js). + squawk: info?.squawk ?? null, + emergency: info?.emergency ? { ...info.emergency } : null, }; } @@ -156,7 +159,8 @@ export function createQueries({ * lon: number|null, altitudeM: number|null, speedMps: number|null, * heading: number|null, verticalRateMps: number|null, onGround: boolean, * military: boolean, aircraftClass: string|null, originCountry: null, - * operator: string|null, routeOrigin: null, routeDestination: null}} + * operator: string|null, squawk: string|null, emergency: boolean, + * emergencyKind: string|null, routeOrigin: null, routeDestination: null}} */ function mapAnalystRecord(icao24, info) { @@ -187,6 +191,12 @@ export function createQueries({ aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)), originCountry: null, operator: text(info?.operator), + squawk: text(info?.squawk), + // True while this poll broadcast an emergency (7500/7600/7700 or the + // ADS-B status); priority statuses (lifeguard, minfuel) report the kind + // without the flag. + emergency: info?.emergency?.severity === 'emergency', + emergencyKind: text(info?.emergency?.kind), routeOrigin: null, routeDestination: null, }; diff --git a/src/layers/military/records.js b/src/layers/military/records.js index cbc62be79..9cd6754e6 100644 --- a/src/layers/military/records.js +++ b/src/layers/military/records.js @@ -1,6 +1,7 @@ import { pickRenderAltitudeM } from '../../data/renderAltitude.js'; import { stickyText, stickyNumber } from '../../data/aircraftMeta.js'; import { classifyAircraft } from '../../data/aircraftClass.js'; +import { aircraftEmergency } from '../../data/aircraftEmergency.js'; import { GROUND_FLOOR_WARM_MAX_ALT_M, LANDED_MISSING_POLL_LIMIT, @@ -161,6 +162,9 @@ export class MilitaryFlightRecords { null, ), turnRateDps: prevMeta?.turnRateDps || 0, + // Per poll, NOT sticky (mirror of flights): a cleared code clears here. + squawk: aircraft.squawk ?? null, + emergency: aircraftEmergency(aircraft), onGround, // Round 7: sticky airborne history (see _likelyLanded). wasAirborne: prevMeta?.wasAirborne === true || !onGround, diff --git a/src/layers/military/records.test.mjs b/src/layers/military/records.test.mjs index df42760ce..5054fb28a 100644 --- a/src/layers/military/records.test.mjs +++ b/src/layers/military/records.test.mjs @@ -98,3 +98,19 @@ test('military records keep bounded partial retention and release metadata only 'remove', ); }); + +test('military emergency status follows the latest poll instead of sticking', () => { + const records = store(); + const first = records.receive( + observation({ squawk: '1234', emergency: 'minfuel' }), + context(), + ); + assert.equal(first.meta.squawk, '1234'); + assert.equal(first.meta.emergency.kind, 'minfuel'); + assert.equal(first.meta.emergency.severity, 'priority'); + const next = records.receive( + observation({ squawk: '1234', emergency: 'none' }), + context(), + ); + assert.equal(next.meta.emergency, null); +}); diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 9ebd03180..48265a777 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -18,6 +18,7 @@ import { screenProjectedRotation, } from '../../data/iconOrientation.js'; import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; +import { isEmergency } from '../../data/aircraftEmergency.js'; import { PLANE_MODEL_SCALE, PLANE_MODEL_URL, @@ -33,6 +34,7 @@ import { COCKPIT_CONTACT_SIZE_PX, MIL_ICON_COLOR, TRACKED_ICON_COLOR, + EMERGENCY_ICON_COLOR, MODEL_ALT_CEIL_M, MODEL_MAX_ALL, MODEL_MAX, @@ -159,7 +161,7 @@ export function createRendering({ bb.height = COCKPIT_CONTACT_SIZE_PX; bb.scale = limbScale; bb.scaleByDistance = _cockpitBillboardScaleByDistance(); - bb.color = MIL_ICON_COLOR.withAlpha(freshnessAlpha); + bb.color = _fleetBillboardColor(icao24).withAlpha(freshnessAlpha); bb.rotation = 0; return; } @@ -173,7 +175,7 @@ export function createRendering({ bb.height = icao24 === flightState._trackedIcao ? 24 : 20; bb.scale = _militaryBillboardScale(icao24) * limbScale; bb.scaleByDistance = _normalBillboardScaleByDistance(); - bb.color = MIL_ICON_COLOR.withAlpha(cyberSonarBaseAlpha(bb)); + bb.color = _fleetBillboardColor(icao24).withAlpha(cyberSonarBaseAlpha(bb)); } /** Sprite kind for one contact's billboard. Identity for every aircraft except @@ -189,7 +191,16 @@ export function createRendering({ function _modelColor(icao24) { if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR; - return MIL_ICON_COLOR; + return _fleetBillboardColor(icao24); + } + + /** Fleet (untracked) tint: red while the latest poll broadcast an emergency, + * amber otherwise. Cockpit far-contact dots use it too. */ + + function _fleetBillboardColor(icao24) { + return isEmergency(flightState.records.data.get(icao24)?.emergency) + ? EMERGENCY_ICON_COLOR + : MIL_ICON_COLOR; } /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude @@ -965,7 +976,7 @@ export function createRendering({ const isCockpitNear = flightState._cockpitContactMode && flightState._cockpitNearContacts.has(icao24); - const baseColor = MIL_ICON_COLOR; + const baseColor = _fleetBillboardColor(icao24); const treatment = applyAircraftBillboardTreatment({ billboard: bb, baseScale: @@ -1082,6 +1093,7 @@ export function createRendering({ _applyFleetBillboardPresentation, _iconKind, _modelColor, + _fleetBillboardColor, _modelRegimeActive, _modelCap, _modelAddDistM, diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js index 9534a5c3c..977801aa7 100644 --- a/src/sources/live/aircraft.js +++ b/src/sources/live/aircraft.js @@ -30,6 +30,9 @@ export function normalizeOpenSkyAircraft(row) { typeCode: null, registration: null, operator: null, + squawk: cleanText(row[14]) || null, + // State vectors carry no ADS-B emergency field; the squawk stands alone. + emergency: null, }; } @@ -68,6 +71,8 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) { typeCode: cleanText(row.t), registration: cleanText(row.r), operator: cleanText(row.ownOp), + squawk: cleanText(row.squawk) || null, + emergency: cleanText(row.emergency).toLowerCase() || null, }; } diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index 391fe1e72..36fa9b95b 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -313,3 +313,23 @@ test('identity lookup validates its response and honors body-parse cancellation' name: 'AbortError', }); }); + +test('transponder squawk and ADS-B emergency status are carried, blanks stay null', () => { + const squawking = [...aircraft, '7700']; + const civil = openSkySnapshot({ states: [squawking] }, { now }).records[0]; + assert.equal(civil.squawk, '7700'); + assert.equal(civil.emergency, null); + assert.equal(openSkySnapshot({ states: [aircraft] }).records[0].squawk, null); + const mil = normalizeReadsbAircraft( + { hex: 'abc123', lat: 30, lon: -97, squawk: '7600', emergency: 'NORDO' }, + now, + ); + assert.equal(mil.squawk, '7600'); + assert.equal(mil.emergency, 'nordo'); + const quiet = normalizeReadsbAircraft( + { hex: 'abc123', lat: 30, lon: -97 }, + now, + ); + assert.equal(quiet.squawk, null); + assert.equal(quiet.emergency, null); +}); diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index e4814517d..437aa9b26 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -12,6 +12,7 @@ import { compassDivisions, formatAltitudeRulerTick, formatCompassDivision, + formatCockpitEmergency, formatSpeedRulerTick, normalizeHeading, relativeBearing, @@ -133,7 +134,15 @@ export function updateHud( : info.stale ? 'STALE FEED' : 'LIVE TRACK'; - this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`; + const fleet = info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'; + const emergency = formatCockpitEmergency(info.emergency); + this.aircraftMeta.textContent = emergency + ? `${fleet} · ${emergency} · ${feedState}` + : `${fleet} · ${feedState} · COURSE ALIGNED`; + this.aircraftMeta.classList.toggle( + 'emergency', + info.emergency?.severity === 'emergency', + ); } this.updateRoute(info); if ( diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index 8436c3a41..df4e599e4 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -496,6 +496,9 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed)) font-size: 7px; letter-spacing: 0.16em; } +.cockpit-aircraft-meta.emergency { + color: #ff4444; +} .cockpit-mode-chip { margin-top: 2px; padding: 7px 11px; diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 5a25bfec8..79ce004e1 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -2270,6 +2270,12 @@ color: rgba(205, 225, 226, 0.48); } +:root[data-ui-theme='cyber'] + body.cockpit-mode + .cockpit-aircraft-meta.emergency { + color: #ff4444; +} + :root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-vision-controls { margin-top: 0; border-color: rgba(105, 203, 209, 0.38); From 15b13e947bbb684ef3359e74fc1219aec44f5f35 Mon Sep 17 00:00:00 2001 From: Leo Pechnicki Date: Tue, 29 Sep 2026 19:47:19 +0200 Subject: [PATCH 27/86] =?UTF-8?q?fix(flights):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20quotable=20squawk=20only,=20all=20military=20tint?= =?UTF-8?q?=20sites,=20no=20test=20churn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aircraftEmergency: `source` is 'squawk' only when the Mode A code itself names the status; the Cockpit HUD no longer quotes an unrelated squawk (e.g. SQUAWK 1200 on an ADS-B `downed` status). - military: new billboards and the untrack restore use the fleet tint, so an emergency stays red instead of snapping back to amber; tint helpers take the record already in hand instead of a second lookup. - cockpit/cyber CSS: emergency colour via a custom property / theme token, meta line bolded so the status does not rely on colour alone in the HUD. - tests: revert Prettier churn in unscoped test files; only semantic edits. - docs/CURRENT-STATE.md: transponder emergency highlighting section. - qa-emergency-squawk: (iv) RELEASE check; new files added to format scope. Co-Authored-By: Claude --- docs/CURRENT-STATE.md | 20 + scripts/format-scope.json | 3 + scripts/qa-emergency-squawk.mjs | 27 + src/cockpitMath.js | 12 +- src/cockpitMath.test.mjs | 158 +--- src/data/aircraftEmergency.js | 6 +- src/data/aircraftEmergency.test.mjs | 9 + src/data/analystEngine.test.mjs | 348 ++----- src/data/flights.test.mjs | 1115 +++++------------------ src/data/militaryFlights.test.mjs | 391 ++------ src/layers/flights/rendering.js | 16 +- src/layers/military/rendering.js | 11 +- src/layers/military/snapshotRenderer.js | 11 +- src/layers/military/tracking.js | 9 +- src/ui/styles/cockpit.css | 5 +- src/ui/styles/cyber.css | 2 +- 16 files changed, 543 insertions(+), 1600 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a9aeeddbc..dea8a5bec 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -814,6 +814,26 @@ use the supplied asset resolver, including the preload path. Enrichment receives an abort signal and cannot update a later lifecycle after destruction. Existing camera, terrain floor, trail, selection and measured model-size policies remain. +## Transponder emergency highlighting + +`src/data/aircraftEmergency.js` derives one status per poll from the Mode A +squawk (7500 unlawful interference, 7600 radio failure, 7700 general emergency; +ICAO-standard worldwide) and, where the feed carries it, the readsb ADS-B +`emergency` field (adsb.lol; OpenSky state vectors carry the squawk only). An +explicit ADS-B status wins over the squawk. `general`, `unlawful`, `nordo` and +`downed` are emergencies; `minfuel` and `lifeguard` are priorities. + +Both aircraft layers keep `squawk` and `emergency` on each record and replace +them on every poll, so the status is never sticky. Civil and military fleet +billboards and Cockpit far-contact dots are tinted `EMERGENCY_TINT_CSS` red +only for emergencies; priorities keep the normal tint. The military layer uses +the same tint when a billboard is first created and when a tracked contact is +released. While a broadcasting aircraft is tracked, the Cockpit aircraft-meta +line reads `SQUAWK 7700 · GENERAL EMERGENCY` when the code names the status, +else `ADS-B EMERGENCY · …` or `ADS-B PRIORITY · …`, in red. Analyst records and +the voice query fields expose `squawk`, `emergency` (flag) and `emergencyKind`. +`scripts/qa-emergency-squawk.mjs` proves the tint, HUD line and clearing. + ## Browser live-source observations Flights, Military Flights and AIS Vessels obtain snapshots and optional history diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 4973bd484..ea1531793 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -18,6 +18,9 @@ "src/tooling/panelStorageDocs.test.mjs", "src/tooling/packageBoundaries.test.mjs", "src/data/infrastructure.js", + "src/data/aircraftEmergency.js", + "src/data/aircraftEmergency.test.mjs", + "scripts/qa-emergency-squawk.mjs", "src/data/infrastructure.test.mjs", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", diff --git a/scripts/qa-emergency-squawk.mjs b/scripts/qa-emergency-squawk.mjs index bbcc98c22..a1625ef6e 100644 --- a/scripts/qa-emergency-squawk.mjs +++ b/scripts/qa-emergency-squawk.mjs @@ -14,6 +14,8 @@ * the broadcast code on the aircraft-meta line, in red. * (iii) CLEAR — the next poll drops the code: the HUD line returns to its * normal wording (the status is not sticky). + * (iv) RELEASE — the code returns and tracking stops: the restored fleet + * billboard is red, not the plain military amber. * * Screenshots saved to qa-shots/emergency-*.png (gitignored). * @@ -410,6 +412,31 @@ async function main() { JSON.stringify(after), ); await shoot(page, 'cockpit-cleared'); + + // ── (iv) RELEASE ────────────────────────────────────────────────────── + console.log('\n(iv) RELEASE — the code returns, then tracking stops...'); + await page.evaluate(async () => { + window.__squawkQaPhase = 'emergency'; + const dm = window.__godsEyeView.dataManager; + await dm.refreshLayer('military'); + dm.layers.get('military').module.stopTracking({ origin: 'user' }); + }); + const releaseProbe = `(${billboardColors})(["ae7700"])`; + const released = await page + .waitForFunction(`Boolean((${releaseProbe}).ae7700)`, { + timeout: 20000, + polling: 250, + }) + .then( + () => true, + () => false, + ); + const releasedColor = (await page.evaluate(releaseProbe)).ae7700; + record( + 'RELEASE: untracked emergency billboard is red', + released && isRed(releasedColor), + JSON.stringify(releasedColor ?? null), + ); record( 'no uncaught browser errors', errors.length === 0, diff --git a/src/cockpitMath.js b/src/cockpitMath.js index 4c9666ee9..74e6d1451 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -85,8 +85,11 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) { /** * HUD wording for a tracked aircraft's transponder emergency, or null. - * States the broadcast code, never a conclusion about the flight. - * @param {{label?: string, squawk?: string|null}|null|undefined} emergency + * States what was broadcast, never a conclusion about the flight: the Mode A + * code when it names the status, else the ADS-B field it came from (an + * unrelated squawk is dropped). + * @param {{label?: string, squawk?: string|null, source?: string, + * severity?: string}|null|undefined} emergency * @returns {string|null} */ export function formatCockpitEmergency(emergency) { @@ -94,7 +97,10 @@ export function formatCockpitEmergency(emergency) { .trim() .toUpperCase(); if (!label) return null; - return emergency.squawk ? `SQUAWK ${emergency.squawk} · ${label}` : label; + if (emergency.source === 'squawk' && emergency.squawk) + return `SQUAWK ${emergency.squawk} · ${label}`; + const field = emergency.severity === 'priority' ? 'PRIORITY' : 'EMERGENCY'; + return `ADS-B ${field} · ${label}`; } /** Format the cockpit Context scope without overstating installation coverage. */ diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index 8abbf1c3e..2189ca35e 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -10,10 +10,10 @@ import { cockpitGroundSafeHeight, cockpitSurfaceWaitExpired, cockpitUiUpdateDue, - formatCockpitEmergency, compassDivisions, formatAltitudeRulerTick, formatCockpitContextScope, + formatCockpitEmergency, formatCompassDivision, formatSpeedRulerTick, normalizeHeading, @@ -42,11 +42,7 @@ test('cockpit anchor correction cannot turn a forward step into a reversal', () const speedMps = 200; const dtSec = 0.1; const forwardStepM = speedMps * dtSec; - const backwardCorrectionM = cockpitAnchorCorrectionStep( - 1000, - speedMps, - dtSec, - ); + const backwardCorrectionM = cockpitAnchorCorrectionStep(1000, speedMps, dtSec); assert.ok(backwardCorrectionM > 0); assert.ok(backwardCorrectionM < forwardStepM); }); @@ -82,7 +78,10 @@ test('cockpit Context scope distinguishes radius-complete feeds from viewport in }); test('cockpit Context scope preserves zero and replaces missing values intentionally', () => { - assert.equal(formatCockpitContextScope('', 0), '— · 0 KM AIR/SEA WINDOW'); + assert.equal( + formatCockpitContextScope('', 0), + '— · 0 KM AIR/SEA WINDOW', + ); assert.equal( formatCockpitContextScope(undefined, Number.NaN, {}), '— · — KM AIR/SEA WINDOW', @@ -170,29 +169,26 @@ test('relative bearing stays on the shortest signed arc', () => { }); test('HUD rail layout centers between intersecting upper and lower obstacles', () => { - assert.deepEqual( - resolveHudRailLayout({ - viewportHeight: 1000, - panelHeight: 200, - laneLeft: 50, - laneRight: 410, - baseTop: 280, - baseBottom: 940, - gap: 10, - obstacles: [ - { left: 40, right: 240, top: 320, bottom: 460 }, - { left: 300, right: 1000, top: 780, bottom: 930 }, - { left: 900, right: 1100, top: 300, bottom: 700 }, - ], - }), - { - top: 520, - maxHeight: 300, - safeTop: 470, - safeBottom: 770, - constrained: false, - }, - ); + assert.deepEqual(resolveHudRailLayout({ + viewportHeight: 1000, + panelHeight: 200, + laneLeft: 50, + laneRight: 410, + baseTop: 280, + baseBottom: 940, + gap: 10, + obstacles: [ + { left: 40, right: 240, top: 320, bottom: 460 }, + { left: 300, right: 1000, top: 780, bottom: 930 }, + { left: 900, right: 1100, top: 300, bottom: 700 }, + ], + }), { + top: 520, + maxHeight: 300, + safeTop: 470, + safeBottom: 770, + constrained: false, + }); }); test('HUD rail layout constrains an oversized panel to the safe corridor', () => { @@ -246,10 +242,7 @@ function contextSnapshot(subject, extra = {}) { } test('Contact panel hides only when there is no snapshot at all', () => { - const readout = resolveCockpitContextReadout({ - snapshot: null, - info: TRACKED_INFO, - }); + const readout = resolveCockpitContextReadout({ snapshot: null, info: TRACKED_INFO }); assert.equal(readout.visible, false); assert.equal(readout.mode, 'standby'); assert.equal( @@ -265,22 +258,14 @@ test('Contact panel survives NEXT onto a vessel or installation subject', () => // camera: "click next... whole left panel disappears"). for (const subject of [ { layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' }, - { - layerId: 'military-installations', - id: 'fort-hood', - label: 'FORT CAVAZOS', - }, + { layerId: 'military-installations', id: 'fort-hood', label: 'FORT CAVAZOS' }, { layerId: 'military', id: 'ae01ce', label: 'RCH451' }, ]) { const readout = resolveCockpitContextReadout({ snapshot: contextSnapshot(subject), info: TRACKED_INFO, }); - assert.equal( - readout.visible, - true, - `${subject.layerId} subject must keep the panel up`, - ); + assert.equal(readout.visible, true, `${subject.layerId} subject must keep the panel up`); assert.equal(readout.mode, 'foreign'); assert.equal(readout.subjectMatchesTracked, false); } @@ -288,11 +273,7 @@ test('Contact panel survives NEXT onto a vessel or installation subject', () => test('a foreign subject dashes the aircraft-relative fields and keeps the rest live', () => { const readout = resolveCockpitContextReadout({ - snapshot: contextSnapshot({ - layerId: 'ais-live-vessels', - id: '353136000', - label: 'MAERSK DETROIT', - }), + snapshot: contextSnapshot({ layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' }), info: TRACKED_INFO, }); assert.equal(readout.visible, true); @@ -301,20 +282,12 @@ test('a foreign subject dashes the aircraft-relative fields and keeps the rest l // frame while every other row value is measured from the subject; rendering // both live would present one mixed-frame reading as a single measurement. assert.equal(readout.aircraftRelative, false); - assert.equal( - readout.contactLost, - false, - 'subject-frame values still refresh', - ); + assert.equal(readout.contactLost, false, 'subject-frame values still refresh'); }); test('the tracked aircraft as subject keeps every field in its own frame', () => { const readout = resolveCockpitContextReadout({ - snapshot: contextSnapshot({ - layerId: 'flights', - id: 'aaa077', - label: 'SWA1234', - }), + snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077', label: 'SWA1234' }), info: TRACKED_INFO, }); assert.equal(readout.visible, true); @@ -333,11 +306,7 @@ test('a fast-culled subject keeps the panel up as CONTACT LOST instead of collap snapshot: contextSnapshot(subject, { subjectPresent: false }), info: TRACKED_INFO, }); - assert.equal( - readout.visible, - true, - 'a lost contact must not take the panel down', - ); + assert.equal(readout.visible, true, 'a lost contact must not take the panel down'); assert.equal(readout.mode, 'lost'); assert.equal(readout.contactLost, true, 'last-known values stay on screen'); } @@ -345,10 +314,7 @@ test('a fast-culled subject keeps the panel up as CONTACT LOST instead of collap test('an explicitly present subject is never reported lost', () => { const present = resolveCockpitContextReadout({ - snapshot: contextSnapshot( - { layerId: 'flights', id: 'aaa077' }, - { subjectPresent: true }, - ), + snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077' }, { subjectPresent: true }), info: TRACKED_INFO, }); assert.equal(present.contactLost, false); @@ -367,36 +333,20 @@ test('the tracked flight layer is resolved by normalized tracked identity', () = // Both layers describe a tracked aircraft during a cross-layer handoff; // civilian-first precedence would hand the cockpit the wrong aircraft. assert.equal( - resolveTrackedAircraftInfo({ - civilian, - military, - trackedId: 'military:ae01ce', - }).layerId, + resolveTrackedAircraftInfo({ civilian, military, trackedId: 'military:ae01ce' }).layerId, 'military', ); assert.equal( - resolveTrackedAircraftInfo({ - civilian, - military, - trackedId: 'military:ae01ce', - }).icao24, + resolveTrackedAircraftInfo({ civilian, military, trackedId: 'military:ae01ce' }).icao24, 'ae01ce', ); assert.equal( - resolveTrackedAircraftInfo({ - civilian, - military, - trackedId: 'flights:aaa077', - }).layerId, + resolveTrackedAircraftInfo({ civilian, military, trackedId: 'flights:aaa077' }).layerId, 'flights', ); // Case-insensitive: layers stamp lowercase hex, callers may not. assert.equal( - resolveTrackedAircraftInfo({ - civilian, - military, - trackedId: 'MILITARY:AE01CE', - }).layerId, + resolveTrackedAircraftInfo({ civilian, military, trackedId: 'MILITARY:AE01CE' }).layerId, 'military', ); }); @@ -406,37 +356,29 @@ test('tracked-identity resolution falls back to layer precedence', () => { const military = { icao24: 'ae01ce' }; // No stamped identity (or one that matches neither layer) keeps the historic // civilian-first order so tracking paths without gevTrackedId still work. + assert.equal(resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights'); assert.equal( - resolveTrackedAircraftInfo({ civilian, military }).layerId, + resolveTrackedAircraftInfo({ civilian, military, trackedId: 'satellites:25544' }).layerId, 'flights', ); - assert.equal( - resolveTrackedAircraftInfo({ - civilian, - military, - trackedId: 'satellites:25544', - }).layerId, - 'flights', - ); - assert.equal( - resolveTrackedAircraftInfo({ military, trackedId: '' }).layerId, - 'military', - ); - assert.equal( - resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), - null, - ); + assert.equal(resolveTrackedAircraftInfo({ military, trackedId: '' }).layerId, 'military'); + assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null); assert.equal(resolveTrackedAircraftInfo(), null); }); test('cockpit emergency wording states the broadcast code, not a conclusion', () => { assert.equal( - formatCockpitEmergency({ label: 'General emergency', squawk: '7700' }), + formatCockpitEmergency({ label: 'General emergency', squawk: '7700', source: 'squawk', severity: 'emergency' }), 'SQUAWK 7700 · GENERAL EMERGENCY', ); assert.equal( - formatCockpitEmergency({ label: 'Minimum fuel', squawk: null }), - 'MINIMUM FUEL', + formatCockpitEmergency({ label: 'Downed aircraft', squawk: '1200', source: 'ads-b', severity: 'emergency' }), + 'ADS-B EMERGENCY · DOWNED AIRCRAFT', + 'an unrelated squawk is not quoted beside an ADS-B status', + ); + assert.equal( + formatCockpitEmergency({ label: 'Minimum fuel', squawk: null, source: 'ads-b', severity: 'priority' }), + 'ADS-B PRIORITY · MINIMUM FUEL', ); assert.equal(formatCockpitEmergency(null), null); assert.equal(formatCockpitEmergency({ label: '' }), null); diff --git a/src/data/aircraftEmergency.js b/src/data/aircraftEmergency.js index e7ee3aca5..3fd6ab688 100644 --- a/src/data/aircraftEmergency.js +++ b/src/data/aircraftEmergency.js @@ -43,7 +43,9 @@ export function normalizeSquawk(value) { * 7x00 squawk counts even when the ADS-B field says `none`. * @param {{squawk?: unknown, emergency?: unknown}} [observation] * @returns {{kind: string, label: string, severity: 'emergency'|'priority', - * squawk: string|null, source: 'ads-b'|'squawk'}|null} + * squawk: string|null, source: 'ads-b'|'squawk'}|null} `source` is + * 'squawk' when the Mode A code itself names the status (so it can be + * quoted), else 'ads-b'. */ export function aircraftEmergency({ squawk, emergency } = {}) { const code = normalizeSquawk(squawk); @@ -57,7 +59,7 @@ export function aircraftEmergency({ squawk, emergency } = {}) { kind, ...KINDS[kind], squawk: code, - source: fromAdsb ? 'ads-b' : 'squawk', + source: SQUAWK_KINDS[code] === kind ? 'squawk' : 'ads-b', }; } diff --git a/src/data/aircraftEmergency.test.mjs b/src/data/aircraftEmergency.test.mjs index cae9f6c50..2cff800b9 100644 --- a/src/data/aircraftEmergency.test.mjs +++ b/src/data/aircraftEmergency.test.mjs @@ -39,6 +39,15 @@ test('the ADS-B emergency field wins, and `none` defers to the squawk', () => { aircraftEmergency({ squawk: '7700', emergency: 'none' }).source, 'squawk', ); + assert.equal( + aircraftEmergency({ squawk: '7700', emergency: 'general' }).source, + 'squawk', + 'a code that names the status stays quotable', + ); + assert.equal( + aircraftEmergency({ squawk: '1200', emergency: 'general' }).source, + 'ads-b', + ); assert.equal(aircraftEmergency({ emergency: 'downed' }).kind, 'downed'); assert.equal(aircraftEmergency({ emergency: 'none' }), null); assert.equal(aircraftEmergency({ emergency: 'reserved' }), null); diff --git a/src/data/analystEngine.test.mjs b/src/data/analystEngine.test.mjs index 3476af7b4..3bb0dd06e 100644 --- a/src/data/analystEngine.test.mjs +++ b/src/data/analystEngine.test.mjs @@ -1,89 +1,18 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { - createAnalystEngine, - applyScope, - haversineKm, -} from './analystEngine.js'; +import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js'; // Stub world: a square "Texland" region, flights + ships + fires around it. -const TEXLAND = { - name: 'Texland', - ring: [ - [-100, 28], - [-94, 28], - [-94, 33], - [-100, 33], - ], -}; +const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] }; const FLIGHTS = [ - { - id: 'SWA1', - lat: 30.2, - lon: -97.7, - altitudeM: 11000, - speedMps: 240, - military: false, - onGround: false, - routeOrigin: 'AUS', - routeDestination: 'LAX', - }, - { - id: 'RCH01', - lat: 31.0, - lon: -97.0, - altitudeM: 13500, - speedMps: 250, - military: true, - onGround: false, - routeOrigin: null, - routeDestination: null, - }, - { - id: 'N123', - lat: 45.0, - lon: -122.0, - altitudeM: 2000, - speedMps: 80, - military: false, - onGround: false, - squawk: '7700', - emergency: true, - emergencyKind: 'general', - routeOrigin: null, - routeDestination: null, - }, - { - id: 'GND1', - lat: 30.19, - lon: -97.66, - altitudeM: 150, - speedMps: 5, - military: false, - onGround: true, - routeOrigin: null, - routeDestination: null, - }, + { id: 'SWA1', lat: 30.2, lon: -97.7, altitudeM: 11000, speedMps: 240, military: false, onGround: false, routeOrigin: 'AUS', routeDestination: 'LAX' }, + { id: 'RCH01', lat: 31.0, lon: -97.0, altitudeM: 13500, speedMps: 250, military: true, onGround: false, routeOrigin: null, routeDestination: null }, + { id: 'N123', lat: 45.0, lon: -122.0, altitudeM: 2000, speedMps: 80, military: false, onGround: false, squawk: '7700', emergency: true, emergencyKind: 'general', routeOrigin: null, routeDestination: null }, + { id: 'GND1', lat: 30.19, lon: -97.66, altitudeM: 150, speedMps: 5, military: false, onGround: true, routeOrigin: null, routeDestination: null }, ]; const SHIPS = [ - { - id: 'EVERGIVEN', - lat: 29.5, - lon: -94.9, - speedKts: 12, - shipType: 'Cargo', - destination: 'OAKLAND', - navStatus: 'under way', - }, - { - id: 'SLOWBOAT', - lat: 29.6, - lon: -95.0, - speedKts: 0.2, - shipType: 'Tanker', - destination: 'HOUSTON', - navStatus: 'anchored', - }, + { id: 'EVERGIVEN', lat: 29.5, lon: -94.9, speedKts: 12, shipType: 'Cargo', destination: 'OAKLAND', navStatus: 'under way' }, + { id: 'SLOWBOAT', lat: 29.6, lon: -95.0, speedKts: 0.2, shipType: 'Tanker', destination: 'HOUSTON', navStatus: 'anchored' }, ]; const FIRES = [ { id: 'FIRE-1', lat: 30.5, lon: -98.2, frp: 1500 }, @@ -93,10 +22,7 @@ const FIRES = [ function makeEngine() { return createAnalystEngine({ - getRecords: (key) => - ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES })[ - key - ] || [], + getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES }[key] || []), resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null), getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }), }); @@ -105,10 +31,7 @@ function makeEngine() { /** Same world, but Contacts is up with a subject far from the parked camera. */ function makeContactsEngine(subject) { return createAnalystEngine({ - getRecords: (key) => - ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES })[ - key - ] || [], + getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES }[key] || []), resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null), // Parked far away, as a high-altitude camera often is. getViewContext: () => ({ lat: 45.0, lon: -122.0, viewRadiusKm: 150 }), @@ -129,11 +52,7 @@ test('analyst: a radius query centers on the active contact, not the parked came assert.equal(r.ok, true); // Austin-area flights, not the Oregon one the camera is parked over. assert.equal(r.count, 3); - assert.equal( - r.centeredOn, - 'SWA1', - 'the answer names the centre it measured from', - ); + assert.equal(r.centeredOn, 'SWA1', 'the answer names the centre it measured from'); assert.ok(r.coverage.scope.includes('@SWA1')); }); @@ -144,11 +63,7 @@ test('analyst: an explicit center still wins over the active contact', async () scope: { kind: 'radius', km: 250, center: { lat: 45.0, lon: -122.0 } }, limit: 50, }); - assert.equal( - r.count, - 1, - 'only the Oregon flight is within 250 km of the given center', - ); + assert.equal(r.count, 1, 'only the Oregon flight is within 250 km of the given center'); assert.equal(r.centeredOn, undefined, 'an explicit center is not relabelled'); }); @@ -158,11 +73,7 @@ test('analyst: with Contacts off, radius still centers on the view', async () => scope: { kind: 'radius', km: 250 }, limit: 50, }); - assert.equal( - r.count, - 1, - 'view-centred behaviour is unchanged outside Contacts', - ); + assert.equal(r.count, 1, 'view-centred behaviour is unchanged outside Contacts'); assert.equal(r.centeredOn, undefined); assert.equal(r.coverage.scope, 'radius:250km'); }); @@ -172,25 +83,13 @@ test('analyst: a subject without usable coordinates cannot lend its name to a ca // number. A subject present but position-less fell back to the camera and // kept the contact's name on the answer, so a camera-centred count read as // contact-centred with nothing in the payload to catch it. - const r = await makeContactsEngine({ - lat: null, - lon: null, - label: 'SWA1', - }).query({ + const r = await makeContactsEngine({ lat: null, lon: null, label: 'SWA1' }).query({ layers: ['flights'], scope: { kind: 'radius', km: 250 }, limit: 50, }); - assert.equal( - r.count, - 1, - 'the count is the camera-centred one it actually measured', - ); - assert.equal( - r.centeredOn, - undefined, - 'and it must not claim a centre it did not use', - ); + assert.equal(r.count, 1, 'the count is the camera-centred one it actually measured'); + assert.equal(r.centeredOn, undefined, 'and it must not claim a centre it did not use'); assert.equal(r.coverage.scope, 'radius:250km'); assert.equal(r.scopeLabel, 'within 250 km'); }); @@ -200,47 +99,33 @@ test('analyst: every scope names itself in words', async () => { // answers look like a contradiction, so each scope carries its own phrasing. const subject = { lat: 30.2, lon: -97.7, label: 'DYNO11' }; const centred = await makeContactsEngine(subject).query({ - layers: ['flights'], - scope: { kind: 'radius', km: 250 }, - limit: 1, + layers: ['flights'], scope: { kind: 'radius', km: 250 }, limit: 1, }); assert.equal(centred.scopeLabel, 'within 250 km of DYNO11'); const plainRadius = await makeContactsEngine(null).query({ - layers: ['flights'], - scope: { kind: 'radius', km: 250 }, - limit: 1, + layers: ['flights'], scope: { kind: 'radius', km: 250 }, limit: 1, }); assert.equal(plainRadius.scopeLabel, 'within 250 km'); const inView = await makeEngine().query({ - layers: ['flights'], - scope: { kind: 'view' }, - limit: 1, + layers: ['flights'], scope: { kind: 'view' }, limit: 1, }); assert.equal(inView.scopeLabel, 'in view'); const region = await makeEngine().query({ - layers: ['flights'], - scope: { kind: 'region', name: 'Texland' }, - limit: 1, + layers: ['flights'], scope: { kind: 'region', name: 'Texland' }, limit: 1, }); assert.equal(region.scopeLabel, 'over Texland'); const anywhere = await makeEngine().query({ - layers: ['flights'], - scope: { kind: 'anywhere' }, - limit: 1, + layers: ['flights'], scope: { kind: 'anywhere' }, limit: 1, }); assert.equal(anywhere.scopeLabel, 'anywhere in the loaded data'); }); test('analyst: count flights over a region', async () => { - const r = await makeEngine().query({ - layers: ['flights'], - scope: { kind: 'region', name: 'Texland' }, - limit: 50, - }); + const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' }, limit: 50 }); assert.equal(r.ok, true); assert.equal(r.count, 3, 'Oregon flight excluded'); assert.ok(r.coverage.scope.includes('Texland')); @@ -248,65 +133,32 @@ test('analyst: count flights over a region', async () => { test('analyst: attribute filter — above 40,000 ft (~12,192 m)', async () => { const r = await makeEngine().query({ - layers: ['flights'], - scope: { kind: 'anywhere' }, + layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM', op: 'gt', value: 12192 }], }); - assert.deepEqual( - r.items.map((i) => i.id), - ['RCH01'], - ); + assert.deepEqual(r.items.map((i) => i.id), ['RCH01']); }); test('analyst: military flag + region compose', async () => { const r = await makeEngine().query({ - layers: ['flights'], - scope: { kind: 'region', name: 'Texland' }, + layers: ['flights'], scope: { kind: 'region', name: 'Texland' }, filters: [{ field: 'military', op: 'eq', value: true }], }); assert.equal(r.count, 1); assert.equal(r.items[0].id, 'RCH01'); }); -test('analyst: aircraft broadcasting an emergency are filterable by flag and code', async () => { - const flagged = await makeEngine().query({ - layers: ['flights'], - scope: { kind: 'anywhere' }, - filters: [{ field: 'emergency', op: 'eq', value: true }], - }); - assert.deepEqual( - flagged.items.map((i) => i.id), - ['N123'], - ); - const coded = await makeEngine().query({ - layers: ['flights'], - scope: { kind: 'anywhere' }, - filters: [{ field: 'squawk', op: 'eq', value: '7700' }], - }); - assert.deepEqual( - coded.items.map((i) => i.id), - ['N123'], - ); -}); - test('analyst: ships headed to Oakland (destination contains)', async () => { const r = await makeEngine().query({ - layers: ['ais-live-vessels'], - scope: { kind: 'anywhere' }, + layers: ['ais-live-vessels'], scope: { kind: 'anywhere' }, filters: [{ field: 'destination', op: 'contains', value: 'oakland' }], }); - assert.deepEqual( - r.items.map((i) => i.id), - ['EVERGIVEN'], - ); + assert.deepEqual(r.items.map((i) => i.id), ['EVERGIVEN']); }); test('analyst: superlative — biggest fire in view radius', async () => { const r = await makeEngine().query({ - layers: ['local-firms'], - scope: { kind: 'view' }, - sortBy: 'frp', - limit: 1, + layers: ['local-firms'], scope: { kind: 'view' }, sortBy: 'frp', limit: 1, }); assert.equal(r.items[0].id, 'FIRE-1', 'BC monster is out of view scope'); assert.equal(r.summary.frpMax, 1500); @@ -314,10 +166,7 @@ test('analyst: superlative — biggest fire in view radius', async () => { test('analyst: nearest sorting attaches distanceKm ascending', async () => { const r = await makeEngine().query({ - layers: ['flights'], - scope: { kind: 'view' }, - sortBy: 'distance', - limit: 3, + layers: ['flights'], scope: { kind: 'view' }, sortBy: 'distance', limit: 3, }); assert.ok(r.items[0].distanceKm <= r.items[1].distanceKm); assert.ok(Number.isFinite(r.items[0].distanceKm)); @@ -417,10 +266,7 @@ test('analyst: failed pre-commit work cannot replace follow-up memory', async () }); test('analyst: unresolved region is an honest failure, not empty success', async () => { - const r = await makeEngine().query({ - layers: ['flights'], - scope: { kind: 'region', name: 'Atlantis' }, - }); + const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'region', name: 'Atlantis' } }); assert.equal(r.ok, false); assert.match(r.error, /Atlantis/); }); @@ -428,17 +274,10 @@ test('analyst: unresolved region is an honest failure, not empty success', async test('analyst: a region lookup timeout is reported as region-timeout', async () => { const eng = createAnalystEngine({ getRecords: () => FLIGHTS, - resolveRegionRing: async (name) => ({ - name, - ring: null, - error: 'region-timeout', - }), + resolveRegionRing: async (name) => ({ name, ring: null, error: 'region-timeout' }), getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }), }); - const r = await eng.query({ - layers: ['flights'], - scope: { kind: 'region', name: 'Texas' }, - }); + const r = await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } }); assert.equal(r.ok, false); assert.equal(r.code, 'region-timeout'); assert.match(r.error, /Texas/); @@ -480,84 +319,45 @@ test('analyst: region lookup receives and obeys the owning action signal', async test('analyst: route fields queryable from cached enrichment only', async () => { const r = await makeEngine().query({ - layers: ['flights'], - scope: { kind: 'anywhere' }, + layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'routeDestination', op: 'eq', value: 'LAX' }], }); - assert.deepEqual( - r.items.map((i) => i.id), - ['SWA1'], - 'null route fields never match', - ); + assert.deepEqual(r.items.map((i) => i.id), ['SWA1'], 'null route fields never match'); +}); + +test('analyst: aircraft broadcasting an emergency are filterable by flag and code', async () => { + const flagged = await makeEngine().query({ + layers: ['flights'], scope: { kind: 'anywhere' }, + filters: [{ field: 'emergency', op: 'eq', value: true }], + }); + assert.deepEqual(flagged.items.map((i) => i.id), ['N123']); + const coded = await makeEngine().query({ + layers: ['flights'], scope: { kind: 'anywhere' }, + filters: [{ field: 'squawk', op: 'eq', value: '7700' }], + }); + assert.deepEqual(coded.items.map((i) => i.id), ['N123']); }); test('analyst: satellites and local infrastructure are queryable layers', async () => { const SATS = [ - { - id: 'ISS (ZARYA)', - noradId: '25544', - name: 'ISS (ZARYA)', - lat: 30.3, - lon: -97.7, - altitudeM: 410000, - satelliteClass: 'STATION · ISS', - group: 'stations', - }, - { - id: 'GPS BIIR-2', - noradId: '24876', - name: 'GPS BIIR-2', - lat: 51.0, - lon: 0.0, - altitudeM: 20200000, - satelliteClass: 'NAV · GPS', - group: 'gps-ops', - }, + { id: 'ISS (ZARYA)', noradId: '25544', name: 'ISS (ZARYA)', lat: 30.3, lon: -97.7, altitudeM: 410000, satelliteClass: 'STATION · ISS', group: 'stations' }, + { id: 'GPS BIIR-2', noradId: '24876', name: 'GPS BIIR-2', lat: 51.0, lon: 0.0, altitudeM: 20200000, satelliteClass: 'NAV · GPS', group: 'gps-ops' }, ]; const DAMS = [ - { - id: 'Austin Dam', - name: 'Austin Dam', - lat: 30.27, - lon: -97.74, - operator: 'LCRA', - river: 'Colorado', - output: '2 MW', - }, - { - id: 'Far Dam', - name: 'Far Dam', - lat: 45.0, - lon: -122.0, - operator: 'USACE', - river: 'Columbia', - output: '1000 MW', - }, + { id: 'Austin Dam', name: 'Austin Dam', lat: 30.27, lon: -97.74, operator: 'LCRA', river: 'Colorado', output: '2 MW' }, + { id: 'Far Dam', name: 'Far Dam', lat: 45.0, lon: -122.0, operator: 'USACE', river: 'Columbia', output: '1000 MW' }, ]; const DCS = [ - { - id: 'AUS-1', - name: 'AUS-1', - lat: 30.28, - lon: -97.75, - operator: 'Example Cloud', - capacity: '27 MW', - }, + { id: 'AUS-1', name: 'AUS-1', lat: 30.28, lon: -97.75, operator: 'Example Cloud', capacity: '27 MW' }, ]; const eng = createAnalystEngine({ - getRecords: (key) => - ({ satellites: SATS, 'local-dams': DAMS, 'local-datacenters': DCS })[ - key - ] || [], + getRecords: (key) => ({ satellites: SATS, 'local-dams': DAMS, 'local-datacenters': DCS }[key] || []), resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null), getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }), }); const sats = await eng.query({ - layers: ['satellites'], - scope: { kind: 'view' }, - sortBy: 'distance', - limit: 2, + layers: ['satellites'], scope: { kind: 'view' }, sortBy: 'distance', limit: 2, }); assert.equal(sats.ok, true); assert.equal(sats.count, 1, 'GPS sat is out of the Austin view radius'); @@ -565,28 +365,20 @@ test('analyst: satellites and local infrastructure are queryable layers', async assert.ok(Number.isFinite(sats.items[0].distanceKm)); const nav = await eng.query({ - layers: ['satellites'], - scope: { kind: 'anywhere' }, + layers: ['satellites'], scope: { kind: 'anywhere' }, filters: [{ field: 'satelliteClass', op: 'contains', value: 'NAV' }], }); - assert.deepEqual( - nav.items.map((i) => i.id), - ['GPS BIIR-2'], - ); + assert.deepEqual(nav.items.map((i) => i.id), ['GPS BIIR-2']); const dams = await eng.query({ - layers: ['local-dams'], - scope: { kind: 'view' }, - sortBy: 'distance', - limit: 5, + layers: ['local-dams'], scope: { kind: 'view' }, sortBy: 'distance', limit: 5, }); assert.equal(dams.count, 1); assert.equal(dams.items[0].id, 'Austin Dam'); assert.equal(dams.items[0].river, 'Colorado'); const dcs = await eng.query({ - layers: ['local-datacenters'], - scope: { kind: 'region', name: 'Texland' }, + layers: ['local-datacenters'], scope: { kind: 'region', name: 'Texland' }, filters: [{ field: 'operator', op: 'contains', value: 'cloud' }], }); assert.equal(dcs.count, 1); @@ -596,11 +388,7 @@ test('analyst: satellites and local infrastructure are queryable layers', async test('helpers: haversine sanity + scope radius', () => { const km = haversineKm(30.2672, -97.7431, 29.7604, -95.3698); // Austin→Houston assert.ok(km > 200 && km < 280, `Austin-Houston ~235km, got ${km}`); - const scoped = applyScope( - FLIGHTS, - { kind: 'radius' }, - { center: { lat: 30.27, lon: -97.74 }, km: 50 }, - ); + const scoped = applyScope(FLIGHTS, { kind: 'radius' }, { center: { lat: 30.27, lon: -97.74 }, km: 50 }); assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']); }); @@ -610,11 +398,7 @@ test('capped cohorts report returned/total/truncated and keep it on follow-up', getRecordCoverage: () => ({ total: 3000, truncated: true }), getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }), }); - const result = await engine.query({ - layers: ['local-dams'], - scope: { kind: 'anywhere' }, - sortBy: 'distance', - }); + const result = await engine.query({ layers: ['local-dams'], scope: { kind: 'anywhere' }, sortBy: 'distance' }); assert.equal(result.count, 1); const layer = result.coverage.layersQueried[0]; assert.deepEqual({ returned: layer.returned, total: layer.total, truncated: layer.truncated }, { returned: 1, total: 3000, truncated: true }); @@ -735,15 +519,12 @@ test('time fields filter by ISO and derive ageHours; the feed window is exposed' assert.equal(age.coverage.layersQueried[0].window, 'last 24 h'); }); + test('follow-up provenance stays attached to old rows after a feed recovers', async () => { let state = 'stale'; const engine = createAnalystEngine({ getRecords: () => [{ id: 'A', lat: 0, lon: 0 }], - getLayerSnapshot: () => ({ - id: 'flights', - enabled: true, - feedState: state, - }), + getLayerSnapshot: () => ({ id: 'flights', enabled: true, feedState: state }), getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }), }); const first = await engine.query(); @@ -753,10 +534,7 @@ test('follow-up provenance stays attached to old rows after a feed recovers', as assert.equal(followUp.coverage.feedProvenance.overall, 'stale'); engine.reset(); assert.equal(engine.hasMemory(), false); - assert.equal( - (await engine.query()).coverage.feedProvenance.overall, - 'nominal', - ); + assert.equal((await engine.query()).coverage.feedProvenance.overall, 'nominal'); }); test('a large loaded set sorts and summarizes without overflowing the stack', async () => { diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index 740f1afcc..0e7021251 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -18,10 +18,8 @@ import flightsLayer, { } from './flights.js'; import { setMilitaryLayerActive } from './militaryRegistry.js'; import { - GROUND_FLOOR_LIFT_M, - reportMeshFloorCell, - setMeshFloorPreferred, - _clearMeshFloorCellsForTest, + GROUND_FLOOR_LIFT_M, reportMeshFloorCell, + setMeshFloorPreferred, _clearMeshFloorCellsForTest, } from './groundFloor.js'; import { _setTrackedOverlayHostForTest, @@ -35,10 +33,7 @@ test('share-Follow absence requires an accepted OpenSky snapshot', async () => { (await flightsLayer.resolveTrackingRestoreTarget('abc123')).status, 'source-unavailable', ); - _setFlightTrackingRefreshOutcomeForTest({ - status: 'accepted', - ids: ['different'], - }); + _setFlightTrackingRefreshOutcomeForTest({ status: 'accepted', ids: ['different'] }); const missing = await flightsLayer.resolveTrackingRestoreTarget('abc123'); assert.equal(missing.status, 'missing'); assert.equal(missing.reason, 'target-absent-from-snapshot'); @@ -60,10 +55,7 @@ const FULL_INFO = { }; test('flights analyst record: full record maps every contract field', () => { - const r = mapAnalystRecord('a1b2c3', FULL_INFO, { - military: false, - routeOk: true, - }); + const r = mapAnalystRecord('a1b2c3', FULL_INFO, { military: false, routeOk: true }); assert.deepEqual(r, { id: 'SWA696', icao24: 'a1b2c3', @@ -101,8 +93,7 @@ test('flights analyst record: empty info yields nulls, never NaN/undefined', () assert.equal(r.military, false); for (const [key, value] of Object.entries(r)) { assert.notEqual(value, undefined, `${key} must not be undefined`); - if (typeof value === 'number') - assert.ok(Number.isFinite(value), `${key} must not be NaN`); + if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`); } }); @@ -110,31 +101,14 @@ test('flights analyst record: no callsign falls back to registration, then icao2 const r = mapAnalystRecord('abc123', { ...FULL_INFO, callsign: ' ' }); assert.equal(r.id, 'abc123'); // FULL_INFO carries no registration assert.equal(r.callsign, null); - assert.equal( - mapAnalystRecord('abc123', { - ...FULL_INFO, - callsign: '', - registration: 'N123AB', - }).id, - 'N123AB', - ); - assert.equal( - mapAnalystRecord('abc123', { callsign: '', registration: ' ' }).id, - 'abc123', - ); + assert.equal(mapAnalystRecord('abc123', { ...FULL_INFO, callsign: '', registration: 'N123AB' }).id, 'N123AB'); + assert.equal(mapAnalystRecord('abc123', { callsign: '', registration: ' ' }).id, 'abc123'); }); test('flights analyst record: NaN kinematics become null, military flag passes through', () => { - const r = mapAnalystRecord( - 'ae01ce', - { - ...FULL_INFO, - velocity: NaN, - true_track: undefined, - verticalRate: null, - }, - { military: true }, - ); + const r = mapAnalystRecord('ae01ce', { + ...FULL_INFO, velocity: NaN, true_track: undefined, verticalRate: null, + }, { military: true }); assert.equal(r.speedMps, null); assert.equal(r.heading, null); assert.equal(r.verticalRateMps, null); @@ -149,25 +123,17 @@ test('flights analyst record: output is JSON-safe (no Cesium types)', () => { test('flights first update forwards caller cancellation into the feed request', async () => { const realFetch = globalThis.fetch; let observedSignal = null; - globalThis.fetch = (_url, options = {}) => - new Promise((_resolve, reject) => { - observedSignal = options.signal; - options.signal?.addEventListener( - 'abort', - () => { - reject(new DOMException('aborted', 'AbortError')); - }, - { once: true }, - ); - }); + globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => { + observedSignal = options.signal; + options.signal?.addEventListener('abort', () => { + reject(new DOMException('aborted', 'AbortError')); + }, { once: true }); + }); try { const controller = new AbortController(); - const work = flightsLayer.update( - { camera: { positionCartographic: null }, scene: {} }, - { - signal: controller.signal, - }, - ); + const work = flightsLayer.update({ camera: { positionCartographic: null }, scene: {} }, { + signal: controller.signal, + }); await Promise.resolve(); assert.ok(observedSignal); controller.abort(); @@ -192,27 +158,14 @@ test('nonempty OpenSky payload with zero usable rows cannot prove share target a ok: true, status: 200, headers: { get: () => null }, - json: async () => ({ - states: [null, {}, ['abc123', null, null, null, null, null, null]], - }), + json: async () => ({ states: [null, {}, ['abc123', null, null, null, null, null, null]] }), }); try { - await flightsLayer.update({ - camera: { positionCartographic: null }, - scene: {}, - }); - const resolution = - await flightsLayer.resolveTrackingRestoreTarget('abc123'); + await flightsLayer.update({ camera: { positionCartographic: null }, scene: {} }); + const resolution = await flightsLayer.resolveTrackingRestoreTarget('abc123'); assert.equal(resolution.status, 'source-unavailable'); - assert.match( - flightsLayer.getStats().error, - /Malformed OpenSky aircraft rows/, - ); - assert.equal( - flightsLayer.getAnalystRecords().length, - 1, - 'warm aircraft data is preserved', - ); + assert.match(flightsLayer.getStats().error, /Malformed OpenSky aircraft rows/); + assert.equal(flightsLayer.getAnalystRecords().length, 1, 'warm aircraft data is preserved'); } finally { globalThis.fetch = realFetch; } @@ -350,31 +303,13 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA if (!String(url).startsWith('/api/flights')) { return { ok: true, status: 200, json: async () => ({ ac: [] }) }; } - const states = - openskyPoll++ === 0 - ? [ - [ - icao24, - 'DAL123 ', - 'United States', - nowSec, - nowSec, - -97.6, - 30.3, - 10_668, - false, - 250, - 95, - 5, - null, - 10_700, - null, - null, - null, - 5, - ], - ] - : []; + const states = openskyPoll++ === 0 + ? [[ + icao24, 'DAL123 ', 'United States', nowSec, nowSec, + -97.6, 30.3, 10_668, false, 250, 95, 5, null, 10_700, + null, null, null, 5, + ]] + : []; return { ok: true, status: 200, @@ -413,18 +348,14 @@ test('real civil track path creates no native label and publishes every cached h trackedEntityChanged, clock: { currentTime: now }, camera: { - cancelFlight() { - cancelledFlights += 1; - }, + cancelFlight() { cancelledFlights += 1; }, position: new Cesium.Cartesian3(0, -10_000, 7_000), positionWC: new Cesium.Cartesian3(0, -10_000, 7_000), direction: Cesium.Cartesian3.UNIT_Y, transform: Cesium.Matrix4.IDENTITY, viewMatrix: Cesium.Matrix4.IDENTITY, frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY }, - lookAtTransform() { - appliedFrames += 1; - }, + lookAtTransform() { appliedFrames += 1; }, }, scene: { canvas: { clientWidth: 1600, clientHeight: 900 }, @@ -463,12 +394,9 @@ test('real civil track path creates no native label and publishes every cached h const realFetch = globalThis.fetch; globalThis.window = new EventTarget(); const selectionEvents = []; - globalThis.window.addEventListener( - 'gev:awareness-subject-selected', - (event) => { - selectionEvents.push(event.detail); - }, - ); + globalThis.window.addEventListener('gev:awareness-subject-selected', (event) => { + selectionEvents.push(event.detail); + }); globalThis.fetch = async () => ({ ok: false }); _setTrackedOverlayHostForTest(host); try { @@ -480,15 +408,13 @@ test('real civil track path creates no native label and publishes every cached h billboardCollection: { show: true, remove() {} }, viewer, tracked: false, - history: [ - { - time: now, - epochMs: Date.now(), - position, - velocity: 250, - track: 95, - }, - ], + history: [{ + time: now, + epochMs: Date.now(), + position, + velocity: 250, + track: 95, + }], meta: { callsign: 'N12345', altitude: 10_668, @@ -511,19 +437,11 @@ test('real civil track path creates no native label and publishes every cached h }, }); - assert.equal( - flightsLayer.trackById(icao24, { origin: 'programmatic' }), - true, - ); + assert.equal(flightsLayer.trackById(icao24, { origin: 'programmatic' }), true); const entity = viewer.trackedEntity; - assert.ok( - entity instanceof Cesium.Entity, - 'trackById must create the real Cesium entity', - ); + assert.ok(entity instanceof Cesium.Entity, 'trackById must create the real Cesium entity'); assert.equal(entity.label, undefined); - assert.ok( - entities.values.every((candidate) => candidate.label === undefined), - ); + assert.ok(entities.values.every((candidate) => candidate.label === undefined)); assert.deepEqual(entity.gevLabelModel, { title: 'N12345 · FL350 · 486 kts', details: ['TEST AIR · A320', 'AUS → LAX'], @@ -533,44 +451,17 @@ test('real civil track path creates no native label and publishes every cached h const initialAppliedFrames = appliedFrames; const initialCancelledFlights = cancelledFlights; assert.equal(flightsLayer.trackById(icao24, { origin: 'user' }), true); - assert.equal( - cancelledFlights, - initialCancelledFlights, - 'ordinary repeated tracking stays camera-idempotent', - ); - assert.equal( - selectionEvents.at(-1)?.origin, - 'user', - 'same-target selection upgrades durable authority', - ); + assert.equal(cancelledFlights, initialCancelledFlights, 'ordinary repeated tracking stays camera-idempotent'); + assert.equal(selectionEvents.at(-1)?.origin, 'user', 'same-target selection upgrades durable authority'); assert.equal(entity.gevSelectionOrigin, 'user'); assert.equal(flightsLayer.refocusTrackedById('different-flight'), false); - assert.equal( - flightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), - true, - ); - assert.equal( - selectionEvents.at(-1)?.origin, - 'voice', - 'same-target refocus forwards explicit authority', - ); + assert.equal(flightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), true); + assert.equal(selectionEvents.at(-1)?.origin, 'voice', 'same-target refocus forwards explicit authority'); assert.equal(flightsLayer.refocusTrackedById(icao24), true); viewer.scene.preUpdate.raiseEvent(); - assert.equal( - viewer.trackedEntity, - entity, - 'refocus must retain the exact tracked entity', - ); - assert.equal( - appliedFrames, - initialAppliedFrames + 1, - 'repeated refocus keeps one camera-frame owner', - ); - assert.equal( - cancelledFlights, - initialCancelledFlights + 2, - 'only explicit refocus requests cancel camera flights', - ); + assert.equal(viewer.trackedEntity, entity, 'refocus must retain the exact tracked entity'); + assert.equal(appliedFrames, initialAppliedFrames + 1, 'repeated refocus keeps one camera-frame owner'); + assert.equal(cancelledFlights, initialCancelledFlights + 2, 'only explicit refocus requests cancel camera flights'); const publication = publications.at(-1); assert.equal(publication.sourceId, 'tracked'); const entry = publication.entries[0]; @@ -581,11 +472,7 @@ test('real civil track path creates no native label and publishes every cached h const display = entity.position.getValue(now); assert.ok(display, 'tracked position callback must seed its frame cache'); - assert.equal( - entry.position(), - display, - 'host must reuse the exact cached display Cartesian', - ); + assert.equal(entry.position(), display, 'host must reuse the exact cached display Cartesian'); flightsLayer.setParams( { selectedFlightsTrackingId: 'late001' }, @@ -593,17 +480,10 @@ test('real civil track path creates no native label and publishes every cached h ); assert.equal(_pendingFlightTrackingRestoreForTest(), 'late001'); assert.equal(flightsLayer.trackById(icao24, { origin: 'user' }), true); - assert.equal( - _pendingFlightTrackingRestoreForTest(), - null, - 'new user selection cancels stale restore', - ); + assert.equal(_pendingFlightTrackingRestoreForTest(), null, 'new user selection cancels stale restore'); _addFlightTrackingCandidateForTest({ icao24: 'late001', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-97.5, 30.4, 9_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.5, 30.4, 9_000) }, meta: { ...FULL_INFO, callsign: 'LATE1', rawLat: 30.4, rawLon: -97.5 }, history: [], }); @@ -617,17 +497,10 @@ test('real civil track path creates no native label and publishes every cached h ); assert.equal(_pendingFlightTrackingRestoreForTest(), 'late002'); flightsLayer.stopTracking({ origin: 'user' }); - assert.equal( - _pendingFlightTrackingRestoreForTest(), - null, - 'explicit clear cancels stale restore', - ); + assert.equal(_pendingFlightTrackingRestoreForTest(), null, 'explicit clear cancels stale restore'); _addFlightTrackingCandidateForTest({ icao24: 'late002', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-97.4, 30.5, 8_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.4, 30.5, 8_000) }, meta: { ...FULL_INFO, callsign: 'LATE2', rawLat: 30.5, rawLon: -97.4 }, history: [], }); @@ -647,10 +520,7 @@ test('real civil track path creates no native label and publishes every cached h ); _addFlightTrackingCandidateForTest({ icao24: 'late003', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000) }, meta: { ...FULL_INFO, callsign: 'LATE3', rawLat: 30.6, rawLon: -97.3 }, history: [], }); @@ -664,10 +534,7 @@ test('real civil track path creates no native label and publishes every cached h assert.equal(_pendingFlightTrackingRestoreForTest(), 'late004'); _addFlightTrackingCandidateForTest({ icao24: 'late004', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000) }, meta: { ...FULL_INFO, callsign: 'LATE4', rawLat: 30.7, rawLon: -97.2 }, history: [], }); @@ -678,10 +545,7 @@ test('real civil track path creates no native label and publishes every cached h { selectedFlightsTrackingId: 'late005' }, { origin: 'share-restore' }, ); - assert.equal( - flightsLayer.trackById(icao24, { origin: 'programmatic' }), - true, - ); + assert.equal(flightsLayer.trackById(icao24, { origin: 'programmatic' }), true); assert.equal( _pendingFlightTrackingRestoreForTest(), 'late005', @@ -689,10 +553,7 @@ test('real civil track path creates no native label and publishes every cached h ); _addFlightTrackingCandidateForTest({ icao24: 'late005', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-97.1, 30.8, 5_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.1, 30.8, 5_000) }, meta: { ...FULL_INFO, callsign: 'LATE5', rawLat: 30.8, rawLon: -97.1 }, history: [], }); @@ -727,11 +588,7 @@ function seedLabelContact({ callsign, registration, tracked = false }) { _setTrackedFlightRefreshStateForTest({ icao24: LABEL_ICAO, entity: null, - billboard: { - position: LABEL_POSITION, - color: Cesium.Color.WHITE, - show: true, - }, + billboard: { position: LABEL_POSITION, color: Cesium.Color.WHITE, show: true }, billboardCollection: { show: true, remove() {} }, viewer: { camera: { positionCartographic: null }, scene: {} }, tracked, @@ -789,11 +646,7 @@ test('civil label chain: a blank callsign falls back to the registration, not th const labels = labelsFor({ callsign: ' ', registration: 'N123AB ' }); for (const [surface, value] of Object.entries(labels)) { if (surface === '_identity') continue; - assert.equal( - value, - 'N123AB', - `${surface} must show the registration, not ${LABEL_ICAO}`, - ); + assert.equal(value, 'N123AB', `${surface} must show the registration, not ${LABEL_ICAO}`); } }); @@ -802,11 +655,7 @@ test('civil label chain: no callsign and no registration still reads as the ICAO const labels = labelsFor({ callsign: null, registration }); for (const [surface, value] of Object.entries(labels)) { if (surface === '_identity') continue; - assert.equal( - value, - LABEL_ICAO, - `${surface} must fall through to the hex`, - ); + assert.equal(value, LABEL_ICAO, `${surface} must fall through to the hex`); } } }); @@ -830,11 +679,7 @@ test('civil label chain: identity stays icao24 while the label moves', () => { ]) { const { _identity } = labelsFor(fixture); for (const [key, value] of Object.entries(_identity)) { - assert.equal( - value, - LABEL_ICAO, - `${key} identity must remain the ICAO hex`, - ); + assert.equal(value, LABEL_ICAO, `${key} identity must remain the ICAO hex`); } } }); @@ -881,11 +726,7 @@ test('civil label chain: the awareness selection EVENT uses the canonical chain' `selection event label for ${JSON.stringify(fixture)}`, ); // Identity must NOT move with the label. - assert.equal( - detail.id, - LABEL_ICAO, - 'selection event identity stays the ICAO hex', - ); + assert.equal(detail.id, LABEL_ICAO, 'selection event identity stays the ICAO hex'); assert.equal(detail.layerId, 'flights'); } }); @@ -896,11 +737,7 @@ test('civil tracked readout: a callsign-less enriched contact reads as its regis _setTrackedFlightRefreshStateForTest({ icao24: LABEL_ICAO, entity, - billboard: { - position: LABEL_POSITION, - color: Cesium.Color.WHITE, - show: false, - }, + billboard: { position: LABEL_POSITION, color: Cesium.Color.WHITE, show: false }, billboardCollection: { show: false, remove() {} }, viewer: { camera: { positionCartographic: null }, scene: {} }, meta: { @@ -921,16 +758,10 @@ test('civil tracked readout: a callsign-less enriched contact reads as its regis const realFetch = globalThis.fetch; globalThis.fetch = async () => ({ - ok: true, - status: 200, - headers: { get: () => null }, - json: async () => ({ time: 0, states: [] }), + ok: true, status: 200, headers: { get: () => null }, json: async () => ({ time: 0, states: [] }), }); try { - await flightsLayer.update({ - camera: { positionCartographic: null }, - scene: {}, - }); + await flightsLayer.update({ camera: { positionCartographic: null }, scene: {} }); assert.match(entity.gevLabelModel.title, /^N123AB\b/); assert.doesNotMatch( [entity.gevLabelModel.title, ...entity.gevLabelModel.details].join(' · '), @@ -1010,8 +841,7 @@ test('military suppression exempts the contact this layer is still restoring', ( // contacts only, never when a 3D model owns the visual (T7 one-shot). // --------------------------------------------------------------------------- -const _floorCarto = (pos) => - Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84); +const _floorCarto = (pos) => Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84); test('a newly seen taxiing aircraft starts on the floor under its delayed position', async () => { // A grounded contact is drawn at its delayed position lifted onto the floor @@ -1116,16 +946,10 @@ test('display floor: a grounded contact that drifted onto a higher cell is lifte // Fix cell floor was 124.7; the display has taxied into a 140.2 m cell. reportMeshFloorCell(30.2004, -97.6604, 140.2); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - ); + const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false); const c = _floorCarto(out); - assert.ok( - Math.abs(c.height - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05, - `height ${c.height} should be 140.2 + lift`, - ); + assert.ok(Math.abs(c.height - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05, + `height ${c.height} should be 140.2 + lift`); // Lifted straight up — the drifted lat/lon is preserved exactly. assert.ok(Math.abs(Cesium.Math.toDegrees(c.latitude) - 30.2004) < 1e-6); assert.ok(Math.abs(Cesium.Math.toDegrees(c.longitude) + 97.6604) < 1e-6); @@ -1137,11 +961,7 @@ test('display floor: an AIRBORNE contact over the same cell is never lifted', () setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140.2); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest( - { onGround: false }, - pos, - false, - ); + const out = _floorGroundedDisplayPositionForTest({ onGround: false }, pos, false); assert.equal(out, pos, 'airborne display positions pass through untouched'); }); @@ -1151,16 +971,8 @@ test('display floor: T7 — a model-owned grounded contact keeps its billboard d setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140.2); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - true, - ); - assert.equal( - out, - pos, - 'moving it would drag groundSnap past its 50 m re-sample threshold', - ); + const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, true); + assert.equal(out, pos, 'moving it would drag groundSnap past its 50 m re-sample threshold'); }); test('display floor: a contact already above the floor is passed through unchanged', () => { @@ -1169,11 +981,7 @@ test('display floor: a contact already above the floor is passed through unchang setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 124.7); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 140.2); - const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - ); + const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false); assert.equal(out, pos, 'no rebuild when nothing needs lifting'); }); @@ -1182,11 +990,7 @@ test('display floor: a cold display cell leaves the contact where it is', () => _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - ); + const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false); assert.equal(out, pos, 'no floor data → no clamp (never invent a surface)'); }); @@ -1197,11 +1001,7 @@ test('display floor: mesh cells are ignored outside the google-3d regime', () => reportMeshFloorCell(30.2004, -97.6604, 140.2); setMeshFloorPreferred(false); // globe stack: the DEM IS the rendered terrain const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - ); + const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false); assert.equal(out, pos); setMeshFloorPreferred(true); }); @@ -1217,23 +1017,14 @@ test('display floor: jitter across a cell edge does not alternate the floor', () reportMeshFloorCell(30.2014, -97.6604, 146); const at = (lat) => { const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, lat, 120), - false, - 'jitter', + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, lat, 120), false, 'jitter', ); return _floorCarto(out).height; }; const settled = at(30.2004); // squarely inside the low cell - assert.ok( - Math.abs(settled - (140 + GROUND_FLOOR_LIFT_M)) < 0.05, - `settled ${settled}`, - ); + assert.ok(Math.abs(settled - (140 + GROUND_FLOOR_LIFT_M)) < 0.05, `settled ${settled}`); // 30.2006 belongs to the HIGH cell but is only ~11 m past the boundary. - assert.ok( - Math.abs(at(30.2006) - settled) < 0.05, - 'held the low cell across the edge', - ); + assert.ok(Math.abs(at(30.2006) - settled) < 0.05, 'held the low cell across the edge'); assert.ok(Math.abs(at(30.2004) - settled) < 0.05, 'and back again — no pop'); }); @@ -1243,21 +1034,12 @@ test('display floor: a real crossing does adopt the next cell', () => { setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140); reportMeshFloorCell(30.2014, -97.6604, 146); - const at = (lat) => - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, lat, 120), - false, - 'crossing', - ), - ).height; + const at = (lat) => _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, lat, 120), false, 'crossing', + )).height; at(30.2004); const crossed = at(30.2014); // properly into the next cell - assert.ok( - Math.abs(crossed - (146 + GROUND_FLOOR_LIFT_M)) < 0.05, - `crossed ${crossed}`, - ); + assert.ok(Math.abs(crossed - (146 + GROUND_FLOOR_LIFT_M)) < 0.05, `crossed ${crossed}`); }); // --- F7: no rebuild when nothing changed ----------------------------------- @@ -1272,28 +1054,10 @@ test('display floor: a clamped stationary contact reuses its previous output', ( setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 120); - const first = _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'parked', - ); - const second = _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'parked', - ); - assert.notEqual( - first, - pos, - 'it really is clamped (not the pass-through case)', - ); - assert.equal( - second, - first, - 'same input + same floor ⇒ the very same object, no rebuild', - ); + const first = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'parked'); + const second = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'parked'); + assert.notEqual(first, pos, 'it really is clamped (not the pass-through case)'); + assert.equal(second, first, 'same input + same floor ⇒ the very same object, no rebuild'); }); test('display floor: the cached output is dropped when the floor changes under it', () => { @@ -1302,31 +1066,14 @@ test('display floor: the cached output is dropped when the floor changes under i setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 120); - const before = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'warming', - ), - ).height; + const before = _floorCarto(_floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'warming')).height; // A late mesh sample raises this cell (a fresh module state stands in for the // one-shot latch, which never overwrites). _clearMeshFloorCellsForTest(); reportMeshFloorCell(30.2004, -97.6604, 175); - const after = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'warming', - ), - ).height; + const after = _floorCarto(_floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'warming')).height; assert.ok(Math.abs(before - 141.5) < 0.05, `before ${before}`); - assert.ok( - Math.abs(after - 176.5) < 0.05, - `after ${after} — a stale cache would still read 141.5`, - ); + assert.ok(Math.abs(after - 176.5) < 0.05, `after ${after} — a stale cache would still read 141.5`); }); // --- F8: hold the last known floor through a floor-data gap ---------------- @@ -1346,28 +1093,18 @@ test('display floor: a cold cell HOLDS the last floor that resolved for this con reportMeshFloorCell(30.2004, -97.6604, 140.2); // On the warm cell: clamped onto the floor as usual. const warm = _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7), - false, - 'held', - ); - assert.ok( - Math.abs(_floorCarto(warm).height - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05, + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7), false, 'held', ); + assert.ok(Math.abs(_floorCarto(warm).height - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05); // Taxis ~550 m onto a cell nothing has resolved, still at the geoid-ish // height the poll path handed it. Pre-fix this passed straight through — // buried. const cold = _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, 30.2054, 124.7), - false, - 'held', + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2054, 124.7), false, 'held', ); const h = _floorCarto(cold).height; - assert.ok( - Math.abs(h - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05, - `held ${h} — a cold cell must hold 140.2 + lift, never the 124.7 it arrived with`, - ); + assert.ok(Math.abs(h - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05, + `held ${h} — a cold cell must hold 140.2 + lift, never the 124.7 it arrived with`); }); test('display floor: with NO prior anywhere the contact is left exactly where it is', () => { @@ -1375,17 +1112,8 @@ test('display floor: with NO prior anywhere the contact is left exactly where it _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'fresh', - ); - assert.equal( - out, - pos, - 'nothing measured anywhere ⇒ nothing invented — same object, no clamp', - ); + const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'fresh'); + assert.equal(out, pos, 'nothing measured anywhere ⇒ nothing invented — same object, no clamp'); }); test('display floor: a fresh contact on a cold cell adopts a resolved NEIGHBOUR', () => { @@ -1398,16 +1126,11 @@ test('display floor: a fresh contact on a cold cell adopts a resolved NEIGHBOUR' reportMeshFloorCell(30.2014, -97.6604, 141.0); reportMeshFloorCell(30.1994, -97.6604, 152.0); const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7), - false, - 'neighbour', + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7), false, 'neighbour', ); const h = _floorCarto(out).height; - assert.ok( - Math.abs(h - (141.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `neighbour hold ${h} — expected the lower of the two resolved neighbours`, - ); + assert.ok(Math.abs(h - (141.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `neighbour hold ${h} — expected the lower of the two resolved neighbours`); }); // Drives a stationary contact at the production 80 ms fleet cadence and reports @@ -1415,28 +1138,16 @@ test('display floor: a fresh contact on a cold cell adopts a resolved NEIGHBOUR' // `mutate` runs before the tick at that index, so a target can be moved // mid-approach. function _driveEase(id, pos, fromMs, ticks, mutate = () => {}) { - let previous = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - id, - fromMs, - ), - ).height; + let previous = _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, pos, false, id, fromMs, + )).height; const opened = previous; let maxStepM = 0; for (let i = 1; i <= ticks; i += 1) { mutate(i); - const h = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - id, - fromMs + i * 80, - ), - ).height; + const h = _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, pos, false, id, fromMs + i * 80, + )).height; maxStepM = Math.max(maxStepM, previous - h); previous = h; } @@ -1455,18 +1166,10 @@ test('display floor: a hold released onto a LOWER floor eases down, never snaps' if (i === 1) reportMeshFloorCell(30.2004, -97.6604, 160.0); // its own cell resolves lower }); const totalDropM = r.opened - (160.0 + GROUND_FLOOR_LIFT_M); - assert.ok( - Math.abs(r.opened - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `opened at ${r.opened}`, - ); - assert.ok( - r.maxStepM <= totalDropM * 0.25, - `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m — a snap is the whole drop at once`, - ); - assert.ok( - Math.abs(r.settled - (160.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `settled at ${r.settled}`, - ); + assert.ok(Math.abs(r.opened - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, `opened at ${r.opened}`); + assert.ok(r.maxStepM <= totalDropM * 0.25, + `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m — a snap is the whole drop at once`); + assert.ok(Math.abs(r.settled - (160.0 + GROUND_FLOOR_LIFT_M)) < 0.05, `settled at ${r.settled}`); }); test('display floor: a hold released onto a HIGHER floor rises immediately', () => { @@ -1474,26 +1177,17 @@ test('display floor: a hold released onto a HIGHER floor rises immediately', () _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const at = (ms) => - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'rise', - ms, - ), - ).height; + const at = (ms) => _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, pos, false, 'rise', ms, + )).height; reportMeshFloorCell(30.2014, -97.6604, 160.0); reportMeshFloorCell(30.1994, -97.6604, 190.0); assert.ok(Math.abs(at(1000) - (160.0 + GROUND_FLOOR_LIFT_M)) < 0.05); reportMeshFloorCell(30.2004, -97.6604, 205.0); // No easing UP: an eased rise is time spent under the mesh, which is the // whole failure this path exists to prevent. - assert.ok( - Math.abs(at(1001) - (205.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - 'the next tick is already on the higher floor', - ); + assert.ok(Math.abs(at(1001) - (205.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + 'the next tick is already on the higher floor'); }); // --- F9: the hold is a live state machine, not a one-shot ------------------ @@ -1507,24 +1201,17 @@ test('display floor: a STATIONARY unresolved contact still adopts a floor that a _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const at = (ms) => - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'parked-cold', - ms, - ); + const at = (ms) => _floorGroundedDisplayPositionForTest( + { onGround: true }, pos, false, 'parked-cold', ms, + ); assert.equal(at(1000), pos, 'nothing anywhere yet — passes through'); // The poll's warm batch lands the cells NEXT DOOR (this contact's own cell // is still cold, so only the hold chain can see them). reportMeshFloorCell(30.2014, -97.6604, 138.0); reportMeshFloorCell(30.1994, -97.6604, 160.0); const adopted = _floorCarto(at(1600)).height; // past the probe throttle - assert.ok( - Math.abs(adopted - (138.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `stationary contact ${adopted} — a memoized unresolved answer would still read 124.7`, - ); + assert.ok(Math.abs(adopted - (138.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `stationary contact ${adopted} — a memoized unresolved answer would still read 124.7`); }); test('display floor: a stationary contact FOLLOWS the neighbourhood as it fills in', () => { @@ -1532,16 +1219,9 @@ test('display floor: a stationary contact FOLLOWS the neighbourhood as it fills _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); - const at = (ms) => - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'upgrade', - ms, - ), - ).height; + const at = (ms) => _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, pos, false, 'upgrade', ms, + )).height; reportMeshFloorCell(30.2014, -97.6604, 200.0); reportMeshFloorCell(30.1994, -97.6604, 240.0); assert.ok(Math.abs(at(1000) - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05); @@ -1551,10 +1231,8 @@ test('display floor: a stationary contact FOLLOWS the neighbourhood as it fills reportMeshFloorCell(30.2004, -97.6614, 120.0); let h = 0; for (let ms = 1600; ms <= 6000; ms += 80) h = at(ms); - assert.ok( - Math.abs(h - (120.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `followed to ${h} — a borrowed floor must not outlive a better one`, - ); + assert.ok(Math.abs(h - (120.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `followed to ${h} — a borrowed floor must not outlive a better one`); }); test('display floor: an on_ground FLAP mid-takeoff-roll never dips below the runway', () => { @@ -1568,35 +1246,23 @@ test('display floor: an on_ground FLAP mid-takeoff-roll never dips below the run // the geoid. Deleting the hold on the airborne poll made that switch visible: // the contact came back grounded with no prior, outrunning its own floor // cells at 23 m/s, and sat under the runway (owner sighting, VIR138M). - const GROUND = -28.5, - GEOID = -32.5, - LON = -73.78; + const GROUND = -28.5, GEOID = -32.5, LON = -73.78; reportMeshFloorCell(40.64, LON, GROUND); // only the cell it STARTED on is warm let lat = 40.64; const grounded = []; for (let i = 0; i <= 22; i += 1) { const onGround = i !== 10; // one airborne poll mid-roll const out = _floorGroundedDisplayPositionForTest( - { onGround }, - Cesium.Cartesian3.fromDegrees(LON, lat, GEOID), - false, - 'VIR138M', - 1000 + i * 80, + { onGround }, Cesium.Cartesian3.fromDegrees(LON, lat, GEOID), false, 'VIR138M', 1000 + i * 80, ); if (onGround) grounded.push(_floorCarto(out).height); lat += 0.00021; // ~23 m per tick at 45 kt } const dipped = grounded.filter((h) => h < GROUND - 0.05); - assert.deepEqual( - dipped, - [], - `${dipped.length} of ${grounded.length} grounded ticks rendered below the runway`, - ); - assert.ok( - Math.abs(grounded[grounded.length - 1] - (GROUND + GROUND_FLOOR_LIFT_M)) < - 0.05, - 'and it is still standing on the floor it held, not on nothing', - ); + assert.deepEqual(dipped, [], + `${dipped.length} of ${grounded.length} grounded ticks rendered below the runway`); + assert.ok(Math.abs(grounded[grounded.length - 1] - (GROUND + GROUND_FLOOR_LIFT_M)) < 0.05, + 'and it is still standing on the floor it held, not on nothing'); }); test('display floor: the rehydration seed EXPIRES on its own age, with NO calls in between', () => { @@ -1606,45 +1272,20 @@ test('display floor: the rehydration seed EXPIRES on its own age, with NO calls const field = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); reportMeshFloorCell(30.2014, -97.6604, 200.0); reportMeshFloorCell(30.1994, -97.6604, 240.0); - assert.ok( - Math.abs( - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - field, - false, - 'longhaul', - 1000, - ), - ).height - - (200.0 + GROUND_FLOOR_LIFT_M), - ) < 0.05, - 'holding 200 m before departure', - ); + assert.ok(Math.abs(_floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, field, false, 'longhaul', 1000, + )).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, 'holding 200 m before departure'); // ONE airborne tick parks the seed, and then this contact is not seen again // for 198 s: off the poll on a cruise, outside the corridor radius, tab // hidden. Nothing runs in between, so the retire path never gets a second // chance to expire it — the age has to be judged where it comes BACK. // An earlier cut checked expiry only on the retire path and cleared // `retiredMs` before validating it, so this reused the 200 m floor. - _floorGroundedDisplayPositionForTest( - { onGround: false }, - field, - false, - 'longhaul', - 2000, - ); + _floorGroundedDisplayPositionForTest({ onGround: false }, field, false, 'longhaul', 2000); _clearMeshFloorCellsForTest(); // a different airport: nothing warm here const back = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); assert.equal( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - back, - false, - 'longhaul', - 200000, - ), - back, + _floorGroundedDisplayPositionForTest({ onGround: true }, back, false, 'longhaul', 200000), back, 'an expired seed is gone — no floor is invented from a 198-second-old measurement', ); }); @@ -1656,40 +1297,15 @@ test('display floor: the seed expires the same way when the contact keeps report const field = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); reportMeshFloorCell(30.2014, -97.6604, 200.0); reportMeshFloorCell(30.1994, -97.6604, 240.0); - _floorGroundedDisplayPositionForTest( - { onGround: true }, - field, - false, - 'cruise', - 1000, - ); + _floorGroundedDisplayPositionForTest({ onGround: true }, field, false, 'cruise', 1000); // The other half of the same clock: an airborne contact that IS still being // ticked. The window is the window either way. - _floorGroundedDisplayPositionForTest( - { onGround: false }, - field, - false, - 'cruise', - 2000, - ); - _floorGroundedDisplayPositionForTest( - { onGround: false }, - field, - false, - 'cruise', - 200000, - ); + _floorGroundedDisplayPositionForTest({ onGround: false }, field, false, 'cruise', 2000); + _floorGroundedDisplayPositionForTest({ onGround: false }, field, false, 'cruise', 200000); _clearMeshFloorCellsForTest(); const back = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); assert.equal( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - back, - false, - 'cruise', - 201000, - ), - back, + _floorGroundedDisplayPositionForTest({ onGround: true }, back, false, 'cruise', 201000), back, 'no memory of the departure field survives the grace window', ); }); @@ -1705,28 +1321,10 @@ test('display floor: a rehydrated seed ranks BELOW fresh neighbour evidence', () setMeshFloorPreferred(true); const departed = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 50); reportMeshFloorCell(30.2004, -97.6604, 200.0); - assert.ok( - Math.abs( - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - departed, - false, - 'short-hop', - 1000, - ), - ).height - - (200.0 + GROUND_FLOOR_LIFT_M), - ) < 0.05, - 'standing on its own 200 m cell', - ); - _floorGroundedDisplayPositionForTest( - { onGround: false }, - departed, - false, - 'short-hop', - 2000, - ); + assert.ok(Math.abs(_floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, departed, false, 'short-hop', 1000, + )).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, 'standing on its own 200 m cell'); + _floorGroundedDisplayPositionForTest({ onGround: false }, departed, false, 'short-hop', 2000); // Re-grounds 0.56 km away — INSIDE HELD_FLOOR_MAX_DRIFT_KM, so the drift // bound does not save us here; the tier order has to. Its own cell is cold, // but two adjacent cells have since resolved, and they are 100 m of ground. @@ -1734,19 +1332,11 @@ test('display floor: a rehydrated seed ranks BELOW fresh neighbour evidence', () reportMeshFloorCell(30.2044, -97.6604, 100.0); reportMeshFloorCell(30.2064, -97.6604, 105.0); const moved = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2054, 50); - const rendered = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - moved, - false, - 'short-hop', - 3000, - ), - ).height; - assert.ok( - Math.abs(rendered - (100.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `rendered at ${rendered} — a seed that outranked the neighbourhood read 201.5, 100 m in the air`, - ); + const rendered = _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, moved, false, 'short-hop', 3000, + )).height; + assert.ok(Math.abs(rendered - (100.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `rendered at ${rendered} — a seed that outranked the neighbourhood read 201.5, 100 m in the air`); }); test('display floor: with nothing fresh to contradict it, the seed still answers', () => { @@ -1758,34 +1348,14 @@ test('display floor: with nothing fresh to contradict it, the seed still answers // warmed either. Demoting the seed must not mean discarding it. const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 50); reportMeshFloorCell(30.2004, -97.6604, 200.0); - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'flap', - 1000, - ); - _floorGroundedDisplayPositionForTest( - { onGround: false }, - pos, - false, - 'flap', - 2000, - ); + _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'flap', 1000); + _floorGroundedDisplayPositionForTest({ onGround: false }, pos, false, 'flap', 2000); _clearMeshFloorCellsForTest(); // the whole neighbourhood goes cold - const held = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, 30.2014, 50), - false, - 'flap', - 3000, - ), - ).height; - assert.ok( - Math.abs(held - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `held ${held} — with no measurement anywhere the seed is the best thing available`, - ); + const held = _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2014, 50), false, 'flap', 3000, + )).height; + assert.ok(Math.abs(held - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `held ${held} — with no measurement anywhere the seed is the best thing available`); }); test('display floor: re-measuring its own cell makes a seeded contact authoritative again', () => { @@ -1794,56 +1364,24 @@ test('display floor: re-measuring its own cell makes a seeded contact authoritat setMeshFloorPreferred(true); const pad = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); reportMeshFloorCell(30.2004, -97.6604, 300.0); - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pad, - false, - 'relatch', - 1000, - ); - _floorGroundedDisplayPositionForTest( - { onGround: false }, - pad, - false, - 'relatch', - 2000, - ); + _floorGroundedDisplayPositionForTest({ onGround: true }, pad, false, 'relatch', 1000); + _floorGroundedDisplayPositionForTest({ onGround: false }, pad, false, 'relatch', 2000); // Re-grounds on its own, still-warm 300 m cell: that is a live reading, so the // demotion is over and the ordinary tier order applies again. - assert.ok( - Math.abs( - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pad, - false, - 'relatch', - 3000, - ), - ).height - - (300.0 + GROUND_FLOOR_LIFT_M), - ) < 0.05, - 'back on its own measured cell', - ); + assert.ok(Math.abs(_floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, pad, false, 'relatch', 3000, + )).height - (300.0 + GROUND_FLOOR_LIFT_M)) < 0.05, 'back on its own measured cell'); // It now taxis 220 m onto a cold cell whose two adjacent cells read 100 m. An // own floor it measured while standing here outranks a borrowed one — the // seed flag has to have been cleared by that reading, or the contact drops // 200 m onto someone else's ground. reportMeshFloorCell(30.2014, -97.6604, 100.0); reportMeshFloorCell(30.2034, -97.6604, 100.0); - const taxied = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, 30.2024, 100), - false, - 'relatch', - 4000, - ), - ).height; - assert.ok( - Math.abs(taxied - (300.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `taxied at ${taxied} — a still-seeded contact would have taken the 100 m neighbours`, - ); + const taxied = _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2024, 100), false, 'relatch', 4000, + )).height; + assert.ok(Math.abs(taxied - (300.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `taxied at ${taxied} — a still-seeded contact would have taken the 100 m neighbours`); }); test('display floor: taking off and landing elsewhere does NOT inherit the old field floor', () => { @@ -1853,62 +1391,25 @@ test('display floor: taking off and landing elsewhere does NOT inherit the old f const field = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); reportMeshFloorCell(30.2004, -97.6604, 200.0); // 1. grounded on a 200 m floor. - assert.ok( - Math.abs( - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - field, - false, - 'trip', - 1000, - ), - ).height - - (200.0 + GROUND_FLOOR_LIFT_M), - ) < 0.05, - ); + assert.ok(Math.abs(_floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, field, false, 'trip', 1000, + )).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05); // 2. it taxis ~330 m onto ground nothing has resolved, so 200 m is now being // HELD — this is the state that must not survive the trip. const heldAt = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2034, 124.7); - assert.ok( - Math.abs( - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - heldAt, - false, - 'trip', - 1500, - ), - ).height - - (200.0 + GROUND_FLOOR_LIFT_M), - ) < 0.05, - 'the hold is active before takeoff', - ); + assert.ok(Math.abs(_floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, heldAt, false, 'trip', 1500, + )).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, 'the hold is active before takeoff'); // 3. airborne — the hold belongs to the grounded-billboard regime, not to the // contact, so leaving that regime must retire it. - _floorGroundedDisplayPositionForTest( - { onGround: false }, - heldAt, - false, - 'trip', - 2000, - ); + _floorGroundedDisplayPositionForTest({ onGround: false }, heldAt, false, 'trip', 2000); // 4. lands at another field on a real 100 m floor. reportMeshFloorCell(31.5004, -97.6604, 100.0); - const landed = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, 31.5004, 60), - false, - 'trip', - 3000, - ), - ).height; - assert.ok( - Math.abs(landed - (100.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `landed at ${landed} — a stale hold would arrive at 201.5 and ease down from it`, - ); + const landed = _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 31.5004, 60), false, 'trip', 3000, + )).height; + assert.ok(Math.abs(landed - (100.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `landed at ${landed} — a stale hold would arrive at 201.5 and ease down from it`); }); test('display floor: a model-owned interval retires the hold too', () => { @@ -1917,47 +1418,21 @@ test('display floor: a model-owned interval retires the hold too', () => { setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); reportMeshFloorCell(30.2004, -97.6604, 200.0); - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'handoff', - 1000, - ); + _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'handoff', 1000); // It moves onto unresolved ground, so 200 m is being HELD. const heldAt = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2034, 124.7); - _floorGroundedDisplayPositionForTest( - { onGround: true }, - heldAt, - false, - 'handoff', - 1500, - ); + _floorGroundedDisplayPositionForTest({ onGround: true }, heldAt, false, 'handoff', 1500); // T7: the 3D model takes the visual. Our state is retired (nothing visual is // touched — groundSnap's one-shot input is untouched either way). - _floorGroundedDisplayPositionForTest( - { onGround: true }, - heldAt, - true, - 'handoff', - 2000, - ); + _floorGroundedDisplayPositionForTest({ onGround: true }, heldAt, true, 'handoff', 2000); // The billboard resumes over ground that has since resolved LOWER. A stale // heldActive would read this as a hold release and ease; it is a fresh start. reportMeshFloorCell(30.2034, -97.6604, 150.0); - const back = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - heldAt, - false, - 'handoff', - 3000, - ), - ).height; - assert.ok( - Math.abs(back - (150.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `resumed at ${back} — no ease, because there was no hold to release`, - ); + const back = _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, heldAt, false, 'handoff', 3000, + )).height; + assert.ok(Math.abs(back - (150.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `resumed at ${back} — no ease, because there was no hold to release`); }); test('display floor: a model handoff back onto a COLD cell takes the neighbourhood, not the seed', () => { @@ -1970,44 +1445,18 @@ test('display floor: a model handoff back onto a COLD cell takes the neighbourho // a floor it measured 330 m ago. Two adjacent cells have resolved at 150 m. const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); reportMeshFloorCell(30.2004, -97.6604, 200.0); - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'cold-handoff', - 1000, - ); + _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'cold-handoff', 1000); const heldAt = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2034, 124.7); - _floorGroundedDisplayPositionForTest( - { onGround: true }, - heldAt, - false, - 'cold-handoff', - 1500, - ); - _floorGroundedDisplayPositionForTest( - { onGround: true }, - heldAt, - true, - 'cold-handoff', - 2000, - ); + _floorGroundedDisplayPositionForTest({ onGround: true }, heldAt, false, 'cold-handoff', 1500); + _floorGroundedDisplayPositionForTest({ onGround: true }, heldAt, true, 'cold-handoff', 2000); _clearMeshFloorCellsForTest(); reportMeshFloorCell(30.2024, -97.6604, 150.0); reportMeshFloorCell(30.2044, -97.6604, 155.0); - const back = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - heldAt, - false, - 'cold-handoff', - 3000, - ), - ).height; - assert.ok( - Math.abs(back - (150.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `resumed at ${back} — a seed that outranked the neighbourhood would resume at 201.5`, - ); + const back = _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, heldAt, false, 'cold-handoff', 3000, + )).height; + assert.ok(Math.abs(back - (150.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `resumed at ${back} — a seed that outranked the neighbourhood would resume at 201.5`); }); // --- F10: no contact waits on another -------------------------------------- @@ -2030,28 +1479,15 @@ test('display floor: every grounded contact is floored on the tick it asks', () } const heights = []; for (let i = 0; i < N; i += 1) { - heights.push( - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.66, latOf(i), 100), - false, - `q${i}`, - 1000, - ), - ).height, - ); + heights.push(_floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.66, latOf(i), 100), false, `q${i}`, 1000, + )).height); } const unfloored = heights - .map((h, i) => - Math.abs(h - (300 + i + GROUND_FLOOR_LIFT_M)) < 0.05 ? null : i, - ) + .map((h, i) => (Math.abs(h - (300 + i + GROUND_FLOOR_LIFT_M)) < 0.05 ? null : i)) .filter((i) => i != null); - assert.deepEqual( - unfloored, - [], - 'a global budget would leave the tail of the fleet on the geoid for later ticks', - ); + assert.deepEqual(unfloored, [], + 'a global budget would leave the tail of the fleet on the geoid for later ticks'); }); test('display floor: a re-probe that finds a LOWER neighbour eases down, never snaps', () => { @@ -2068,14 +1504,9 @@ test('display floor: a re-probe that finds a LOWER neighbour eases down, never s if (i === 8) reportMeshFloorCell(30.2004, -97.6614, 120.0); // past the 500 ms probe throttle }); const totalDropM = r.opened - target; - assert.ok( - r.maxStepM <= totalDropM * 0.25, - `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m`, - ); - assert.ok( - Math.abs(r.settled - target) < 0.05, - `settled at ${r.settled}, expected ${target}`, - ); + assert.ok(r.maxStepM <= totalDropM * 0.25, + `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m`); + assert.ok(Math.abs(r.settled - target) < 0.05, `settled at ${r.settled}, expected ${target}`); }); test('display floor: a SECOND lower re-latch mid-approach retargets without a jump', () => { @@ -2095,14 +1526,9 @@ test('display floor: a SECOND lower re-latch mid-approach retargets without a ju if (i === 16) reportMeshFloorCell(30.2004, -97.6594, 100.0); // -> 100 m, mid-approach }); const totalDropM = r.opened - target; - assert.ok( - r.maxStepM <= totalDropM * 0.25, - `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m — the retarget must not be a seam`, - ); - assert.ok( - Math.abs(r.settled - target) < 0.05, - `settled at ${r.settled}, expected ${target}`, - ); + assert.ok(r.maxStepM <= totalDropM * 0.25, + `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m — the retarget must not be a seam`); + assert.ok(Math.abs(r.settled - target) < 0.05, `settled at ${r.settled}, expected ${target}`); }); test('display floor: a DELAYED tick cannot close more of the gap than a prompt one', () => { @@ -2110,16 +1536,9 @@ test('display floor: a DELAYED tick cannot close more of the gap than a prompt o _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); - const at = (ms) => - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'stall', - ms, - ), - ).height; + const at = (ms) => _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, pos, false, 'stall', ms, + )).height; reportMeshFloorCell(30.2014, -97.6604, 400.0); reportMeshFloorCell(30.1994, -97.6604, 440.0); // borrowed floor = 400 m const opened = at(1000); @@ -2138,10 +1557,8 @@ test('display floor: a DELAYED tick cannot close more of the gap than a prompt o maxStepM = Math.max(maxStepM, previous - h); previous = h; } - assert.ok( - maxStepM <= totalDropM * 0.25, - `largest step ${maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m across a stalled schedule`, - ); + assert.ok(maxStepM <= totalDropM * 0.25, + `largest step ${maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m across a stalled schedule`); assert.ok(previous < opened, 'and it is still making progress downward'); }); @@ -2152,16 +1569,9 @@ test('display floor: a floor rising MID-APPROACH is taken whole, on that tick', const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); reportMeshFloorCell(30.2014, -97.6604, 400.0); reportMeshFloorCell(30.1994, -97.6604, 440.0); // borrowed floor = 400 m - const at = (ms) => - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'riseMid', - ms, - ), - ).height; + const at = (ms) => _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, pos, false, 'riseMid', ms, + )).height; at(1000); reportMeshFloorCell(30.2004, -97.6604, 150.0); // own cell resolves far below let h = 0; @@ -2172,10 +1582,8 @@ test('display floor: a floor rising MID-APPROACH is taken whole, on that tick', _clearMeshFloorCellsForTest(); reportMeshFloorCell(30.2004, -97.6604, 500.0); const risen = at(1000 + 5 * 80); - assert.ok( - Math.abs(risen - (500.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `rose to ${risen} — an eased rise is time spent under the mesh`, - ); + assert.ok(Math.abs(risen - (500.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `rose to ${risen} — an eased rise is time spent under the mesh`); }); test('display floor: a held floor is dropped once the contact leaves the ground it describes', () => { @@ -2183,38 +1591,18 @@ test('display floor: a held floor is dropped once the contact leaves the ground _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 200.0); - const at = (lat, ms) => - _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, lat, 124.7), - false, - 'drift', - ms, - ); - assert.ok( - Math.abs( - _floorCarto(at(30.2004, 1000)).height - (200.0 + GROUND_FLOOR_LIFT_M), - ) < 0.05, + const at = (lat, ms) => _floorGroundedDisplayPositionForTest( + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, lat, 124.7), false, 'drift', ms, ); + assert.ok(Math.abs(_floorCarto(at(30.2004, 1000)).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05); // ~670 m on: cold cell, but that is inside one poll's rollout. Hold stands. - assert.ok( - Math.abs( - _floorCarto(at(30.2064, 2000)).height - (200.0 + GROUND_FLOOR_LIFT_M), - ) < 0.05, - 'inside the drift bound the held floor still describes the ground', - ); + assert.ok(Math.abs(_floorCarto(at(30.2064, 2000)).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + 'inside the drift bound the held floor still describes the ground'); // ~3.3 km on: the held value is no longer a measurement of anywhere this // contact has been, so it is dropped rather than stretched. const far = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2304, 124.7); assert.equal( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - far, - false, - 'drift', - 3000, - ), - far, + _floorGroundedDisplayPositionForTest({ onGround: true }, far, false, 'drift', 3000), far, 'past the drift bound: back to no clamp, not a stretched one', ); }); @@ -2228,21 +1616,12 @@ test('display floor: an ordinary cell-to-cell floor DROP keeps its existing timi // untouched by this round. reportMeshFloorCell(30.2004, -97.6604, 200.0); reportMeshFloorCell(30.2044, -97.6604, 160.0); - const at = (lat, ms) => - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, lat, 124.7), - false, - 'plain', - ms, - ), - ).height; + const at = (lat, ms) => _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, lat, 124.7), false, 'plain', ms, + )).height; assert.ok(Math.abs(at(30.2004, 1000) - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05); - assert.ok( - Math.abs(at(30.2044, 1050) - (160.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - 'the very next tick is on the new cell — no ease was introduced here', - ); + assert.ok(Math.abs(at(30.2044, 1050) - (160.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + 'the very next tick is on the new cell — no ease was introduced here'); }); test('display floor: the hold never applies to an airborne contact or a modelled one', () => { @@ -2254,23 +1633,11 @@ test('display floor: the hold never applies to an airborne contact or a modelled _floorGroundedDisplayPositionForTest({ onGround: true }, on, false, 'gates'); const cold = Cesium.Cartesian3.fromDegrees(-97.6704, 30.2104, 124.7); assert.equal( - _floorGroundedDisplayPositionForTest( - { onGround: false }, - cold, - false, - 'gates', - ), - cold, + _floorGroundedDisplayPositionForTest({ onGround: false }, cold, false, 'gates'), cold, 'airborne heights belong to the fix-time clamp, held floor or not', ); assert.equal( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - cold, - true, - 'gates', - ), - cold, + _floorGroundedDisplayPositionForTest({ onGround: true }, cold, true, 'gates'), cold, 'T7: a model-owned contact keeps its billboard datum, held floor or not', ); }); @@ -2281,21 +1648,11 @@ test('display floor: two contacts on the same cell get their own outputs', () => setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140); const a = _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 120), - false, - 'A', + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 120), false, 'A', ); const b = _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.66041, 30.20041, 121), - false, - 'B', - ); - assert.notEqual( - a, - b, - 'a shared scratch would hand both contacts the same object', + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.66041, 30.20041, 121), false, 'B', ); + assert.notEqual(a, b, 'a shared scratch would hand both contacts the same object'); assert.ok(Math.abs(_floorCarto(a).height - _floorCarto(b).height) < 0.05); }); diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index cd29c1ebd..9e6e4aea8 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -24,12 +24,8 @@ test('share-Follow absence requires an accepted adsb.lol snapshot', async () => (await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234')).status, 'source-unavailable', ); - _setMilitaryTrackingRefreshOutcomeForTest({ - status: 'accepted', - ids: ['different'], - }); - const missing = - await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234'); + _setMilitaryTrackingRefreshOutcomeForTest({ status: 'accepted', ids: ['different'] }); + const missing = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234'); assert.equal(missing.status, 'missing'); assert.equal(missing.reason, 'target-absent-from-snapshot'); }); @@ -105,14 +101,8 @@ test('military analyst record: military is ALWAYS true, routes/country always nu }); test('military analyst record: no callsign falls back to registration, then icao24', () => { - assert.equal( - mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id, - '05-8152', - ); - assert.equal( - mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id, - 'ae01ce', - ); + assert.equal(mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id, '05-8152'); + assert.equal(mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id, 'ae01ce'); }); test('military analyst record: empty info yields nulls, never NaN/undefined', () => { @@ -121,8 +111,7 @@ test('military analyst record: empty info yields nulls, never NaN/undefined', () assert.equal(r.onGround, false); for (const [key, value] of Object.entries(r)) { assert.notEqual(value, undefined, `${key} must not be undefined`); - if (typeof value === 'number') - assert.ok(Number.isFinite(value), `${key} must not be NaN`); + if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`); } }); @@ -134,17 +123,12 @@ test('military analyst record: output is JSON-safe (no Cesium types)', () => { test('military first update forwards caller cancellation into the feed request', async () => { const realFetch = globalThis.fetch; let observedSignal = null; - globalThis.fetch = (_url, options = {}) => - new Promise((_resolve, reject) => { - observedSignal = options.signal; - options.signal?.addEventListener( - 'abort', - () => { - reject(new DOMException('aborted', 'AbortError')); - }, - { once: true }, - ); - }); + globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => { + observedSignal = options.signal; + options.signal?.addEventListener('abort', () => { + reject(new DOMException('aborted', 'AbortError')); + }, { once: true }); + }); try { const controller = new AbortController(); const work = militaryFlightsLayer.update({}, { signal: controller.signal }); @@ -174,22 +158,11 @@ test('nonempty adsb.lol payload with zero usable rows cannot prove share target json: async () => ({ ac: [null, {}, { hex: 'ae1234' }] }), }); try { - await militaryFlightsLayer.update({ - camera: { positionCartographic: null }, - scene: {}, - }); - const resolution = - await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234'); + await militaryFlightsLayer.update({ camera: { positionCartographic: null }, scene: {} }); + const resolution = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234'); assert.equal(resolution.status, 'source-unavailable'); - assert.match( - militaryFlightsLayer.getStats().error, - /Malformed adsb\.lol aircraft rows/, - ); - assert.equal( - militaryFlightsLayer.getAnalystRecords().length, - 1, - 'warm aircraft data is preserved', - ); + assert.match(militaryFlightsLayer.getStats().error, /Malformed adsb\.lol aircraft rows/); + assert.equal(militaryFlightsLayer.getAnalystRecords().length, 1, 'warm aircraft data is preserved'); } finally { globalThis.fetch = realFetch; } @@ -235,26 +208,21 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p ok: true, status: 200, json: async () => ({ - ac: - poll++ === 0 - ? [ - { - hex: icao24, - lon: -96.9, - lat: 31.1, - alt_baro: 28_000, - alt_geom: 28_100, - track: 95, - gs: 400, - seen: 0, - seen_pos: 0, - flight: 'RCH451 ', - t: 'C17', - r: '05-8152', - ownOp: 'United States Air Force', - }, - ] - : [], + ac: poll++ === 0 ? [{ + hex: icao24, + lon: -96.9, + lat: 31.1, + alt_baro: 28_000, + alt_geom: 28_100, + track: 95, + gs: 400, + seen: 0, + seen_pos: 0, + flight: 'RCH451 ', + t: 'C17', + r: '05-8152', + ownOp: 'United States Air Force', + }] : [], }), }); @@ -279,10 +247,7 @@ test('cached military snapshots retain their observation time and expose stale f _setTrackedMilitaryRefreshStateForTest({ icao24: 'ae01ce', entity: { gevLabelModel: { title: 'RCH451', details: [] } }, - billboard: { - position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000), - show: false, - }, + billboard: { position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000), show: false }, billboardCollection: { show: false, remove() {} }, viewer, history, @@ -320,18 +285,11 @@ test('cached military snapshots retain their observation time and expose stale f await militaryFlightsLayer.update(viewer); assert.equal(history.length, 1); assert.equal(history[0].epochMs, receivedAt - 2000); - for (const [status, age] of [ - ['HIT', 5000], - ['STALE', 45000], - ]) { + for (const [status, age] of [['HIT', 5000], ['STALE', 45000]]) { cache = status; now = receivedAt + age; await militaryFlightsLayer.update(viewer); - assert.equal( - history.length, - 1, - 'replayed positions cannot create a new fix', - ); + assert.equal(history.length, 1, 'replayed positions cannot create a new fix'); assert.equal(militaryFlightsLayer.getStats().lastUpdate, receivedAt); assert.equal(militaryFlightsLayer.getStats().stale, status === 'STALE'); assert.equal(militaryFlightsLayer.getStats().error, null); @@ -358,18 +316,14 @@ test('real military track path creates no native label and publishes every cache trackedEntityChanged, clock: { currentTime: now }, camera: { - cancelFlight() { - cancelledFlights += 1; - }, + cancelFlight() { cancelledFlights += 1; }, position: new Cesium.Cartesian3(0, -10_000, 7_000), positionWC: new Cesium.Cartesian3(0, -10_000, 7_000), direction: Cesium.Cartesian3.UNIT_Y, transform: Cesium.Matrix4.IDENTITY, viewMatrix: Cesium.Matrix4.IDENTITY, frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY }, - lookAtTransform() { - appliedFrames += 1; - }, + lookAtTransform() { appliedFrames += 1; }, }, scene: { canvas: { clientWidth: 1600, clientHeight: 900 }, @@ -408,12 +362,9 @@ test('real military track path creates no native label and publishes every cache const realFetch = globalThis.fetch; globalThis.window = new EventTarget(); const selectionEvents = []; - globalThis.window.addEventListener( - 'gev:awareness-subject-selected', - (event) => { - selectionEvents.push(event.detail); - }, - ); + globalThis.window.addEventListener('gev:awareness-subject-selected', (event) => { + selectionEvents.push(event.detail); + }); globalThis.fetch = async () => ({ ok: false }); _setTrackedOverlayHostForTest(host); try { @@ -425,15 +376,13 @@ test('real military track path creates no native label and publishes every cache billboardCollection: { show: true, remove() {} }, viewer, tracked: false, - history: [ - { - time: now, - epochMs: Date.now(), - position, - velocity: 231.5, - track: 92.1, - }, - ], + history: [{ + time: now, + epochMs: Date.now(), + position, + velocity: 231.5, + track: 92.1, + }], meta: { ...FULL_INFO, type: 'C17', @@ -443,72 +392,34 @@ test('real military track path creates no native label and publishes every cache }, }); - assert.equal( - militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), - true, - ); + assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true); const entity = viewer.trackedEntity; - assert.ok( - entity instanceof Cesium.Entity, - 'trackById must create the real Cesium entity', - ); + assert.ok(entity instanceof Cesium.Entity, 'trackById must create the real Cesium entity'); assert.equal(entity.label, undefined); - assert.ok( - entities.values.every((candidate) => candidate.label === undefined), - ); + assert.ok(entities.values.every((candidate) => candidate.label === undefined)); assert.deepEqual(entity.gevLabelModel, { title: 'RCH451', - details: ['C17 · 05-8152', 'United States Air Force · 28000 ft · 450 kt'], + details: [ + 'C17 · 05-8152', + 'United States Air Force · 28000 ft · 450 kt', + ], accent: '#ffd166', }); viewer.scene.preUpdate.raiseEvent(); const initialAppliedFrames = appliedFrames; const initialCancelledFlights = cancelledFlights; - assert.equal( - militaryFlightsLayer.trackById(icao24, { origin: 'user' }), - true, - ); - assert.equal( - cancelledFlights, - initialCancelledFlights, - 'ordinary repeated tracking stays camera-idempotent', - ); - assert.equal( - selectionEvents.at(-1)?.origin, - 'user', - 'same-target selection upgrades durable authority', - ); + assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true); + assert.equal(cancelledFlights, initialCancelledFlights, 'ordinary repeated tracking stays camera-idempotent'); + assert.equal(selectionEvents.at(-1)?.origin, 'user', 'same-target selection upgrades durable authority'); assert.equal(entity.gevSelectionOrigin, 'user'); - assert.equal( - militaryFlightsLayer.refocusTrackedById('different-flight'), - false, - ); - assert.equal( - militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), - true, - ); - assert.equal( - selectionEvents.at(-1)?.origin, - 'voice', - 'same-target refocus forwards explicit authority', - ); + assert.equal(militaryFlightsLayer.refocusTrackedById('different-flight'), false); + assert.equal(militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), true); + assert.equal(selectionEvents.at(-1)?.origin, 'voice', 'same-target refocus forwards explicit authority'); assert.equal(militaryFlightsLayer.refocusTrackedById(icao24), true); viewer.scene.preUpdate.raiseEvent(); - assert.equal( - viewer.trackedEntity, - entity, - 'refocus must retain the exact tracked entity', - ); - assert.equal( - appliedFrames, - initialAppliedFrames + 1, - 'repeated refocus keeps one camera-frame owner', - ); - assert.equal( - cancelledFlights, - initialCancelledFlights + 2, - 'only explicit refocus requests cancel camera flights', - ); + assert.equal(viewer.trackedEntity, entity, 'refocus must retain the exact tracked entity'); + assert.equal(appliedFrames, initialAppliedFrames + 1, 'repeated refocus keeps one camera-frame owner'); + assert.equal(cancelledFlights, initialCancelledFlights + 2, 'only explicit refocus requests cancel camera flights'); const publication = publications.at(-1); assert.equal(publication.sourceId, 'tracked'); const entry = publication.entries[0]; @@ -519,40 +430,23 @@ test('real military track path creates no native label and publishes every cache const display = entity.position.getValue(now); assert.ok(display, 'tracked position callback must seed its frame cache'); - assert.equal( - entry.position(), - display, - 'host must reuse the exact cached display Cartesian', - ); + assert.equal(entry.position(), display, 'host must reuse the exact cached display Cartesian'); militaryFlightsLayer.setParams( { selectedMilitaryTrackingId: 'late001' }, { origin: 'share-restore' }, ); assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late001'); - assert.equal( - militaryFlightsLayer.trackById(icao24, { origin: 'user' }), - true, - ); - assert.equal( - _pendingMilitaryTrackingRestoreForTest(), - null, - 'new user selection cancels stale restore', - ); + assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true); + assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'new user selection cancels stale restore'); _addMilitaryTrackingCandidateForTest({ icao24: 'late001', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000) }, meta: { ...FULL_INFO, callsign: 'LATE1', rawLat: 31.1, rawLon: -96.9 }, history: [], }); _applyPendingMilitaryTrackingRestoreForTest(); - assert.equal( - militaryFlightsLayer.getParams().selectedMilitaryTrackingId, - icao24, - ); + assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, icao24); militaryFlightsLayer.stopTracking(); militaryFlightsLayer.setParams( @@ -561,25 +455,15 @@ test('real military track path creates no native label and publishes every cache ); assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late002'); militaryFlightsLayer.stopTracking({ origin: 'user' }); - assert.equal( - _pendingMilitaryTrackingRestoreForTest(), - null, - 'explicit clear cancels stale restore', - ); + assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'explicit clear cancels stale restore'); _addMilitaryTrackingCandidateForTest({ icao24: 'late002', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000) }, meta: { ...FULL_INFO, callsign: 'LATE2', rawLat: 31.2, rawLon: -96.8 }, history: [], }); _applyPendingMilitaryTrackingRestoreForTest(); - assert.equal( - militaryFlightsLayer.getParams().selectedMilitaryTrackingId, - null, - ); + assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null); militaryFlightsLayer.setParams( { selectedMilitaryTrackingId: 'late003' }, @@ -594,18 +478,12 @@ test('real military track path creates no native label and publishes every cache ); _addMilitaryTrackingCandidateForTest({ icao24: 'late003', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000) }, meta: { ...FULL_INFO, callsign: 'LATE3', rawLat: 30.6, rawLon: -97.3 }, history: [], }); _applyPendingMilitaryTrackingRestoreForTest(); - assert.equal( - militaryFlightsLayer.getParams().selectedMilitaryTrackingId, - null, - ); + assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null); militaryFlightsLayer.setParams( { selectedMilitaryTrackingId: 'late004' }, @@ -614,27 +492,18 @@ test('real military track path creates no native label and publishes every cache assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late004'); _addMilitaryTrackingCandidateForTest({ icao24: 'late004', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000) }, meta: { ...FULL_INFO, callsign: 'LATE4', rawLat: 30.7, rawLon: -97.2 }, history: [], }); assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true); - assert.equal( - militaryFlightsLayer.getParams().selectedMilitaryTrackingId, - 'late004', - ); + assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late004'); militaryFlightsLayer.setParams( { selectedMilitaryTrackingId: 'late005' }, { origin: 'local-restore' }, ); - assert.equal( - militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), - true, - ); + assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true); assert.equal( _pendingMilitaryTrackingRestoreForTest(), 'late005', @@ -642,18 +511,12 @@ test('real military track path creates no native label and publishes every cache ); _addMilitaryTrackingCandidateForTest({ icao24: 'late005', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000) }, meta: { ...FULL_INFO, callsign: 'LATE5', rawLat: 31.4, rawLon: -96.6 }, history: [], }); assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true); - assert.equal( - militaryFlightsLayer.getParams().selectedMilitaryTrackingId, - 'late005', - ); + assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late005'); } finally { militaryFlightsLayer.stopTracking(); viewer.scene.preUpdate.raiseEvent(); @@ -698,47 +561,21 @@ test('a callsign-less contact is findable by the tail number the app displays', // The identity analyst_query hands back for this contact. const analystId = mapAnalystRecord(icao24, { - callsign: null, - registration: '6606', - rawLat: 40.71, - rawLon: -74.01, + callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01, }).id; - assert.equal( - analystId, - '6606', - 'the analyst names a callsign-less contact by its tail', - ); + assert.equal(analystId, '6606', 'the analyst names a callsign-less contact by its tail'); const found = militaryFlightsLayer.findByQuery(analystId); - assert.equal( - found?.icao24, - icao24, - 'and the tracker must resolve that same identity', - ); - assert.equal( - militaryFlightsLayer.findByQuery('660')?.icao24, - icao24, - 'prefix match too', - ); - assert.equal( - militaryFlightsLayer.findByQuery(icao24)?.icao24, - icao24, - 'hex still wins outright', - ); + assert.equal(found?.icao24, icao24, 'and the tracker must resolve that same identity'); + assert.equal(militaryFlightsLayer.findByQuery('660')?.icao24, icao24, 'prefix match too'); + assert.equal(militaryFlightsLayer.findByQuery(icao24)?.icao24, icao24, 'hex still wins outright'); }); test('the analyst record carries the hex key the tracker keys on', () => { const record = mapAnalystRecord('ae7f01', { - callsign: null, - registration: '6606', - rawLat: 40.71, - rawLon: -74.01, + callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01, }); - assert.equal( - record.icao24, - 'ae7f01', - 'a resolvable key must ride along with the display label', - ); + assert.equal(record.icao24, 'ae7f01', 'a resolvable key must ride along with the display label'); }); test('a registration never outranks another contact’s exact callsign', () => { @@ -751,35 +588,18 @@ test('a registration never outranks another contact’s exact callsign', () => { show: true, }); const base = { - type: 'C17', - operator: 'USAF', - altitudeFt: 25000, - speedMps: 180, - track: 90, - klass: 'widebody', - onGround: false, + type: 'C17', operator: 'USAF', altitudeFt: 25000, speedMps: 180, + track: 90, klass: 'widebody', onGround: false, }; // Registered FIRST, so insertion order favours it. _addMilitaryTrackingCandidateForTest({ icao24: 'ae0aa1', - meta: { - ...base, - callsign: null, - registration: 'ZULU777', - rawLat: 31.0, - rawLon: -97.0, - }, + meta: { ...base, callsign: null, registration: 'ZULU777', rawLat: 31.0, rawLon: -97.0 }, billboard: billboardAt(-97.0, 31.0), }); _addMilitaryTrackingCandidateForTest({ icao24: 'ae0bb2', - meta: { - ...base, - callsign: 'ZULU777', - registration: '05-9999', - rawLat: 31.2, - rawLon: -97.2, - }, + meta: { ...base, callsign: 'ZULU777', registration: '05-9999', rawLat: 31.2, rawLon: -97.2 }, billboard: billboardAt(-97.2, 31.2), }); @@ -802,49 +622,24 @@ test('two contacts matching at the same strength resolve deterministically', () show: true, }); const base = { - type: 'C130', - operator: 'USAF', - altitudeFt: 21000, - speedMps: 150, - track: 45, - klass: 'turboprop', - onGround: false, + type: 'C130', operator: 'USAF', altitudeFt: 21000, speedMps: 150, + track: 45, klass: 'turboprop', onGround: false, }; // Both registrations begin with QQ7 — a same-tier prefix tie. Registered // highest-hex first so insertion order would pick the wrong one. _addMilitaryTrackingCandidateForTest({ icao24: 'aef002', - meta: { - ...base, - callsign: null, - registration: 'QQ7-222', - rawLat: 32.0, - rawLon: -98.0, - }, + meta: { ...base, callsign: null, registration: 'QQ7-222', rawLat: 32.0, rawLon: -98.0 }, billboard: billboardAt(-98.0, 32.0), }); _addMilitaryTrackingCandidateForTest({ icao24: 'aef001', - meta: { - ...base, - callsign: null, - registration: 'QQ7-111', - rawLat: 32.1, - rawLon: -98.1, - }, + meta: { ...base, callsign: null, registration: 'QQ7-111', rawLat: 32.1, rawLon: -98.1 }, billboard: billboardAt(-98.1, 32.1), }); const first = militaryFlightsLayer.findByQuery('QQ7')?.icao24; const second = militaryFlightsLayer.findByQuery('qq7')?.icao24; - assert.equal( - first, - 'aef001', - 'the stable key (lowest hex) wins, not the feed order', - ); - assert.equal( - second, - first, - 'and the same query resolves the same way every time', - ); + assert.equal(first, 'aef001', 'the stable key (lowest hex) wins, not the feed order'); + assert.equal(second, first, 'and the same query resolves the same way every time'); }); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index e84e05e01..a58587100 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -58,23 +58,23 @@ export function createRendering({ /** True while this contact's latest poll broadcast an emergency code. */ - function _isEmergency(icao24) { - return isEmergency(flightState.records.data.get(icao24)?.emergency); + function _isEmergency(icao24, info = flightState.records.data.get(icao24)) { + return isEmergency(info?.emergency); } /** Fleet (untracked) billboard tint: red while broadcasting an emergency, * amber for known-military, white otherwise. * Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */ - function _fleetBillboardColor(icao24) { - if (_isEmergency(icao24)) return EMERGENCY_TINT; + function _fleetBillboardColor(icao24, info) { + if (_isEmergency(icao24, info)) return EMERGENCY_TINT; return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE; } /** Cockpit far-contact dot tint: the emergency red outranks the category tint. */ - function _cockpitContactColor(icao24) { - if (_isEmergency(icao24)) return EMERGENCY_TINT; + function _cockpitContactColor(icao24, info) { + if (_isEmergency(icao24, info)) return EMERGENCY_TINT; return isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR; } @@ -1024,8 +1024,8 @@ export function createRendering({ flightState._cockpitNearContacts.has(icao24); const layerBaseColor = flightState._cockpitContactMode && !isCockpitNear - ? _cockpitContactColor(icao24) - : _fleetBillboardColor(icao24); + ? _cockpitContactColor(icao24, info) + : _fleetBillboardColor(icao24, info); const baseColor = layerBaseColor; const treatment = applyAircraftBillboardTreatment({ billboard: bb, diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 48265a777..2beb81bc5 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -197,10 +197,11 @@ export function createRendering({ /** Fleet (untracked) tint: red while the latest poll broadcast an emergency, * amber otherwise. Cockpit far-contact dots use it too. */ - function _fleetBillboardColor(icao24) { - return isEmergency(flightState.records.data.get(icao24)?.emergency) - ? EMERGENCY_ICON_COLOR - : MIL_ICON_COLOR; + function _fleetBillboardColor( + icao24, + info = flightState.records.data.get(icao24), + ) { + return isEmergency(info?.emergency) ? EMERGENCY_ICON_COLOR : MIL_ICON_COLOR; } /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude @@ -976,7 +977,7 @@ export function createRendering({ const isCockpitNear = flightState._cockpitContactMode && flightState._cockpitNearContacts.has(icao24); - const baseColor = _fleetBillboardColor(icao24); + const baseColor = _fleetBillboardColor(icao24, info); const treatment = applyAircraftBillboardTreatment({ billboard: bb, baseScale: diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 58eb57972..b9e54c173 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -7,12 +7,7 @@ import { } from '../../data/motionModel.js'; import { aircraftIcon } from '../../data/aircraftIcons.js'; import { POSITION_HISTORY_LIMIT } from './recordPolicy.js'; -import { - BILLBOARD_SCALE, - GROUND_SCALE, - TRACKED_ICON_COLOR, - MIL_ICON_COLOR, -} from './policy.js'; +import { BILLBOARD_SCALE, GROUND_SCALE, TRACKED_ICON_COLOR } from './policy.js'; /** Apply military record changes to Cesium resources and existing follow operations. */ export function createMilitarySnapshotRenderer({ @@ -161,7 +156,9 @@ export function createMilitarySnapshotRenderer({ // Screen-projected rotation lands on the next fleet tick. rotation: 0, alignedAxis: Cesium.Cartesian3.ZERO, - color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR, + color: isTracked + ? TRACKED_ICON_COLOR + : rendering._fleetBillboardColor(icao24, meta), sizeInMeters: false, scaleByDistance: rendering._normalBillboardScaleByDistance(), // Grounded/near-surface planes sit at/below the photoreal tile diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index b0cfb0368..5723ac39e 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -21,7 +21,6 @@ import { TRAIL_COLOR, BILLBOARD_SCALE, GROUND_SCALE, - MIL_ICON_COLOR, AMBER_TRANSPARENT, TRACKED_ICON_COLOR, } from './policy.js'; @@ -780,12 +779,16 @@ export function createTracking({ bb.width = 20; bb.height = 20; // Ground-aware restore (a plane untracked while taxiing comes back at - // ground scale; tint is full amber on the ground and in the air). + // ground scale; the fleet tint is amber, or red while broadcasting an + // emergency, on the ground and in the air). bb.scale = BILLBOARD_SCALE * (CLASS_SCALE_2D[meta?.klass] || 1) * (meta?.onGround ? GROUND_SCALE : 1); - bb.color = MIL_ICON_COLOR; + bb.color = parts.rendering._fleetBillboardColor( + flightState._trackedIcao, + meta, + ); bb.rotation = flightState._lastTrackedRotation; } flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index df4e599e4..bed0c1772 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -43,6 +43,8 @@ #cockpit-hud { --cockpit-accent: #00dcff; + /* Matches EMERGENCY_TINT_CSS in src/data/aircraftEmergency.js. */ + --cockpit-emergency: #ff4444; --cockpit-side-panel-width: min(340px, 28vw); /* HUD bottom offset + tallest scaled telemetry block + 20px clear border. */ --cockpit-bottom-rail-safe: calc(clamp(84px, 11vh, 118px) + 100px); @@ -497,7 +499,8 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed)) letter-spacing: 0.16em; } .cockpit-aircraft-meta.emergency { - color: #ff4444; + color: var(--cockpit-emergency); + font-weight: 600; } .cockpit-mode-chip { margin-top: 2px; diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 79ce004e1..c8f5b139f 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -2273,7 +2273,7 @@ :root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-aircraft-meta.emergency { - color: #ff4444; + color: var(--cyber-red-bright); } :root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-vision-controls { From 9af1ee0e09a8eea936c85e3c67866d12fc01933b Mon Sep 17 00:00:00 2001 From: Leo Pechnicki Date: Tue, 29 Sep 2026 19:57:31 +0200 Subject: [PATCH 28/86] refactor(flights): pass the record already in hand to the military fleet tint; default info on both civil tint helpers Co-Authored-By: Claude --- src/layers/flights/rendering.js | 10 ++++++++-- src/layers/military/rendering.js | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index a58587100..ae4b7c7ca 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -66,14 +66,20 @@ export function createRendering({ * amber for known-military, white otherwise. * Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */ - function _fleetBillboardColor(icao24, info) { + function _fleetBillboardColor( + icao24, + info = flightState.records.data.get(icao24), + ) { if (_isEmergency(icao24, info)) return EMERGENCY_TINT; return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE; } /** Cockpit far-contact dot tint: the emergency red outranks the category tint. */ - function _cockpitContactColor(icao24, info) { + function _cockpitContactColor( + icao24, + info = flightState.records.data.get(icao24), + ) { if (_isEmergency(icao24, info)) return EMERGENCY_TINT; return isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR; } diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 2beb81bc5..089ae9ec8 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -175,7 +175,9 @@ export function createRendering({ bb.height = icao24 === flightState._trackedIcao ? 24 : 20; bb.scale = _militaryBillboardScale(icao24) * limbScale; bb.scaleByDistance = _normalBillboardScaleByDistance(); - bb.color = _fleetBillboardColor(icao24).withAlpha(cyberSonarBaseAlpha(bb)); + bb.color = _fleetBillboardColor(icao24, meta).withAlpha( + cyberSonarBaseAlpha(bb), + ); } /** Sprite kind for one contact's billboard. Identity for every aircraft except From 6c643c8113bb0a22d0faee0885705fd6eaf39094 Mon Sep 17 00:00:00 2001 From: Leo Pechnicki Date: Thu, 1 Oct 2026 11:00:18 +0200 Subject: [PATCH 29/86] chore(squawk): scaffold review fixes for #856 From 7d6f31e54d397690dc8cfa3d3dfa3415a9a5602a Mon Sep 17 00:00:00 2001 From: Leo Pechnicki Date: Thu, 1 Oct 2026 11:53:33 +0200 Subject: [PATCH 30/86] feat(squawk): carry emergencySource + observation time into analyst records Review on #856 (kvnloo): the analyst projection flattened a 7700 squawk and the readsb ADS-B emergency field into the same emergency/emergencyKind pair. - mapAnalystRecord (flights, military) adds emergencySource ('squawk' | 'ads-b' | null, from aircraftEmergency().source), emergencyBroadcast (new pure emergencyBroadcastPhrase: "broadcasting squawk 7700 (general emergency)" vs "ADS-B reports general emergency") and observedAtMs (the record's lastContactEpochMs: last transponder message, epoch ms). - emergencySource is a queryable analyst text field; the voice payload passes it and emergencyBroadcast through, and observedAtMs only for aircraft with a status. Tool description asks the model to narrate with the phrase. - Source-to-analyst fixture test: OpenSky 7700 vs adsb.lol emergency=general on squawk 1200 differ in provenance and timestamp; filterable by source. - Voice tool-payload pins re-derived for the additive description change. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 5 +- docs/CURRENT-STATE.md | 7 +- scripts/format-scope.json | 1 + src/data/aircraftEmergency.js | 16 +++ src/data/aircraftEmergency.test.mjs | 20 +++ src/data/aircraftEmergencyAnalyst.test.mjs | 152 +++++++++++++++++++++ src/data/flights.test.mjs | 3 + src/data/militaryFlights.test.mjs | 3 + src/layers/flights/queries.js | 12 +- src/layers/military/queries.js | 12 +- 10 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 src/data/aircraftEmergencyAnalyst.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 50e731268..cab1f1f72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,7 +103,10 @@ Minimum-fuel and lifeguard statuses are reported but not tinted. The status follows the latest poll instead of sticking, so a cleared code stops highlighting on the next refresh. The analyst and voice query engine can - filter by `squawk`, `emergency` and `emergencyKind`. No new source or + filter by `squawk`, `emergency`, `emergencyKind` and `emergencySource` + (whether the squawk or the ADS-B field named the status), and each result + says when the transponder was last heard, so answers read "broadcasting + squawk 7700" or "ADS-B reports general emergency". No new source or request: OpenSky already carries the squawk and adsb.lol carries both. - Street Level: Google Street View is the second provider. On the Google 3D diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dea8a5bec..6244311a5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -831,7 +831,12 @@ the same tint when a billboard is first created and when a tracked contact is released. While a broadcasting aircraft is tracked, the Cockpit aircraft-meta line reads `SQUAWK 7700 · GENERAL EMERGENCY` when the code names the status, else `ADS-B EMERGENCY · …` or `ADS-B PRIORITY · …`, in red. Analyst records and -the voice query fields expose `squawk`, `emergency` (flag) and `emergencyKind`. +the voice query fields expose `squawk`, `emergency` (flag), `emergencyKind` and +`emergencySource` (`squawk` when the Mode A code names the status, `ads-b` for +the readsb field), plus `emergencyBroadcast` (`broadcasting squawk 7700 (general +emergency)` vs `ADS-B reports general emergency`) and `observedAtMs`, the epoch +ms of the last transponder message. Voice results carry `observedAtMs` only for +aircraft with a status. `scripts/qa-emergency-squawk.mjs` proves the tint, HUD line and clearing. ## Browser live-source observations diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ea1531793..a2ec00096 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -20,6 +20,7 @@ "src/data/infrastructure.js", "src/data/aircraftEmergency.js", "src/data/aircraftEmergency.test.mjs", + "src/data/aircraftEmergencyAnalyst.test.mjs", "scripts/qa-emergency-squawk.mjs", "src/data/infrastructure.test.mjs", "src/data/localGeojsonCore.js", diff --git a/src/data/aircraftEmergency.js b/src/data/aircraftEmergency.js index 3fd6ab688..3a555399e 100644 --- a/src/data/aircraftEmergency.js +++ b/src/data/aircraftEmergency.js @@ -63,6 +63,22 @@ export function aircraftEmergency({ squawk, emergency } = {}) { }; } +/** + * How a summary may phrase the status without flattening it into an incident: + * quote the code when the Mode A squawk itself names the status, otherwise + * attribute it to the ADS-B emergency field. + * @param {{kind?: string, squawk?: string|null, source?: string}|null|undefined} status + * @returns {string|null} e.g. 'broadcasting squawk 7700 (general emergency)' + * or 'ADS-B reports general emergency'; null when there is no status. + */ +export function emergencyBroadcastPhrase(status) { + if (!status || !Object.hasOwn(KINDS, status.kind)) return null; + const label = KINDS[status.kind].label.toLowerCase(); + return status.source === 'squawk' && status.squawk + ? `broadcasting squawk ${status.squawk} (${label})` + : `ADS-B reports ${label}`; +} + /** * True when the aircraft is broadcasting an emergency (not a priority status). * @param {{severity?: string}|null|undefined} status diff --git a/src/data/aircraftEmergency.test.mjs b/src/data/aircraftEmergency.test.mjs index 2cff800b9..4304dcb14 100644 --- a/src/data/aircraftEmergency.test.mjs +++ b/src/data/aircraftEmergency.test.mjs @@ -3,6 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { aircraftEmergency, + emergencyBroadcastPhrase, isEmergency, normalizeSquawk, } from './aircraftEmergency.js'; @@ -62,3 +63,22 @@ test('priority statuses are reported but are not emergencies', () => { assert.equal(isEmergency(aircraftEmergency({ emergency: 'minfuel' })), false); assert.equal(isEmergency(null), false); }); + +test('the broadcast phrase quotes a naming code, else attributes ADS-B', () => { + assert.equal( + emergencyBroadcastPhrase(aircraftEmergency({ squawk: '7700' })), + 'broadcasting squawk 7700 (general emergency)', + ); + assert.equal( + emergencyBroadcastPhrase( + aircraftEmergency({ squawk: '1200', emergency: 'general' }), + ), + 'ADS-B reports general emergency', + ); + assert.equal( + emergencyBroadcastPhrase(aircraftEmergency({ emergency: 'lifeguard' })), + 'ADS-B reports lifeguard (medical priority)', + ); + assert.equal(emergencyBroadcastPhrase(null), null); + assert.equal(emergencyBroadcastPhrase({ kind: 'reserved' }), null); +}); diff --git a/src/data/aircraftEmergencyAnalyst.test.mjs b/src/data/aircraftEmergencyAnalyst.test.mjs new file mode 100644 index 000000000..8df428162 --- /dev/null +++ b/src/data/aircraftEmergencyAnalyst.test.mjs @@ -0,0 +1,152 @@ +// src/data/aircraftEmergencyAnalyst.test.mjs +// Source-to-analyst fixture: an OpenSky state vector squawking 7700 and an +// adsb.lol (readsb) aircraft whose ADS-B `emergency` field says `general` +// on an ordinary code must reach the analyst with the same kind but distinct +// provenance, so a summary never flattens one into the other. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { openSkySnapshot, readsbSnapshot } from '../sources/live/index.js'; +import { FlightRecords } from '../layers/flights/records.js'; +import { MilitaryFlightRecords } from '../layers/military/records.js'; +import { mapAnalystRecord as mapFlight } from './flights.js'; +import { mapAnalystRecord as mapMilitary } from './militaryFlights.js'; +import { createAnalystEngine } from './analystEngine.js'; + +const NOW_MS = 1800000000000; + +// OpenSky /states/all: [icao24, callsign, origin_country, time_position, +// last_contact, lon, lat, baro_alt, on_ground, velocity, true_track, +// vertical_rate, sensors, geo_alt, squawk, spi, position_source, category]. +const OPENSKY = { + time: NOW_MS / 1000, + states: [ + [ + 'a1b2c3', + 'SWA696 ', + 'United States', + NOW_MS / 1000 - 6, + NOW_MS / 1000 - 3, + -97.67, + 30.19, + 3000, + false, + 150, + 180, + -5, + null, + 3040, + '7700', + false, + 0, + 4, + ], + ], +}; + +// adsb.lol /v2/mil: readsb aircraft with `seen`/`seen_pos` ages in seconds. +const ADSB_LOL = { + ac: [ + { + hex: 'ae01ce', + flight: 'RCH123 ', + t: 'C17', + lat: 31.1, + lon: -97.4, + alt_baro: 18000, + gs: 300, + track: 90, + squawk: '1200', + emergency: 'general', + seen: 1.5, + seen_pos: 2, + }, + ], +}; + +function civilAnalystRecord() { + const [row] = openSkySnapshot(OPENSKY, { now: NOW_MS }).records; + const store = new FlightRecords({ + geoidHeight: () => 0, + cachedGroundFloor: () => null, + floorAltitudeM: (altitude) => altitude, + approxDistanceKm: () => 0, + }); + const { meta } = store.receive(row, { + viewerLatDeg: null, + viewerLonDeg: null, + trackedId: null, + floorWarmPoints: [], + }); + return mapFlight(row.id, meta); +} + +function militaryAnalystRecord() { + const [row] = readsbSnapshot(ADSB_LOL, { + observedAtMs: NOW_MS, + now: NOW_MS, + }).records; + const store = new MilitaryFlightRecords({ + geoidHeight: () => 0, + cachedGroundFloor: () => null, + floorAltitudeM: (altitude) => altitude, + }); + const { meta } = store.receive(row, { + observedAtMs: NOW_MS, + floorWarmPoints: [], + modelOwnsVisual: false, + }); + return mapMilitary(row.id, meta); +} + +test('OpenSky 7700 and adsb.lol emergency=general differ only in provenance', () => { + const civil = civilAnalystRecord(); + const military = militaryAnalystRecord(); + + assert.equal(civil.emergency, true); + assert.equal(civil.emergencyKind, 'general'); + assert.equal(civil.squawk, '7700'); + assert.equal(civil.emergencySource, 'squawk'); + assert.equal( + civil.emergencyBroadcast, + 'broadcasting squawk 7700 (general emergency)', + ); + assert.equal(civil.observedAtMs, NOW_MS - 3000, 'OpenSky last_contact'); + + assert.equal(military.emergency, true); + assert.equal(military.emergencyKind, 'general'); + assert.equal(military.squawk, '1200', 'the code is not an emergency code'); + assert.equal(military.emergencySource, 'ads-b'); + assert.equal(military.emergencyBroadcast, 'ADS-B reports general emergency'); + assert.equal(military.observedAtMs, NOW_MS - 1500, 'snapshot time - seen'); +}); + +test('the analyst can filter emergencies by source', async () => { + const records = { + flights: [civilAnalystRecord()], + military: [militaryAnalystRecord()], + }; + const engine = createAnalystEngine({ + getRecords: (key) => records[key] || [], + resolveRegionRing: async () => null, + getViewContext: () => ({ lat: 30.5, lon: -97.5, viewRadiusKm: 500 }), + }); + const query = (value) => + engine.query({ + layers: ['flights', 'military'], + scope: { kind: 'anywhere' }, + filters: [ + { field: 'emergency', op: 'eq', value: true }, + { field: 'emergencySource', op: 'eq', value }, + ], + }); + const bySquawk = await query('squawk'); + assert.deepEqual( + bySquawk.items.map((item) => [item.icao24, item.emergencyBroadcast]), + [['a1b2c3', 'broadcasting squawk 7700 (general emergency)']], + ); + const byAdsb = await query('ads-b'); + assert.deepEqual( + byAdsb.items.map((item) => [item.icao24, item.emergencyBroadcast]), + [['ae01ce', 'ADS-B reports general emergency']], + ); +}); diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index 0e7021251..6c6a586d6 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -74,6 +74,9 @@ test('flights analyst record: full record maps every contract field', () => { squawk: null, emergency: false, emergencyKind: null, + emergencySource: null, + emergencyBroadcast: null, + observedAtMs: null, routeOrigin: 'AUS', routeDestination: 'LAX', }); diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index 9e6e4aea8..b6441aa52 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -70,6 +70,9 @@ test('military analyst record: full record maps every contract field', () => { squawk: null, emergency: false, emergencyKind: null, + emergencySource: null, + emergencyBroadcast: null, + observedAtMs: null, routeOrigin: null, routeDestination: null, }); diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 89ea44214..0714acfc5 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -15,6 +15,7 @@ import { FOCUS_EVIDENCE_DEV, FLEET_DR_INTERVAL_MS, } from './policy.js'; +import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js'; export function createQueries({ flightState, @@ -223,7 +224,8 @@ export function createQueries({ * heading: number|null, verticalRateMps: number|null, onGround: boolean, * military: boolean, aircraftClass: string|null, originCountry: string|null, * operator: string|null, squawk: string|null, emergency: boolean, - * emergencyKind: string|null, routeOrigin: string|null, + * emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null, + * emergencyBroadcast: string|null, observedAtMs: number|null, routeOrigin: string|null, * routeDestination: string|null}} */ @@ -264,6 +266,14 @@ export function createQueries({ // without the flag. emergency: info?.emergency?.severity === 'emergency', emergencyKind: text(info?.emergency?.kind), + // Provenance, so a summary can say "broadcasting squawk 7700" rather + // than "ADS-B reports general emergency" (or flatten both): 'squawk' + // when the Mode A code names the status, 'ads-b' for the readsb field. + emergencySource: text(info?.emergency?.source), + emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency), + // Epoch ms of the last transponder message — when this squawk/status + // was heard (OpenSky time_contact; readsb snapshot time minus `seen`). + observedAtMs: num(info?.lastContactEpochMs), routeOrigin: routeOk ? text(info?.route?.origin?.code) : null, routeDestination: routeOk ? text(info?.route?.destination?.code) : null, }; diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index eab06458c..0d418ddc1 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -9,6 +9,7 @@ import { CONTACT_MATCH_TIER, } from '../../data/contactMatch.js'; import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js'; +import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js'; export function createQueries({ flightState, @@ -160,7 +161,8 @@ export function createQueries({ * heading: number|null, verticalRateMps: number|null, onGround: boolean, * military: boolean, aircraftClass: string|null, originCountry: null, * operator: string|null, squawk: string|null, emergency: boolean, - * emergencyKind: string|null, routeOrigin: null, routeDestination: null}} + * emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null, + * emergencyBroadcast: string|null, observedAtMs: number|null, routeOrigin: null, routeDestination: null}} */ function mapAnalystRecord(icao24, info) { @@ -197,6 +199,14 @@ export function createQueries({ // without the flag. emergency: info?.emergency?.severity === 'emergency', emergencyKind: text(info?.emergency?.kind), + // Provenance, so a summary can say "broadcasting squawk 7700" rather + // than "ADS-B reports general emergency" (or flatten both): 'squawk' + // when the Mode A code names the status, 'ads-b' for the readsb field. + emergencySource: text(info?.emergency?.source), + emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency), + // Epoch ms of the last transponder message — when this squawk/status + // was heard (OpenSky time_contact; readsb snapshot time minus `seen`). + observedAtMs: num(info?.lastContactEpochMs), routeOrigin: null, routeDestination: null, }; From ec80c89c1e3e142bd3a49b88052c512dc2b723ea Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Tue, 13 Oct 2026 17:42:30 +0200 Subject: [PATCH 31/86] fix: integrate GNSS and emergency features with current main contracts --- CHANGELOG.md | 9 + DATA_SOURCES.md | 2 +- docs/CURRENT-STATE.md | 16 +- scripts/qa-emergency-squawk.mjs | 2 - scripts/qa-gnss.mjs | 29 +- server/providers/gnssIntegrity.js | 20 +- server/providers/openai/toolDescriptions.js | 2 +- src/app/constructCatalog.test.mjs | 4 +- src/app/sourceComposition.js | 1 + src/app/sourceComposition.test.mjs | 2 +- src/data/adsbLolFallback.js | 21 +- src/data/aircraftEmergencyAnalyst.test.mjs | 189 +++- src/data/analystEngine.test.mjs | 16 +- src/data/flights.test.mjs | 1117 +++++++++++++++---- src/data/gnssIntegrityProxy.test.mjs | 43 +- src/data/layerState.js | 2 +- src/data/layerState.test.mjs | 26 +- src/data/layerStateTokenLedger.test.mjs | 62 +- src/data/layerStateTokenReservations.json | 2 +- src/data/militaryFlights.test.mjs | 410 +++++-- src/firstRunExperience.test.mjs | 6 +- src/layers/flights/queries.js | 8 +- src/layers/military/queries.js | 8 +- src/sources/live/aircraft.js | 8 +- src/voice/actionSchemas.test.mjs | 4 +- src/voice/gevActions.js | 5 +- src/voice/layerManifest.js | 20 + 27 files changed, 1581 insertions(+), 453 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e288e8ed..f7bf0a65a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,15 @@ ## [Unreleased] +- Preserve full GNSS upstream retry deadlines and concurrent cooldowns; keep + published Street Level token `0` and assign GNSS token `3`. Live QA requires + a successful dated snapshot even in empty airspace. +- Civil Flights fallback retains ADS-B-only emergency/priority status and clears + it on later polls. Analyst/voice results label the retained general aircraft + contact time as `lastContactAtMs`, not the time of the current status. + GNSS Integrity and aircraft emergency highlighting contributed by + [Leo Pechnicki](https://github.com/leopechnicki) (#855, #856). + - Flights and Military Flights highlight aircraft broadcasting a transponder emergency. Squawk 7500 (unlawful interference), 7600 (radio failure) and 7700 (general emergency) — or adsb.lol's ADS-B `emergency` status, which diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 4681f8984..9afeec60b 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -79,7 +79,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. - **GNSS integrity (adsb.lol integrity fields).** The GNSS Integrity layer (id `gnss-interference`) reads the same keyless 250 nm point snapshot around the camera (anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. The method has three separately sourced parts, exposed as machine-readable `provenance` on the layer and its stats: (1) a GEV-defined per-aircraft classifier (`gev-nic-nacp-v1`): an aircraft has low navigation accuracy when it reports NIC < 7 or NACp < 8 or readsb flags GPS loss. The numbers are borrowed from the 14 CFR 91.227(c) ADS-B Out performance minima, a US regulation, not a published interference classifier; gpsjam.org does not publish its per-aircraft threshold ([FAQ](https://gpsjam.org/faq)), and this one is not yet validated against known events. (2) gpsjam.org's published cell formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %). (3) A GEV-defined window: at least 3 aircraft per 0.5° cell over a rolling 30 minutes around the views the user visits. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is a secondary interpretation, not a confirmed source or location: avionics faults also report low values. Verified 2026-09-29. -- **Transponder emergencies.** Emergency highlighting reads fields the flight feeds already return: OpenSky state vectors' `squawk` and adsb.lol/readsb's `squawk` and `emergency`. It makes no extra request. A broadcast code states what the transponder sends, not a verified incident; OpenSky has no ADS-B emergency field, so civil highlighting there relies on squawk 7500/7600/7700 alone. +- **Transponder emergencies.** Emergency highlighting reads fields the flight feeds already return: OpenSky state vectors' `squawk` and adsb.lol/readsb's `squawk` and `emergency`. It makes no extra request. A broadcast code states what the transponder sends, not a verified incident; OpenSky has no ADS-B emergency field, so genuine OpenSky highlighting relies on squawk 7500/7600/7700 alone. The civil adsb.lol fallback preserves its additional ADS-B status beside the OpenSky-compatible vectors, without another request. - **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. - **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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a186b9c28..c894d49de 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -834,9 +834,13 @@ else `ADS-B EMERGENCY · …` or `ADS-B PRIORITY · …`, in red. Analyst record the voice query fields expose `squawk`, `emergency` (flag), `emergencyKind` and `emergencySource` (`squawk` when the Mode A code names the status, `ads-b` for the readsb field), plus `emergencyBroadcast` (`broadcasting squawk 7700 (general -emergency)` vs `ADS-B reports general emergency`) and `observedAtMs`, the epoch -ms of the last transponder message. Voice results carry `observedAtMs` only for -aircraft with a status. +emergency)` vs `ADS-B reports general emergency`) and `lastContactAtMs`, the latest known general aircraft-message contact in epoch +ms. Missing contact times retain the preceding known contact, or stay null if +none is known. This does not timestamp the current emergency broadcast. Civil +adsb.lol fallback preserves the readsb status in `emergencyByIcao` alongside +unchanged OpenSky-compatible vectors, including clearing on subsequent polls. +Compact voice results retain the emergency flag, source, phrase and named +last-contact time; they do not label that time as the status receipt. `scripts/qa-emergency-squawk.mjs` proves the tint, HUD line and clearing. ## Browser live-source observations @@ -5207,3 +5211,9 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + +GNSS integration reserves share token `3`, retaining published Street Level `0`. +It uses the catalog source contract and voice layer manifest. Upstream 429 +Retry-After seconds/HTTP dates retain their full deadlines; overlapping requests +keep the longest cooldown. Live GNSS QA requires a successful dated response +even when there are no qualifying cells. diff --git a/scripts/qa-emergency-squawk.mjs b/scripts/qa-emergency-squawk.mjs index a1625ef6e..88f1adf37 100644 --- a/scripts/qa-emergency-squawk.mjs +++ b/scripts/qa-emergency-squawk.mjs @@ -202,8 +202,6 @@ async function main() { ? { executablePath } : {}), args: [ - '--no-sandbox', - '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', '--disable-dev-shm-usage', diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index e673ac224..12cab5ebd 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -329,8 +329,6 @@ async function main() { ? { executablePath } : {}), args: [ - '--no-sandbox', - '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', '--disable-dev-shm-usage', @@ -353,7 +351,7 @@ async function main() { const liveBodies = []; live.on('response', (res) => { if (res.url().includes('/api/gnss-integrity') && res.ok()) - liveBodies.push(res.json().then((body) => body.rows ?? [])); + liveBodies.push(res.json()); }); await boot(live, { lon: 19.5, lat: 52.5, height: 1_100_000 }); const l = await enableAndSettle(live); @@ -364,10 +362,29 @@ async function main() { const liveSnapshots = (await Promise.allSettled(liveBodies)) .filter((r) => r.status === 'fulfilled') .map((r) => r.value); - const liveRows = liveSnapshots.reduce((sum, rows) => sum + rows.length, 0); + record( + 'LIVE: a successful, dated integrity response was received', + liveSnapshots.length > 0 && + liveSnapshots.every( + (body) => + Array.isArray(body.rows) && + Number.isFinite(body.ageMs) && + body.ageMs >= 0 && + body.classifier === 'gev-nic-nacp-v1', + ), + `successful responses=${liveSnapshots.length}`, + ); + const liveRows = liveSnapshots.reduce( + (sum, body) => sum + (body.rows?.length || 0), + 0, + ); const expectedStore = new Map(); - for (const rows of liveSnapshots) - accumulateGnssObservations(expectedStore, rows, Date.now()); + for (const body of liveSnapshots) { + const at = Date.now(); + accumulateGnssObservations(expectedStore, body.rows, at, { + observedAt: at - body.ageMs, + }); + } const expectedCells = binGnssCells(expectedStore).length; record( 'LIVE: no error, one entity per cell', diff --git a/server/providers/gnssIntegrity.js b/server/providers/gnssIntegrity.js index 277fc119f..4b27bff4f 100644 --- a/server/providers/gnssIntegrity.js +++ b/server/providers/gnssIntegrity.js @@ -18,9 +18,8 @@ const CACHE_MS = 60_000; const CACHE_MAX = 64; /** Cooldown after a 429 or 5xx when upstream sends no usable Retry-After. */ const COOLDOWN_MS = 60_000; -/** Bounds for an upstream-supplied Retry-After. */ +/** Minimum backoff; a valid longer upstream deadline is never shortened. */ const COOLDOWN_MIN_MS = 5_000; -const COOLDOWN_MAX_MS = 120_000; const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; const TIMEOUT_MS = 10_000; @@ -36,13 +35,14 @@ export function gnssIntegrityAnchor(url) { /** Cooldown (ms) an upstream 429 earns, honouring Retry-After when sane. */ export function gnssRetryCooldownMs(raw, nowMs) { - const clamp = (ms) => - Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, ms)); if (raw) { const seconds = Number(raw); - if (Number.isFinite(seconds) && seconds > 0) return clamp(seconds * 1000); + const ms = seconds * 1000; + if (Number.isFinite(ms) && seconds > 0) + return Math.max(COOLDOWN_MIN_MS, ms); const at = Date.parse(raw); - if (Number.isFinite(at) && at > nowMs) return clamp(at - nowMs); + if (Number.isFinite(at) && at > nowMs) + return Math.max(COOLDOWN_MIN_MS, at - nowMs); } return COOLDOWN_MS; } @@ -75,9 +75,11 @@ export function gnssIntegrityProxy({ if (!response.ok) { await response.body?.cancel(); if (response.status === 429) { - cooldownUntil = - now() + - gnssRetryCooldownMs(response.headers.get('retry-after'), now()); + const at = now(); + cooldownUntil = Math.max( + cooldownUntil, + at + gnssRetryCooldownMs(response.headers.get('retry-after'), at), + ); throw Object.assign(new Error('upstream_rate_limited'), { status: 429, }); diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 450c9da81..3504be9a1 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -543,7 +543,7 @@ export const ACTION_DESCRIPTIONS = { }, analyst_query: { description: - 'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values).', + 'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values). Aircraft emergency fields describe broadcasts, not confirmed incidents: narrate emergencyBroadcast with emergencySource. lastContactAtMs is retained general aircraft-message contact and never a status-specific receipt timestamp.', $position: 1, parameters: { properties: { diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index f1e4c8c4b..d5a1fff63 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 30); + assert.equal(first.layers.length, 31); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'local-adsb'), @@ -154,7 +154,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership', surface: fixtureSurface(lifetime.signal), }; const catalog = createApplicationCatalog(options); - assert.equal(catalog.layers.length, 30); + assert.equal(catalog.layers.length, 31); assert.deepEqual(catalog.get('street-level').providerIds, []); assert.equal( catalogControlServices(catalog).streetLevelLayer, diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 14c864ac5..826b90b1f 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -46,6 +46,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ cyclones: contract(['weather-cyclones'], ['getSnapshot']), earthquakes: contract(['earthquakes'], ['getSnapshot']), 'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']), + 'gnss-interference': contract(['gnss-interference'], ['getSnapshot']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), }); diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index 44a7c8fa0..cf8646e25 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); - assert.equal(actual.layers.length, 30); + assert.equal(actual.layers.length, 31); const manager = new LayerLifecycle( {}, { getSourceAvailability: actual.getSourceAvailability }, diff --git a/src/data/adsbLolFallback.js b/src/data/adsbLolFallback.js index 7cd887dc6..2ae17fbe6 100644 --- a/src/data/adsbLolFallback.js +++ b/src/data/adsbLolFallback.js @@ -94,8 +94,25 @@ export function normalizeAdsbLolPointResponse(payload) { : Math.floor( responseNow > 10_000_000_000 ? responseNow / 1000 : responseNow, ); - const states = (Array.isArray(payload?.ac) ? payload.ac : []) + const aircraft = Array.isArray(payload?.ac) ? payload.ac : []; + const states = aircraft .map((aircraft) => normalizeAdsbLolAircraftState(aircraft, nowSeconds)) .filter(Boolean); - return { time: nowSeconds, states }; + // Keep the OpenSky vectors unchanged. The regional fallback's extra readsb + // status travels alongside them, keyed only to admitted aircraft. + const admitted = new Set(states.map((row) => row[0])); + const emergencyByIcao = Object.fromEntries( + aircraft.flatMap((row) => { + const id = String(row?.hex || '') + .trim() + .toLowerCase(); + if (!admitted.has(id) || typeof row?.emergency !== 'string') return []; + return [[id, row.emergency.trim().toLowerCase()]]; + }), + ); + return { + time: nowSeconds, + states, + ...(Object.keys(emergencyByIcao).length ? { emergencyByIcao } : {}), + }; } diff --git a/src/data/aircraftEmergencyAnalyst.test.mjs b/src/data/aircraftEmergencyAnalyst.test.mjs index 8df428162..2bb9ede1b 100644 --- a/src/data/aircraftEmergencyAnalyst.test.mjs +++ b/src/data/aircraftEmergencyAnalyst.test.mjs @@ -11,9 +11,157 @@ import { MilitaryFlightRecords } from '../layers/military/records.js'; import { mapAnalystRecord as mapFlight } from './flights.js'; import { mapAnalystRecord as mapMilitary } from './militaryFlights.js'; import { createAnalystEngine } from './analystEngine.js'; +import { normalizeAdsbLolPointResponse } from './adsbLolFallback.js'; +import { createGevActionRunner } from '../voice/gevActions.js'; const NOW_MS = 1800000000000; +function civilStore() { + return new FlightRecords({ + geoidHeight: () => 0, + cachedGroundFloor: () => null, + floorAltitudeM: (value) => value, + approxDistanceKm: () => 0, + }); +} +const receiveContext = { + viewerLatDeg: null, + viewerLonDeg: null, + trackedId: null, + floorWarmPoints: [], +}; + +test('real civil fallback preserves ADS-B emergency and priority, then clears', () => { + const store = civilStore(); + const aircraft = { + hex: 'a1b2c3', + flight: 'TEST1', + lat: 30, + lon: -97, + alt_baro: 10000, + gs: 150, + track: 90, + squawk: '1200', + seen: 3, + seen_pos: 1, + }; + for (const [status, expectedKind, flagged] of [ + ['general', 'general', true], + ['minfuel', 'minfuel', false], + ['none', null, false], + ]) { + const payload = normalizeAdsbLolPointResponse({ + now: NOW_MS, + ac: [{ ...aircraft, emergency: status }], + }); + assert.equal( + payload.states[0].length, + 18, + 'fallback vectors retain the OpenSky shape', + ); + const [observation] = openSkySnapshot(payload, { now: NOW_MS }).records; + const result = mapFlight( + aircraft.hex, + store.receive(observation, receiveContext).meta, + ); + assert.equal(result.emergency, flagged); + assert.equal(result.emergencyKind, expectedKind); + assert.equal(result.emergencySource, expectedKind ? 'ads-b' : null); + assert.equal(result.lastContactAtMs, NOW_MS - 3000); + } +}); + +test('changed status without contact time retains general contact, including voice projection', async () => { + const store = civilStore(); + const [observation] = readsbSnapshot( + { + ac: [ + { + hex: 'a1b2c3', + flight: 'TEST1', + lat: 30, + lon: -97, + alt_baro: 10000, + gs: 150, + track: 90, + squawk: '1200', + emergency: 'general', + seen: 3, + seen_pos: 1, + }, + ], + }, + { observedAtMs: NOW_MS, now: NOW_MS }, + ).records; + store.receive(observation, receiveContext); + const meta = store.receive( + { ...observation, contactTimeMs: null, squawk: '7600', emergency: 'none' }, + receiveContext, + ).meta; + const record = mapFlight(observation.id, meta); + assert.equal(record.emergencyKind, 'nordo'); + assert.equal(record.lastContactAtMs, NOW_MS - 3000); + assert.equal(Object.hasOwn(record, 'observedAtMs'), false); + globalThis.window = globalThis.window || { + clearTimeout, + setTimeout, + requestIdleCallback: null, + }; + const flights = { + id: 'flights', + source: 'fixture', + getStats: () => ({ count: 1 }), + getAnalystRecords: () => [record], + }; + const runner = createGevActionRunner({ + viewer: { + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, + camera: { + moveEnd: { addEventListener() {} }, + positionCartographic: { + height: 300000, + latitude: 0.52, + longitude: -1.71, + }, + }, + }, + styleManager: { + getContextModeState: () => ({ mode: null, active: false }), + getCockpitState: () => ({ active: false }), + }, + dataManager: { + layers: new Map([['flights', { module: flights }]]), + isEnabled: (id) => id === 'flights', + getAll: () => [ + { id: 'flights', name: 'Flights', enabled: true, stats: { count: 1 } }, + ], + }, + }); + try { + const result = await runner('analyst_query', { + layers: ['flights'], + scope: { kind: 'anywhere' }, + filters: [{ field: 'emergency', op: 'eq', value: true }], + }); + assert.equal(result.ok, true); + assert.equal(result.items[0].emergency, true); + assert.equal(result.items[0].lastContactAtMs, NOW_MS - 3000); + assert.equal(result.items[0].emergencySource, 'squawk'); + assert.equal( + result.items[0].emergencyBroadcast, + 'broadcasting squawk 7600 (radio failure)', + ); + assert.equal( + Object.hasOwn(result.items[0], 'time'), + false, + 'general contact is not a generic incident timestamp', + ); + } finally { + runner.dispose?.(); + } +}); + // OpenSky /states/all: [icao24, callsign, origin_country, time_position, // last_contact, lon, lat, baro_alt, on_ground, velocity, true_track, // vertical_rate, sensors, geo_alt, squawk, spi, position_source, category]. @@ -110,14 +258,14 @@ test('OpenSky 7700 and adsb.lol emergency=general differ only in provenance', () civil.emergencyBroadcast, 'broadcasting squawk 7700 (general emergency)', ); - assert.equal(civil.observedAtMs, NOW_MS - 3000, 'OpenSky last_contact'); + assert.equal(civil.lastContactAtMs, NOW_MS - 3000, 'OpenSky last_contact'); assert.equal(military.emergency, true); assert.equal(military.emergencyKind, 'general'); assert.equal(military.squawk, '1200', 'the code is not an emergency code'); assert.equal(military.emergencySource, 'ads-b'); assert.equal(military.emergencyBroadcast, 'ADS-B reports general emergency'); - assert.equal(military.observedAtMs, NOW_MS - 1500, 'snapshot time - seen'); + assert.equal(military.lastContactAtMs, NOW_MS - 1500, 'snapshot time - seen'); }); test('the analyst can filter emergencies by source', async () => { @@ -150,3 +298,40 @@ test('the analyst can filter emergencies by source', async () => { [['ae01ce', 'ADS-B reports general emergency']], ); }); + +test('military status changes retain only the latest known general contact', () => { + const store = new MilitaryFlightRecords({ + geoidHeight: () => 0, + cachedGroundFloor: () => null, + floorAltitudeM: (v) => v, + }); + const [observation] = readsbSnapshot(ADSB_LOL, { + observedAtMs: NOW_MS, + now: NOW_MS, + }).records; + const context = { + observedAtMs: NOW_MS, + floorWarmPoints: [], + modelOwnsVisual: false, + }; + const unknown = mapMilitary( + observation.id, + store.receive({ ...observation, contactTimeMs: null }, context).meta, + ); + assert.equal(unknown.lastContactAtMs, null); + store.receive(observation, context); + const changed = mapMilitary( + observation.id, + store.receive( + { + ...observation, + contactTimeMs: null, + squawk: '7600', + emergency: 'none', + }, + context, + ).meta, + ); + assert.equal(changed.emergencyKind, 'nordo'); + assert.equal(changed.lastContactAtMs, NOW_MS - 1500); +}); diff --git a/src/data/analystEngine.test.mjs b/src/data/analystEngine.test.mjs index 3bb0dd06e..8da85a4d6 100644 --- a/src/data/analystEngine.test.mjs +++ b/src/data/analystEngine.test.mjs @@ -7,7 +7,7 @@ const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-10 const FLIGHTS = [ { id: 'SWA1', lat: 30.2, lon: -97.7, altitudeM: 11000, speedMps: 240, military: false, onGround: false, routeOrigin: 'AUS', routeDestination: 'LAX' }, { id: 'RCH01', lat: 31.0, lon: -97.0, altitudeM: 13500, speedMps: 250, military: true, onGround: false, routeOrigin: null, routeDestination: null }, - { id: 'N123', lat: 45.0, lon: -122.0, altitudeM: 2000, speedMps: 80, military: false, onGround: false, squawk: '7700', emergency: true, emergencyKind: 'general', routeOrigin: null, routeDestination: null }, + { id: 'N123', lat: 45.0, lon: -122.0, altitudeM: 2000, speedMps: 80, military: false, onGround: false, routeOrigin: null, routeDestination: null }, { id: 'GND1', lat: 30.19, lon: -97.66, altitudeM: 150, speedMps: 5, military: false, onGround: true, routeOrigin: null, routeDestination: null }, ]; const SHIPS = [ @@ -325,19 +325,6 @@ test('analyst: route fields queryable from cached enrichment only', async () => assert.deepEqual(r.items.map((i) => i.id), ['SWA1'], 'null route fields never match'); }); -test('analyst: aircraft broadcasting an emergency are filterable by flag and code', async () => { - const flagged = await makeEngine().query({ - layers: ['flights'], scope: { kind: 'anywhere' }, - filters: [{ field: 'emergency', op: 'eq', value: true }], - }); - assert.deepEqual(flagged.items.map((i) => i.id), ['N123']); - const coded = await makeEngine().query({ - layers: ['flights'], scope: { kind: 'anywhere' }, - filters: [{ field: 'squawk', op: 'eq', value: '7700' }], - }); - assert.deepEqual(coded.items.map((i) => i.id), ['N123']); -}); - test('analyst: satellites and local infrastructure are queryable layers', async () => { const SATS = [ { id: 'ISS (ZARYA)', noradId: '25544', name: 'ISS (ZARYA)', lat: 30.3, lon: -97.7, altitudeM: 410000, satelliteClass: 'STATION · ISS', group: 'stations' }, @@ -519,7 +506,6 @@ test('time fields filter by ISO and derive ageHours; the feed window is exposed' assert.equal(age.coverage.layersQueried[0].window, 'last 24 h'); }); - test('follow-up provenance stays attached to old rows after a feed recovers', async () => { let state = 'stale'; const engine = createAnalystEngine({ diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index 6c6a586d6..c325ea744 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -18,8 +18,10 @@ import flightsLayer, { } from './flights.js'; import { setMilitaryLayerActive } from './militaryRegistry.js'; import { - GROUND_FLOOR_LIFT_M, reportMeshFloorCell, - setMeshFloorPreferred, _clearMeshFloorCellsForTest, + GROUND_FLOOR_LIFT_M, + reportMeshFloorCell, + setMeshFloorPreferred, + _clearMeshFloorCellsForTest, } from './groundFloor.js'; import { _setTrackedOverlayHostForTest, @@ -33,7 +35,10 @@ test('share-Follow absence requires an accepted OpenSky snapshot', async () => { (await flightsLayer.resolveTrackingRestoreTarget('abc123')).status, 'source-unavailable', ); - _setFlightTrackingRefreshOutcomeForTest({ status: 'accepted', ids: ['different'] }); + _setFlightTrackingRefreshOutcomeForTest({ + status: 'accepted', + ids: ['different'], + }); const missing = await flightsLayer.resolveTrackingRestoreTarget('abc123'); assert.equal(missing.status, 'missing'); assert.equal(missing.reason, 'target-absent-from-snapshot'); @@ -55,7 +60,10 @@ const FULL_INFO = { }; test('flights analyst record: full record maps every contract field', () => { - const r = mapAnalystRecord('a1b2c3', FULL_INFO, { military: false, routeOk: true }); + const r = mapAnalystRecord('a1b2c3', FULL_INFO, { + military: false, + routeOk: true, + }); assert.deepEqual(r, { id: 'SWA696', icao24: 'a1b2c3', @@ -76,7 +84,7 @@ test('flights analyst record: full record maps every contract field', () => { emergencyKind: null, emergencySource: null, emergencyBroadcast: null, - observedAtMs: null, + lastContactAtMs: null, routeOrigin: 'AUS', routeDestination: 'LAX', }); @@ -96,7 +104,8 @@ test('flights analyst record: empty info yields nulls, never NaN/undefined', () assert.equal(r.military, false); for (const [key, value] of Object.entries(r)) { assert.notEqual(value, undefined, `${key} must not be undefined`); - if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`); + if (typeof value === 'number') + assert.ok(Number.isFinite(value), `${key} must not be NaN`); } }); @@ -104,14 +113,31 @@ test('flights analyst record: no callsign falls back to registration, then icao2 const r = mapAnalystRecord('abc123', { ...FULL_INFO, callsign: ' ' }); assert.equal(r.id, 'abc123'); // FULL_INFO carries no registration assert.equal(r.callsign, null); - assert.equal(mapAnalystRecord('abc123', { ...FULL_INFO, callsign: '', registration: 'N123AB' }).id, 'N123AB'); - assert.equal(mapAnalystRecord('abc123', { callsign: '', registration: ' ' }).id, 'abc123'); + assert.equal( + mapAnalystRecord('abc123', { + ...FULL_INFO, + callsign: '', + registration: 'N123AB', + }).id, + 'N123AB', + ); + assert.equal( + mapAnalystRecord('abc123', { callsign: '', registration: ' ' }).id, + 'abc123', + ); }); test('flights analyst record: NaN kinematics become null, military flag passes through', () => { - const r = mapAnalystRecord('ae01ce', { - ...FULL_INFO, velocity: NaN, true_track: undefined, verticalRate: null, - }, { military: true }); + const r = mapAnalystRecord( + 'ae01ce', + { + ...FULL_INFO, + velocity: NaN, + true_track: undefined, + verticalRate: null, + }, + { military: true }, + ); assert.equal(r.speedMps, null); assert.equal(r.heading, null); assert.equal(r.verticalRateMps, null); @@ -126,17 +152,25 @@ test('flights analyst record: output is JSON-safe (no Cesium types)', () => { test('flights first update forwards caller cancellation into the feed request', async () => { const realFetch = globalThis.fetch; let observedSignal = null; - globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => { - observedSignal = options.signal; - options.signal?.addEventListener('abort', () => { - reject(new DOMException('aborted', 'AbortError')); - }, { once: true }); - }); + globalThis.fetch = (_url, options = {}) => + new Promise((_resolve, reject) => { + observedSignal = options.signal; + options.signal?.addEventListener( + 'abort', + () => { + reject(new DOMException('aborted', 'AbortError')); + }, + { once: true }, + ); + }); try { const controller = new AbortController(); - const work = flightsLayer.update({ camera: { positionCartographic: null }, scene: {} }, { - signal: controller.signal, - }); + const work = flightsLayer.update( + { camera: { positionCartographic: null }, scene: {} }, + { + signal: controller.signal, + }, + ); await Promise.resolve(); assert.ok(observedSignal); controller.abort(); @@ -161,14 +195,27 @@ test('nonempty OpenSky payload with zero usable rows cannot prove share target a ok: true, status: 200, headers: { get: () => null }, - json: async () => ({ states: [null, {}, ['abc123', null, null, null, null, null, null]] }), + json: async () => ({ + states: [null, {}, ['abc123', null, null, null, null, null, null]], + }), }); try { - await flightsLayer.update({ camera: { positionCartographic: null }, scene: {} }); - const resolution = await flightsLayer.resolveTrackingRestoreTarget('abc123'); + await flightsLayer.update({ + camera: { positionCartographic: null }, + scene: {}, + }); + const resolution = + await flightsLayer.resolveTrackingRestoreTarget('abc123'); assert.equal(resolution.status, 'source-unavailable'); - assert.match(flightsLayer.getStats().error, /Malformed OpenSky aircraft rows/); - assert.equal(flightsLayer.getAnalystRecords().length, 1, 'warm aircraft data is preserved'); + assert.match( + flightsLayer.getStats().error, + /Malformed OpenSky aircraft rows/, + ); + assert.equal( + flightsLayer.getAnalystRecords().length, + 1, + 'warm aircraft data is preserved', + ); } finally { globalThis.fetch = realFetch; } @@ -306,13 +353,31 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA if (!String(url).startsWith('/api/flights')) { return { ok: true, status: 200, json: async () => ({ ac: [] }) }; } - const states = openskyPoll++ === 0 - ? [[ - icao24, 'DAL123 ', 'United States', nowSec, nowSec, - -97.6, 30.3, 10_668, false, 250, 95, 5, null, 10_700, - null, null, null, 5, - ]] - : []; + const states = + openskyPoll++ === 0 + ? [ + [ + icao24, + 'DAL123 ', + 'United States', + nowSec, + nowSec, + -97.6, + 30.3, + 10_668, + false, + 250, + 95, + 5, + null, + 10_700, + null, + null, + null, + 5, + ], + ] + : []; return { ok: true, status: 200, @@ -351,14 +416,18 @@ test('real civil track path creates no native label and publishes every cached h trackedEntityChanged, clock: { currentTime: now }, camera: { - cancelFlight() { cancelledFlights += 1; }, + cancelFlight() { + cancelledFlights += 1; + }, position: new Cesium.Cartesian3(0, -10_000, 7_000), positionWC: new Cesium.Cartesian3(0, -10_000, 7_000), direction: Cesium.Cartesian3.UNIT_Y, transform: Cesium.Matrix4.IDENTITY, viewMatrix: Cesium.Matrix4.IDENTITY, frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY }, - lookAtTransform() { appliedFrames += 1; }, + lookAtTransform() { + appliedFrames += 1; + }, }, scene: { canvas: { clientWidth: 1600, clientHeight: 900 }, @@ -397,9 +466,12 @@ test('real civil track path creates no native label and publishes every cached h const realFetch = globalThis.fetch; globalThis.window = new EventTarget(); const selectionEvents = []; - globalThis.window.addEventListener('gev:awareness-subject-selected', (event) => { - selectionEvents.push(event.detail); - }); + globalThis.window.addEventListener( + 'gev:awareness-subject-selected', + (event) => { + selectionEvents.push(event.detail); + }, + ); globalThis.fetch = async () => ({ ok: false }); _setTrackedOverlayHostForTest(host); try { @@ -411,13 +483,15 @@ test('real civil track path creates no native label and publishes every cached h billboardCollection: { show: true, remove() {} }, viewer, tracked: false, - history: [{ - time: now, - epochMs: Date.now(), - position, - velocity: 250, - track: 95, - }], + history: [ + { + time: now, + epochMs: Date.now(), + position, + velocity: 250, + track: 95, + }, + ], meta: { callsign: 'N12345', altitude: 10_668, @@ -440,11 +514,19 @@ test('real civil track path creates no native label and publishes every cached h }, }); - assert.equal(flightsLayer.trackById(icao24, { origin: 'programmatic' }), true); + assert.equal( + flightsLayer.trackById(icao24, { origin: 'programmatic' }), + true, + ); const entity = viewer.trackedEntity; - assert.ok(entity instanceof Cesium.Entity, 'trackById must create the real Cesium entity'); + assert.ok( + entity instanceof Cesium.Entity, + 'trackById must create the real Cesium entity', + ); assert.equal(entity.label, undefined); - assert.ok(entities.values.every((candidate) => candidate.label === undefined)); + assert.ok( + entities.values.every((candidate) => candidate.label === undefined), + ); assert.deepEqual(entity.gevLabelModel, { title: 'N12345 · FL350 · 486 kts', details: ['TEST AIR · A320', 'AUS → LAX'], @@ -454,17 +536,44 @@ test('real civil track path creates no native label and publishes every cached h const initialAppliedFrames = appliedFrames; const initialCancelledFlights = cancelledFlights; assert.equal(flightsLayer.trackById(icao24, { origin: 'user' }), true); - assert.equal(cancelledFlights, initialCancelledFlights, 'ordinary repeated tracking stays camera-idempotent'); - assert.equal(selectionEvents.at(-1)?.origin, 'user', 'same-target selection upgrades durable authority'); + assert.equal( + cancelledFlights, + initialCancelledFlights, + 'ordinary repeated tracking stays camera-idempotent', + ); + assert.equal( + selectionEvents.at(-1)?.origin, + 'user', + 'same-target selection upgrades durable authority', + ); assert.equal(entity.gevSelectionOrigin, 'user'); assert.equal(flightsLayer.refocusTrackedById('different-flight'), false); - assert.equal(flightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), true); - assert.equal(selectionEvents.at(-1)?.origin, 'voice', 'same-target refocus forwards explicit authority'); + assert.equal( + flightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), + true, + ); + assert.equal( + selectionEvents.at(-1)?.origin, + 'voice', + 'same-target refocus forwards explicit authority', + ); assert.equal(flightsLayer.refocusTrackedById(icao24), true); viewer.scene.preUpdate.raiseEvent(); - assert.equal(viewer.trackedEntity, entity, 'refocus must retain the exact tracked entity'); - assert.equal(appliedFrames, initialAppliedFrames + 1, 'repeated refocus keeps one camera-frame owner'); - assert.equal(cancelledFlights, initialCancelledFlights + 2, 'only explicit refocus requests cancel camera flights'); + assert.equal( + viewer.trackedEntity, + entity, + 'refocus must retain the exact tracked entity', + ); + assert.equal( + appliedFrames, + initialAppliedFrames + 1, + 'repeated refocus keeps one camera-frame owner', + ); + assert.equal( + cancelledFlights, + initialCancelledFlights + 2, + 'only explicit refocus requests cancel camera flights', + ); const publication = publications.at(-1); assert.equal(publication.sourceId, 'tracked'); const entry = publication.entries[0]; @@ -475,7 +584,11 @@ test('real civil track path creates no native label and publishes every cached h const display = entity.position.getValue(now); assert.ok(display, 'tracked position callback must seed its frame cache'); - assert.equal(entry.position(), display, 'host must reuse the exact cached display Cartesian'); + assert.equal( + entry.position(), + display, + 'host must reuse the exact cached display Cartesian', + ); flightsLayer.setParams( { selectedFlightsTrackingId: 'late001' }, @@ -483,10 +596,17 @@ test('real civil track path creates no native label and publishes every cached h ); assert.equal(_pendingFlightTrackingRestoreForTest(), 'late001'); assert.equal(flightsLayer.trackById(icao24, { origin: 'user' }), true); - assert.equal(_pendingFlightTrackingRestoreForTest(), null, 'new user selection cancels stale restore'); + assert.equal( + _pendingFlightTrackingRestoreForTest(), + null, + 'new user selection cancels stale restore', + ); _addFlightTrackingCandidateForTest({ icao24: 'late001', - billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.5, 30.4, 9_000) }, + billboard: { + ...billboard, + position: Cesium.Cartesian3.fromDegrees(-97.5, 30.4, 9_000), + }, meta: { ...FULL_INFO, callsign: 'LATE1', rawLat: 30.4, rawLon: -97.5 }, history: [], }); @@ -500,10 +620,17 @@ test('real civil track path creates no native label and publishes every cached h ); assert.equal(_pendingFlightTrackingRestoreForTest(), 'late002'); flightsLayer.stopTracking({ origin: 'user' }); - assert.equal(_pendingFlightTrackingRestoreForTest(), null, 'explicit clear cancels stale restore'); + assert.equal( + _pendingFlightTrackingRestoreForTest(), + null, + 'explicit clear cancels stale restore', + ); _addFlightTrackingCandidateForTest({ icao24: 'late002', - billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.4, 30.5, 8_000) }, + billboard: { + ...billboard, + position: Cesium.Cartesian3.fromDegrees(-97.4, 30.5, 8_000), + }, meta: { ...FULL_INFO, callsign: 'LATE2', rawLat: 30.5, rawLon: -97.4 }, history: [], }); @@ -523,7 +650,10 @@ test('real civil track path creates no native label and publishes every cached h ); _addFlightTrackingCandidateForTest({ icao24: 'late003', - billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000) }, + billboard: { + ...billboard, + position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000), + }, meta: { ...FULL_INFO, callsign: 'LATE3', rawLat: 30.6, rawLon: -97.3 }, history: [], }); @@ -537,7 +667,10 @@ test('real civil track path creates no native label and publishes every cached h assert.equal(_pendingFlightTrackingRestoreForTest(), 'late004'); _addFlightTrackingCandidateForTest({ icao24: 'late004', - billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000) }, + billboard: { + ...billboard, + position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000), + }, meta: { ...FULL_INFO, callsign: 'LATE4', rawLat: 30.7, rawLon: -97.2 }, history: [], }); @@ -548,7 +681,10 @@ test('real civil track path creates no native label and publishes every cached h { selectedFlightsTrackingId: 'late005' }, { origin: 'share-restore' }, ); - assert.equal(flightsLayer.trackById(icao24, { origin: 'programmatic' }), true); + assert.equal( + flightsLayer.trackById(icao24, { origin: 'programmatic' }), + true, + ); assert.equal( _pendingFlightTrackingRestoreForTest(), 'late005', @@ -556,7 +692,10 @@ test('real civil track path creates no native label and publishes every cached h ); _addFlightTrackingCandidateForTest({ icao24: 'late005', - billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.1, 30.8, 5_000) }, + billboard: { + ...billboard, + position: Cesium.Cartesian3.fromDegrees(-97.1, 30.8, 5_000), + }, meta: { ...FULL_INFO, callsign: 'LATE5', rawLat: 30.8, rawLon: -97.1 }, history: [], }); @@ -591,7 +730,11 @@ function seedLabelContact({ callsign, registration, tracked = false }) { _setTrackedFlightRefreshStateForTest({ icao24: LABEL_ICAO, entity: null, - billboard: { position: LABEL_POSITION, color: Cesium.Color.WHITE, show: true }, + billboard: { + position: LABEL_POSITION, + color: Cesium.Color.WHITE, + show: true, + }, billboardCollection: { show: true, remove() {} }, viewer: { camera: { positionCartographic: null }, scene: {} }, tracked, @@ -649,7 +792,11 @@ test('civil label chain: a blank callsign falls back to the registration, not th const labels = labelsFor({ callsign: ' ', registration: 'N123AB ' }); for (const [surface, value] of Object.entries(labels)) { if (surface === '_identity') continue; - assert.equal(value, 'N123AB', `${surface} must show the registration, not ${LABEL_ICAO}`); + assert.equal( + value, + 'N123AB', + `${surface} must show the registration, not ${LABEL_ICAO}`, + ); } }); @@ -658,7 +805,11 @@ test('civil label chain: no callsign and no registration still reads as the ICAO const labels = labelsFor({ callsign: null, registration }); for (const [surface, value] of Object.entries(labels)) { if (surface === '_identity') continue; - assert.equal(value, LABEL_ICAO, `${surface} must fall through to the hex`); + assert.equal( + value, + LABEL_ICAO, + `${surface} must fall through to the hex`, + ); } } }); @@ -682,7 +833,11 @@ test('civil label chain: identity stays icao24 while the label moves', () => { ]) { const { _identity } = labelsFor(fixture); for (const [key, value] of Object.entries(_identity)) { - assert.equal(value, LABEL_ICAO, `${key} identity must remain the ICAO hex`); + assert.equal( + value, + LABEL_ICAO, + `${key} identity must remain the ICAO hex`, + ); } } }); @@ -729,7 +884,11 @@ test('civil label chain: the awareness selection EVENT uses the canonical chain' `selection event label for ${JSON.stringify(fixture)}`, ); // Identity must NOT move with the label. - assert.equal(detail.id, LABEL_ICAO, 'selection event identity stays the ICAO hex'); + assert.equal( + detail.id, + LABEL_ICAO, + 'selection event identity stays the ICAO hex', + ); assert.equal(detail.layerId, 'flights'); } }); @@ -740,7 +899,11 @@ test('civil tracked readout: a callsign-less enriched contact reads as its regis _setTrackedFlightRefreshStateForTest({ icao24: LABEL_ICAO, entity, - billboard: { position: LABEL_POSITION, color: Cesium.Color.WHITE, show: false }, + billboard: { + position: LABEL_POSITION, + color: Cesium.Color.WHITE, + show: false, + }, billboardCollection: { show: false, remove() {} }, viewer: { camera: { positionCartographic: null }, scene: {} }, meta: { @@ -761,10 +924,16 @@ test('civil tracked readout: a callsign-less enriched contact reads as its regis const realFetch = globalThis.fetch; globalThis.fetch = async () => ({ - ok: true, status: 200, headers: { get: () => null }, json: async () => ({ time: 0, states: [] }), + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({ time: 0, states: [] }), }); try { - await flightsLayer.update({ camera: { positionCartographic: null }, scene: {} }); + await flightsLayer.update({ + camera: { positionCartographic: null }, + scene: {}, + }); assert.match(entity.gevLabelModel.title, /^N123AB\b/); assert.doesNotMatch( [entity.gevLabelModel.title, ...entity.gevLabelModel.details].join(' · '), @@ -844,7 +1013,8 @@ test('military suppression exempts the contact this layer is still restoring', ( // contacts only, never when a 3D model owns the visual (T7 one-shot). // --------------------------------------------------------------------------- -const _floorCarto = (pos) => Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84); +const _floorCarto = (pos) => + Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84); test('a newly seen taxiing aircraft starts on the floor under its delayed position', async () => { // A grounded contact is drawn at its delayed position lifted onto the floor @@ -949,10 +1119,16 @@ test('display floor: a grounded contact that drifted onto a higher cell is lifte // Fix cell floor was 124.7; the display has taxied into a 140.2 m cell. reportMeshFloorCell(30.2004, -97.6604, 140.2); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false); + const out = _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + ); const c = _floorCarto(out); - assert.ok(Math.abs(c.height - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05, - `height ${c.height} should be 140.2 + lift`); + assert.ok( + Math.abs(c.height - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05, + `height ${c.height} should be 140.2 + lift`, + ); // Lifted straight up — the drifted lat/lon is preserved exactly. assert.ok(Math.abs(Cesium.Math.toDegrees(c.latitude) - 30.2004) < 1e-6); assert.ok(Math.abs(Cesium.Math.toDegrees(c.longitude) + 97.6604) < 1e-6); @@ -964,7 +1140,11 @@ test('display floor: an AIRBORNE contact over the same cell is never lifted', () setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140.2); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest({ onGround: false }, pos, false); + const out = _floorGroundedDisplayPositionForTest( + { onGround: false }, + pos, + false, + ); assert.equal(out, pos, 'airborne display positions pass through untouched'); }); @@ -974,8 +1154,16 @@ test('display floor: T7 — a model-owned grounded contact keeps its billboard d setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140.2); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, true); - assert.equal(out, pos, 'moving it would drag groundSnap past its 50 m re-sample threshold'); + const out = _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + true, + ); + assert.equal( + out, + pos, + 'moving it would drag groundSnap past its 50 m re-sample threshold', + ); }); test('display floor: a contact already above the floor is passed through unchanged', () => { @@ -984,7 +1172,11 @@ test('display floor: a contact already above the floor is passed through unchang setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 124.7); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 140.2); - const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false); + const out = _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + ); assert.equal(out, pos, 'no rebuild when nothing needs lifting'); }); @@ -993,7 +1185,11 @@ test('display floor: a cold display cell leaves the contact where it is', () => _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false); + const out = _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + ); assert.equal(out, pos, 'no floor data → no clamp (never invent a surface)'); }); @@ -1004,7 +1200,11 @@ test('display floor: mesh cells are ignored outside the google-3d regime', () => reportMeshFloorCell(30.2004, -97.6604, 140.2); setMeshFloorPreferred(false); // globe stack: the DEM IS the rendered terrain const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false); + const out = _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + ); assert.equal(out, pos); setMeshFloorPreferred(true); }); @@ -1020,14 +1220,23 @@ test('display floor: jitter across a cell edge does not alternate the floor', () reportMeshFloorCell(30.2014, -97.6604, 146); const at = (lat) => { const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, lat, 120), false, 'jitter', + { onGround: true }, + Cesium.Cartesian3.fromDegrees(-97.6604, lat, 120), + false, + 'jitter', ); return _floorCarto(out).height; }; const settled = at(30.2004); // squarely inside the low cell - assert.ok(Math.abs(settled - (140 + GROUND_FLOOR_LIFT_M)) < 0.05, `settled ${settled}`); + assert.ok( + Math.abs(settled - (140 + GROUND_FLOOR_LIFT_M)) < 0.05, + `settled ${settled}`, + ); // 30.2006 belongs to the HIGH cell but is only ~11 m past the boundary. - assert.ok(Math.abs(at(30.2006) - settled) < 0.05, 'held the low cell across the edge'); + assert.ok( + Math.abs(at(30.2006) - settled) < 0.05, + 'held the low cell across the edge', + ); assert.ok(Math.abs(at(30.2004) - settled) < 0.05, 'and back again — no pop'); }); @@ -1037,12 +1246,21 @@ test('display floor: a real crossing does adopt the next cell', () => { setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140); reportMeshFloorCell(30.2014, -97.6604, 146); - const at = (lat) => _floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, lat, 120), false, 'crossing', - )).height; + const at = (lat) => + _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + Cesium.Cartesian3.fromDegrees(-97.6604, lat, 120), + false, + 'crossing', + ), + ).height; at(30.2004); const crossed = at(30.2014); // properly into the next cell - assert.ok(Math.abs(crossed - (146 + GROUND_FLOOR_LIFT_M)) < 0.05, `crossed ${crossed}`); + assert.ok( + Math.abs(crossed - (146 + GROUND_FLOOR_LIFT_M)) < 0.05, + `crossed ${crossed}`, + ); }); // --- F7: no rebuild when nothing changed ----------------------------------- @@ -1057,10 +1275,28 @@ test('display floor: a clamped stationary contact reuses its previous output', ( setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 120); - const first = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'parked'); - const second = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'parked'); - assert.notEqual(first, pos, 'it really is clamped (not the pass-through case)'); - assert.equal(second, first, 'same input + same floor ⇒ the very same object, no rebuild'); + const first = _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + 'parked', + ); + const second = _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + 'parked', + ); + assert.notEqual( + first, + pos, + 'it really is clamped (not the pass-through case)', + ); + assert.equal( + second, + first, + 'same input + same floor ⇒ the very same object, no rebuild', + ); }); test('display floor: the cached output is dropped when the floor changes under it', () => { @@ -1069,14 +1305,31 @@ test('display floor: the cached output is dropped when the floor changes under i setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 120); - const before = _floorCarto(_floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'warming')).height; + const before = _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + 'warming', + ), + ).height; // A late mesh sample raises this cell (a fresh module state stands in for the // one-shot latch, which never overwrites). _clearMeshFloorCellsForTest(); reportMeshFloorCell(30.2004, -97.6604, 175); - const after = _floorCarto(_floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'warming')).height; + const after = _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + 'warming', + ), + ).height; assert.ok(Math.abs(before - 141.5) < 0.05, `before ${before}`); - assert.ok(Math.abs(after - 176.5) < 0.05, `after ${after} — a stale cache would still read 141.5`); + assert.ok( + Math.abs(after - 176.5) < 0.05, + `after ${after} — a stale cache would still read 141.5`, + ); }); // --- F8: hold the last known floor through a floor-data gap ---------------- @@ -1096,18 +1349,28 @@ test('display floor: a cold cell HOLDS the last floor that resolved for this con reportMeshFloorCell(30.2004, -97.6604, 140.2); // On the warm cell: clamped onto the floor as usual. const warm = _floorGroundedDisplayPositionForTest( - { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7), false, 'held', + { onGround: true }, + Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7), + false, + 'held', + ); + assert.ok( + Math.abs(_floorCarto(warm).height - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05, ); - assert.ok(Math.abs(_floorCarto(warm).height - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05); // Taxis ~550 m onto a cell nothing has resolved, still at the geoid-ish // height the poll path handed it. Pre-fix this passed straight through — // buried. const cold = _floorGroundedDisplayPositionForTest( - { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2054, 124.7), false, 'held', + { onGround: true }, + Cesium.Cartesian3.fromDegrees(-97.6604, 30.2054, 124.7), + false, + 'held', ); const h = _floorCarto(cold).height; - assert.ok(Math.abs(h - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05, - `held ${h} — a cold cell must hold 140.2 + lift, never the 124.7 it arrived with`); + assert.ok( + Math.abs(h - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05, + `held ${h} — a cold cell must hold 140.2 + lift, never the 124.7 it arrived with`, + ); }); test('display floor: with NO prior anywhere the contact is left exactly where it is', () => { @@ -1115,8 +1378,17 @@ test('display floor: with NO prior anywhere the contact is left exactly where it _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'fresh'); - assert.equal(out, pos, 'nothing measured anywhere ⇒ nothing invented — same object, no clamp'); + const out = _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + 'fresh', + ); + assert.equal( + out, + pos, + 'nothing measured anywhere ⇒ nothing invented — same object, no clamp', + ); }); test('display floor: a fresh contact on a cold cell adopts a resolved NEIGHBOUR', () => { @@ -1129,11 +1401,16 @@ test('display floor: a fresh contact on a cold cell adopts a resolved NEIGHBOUR' reportMeshFloorCell(30.2014, -97.6604, 141.0); reportMeshFloorCell(30.1994, -97.6604, 152.0); const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7), false, 'neighbour', + { onGround: true }, + Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7), + false, + 'neighbour', ); const h = _floorCarto(out).height; - assert.ok(Math.abs(h - (141.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `neighbour hold ${h} — expected the lower of the two resolved neighbours`); + assert.ok( + Math.abs(h - (141.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `neighbour hold ${h} — expected the lower of the two resolved neighbours`, + ); }); // Drives a stationary contact at the production 80 ms fleet cadence and reports @@ -1141,16 +1418,28 @@ test('display floor: a fresh contact on a cold cell adopts a resolved NEIGHBOUR' // `mutate` runs before the tick at that index, so a target can be moved // mid-approach. function _driveEase(id, pos, fromMs, ticks, mutate = () => {}) { - let previous = _floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, pos, false, id, fromMs, - )).height; + let previous = _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + id, + fromMs, + ), + ).height; const opened = previous; let maxStepM = 0; for (let i = 1; i <= ticks; i += 1) { mutate(i); - const h = _floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, pos, false, id, fromMs + i * 80, - )).height; + const h = _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + id, + fromMs + i * 80, + ), + ).height; maxStepM = Math.max(maxStepM, previous - h); previous = h; } @@ -1169,10 +1458,18 @@ test('display floor: a hold released onto a LOWER floor eases down, never snaps' if (i === 1) reportMeshFloorCell(30.2004, -97.6604, 160.0); // its own cell resolves lower }); const totalDropM = r.opened - (160.0 + GROUND_FLOOR_LIFT_M); - assert.ok(Math.abs(r.opened - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, `opened at ${r.opened}`); - assert.ok(r.maxStepM <= totalDropM * 0.25, - `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m — a snap is the whole drop at once`); - assert.ok(Math.abs(r.settled - (160.0 + GROUND_FLOOR_LIFT_M)) < 0.05, `settled at ${r.settled}`); + assert.ok( + Math.abs(r.opened - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `opened at ${r.opened}`, + ); + assert.ok( + r.maxStepM <= totalDropM * 0.25, + `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m — a snap is the whole drop at once`, + ); + assert.ok( + Math.abs(r.settled - (160.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `settled at ${r.settled}`, + ); }); test('display floor: a hold released onto a HIGHER floor rises immediately', () => { @@ -1180,17 +1477,26 @@ test('display floor: a hold released onto a HIGHER floor rises immediately', () _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const at = (ms) => _floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, pos, false, 'rise', ms, - )).height; + const at = (ms) => + _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + 'rise', + ms, + ), + ).height; reportMeshFloorCell(30.2014, -97.6604, 160.0); reportMeshFloorCell(30.1994, -97.6604, 190.0); assert.ok(Math.abs(at(1000) - (160.0 + GROUND_FLOOR_LIFT_M)) < 0.05); reportMeshFloorCell(30.2004, -97.6604, 205.0); // No easing UP: an eased rise is time spent under the mesh, which is the // whole failure this path exists to prevent. - assert.ok(Math.abs(at(1001) - (205.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - 'the next tick is already on the higher floor'); + assert.ok( + Math.abs(at(1001) - (205.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + 'the next tick is already on the higher floor', + ); }); // --- F9: the hold is a live state machine, not a one-shot ------------------ @@ -1204,17 +1510,24 @@ test('display floor: a STATIONARY unresolved contact still adopts a floor that a _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const at = (ms) => _floorGroundedDisplayPositionForTest( - { onGround: true }, pos, false, 'parked-cold', ms, - ); + const at = (ms) => + _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + 'parked-cold', + ms, + ); assert.equal(at(1000), pos, 'nothing anywhere yet — passes through'); // The poll's warm batch lands the cells NEXT DOOR (this contact's own cell // is still cold, so only the hold chain can see them). reportMeshFloorCell(30.2014, -97.6604, 138.0); reportMeshFloorCell(30.1994, -97.6604, 160.0); const adopted = _floorCarto(at(1600)).height; // past the probe throttle - assert.ok(Math.abs(adopted - (138.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `stationary contact ${adopted} — a memoized unresolved answer would still read 124.7`); + assert.ok( + Math.abs(adopted - (138.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `stationary contact ${adopted} — a memoized unresolved answer would still read 124.7`, + ); }); test('display floor: a stationary contact FOLLOWS the neighbourhood as it fills in', () => { @@ -1222,9 +1535,16 @@ test('display floor: a stationary contact FOLLOWS the neighbourhood as it fills _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); - const at = (ms) => _floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, pos, false, 'upgrade', ms, - )).height; + const at = (ms) => + _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + 'upgrade', + ms, + ), + ).height; reportMeshFloorCell(30.2014, -97.6604, 200.0); reportMeshFloorCell(30.1994, -97.6604, 240.0); assert.ok(Math.abs(at(1000) - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05); @@ -1234,8 +1554,10 @@ test('display floor: a stationary contact FOLLOWS the neighbourhood as it fills reportMeshFloorCell(30.2004, -97.6614, 120.0); let h = 0; for (let ms = 1600; ms <= 6000; ms += 80) h = at(ms); - assert.ok(Math.abs(h - (120.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `followed to ${h} — a borrowed floor must not outlive a better one`); + assert.ok( + Math.abs(h - (120.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `followed to ${h} — a borrowed floor must not outlive a better one`, + ); }); test('display floor: an on_ground FLAP mid-takeoff-roll never dips below the runway', () => { @@ -1249,23 +1571,35 @@ test('display floor: an on_ground FLAP mid-takeoff-roll never dips below the run // the geoid. Deleting the hold on the airborne poll made that switch visible: // the contact came back grounded with no prior, outrunning its own floor // cells at 23 m/s, and sat under the runway (owner sighting, VIR138M). - const GROUND = -28.5, GEOID = -32.5, LON = -73.78; + const GROUND = -28.5, + GEOID = -32.5, + LON = -73.78; reportMeshFloorCell(40.64, LON, GROUND); // only the cell it STARTED on is warm let lat = 40.64; const grounded = []; for (let i = 0; i <= 22; i += 1) { const onGround = i !== 10; // one airborne poll mid-roll const out = _floorGroundedDisplayPositionForTest( - { onGround }, Cesium.Cartesian3.fromDegrees(LON, lat, GEOID), false, 'VIR138M', 1000 + i * 80, + { onGround }, + Cesium.Cartesian3.fromDegrees(LON, lat, GEOID), + false, + 'VIR138M', + 1000 + i * 80, ); if (onGround) grounded.push(_floorCarto(out).height); lat += 0.00021; // ~23 m per tick at 45 kt } const dipped = grounded.filter((h) => h < GROUND - 0.05); - assert.deepEqual(dipped, [], - `${dipped.length} of ${grounded.length} grounded ticks rendered below the runway`); - assert.ok(Math.abs(grounded[grounded.length - 1] - (GROUND + GROUND_FLOOR_LIFT_M)) < 0.05, - 'and it is still standing on the floor it held, not on nothing'); + assert.deepEqual( + dipped, + [], + `${dipped.length} of ${grounded.length} grounded ticks rendered below the runway`, + ); + assert.ok( + Math.abs(grounded[grounded.length - 1] - (GROUND + GROUND_FLOOR_LIFT_M)) < + 0.05, + 'and it is still standing on the floor it held, not on nothing', + ); }); test('display floor: the rehydration seed EXPIRES on its own age, with NO calls in between', () => { @@ -1275,20 +1609,45 @@ test('display floor: the rehydration seed EXPIRES on its own age, with NO calls const field = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); reportMeshFloorCell(30.2014, -97.6604, 200.0); reportMeshFloorCell(30.1994, -97.6604, 240.0); - assert.ok(Math.abs(_floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, field, false, 'longhaul', 1000, - )).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, 'holding 200 m before departure'); + assert.ok( + Math.abs( + _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + field, + false, + 'longhaul', + 1000, + ), + ).height - + (200.0 + GROUND_FLOOR_LIFT_M), + ) < 0.05, + 'holding 200 m before departure', + ); // ONE airborne tick parks the seed, and then this contact is not seen again // for 198 s: off the poll on a cruise, outside the corridor radius, tab // hidden. Nothing runs in between, so the retire path never gets a second // chance to expire it — the age has to be judged where it comes BACK. // An earlier cut checked expiry only on the retire path and cleared // `retiredMs` before validating it, so this reused the 200 m floor. - _floorGroundedDisplayPositionForTest({ onGround: false }, field, false, 'longhaul', 2000); + _floorGroundedDisplayPositionForTest( + { onGround: false }, + field, + false, + 'longhaul', + 2000, + ); _clearMeshFloorCellsForTest(); // a different airport: nothing warm here const back = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); assert.equal( - _floorGroundedDisplayPositionForTest({ onGround: true }, back, false, 'longhaul', 200000), back, + _floorGroundedDisplayPositionForTest( + { onGround: true }, + back, + false, + 'longhaul', + 200000, + ), + back, 'an expired seed is gone — no floor is invented from a 198-second-old measurement', ); }); @@ -1300,15 +1659,40 @@ test('display floor: the seed expires the same way when the contact keeps report const field = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); reportMeshFloorCell(30.2014, -97.6604, 200.0); reportMeshFloorCell(30.1994, -97.6604, 240.0); - _floorGroundedDisplayPositionForTest({ onGround: true }, field, false, 'cruise', 1000); + _floorGroundedDisplayPositionForTest( + { onGround: true }, + field, + false, + 'cruise', + 1000, + ); // The other half of the same clock: an airborne contact that IS still being // ticked. The window is the window either way. - _floorGroundedDisplayPositionForTest({ onGround: false }, field, false, 'cruise', 2000); - _floorGroundedDisplayPositionForTest({ onGround: false }, field, false, 'cruise', 200000); + _floorGroundedDisplayPositionForTest( + { onGround: false }, + field, + false, + 'cruise', + 2000, + ); + _floorGroundedDisplayPositionForTest( + { onGround: false }, + field, + false, + 'cruise', + 200000, + ); _clearMeshFloorCellsForTest(); const back = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); assert.equal( - _floorGroundedDisplayPositionForTest({ onGround: true }, back, false, 'cruise', 201000), back, + _floorGroundedDisplayPositionForTest( + { onGround: true }, + back, + false, + 'cruise', + 201000, + ), + back, 'no memory of the departure field survives the grace window', ); }); @@ -1324,10 +1708,28 @@ test('display floor: a rehydrated seed ranks BELOW fresh neighbour evidence', () setMeshFloorPreferred(true); const departed = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 50); reportMeshFloorCell(30.2004, -97.6604, 200.0); - assert.ok(Math.abs(_floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, departed, false, 'short-hop', 1000, - )).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, 'standing on its own 200 m cell'); - _floorGroundedDisplayPositionForTest({ onGround: false }, departed, false, 'short-hop', 2000); + assert.ok( + Math.abs( + _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + departed, + false, + 'short-hop', + 1000, + ), + ).height - + (200.0 + GROUND_FLOOR_LIFT_M), + ) < 0.05, + 'standing on its own 200 m cell', + ); + _floorGroundedDisplayPositionForTest( + { onGround: false }, + departed, + false, + 'short-hop', + 2000, + ); // Re-grounds 0.56 km away — INSIDE HELD_FLOOR_MAX_DRIFT_KM, so the drift // bound does not save us here; the tier order has to. Its own cell is cold, // but two adjacent cells have since resolved, and they are 100 m of ground. @@ -1335,11 +1737,19 @@ test('display floor: a rehydrated seed ranks BELOW fresh neighbour evidence', () reportMeshFloorCell(30.2044, -97.6604, 100.0); reportMeshFloorCell(30.2064, -97.6604, 105.0); const moved = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2054, 50); - const rendered = _floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, moved, false, 'short-hop', 3000, - )).height; - assert.ok(Math.abs(rendered - (100.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `rendered at ${rendered} — a seed that outranked the neighbourhood read 201.5, 100 m in the air`); + const rendered = _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + moved, + false, + 'short-hop', + 3000, + ), + ).height; + assert.ok( + Math.abs(rendered - (100.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `rendered at ${rendered} — a seed that outranked the neighbourhood read 201.5, 100 m in the air`, + ); }); test('display floor: with nothing fresh to contradict it, the seed still answers', () => { @@ -1351,14 +1761,34 @@ test('display floor: with nothing fresh to contradict it, the seed still answers // warmed either. Demoting the seed must not mean discarding it. const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 50); reportMeshFloorCell(30.2004, -97.6604, 200.0); - _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'flap', 1000); - _floorGroundedDisplayPositionForTest({ onGround: false }, pos, false, 'flap', 2000); + _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + 'flap', + 1000, + ); + _floorGroundedDisplayPositionForTest( + { onGround: false }, + pos, + false, + 'flap', + 2000, + ); _clearMeshFloorCellsForTest(); // the whole neighbourhood goes cold - const held = _floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2014, 50), false, 'flap', 3000, - )).height; - assert.ok(Math.abs(held - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `held ${held} — with no measurement anywhere the seed is the best thing available`); + const held = _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + Cesium.Cartesian3.fromDegrees(-97.6604, 30.2014, 50), + false, + 'flap', + 3000, + ), + ).height; + assert.ok( + Math.abs(held - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `held ${held} — with no measurement anywhere the seed is the best thing available`, + ); }); test('display floor: re-measuring its own cell makes a seeded contact authoritative again', () => { @@ -1367,24 +1797,56 @@ test('display floor: re-measuring its own cell makes a seeded contact authoritat setMeshFloorPreferred(true); const pad = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); reportMeshFloorCell(30.2004, -97.6604, 300.0); - _floorGroundedDisplayPositionForTest({ onGround: true }, pad, false, 'relatch', 1000); - _floorGroundedDisplayPositionForTest({ onGround: false }, pad, false, 'relatch', 2000); + _floorGroundedDisplayPositionForTest( + { onGround: true }, + pad, + false, + 'relatch', + 1000, + ); + _floorGroundedDisplayPositionForTest( + { onGround: false }, + pad, + false, + 'relatch', + 2000, + ); // Re-grounds on its own, still-warm 300 m cell: that is a live reading, so the // demotion is over and the ordinary tier order applies again. - assert.ok(Math.abs(_floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, pad, false, 'relatch', 3000, - )).height - (300.0 + GROUND_FLOOR_LIFT_M)) < 0.05, 'back on its own measured cell'); + assert.ok( + Math.abs( + _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + pad, + false, + 'relatch', + 3000, + ), + ).height - + (300.0 + GROUND_FLOOR_LIFT_M), + ) < 0.05, + 'back on its own measured cell', + ); // It now taxis 220 m onto a cold cell whose two adjacent cells read 100 m. An // own floor it measured while standing here outranks a borrowed one — the // seed flag has to have been cleared by that reading, or the contact drops // 200 m onto someone else's ground. reportMeshFloorCell(30.2014, -97.6604, 100.0); reportMeshFloorCell(30.2034, -97.6604, 100.0); - const taxied = _floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2024, 100), false, 'relatch', 4000, - )).height; - assert.ok(Math.abs(taxied - (300.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `taxied at ${taxied} — a still-seeded contact would have taken the 100 m neighbours`); + const taxied = _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + Cesium.Cartesian3.fromDegrees(-97.6604, 30.2024, 100), + false, + 'relatch', + 4000, + ), + ).height; + assert.ok( + Math.abs(taxied - (300.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `taxied at ${taxied} — a still-seeded contact would have taken the 100 m neighbours`, + ); }); test('display floor: taking off and landing elsewhere does NOT inherit the old field floor', () => { @@ -1394,25 +1856,62 @@ test('display floor: taking off and landing elsewhere does NOT inherit the old f const field = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); reportMeshFloorCell(30.2004, -97.6604, 200.0); // 1. grounded on a 200 m floor. - assert.ok(Math.abs(_floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, field, false, 'trip', 1000, - )).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05); + assert.ok( + Math.abs( + _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + field, + false, + 'trip', + 1000, + ), + ).height - + (200.0 + GROUND_FLOOR_LIFT_M), + ) < 0.05, + ); // 2. it taxis ~330 m onto ground nothing has resolved, so 200 m is now being // HELD — this is the state that must not survive the trip. const heldAt = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2034, 124.7); - assert.ok(Math.abs(_floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, heldAt, false, 'trip', 1500, - )).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, 'the hold is active before takeoff'); + assert.ok( + Math.abs( + _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + heldAt, + false, + 'trip', + 1500, + ), + ).height - + (200.0 + GROUND_FLOOR_LIFT_M), + ) < 0.05, + 'the hold is active before takeoff', + ); // 3. airborne — the hold belongs to the grounded-billboard regime, not to the // contact, so leaving that regime must retire it. - _floorGroundedDisplayPositionForTest({ onGround: false }, heldAt, false, 'trip', 2000); + _floorGroundedDisplayPositionForTest( + { onGround: false }, + heldAt, + false, + 'trip', + 2000, + ); // 4. lands at another field on a real 100 m floor. reportMeshFloorCell(31.5004, -97.6604, 100.0); - const landed = _floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 31.5004, 60), false, 'trip', 3000, - )).height; - assert.ok(Math.abs(landed - (100.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `landed at ${landed} — a stale hold would arrive at 201.5 and ease down from it`); + const landed = _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + Cesium.Cartesian3.fromDegrees(-97.6604, 31.5004, 60), + false, + 'trip', + 3000, + ), + ).height; + assert.ok( + Math.abs(landed - (100.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `landed at ${landed} — a stale hold would arrive at 201.5 and ease down from it`, + ); }); test('display floor: a model-owned interval retires the hold too', () => { @@ -1421,21 +1920,47 @@ test('display floor: a model-owned interval retires the hold too', () => { setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); reportMeshFloorCell(30.2004, -97.6604, 200.0); - _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'handoff', 1000); + _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + 'handoff', + 1000, + ); // It moves onto unresolved ground, so 200 m is being HELD. const heldAt = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2034, 124.7); - _floorGroundedDisplayPositionForTest({ onGround: true }, heldAt, false, 'handoff', 1500); + _floorGroundedDisplayPositionForTest( + { onGround: true }, + heldAt, + false, + 'handoff', + 1500, + ); // T7: the 3D model takes the visual. Our state is retired (nothing visual is // touched — groundSnap's one-shot input is untouched either way). - _floorGroundedDisplayPositionForTest({ onGround: true }, heldAt, true, 'handoff', 2000); + _floorGroundedDisplayPositionForTest( + { onGround: true }, + heldAt, + true, + 'handoff', + 2000, + ); // The billboard resumes over ground that has since resolved LOWER. A stale // heldActive would read this as a hold release and ease; it is a fresh start. reportMeshFloorCell(30.2034, -97.6604, 150.0); - const back = _floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, heldAt, false, 'handoff', 3000, - )).height; - assert.ok(Math.abs(back - (150.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `resumed at ${back} — no ease, because there was no hold to release`); + const back = _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + heldAt, + false, + 'handoff', + 3000, + ), + ).height; + assert.ok( + Math.abs(back - (150.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `resumed at ${back} — no ease, because there was no hold to release`, + ); }); test('display floor: a model handoff back onto a COLD cell takes the neighbourhood, not the seed', () => { @@ -1448,18 +1973,44 @@ test('display floor: a model handoff back onto a COLD cell takes the neighbourho // a floor it measured 330 m ago. Two adjacent cells have resolved at 150 m. const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); reportMeshFloorCell(30.2004, -97.6604, 200.0); - _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'cold-handoff', 1000); + _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + 'cold-handoff', + 1000, + ); const heldAt = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2034, 124.7); - _floorGroundedDisplayPositionForTest({ onGround: true }, heldAt, false, 'cold-handoff', 1500); - _floorGroundedDisplayPositionForTest({ onGround: true }, heldAt, true, 'cold-handoff', 2000); + _floorGroundedDisplayPositionForTest( + { onGround: true }, + heldAt, + false, + 'cold-handoff', + 1500, + ); + _floorGroundedDisplayPositionForTest( + { onGround: true }, + heldAt, + true, + 'cold-handoff', + 2000, + ); _clearMeshFloorCellsForTest(); reportMeshFloorCell(30.2024, -97.6604, 150.0); reportMeshFloorCell(30.2044, -97.6604, 155.0); - const back = _floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, heldAt, false, 'cold-handoff', 3000, - )).height; - assert.ok(Math.abs(back - (150.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `resumed at ${back} — a seed that outranked the neighbourhood would resume at 201.5`); + const back = _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + heldAt, + false, + 'cold-handoff', + 3000, + ), + ).height; + assert.ok( + Math.abs(back - (150.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `resumed at ${back} — a seed that outranked the neighbourhood would resume at 201.5`, + ); }); // --- F10: no contact waits on another -------------------------------------- @@ -1482,15 +2033,28 @@ test('display floor: every grounded contact is floored on the tick it asks', () } const heights = []; for (let i = 0; i < N; i += 1) { - heights.push(_floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.66, latOf(i), 100), false, `q${i}`, 1000, - )).height); + heights.push( + _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + Cesium.Cartesian3.fromDegrees(-97.66, latOf(i), 100), + false, + `q${i}`, + 1000, + ), + ).height, + ); } const unfloored = heights - .map((h, i) => (Math.abs(h - (300 + i + GROUND_FLOOR_LIFT_M)) < 0.05 ? null : i)) + .map((h, i) => + Math.abs(h - (300 + i + GROUND_FLOOR_LIFT_M)) < 0.05 ? null : i, + ) .filter((i) => i != null); - assert.deepEqual(unfloored, [], - 'a global budget would leave the tail of the fleet on the geoid for later ticks'); + assert.deepEqual( + unfloored, + [], + 'a global budget would leave the tail of the fleet on the geoid for later ticks', + ); }); test('display floor: a re-probe that finds a LOWER neighbour eases down, never snaps', () => { @@ -1507,9 +2071,14 @@ test('display floor: a re-probe that finds a LOWER neighbour eases down, never s if (i === 8) reportMeshFloorCell(30.2004, -97.6614, 120.0); // past the 500 ms probe throttle }); const totalDropM = r.opened - target; - assert.ok(r.maxStepM <= totalDropM * 0.25, - `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m`); - assert.ok(Math.abs(r.settled - target) < 0.05, `settled at ${r.settled}, expected ${target}`); + assert.ok( + r.maxStepM <= totalDropM * 0.25, + `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m`, + ); + assert.ok( + Math.abs(r.settled - target) < 0.05, + `settled at ${r.settled}, expected ${target}`, + ); }); test('display floor: a SECOND lower re-latch mid-approach retargets without a jump', () => { @@ -1529,9 +2098,14 @@ test('display floor: a SECOND lower re-latch mid-approach retargets without a ju if (i === 16) reportMeshFloorCell(30.2004, -97.6594, 100.0); // -> 100 m, mid-approach }); const totalDropM = r.opened - target; - assert.ok(r.maxStepM <= totalDropM * 0.25, - `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m — the retarget must not be a seam`); - assert.ok(Math.abs(r.settled - target) < 0.05, `settled at ${r.settled}, expected ${target}`); + assert.ok( + r.maxStepM <= totalDropM * 0.25, + `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m — the retarget must not be a seam`, + ); + assert.ok( + Math.abs(r.settled - target) < 0.05, + `settled at ${r.settled}, expected ${target}`, + ); }); test('display floor: a DELAYED tick cannot close more of the gap than a prompt one', () => { @@ -1539,9 +2113,16 @@ test('display floor: a DELAYED tick cannot close more of the gap than a prompt o _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); - const at = (ms) => _floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, pos, false, 'stall', ms, - )).height; + const at = (ms) => + _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + 'stall', + ms, + ), + ).height; reportMeshFloorCell(30.2014, -97.6604, 400.0); reportMeshFloorCell(30.1994, -97.6604, 440.0); // borrowed floor = 400 m const opened = at(1000); @@ -1560,8 +2141,10 @@ test('display floor: a DELAYED tick cannot close more of the gap than a prompt o maxStepM = Math.max(maxStepM, previous - h); previous = h; } - assert.ok(maxStepM <= totalDropM * 0.25, - `largest step ${maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m across a stalled schedule`); + assert.ok( + maxStepM <= totalDropM * 0.25, + `largest step ${maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m across a stalled schedule`, + ); assert.ok(previous < opened, 'and it is still making progress downward'); }); @@ -1572,9 +2155,16 @@ test('display floor: a floor rising MID-APPROACH is taken whole, on that tick', const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); reportMeshFloorCell(30.2014, -97.6604, 400.0); reportMeshFloorCell(30.1994, -97.6604, 440.0); // borrowed floor = 400 m - const at = (ms) => _floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, pos, false, 'riseMid', ms, - )).height; + const at = (ms) => + _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + pos, + false, + 'riseMid', + ms, + ), + ).height; at(1000); reportMeshFloorCell(30.2004, -97.6604, 150.0); // own cell resolves far below let h = 0; @@ -1585,8 +2175,10 @@ test('display floor: a floor rising MID-APPROACH is taken whole, on that tick', _clearMeshFloorCellsForTest(); reportMeshFloorCell(30.2004, -97.6604, 500.0); const risen = at(1000 + 5 * 80); - assert.ok(Math.abs(risen - (500.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `rose to ${risen} — an eased rise is time spent under the mesh`); + assert.ok( + Math.abs(risen - (500.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `rose to ${risen} — an eased rise is time spent under the mesh`, + ); }); test('display floor: a held floor is dropped once the contact leaves the ground it describes', () => { @@ -1594,18 +2186,38 @@ test('display floor: a held floor is dropped once the contact leaves the ground _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 200.0); - const at = (lat, ms) => _floorGroundedDisplayPositionForTest( - { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, lat, 124.7), false, 'drift', ms, + const at = (lat, ms) => + _floorGroundedDisplayPositionForTest( + { onGround: true }, + Cesium.Cartesian3.fromDegrees(-97.6604, lat, 124.7), + false, + 'drift', + ms, + ); + assert.ok( + Math.abs( + _floorCarto(at(30.2004, 1000)).height - (200.0 + GROUND_FLOOR_LIFT_M), + ) < 0.05, ); - assert.ok(Math.abs(_floorCarto(at(30.2004, 1000)).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05); // ~670 m on: cold cell, but that is inside one poll's rollout. Hold stands. - assert.ok(Math.abs(_floorCarto(at(30.2064, 2000)).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - 'inside the drift bound the held floor still describes the ground'); + assert.ok( + Math.abs( + _floorCarto(at(30.2064, 2000)).height - (200.0 + GROUND_FLOOR_LIFT_M), + ) < 0.05, + 'inside the drift bound the held floor still describes the ground', + ); // ~3.3 km on: the held value is no longer a measurement of anywhere this // contact has been, so it is dropped rather than stretched. const far = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2304, 124.7); assert.equal( - _floorGroundedDisplayPositionForTest({ onGround: true }, far, false, 'drift', 3000), far, + _floorGroundedDisplayPositionForTest( + { onGround: true }, + far, + false, + 'drift', + 3000, + ), + far, 'past the drift bound: back to no clamp, not a stretched one', ); }); @@ -1619,12 +2231,21 @@ test('display floor: an ordinary cell-to-cell floor DROP keeps its existing timi // untouched by this round. reportMeshFloorCell(30.2004, -97.6604, 200.0); reportMeshFloorCell(30.2044, -97.6604, 160.0); - const at = (lat, ms) => _floorCarto(_floorGroundedDisplayPositionForTest( - { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, lat, 124.7), false, 'plain', ms, - )).height; + const at = (lat, ms) => + _floorCarto( + _floorGroundedDisplayPositionForTest( + { onGround: true }, + Cesium.Cartesian3.fromDegrees(-97.6604, lat, 124.7), + false, + 'plain', + ms, + ), + ).height; assert.ok(Math.abs(at(30.2004, 1000) - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05); - assert.ok(Math.abs(at(30.2044, 1050) - (160.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - 'the very next tick is on the new cell — no ease was introduced here'); + assert.ok( + Math.abs(at(30.2044, 1050) - (160.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + 'the very next tick is on the new cell — no ease was introduced here', + ); }); test('display floor: the hold never applies to an airborne contact or a modelled one', () => { @@ -1636,11 +2257,23 @@ test('display floor: the hold never applies to an airborne contact or a modelled _floorGroundedDisplayPositionForTest({ onGround: true }, on, false, 'gates'); const cold = Cesium.Cartesian3.fromDegrees(-97.6704, 30.2104, 124.7); assert.equal( - _floorGroundedDisplayPositionForTest({ onGround: false }, cold, false, 'gates'), cold, + _floorGroundedDisplayPositionForTest( + { onGround: false }, + cold, + false, + 'gates', + ), + cold, 'airborne heights belong to the fix-time clamp, held floor or not', ); assert.equal( - _floorGroundedDisplayPositionForTest({ onGround: true }, cold, true, 'gates'), cold, + _floorGroundedDisplayPositionForTest( + { onGround: true }, + cold, + true, + 'gates', + ), + cold, 'T7: a model-owned contact keeps its billboard datum, held floor or not', ); }); @@ -1651,11 +2284,21 @@ test('display floor: two contacts on the same cell get their own outputs', () => setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140); const a = _floorGroundedDisplayPositionForTest( - { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 120), false, 'A', + { onGround: true }, + Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 120), + false, + 'A', ); const b = _floorGroundedDisplayPositionForTest( - { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.66041, 30.20041, 121), false, 'B', + { onGround: true }, + Cesium.Cartesian3.fromDegrees(-97.66041, 30.20041, 121), + false, + 'B', + ); + assert.notEqual( + a, + b, + 'a shared scratch would hand both contacts the same object', ); - assert.notEqual(a, b, 'a shared scratch would hand both contacts the same object'); assert.ok(Math.abs(_floorCarto(a).height - _floorCarto(b).height) < 0.05); }); diff --git a/src/data/gnssIntegrityProxy.test.mjs b/src/data/gnssIntegrityProxy.test.mjs index 7c5ca93ee..76849c8aa 100644 --- a/src/data/gnssIntegrityProxy.test.mjs +++ b/src/data/gnssIntegrityProxy.test.mjs @@ -205,11 +205,16 @@ test('an upstream 429 starts a cooldown with no further upstream calls', async ( assert.equal(calls, 2); }); -test('Retry-After accepts seconds or an HTTP date, clamped to 5 s – 120 s', () => { +test('Retry-After accepts seconds or an HTTP date, with a 5 s minimum and no truncated deadline', () => { const nowMs = Date.parse('2026-09-29T12:00:00Z'); assert.equal(gnssRetryCooldownMs('30', nowMs), 30_000); assert.equal(gnssRetryCooldownMs('1', nowMs), 5_000); - assert.equal(gnssRetryCooldownMs('3600', nowMs), 120_000); + assert.equal(gnssRetryCooldownMs('3600', nowMs), 3_600_000); + assert.equal( + gnssRetryCooldownMs('Tue, 29 Sep 2026 13:00:00 GMT', nowMs), + 3_600_000, + ); + assert.equal(gnssRetryCooldownMs('1e309', nowMs), 60_000); assert.equal( gnssRetryCooldownMs('Tue, 29 Sep 2026 12:00:45 GMT', nowMs), 45_000, @@ -223,6 +228,40 @@ test('Retry-After accepts seconds or an HTTP date, clamped to 5 s – 120 s', () assert.equal(gnssRetryCooldownMs(null, nowMs), 60_000); }); +test('overlapping 429s preserve the longest shared deadline', async () => { + let clock = 0, + calls = 0; + const pending = []; + const request = install({ + now: () => clock, + fetchImpl: () => { + calls += 1; + return new Promise((resolve) => pending.push(resolve)); + }, + }); + const long = request('/?lat=1&lon=0'), + short = request('/?lat=2&lon=0'); + await new Promise((resolve) => setImmediate(resolve)); + pending[0]( + new Response('', { status: 429, headers: { 'Retry-After': '3600' } }), + ); + assert.equal((await long).headers['Retry-After'], '3600'); + clock = 1000; + pending[1]( + new Response('', { status: 429, headers: { 'Retry-After': '5' } }), + ); + assert.equal((await short).headers['Retry-After'], '3599'); + clock = 7000; + assert.equal((await request('/?lat=3&lon=0')).status, 429); + assert.equal(calls, 2); + clock = 3_600_001; + const resumed = request('/?lat=3&lon=0'); + await new Promise((resolve) => setImmediate(resolve)); + pending[2](Response.json(snapshot)); + assert.equal((await resumed).status, 200); + assert.equal(calls, 3); +}); + test('a cached anchor keeps serving its last snapshot as stale during a cooldown', async () => { let clock = 0; let limited = false; diff --git a/src/data/layerState.js b/src/data/layerState.js index 468e9d117..72a3351b9 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -609,7 +609,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ }), Object.freeze({ id: 'gnss-interference', - token: '0', + token: '3', disposition: 'enabled-only', }), Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }), diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 9aa0d6e1d..b782de5e2 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -202,8 +202,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 29); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29); + assert.equal(REGISTERED_LAYER_IDS.length, 30); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 30); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -242,19 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - assert.equal(nextLayerStateToken(), '3'); + assert.equal(nextLayerStateToken(), '4'); assert.equal( nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, - alpha: '3', - bravo: '4', + alpha: '4', + bravo: '5', }), - '5', + '6', ); const digitsExhausted = { ...LAYER_STATE_TOKEN_RESERVATIONS, ...Object.fromEntries( - [...'3456789'].map((digit) => [`prior-${digit}`, digit]), + [...'456789'].map((digit) => [`prior-${digit}`, digit]), ), }; assert.equal(nextLayerStateToken(digitsExhausted), '00'); @@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.equal( validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '3', - next: '4', + future: '4', + next: '5', }), true, ); @@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' ...LAYER_STATE_TOKEN_RESERVATIONS, future: '00', }), - /next free token 3/, + /next free token 4/, ); const beforeLastDigit = { ...digitsExhausted }; delete beforeLastDigit['prior-9']; @@ -2639,8 +2639,8 @@ test('the recent-imagery split is share-link only: never stored locally, and a s ); }); -test('GNSS interference uses digit 0 without colliding with other digit tokens', () => { - const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=0.2.1')); +test('GNSS interference uses digit 3 without colliding with other digit tokens', () => { + const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2.1')); assert.deepEqual(decoded.enabledLayerIds, [ 'fire-perimeters', 'gnss-interference', @@ -2648,7 +2648,7 @@ test('GNSS interference uses digit 0 without colliding with other digit tokens', ]); assert.equal( LAYER_STATE_REGISTRY.find(({ id }) => id === 'gnss-interference').token, - '0', + '3', ); assert.deepEqual( decodeLayerStateParams(new URLSearchParams(encode(decoded))), diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 29a5d5eeb..9d7544109 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -128,10 +128,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 30); assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, - { ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0', 'gnss-interference': '0' }, + { + ...LEGACY_LAYER_STATE_TOKENS, + 'street-level': '0', + 'gnss-interference': '3', + }, ); assert.throws( () => parseLayerStateTokenReservations(reservationRows.slice(1)), @@ -151,9 +155,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '3']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '4']]) .future, - '3', + '4', ); assert.throws( () => @@ -171,14 +175,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed test('published retired digits and competing PRs advance the merge-time allocation', () => { withPublishedBase( - { rows: [...reservationRows, ['retired-layer', '3']] }, + { rows: [...reservationRows, ['retired-layer', '4']] }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '4'); + assert.equal(nextLayerStateToken(published), '5'); assert.equal( validateLayerStateAllocations(published, { ...published, - newcomer: '4', + newcomer: '5', }), true, ); @@ -186,15 +190,15 @@ test('published retired digits and competing PRs advance the merge-time allocati () => validateLayerStateAllocations(published, { ...published, - competing: '3', + competing: '4', }), - /next free token 4/, + /next free token 5/, ); assert.throws( () => validateLayerStateAllocations(published, { ...LAYER_STATE_TOKEN_RESERVATIONS, - newcomer: '3', + newcomer: '4', }), /changed or removed: retired-layer/, ); @@ -202,8 +206,8 @@ test('published retired digits and competing PRs advance the merge-time allocati ); }); -test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => { - withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (cwd) => { +test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () => { + withPublishedBase({ rows: [...reservationRows, ['pr-a', '4']] }, (cwd) => { mkdirSync(path.join(cwd, 'scripts')); const fixtureChecker = path.join( cwd, @@ -224,8 +228,8 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-b', '3']], - [{ id: 'pr-b', token: '3' }], + [...reservationRows, ['pr-b', '4']], + [{ id: 'pr-b', token: '4' }], ); const beforeRebase = runCheck(); assert.equal(beforeRebase.status, 1); @@ -233,10 +237,10 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '3'], ['pr-b', '3']], + [...reservationRows, ['pr-a', '4'], ['pr-b', '4']], [ - { id: 'pr-a', token: '3' }, - { id: 'pr-b', token: '3' }, + { id: 'pr-a', token: '4' }, + { id: 'pr-b', token: '4' }, ], ); const staleRebase = runCheck(); @@ -244,16 +248,16 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/); const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '4'); - const validBaselineRows = [...reservationRows, ['pr-a', '3']]; - writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]); + assert.equal(nextLayerStateToken(published), '5'); + const validBaselineRows = [...reservationRows, ['pr-a', '4']]; + writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '4' }]); const helper = spawnSync( process.execPath, [realpathSync(fixtureNext), 'pr-b'], { cwd, encoding: 'utf8' }, ); assert.equal(helper.status, 0, helper.stderr); - assert.match(helper.stdout, /pr-b: 4/); + assert.match(helper.stdout, /pr-b: 5/); assert.deepEqual( JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')), validBaselineRows, @@ -261,20 +265,20 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => ); writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '3'], ['pr-b', '4']], + [...reservationRows, ['pr-a', '4'], ['pr-b', '5']], [ - { id: 'pr-a', token: '3' }, - { id: 'pr-b', token: '4' }, + { id: 'pr-a', token: '4' }, + { id: 'pr-b', token: '5' }, ], ); const corrected = runCheck(); assert.equal(corrected.status, 0, corrected.stderr); - assert.match(corrected.stdout, /30 published, 1 new/); + assert.match(corrected.stdout, /31 published, 1 new/); }); }); test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { - const digitRows = [...'3456789'].map((digit) => [`retired-${digit}`, digit]); + const digitRows = [...'456789'].map((digit) => [`retired-${digit}`, digit]); const allDigits = parseLayerStateTokenReservations([ ...reservationRows, ...digitRows, @@ -326,7 +330,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => { const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-')); try { - const priorDigits = [...'3456789'].map((digit) => [ + const priorDigits = [...'456789'].map((digit) => [ `qa-prior-digit-${digit}`, digit, ]); @@ -363,7 +367,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the const params = new URLSearchParams([['v', '2']]); codec.encodeLayerStateParams(params, state); assert.ok(params.get('l').length > 64, 'fixture must cross the old cap'); - assert.equal(params.get('l').length, 69); + assert.equal(params.get('l').length, 71); const restored = codec.decodeLayerStateParams(params); assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds); assert.deepEqual(restored?.options, state.options); @@ -381,7 +385,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 30); + assert.equal(Object.keys(published).length, 31); assert.equal(published['retired-layer'], '00'); assert.throws( () => diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index b859de677..13a5d136b 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -9,7 +9,7 @@ ["earthquakes", "e"], ["fire-perimeters", "2"], ["flights", "f"], - ["gnss-interference", "0"], + ["gnss-interference", "3"], ["local-dams", "q"], ["local-datacenters", "d"], ["local-firms", "w"], diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index b6441aa52..0c518eb5f 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -24,8 +24,12 @@ test('share-Follow absence requires an accepted adsb.lol snapshot', async () => (await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234')).status, 'source-unavailable', ); - _setMilitaryTrackingRefreshOutcomeForTest({ status: 'accepted', ids: ['different'] }); - const missing = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234'); + _setMilitaryTrackingRefreshOutcomeForTest({ + status: 'accepted', + ids: ['different'], + }); + const missing = + await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234'); assert.equal(missing.status, 'missing'); assert.equal(missing.reason, 'target-absent-from-snapshot'); }); @@ -72,29 +76,12 @@ test('military analyst record: full record maps every contract field', () => { emergencyKind: null, emergencySource: null, emergencyBroadcast: null, - observedAtMs: null, + lastContactAtMs: null, routeOrigin: null, routeDestination: null, }); }); -test('military analyst record: emergency flag is only raised for emergency severity', () => { - const hijack = mapAnalystRecord('ae01ce', { - ...FULL_INFO, - squawk: '7500', - emergency: { kind: 'unlawful', severity: 'emergency', squawk: '7500' }, - }); - assert.equal(hijack.squawk, '7500'); - assert.equal(hijack.emergency, true); - assert.equal(hijack.emergencyKind, 'unlawful'); - const lifeguard = mapAnalystRecord('ae01ce', { - ...FULL_INFO, - emergency: { kind: 'lifeguard', severity: 'priority', squawk: null }, - }); - assert.equal(lifeguard.emergency, false); - assert.equal(lifeguard.emergencyKind, 'lifeguard'); -}); - test('military analyst record: military is ALWAYS true, routes/country always null', () => { const r = mapAnalystRecord('ae01ce', undefined); assert.equal(r.military, true); @@ -104,8 +91,14 @@ test('military analyst record: military is ALWAYS true, routes/country always nu }); test('military analyst record: no callsign falls back to registration, then icao24', () => { - assert.equal(mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id, '05-8152'); - assert.equal(mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id, 'ae01ce'); + assert.equal( + mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id, + '05-8152', + ); + assert.equal( + mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id, + 'ae01ce', + ); }); test('military analyst record: empty info yields nulls, never NaN/undefined', () => { @@ -114,7 +107,8 @@ test('military analyst record: empty info yields nulls, never NaN/undefined', () assert.equal(r.onGround, false); for (const [key, value] of Object.entries(r)) { assert.notEqual(value, undefined, `${key} must not be undefined`); - if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`); + if (typeof value === 'number') + assert.ok(Number.isFinite(value), `${key} must not be NaN`); } }); @@ -126,12 +120,17 @@ test('military analyst record: output is JSON-safe (no Cesium types)', () => { test('military first update forwards caller cancellation into the feed request', async () => { const realFetch = globalThis.fetch; let observedSignal = null; - globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => { - observedSignal = options.signal; - options.signal?.addEventListener('abort', () => { - reject(new DOMException('aborted', 'AbortError')); - }, { once: true }); - }); + globalThis.fetch = (_url, options = {}) => + new Promise((_resolve, reject) => { + observedSignal = options.signal; + options.signal?.addEventListener( + 'abort', + () => { + reject(new DOMException('aborted', 'AbortError')); + }, + { once: true }, + ); + }); try { const controller = new AbortController(); const work = militaryFlightsLayer.update({}, { signal: controller.signal }); @@ -161,11 +160,22 @@ test('nonempty adsb.lol payload with zero usable rows cannot prove share target json: async () => ({ ac: [null, {}, { hex: 'ae1234' }] }), }); try { - await militaryFlightsLayer.update({ camera: { positionCartographic: null }, scene: {} }); - const resolution = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234'); + await militaryFlightsLayer.update({ + camera: { positionCartographic: null }, + scene: {}, + }); + const resolution = + await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234'); assert.equal(resolution.status, 'source-unavailable'); - assert.match(militaryFlightsLayer.getStats().error, /Malformed adsb\.lol aircraft rows/); - assert.equal(militaryFlightsLayer.getAnalystRecords().length, 1, 'warm aircraft data is preserved'); + assert.match( + militaryFlightsLayer.getStats().error, + /Malformed adsb\.lol aircraft rows/, + ); + assert.equal( + militaryFlightsLayer.getAnalystRecords().length, + 1, + 'warm aircraft data is preserved', + ); } finally { globalThis.fetch = realFetch; } @@ -211,21 +221,26 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p ok: true, status: 200, json: async () => ({ - ac: poll++ === 0 ? [{ - hex: icao24, - lon: -96.9, - lat: 31.1, - alt_baro: 28_000, - alt_geom: 28_100, - track: 95, - gs: 400, - seen: 0, - seen_pos: 0, - flight: 'RCH451 ', - t: 'C17', - r: '05-8152', - ownOp: 'United States Air Force', - }] : [], + ac: + poll++ === 0 + ? [ + { + hex: icao24, + lon: -96.9, + lat: 31.1, + alt_baro: 28_000, + alt_geom: 28_100, + track: 95, + gs: 400, + seen: 0, + seen_pos: 0, + flight: 'RCH451 ', + t: 'C17', + r: '05-8152', + ownOp: 'United States Air Force', + }, + ] + : [], }), }); @@ -250,7 +265,10 @@ test('cached military snapshots retain their observation time and expose stale f _setTrackedMilitaryRefreshStateForTest({ icao24: 'ae01ce', entity: { gevLabelModel: { title: 'RCH451', details: [] } }, - billboard: { position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000), show: false }, + billboard: { + position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000), + show: false, + }, billboardCollection: { show: false, remove() {} }, viewer, history, @@ -288,11 +306,18 @@ test('cached military snapshots retain their observation time and expose stale f await militaryFlightsLayer.update(viewer); assert.equal(history.length, 1); assert.equal(history[0].epochMs, receivedAt - 2000); - for (const [status, age] of [['HIT', 5000], ['STALE', 45000]]) { + for (const [status, age] of [ + ['HIT', 5000], + ['STALE', 45000], + ]) { cache = status; now = receivedAt + age; await militaryFlightsLayer.update(viewer); - assert.equal(history.length, 1, 'replayed positions cannot create a new fix'); + assert.equal( + history.length, + 1, + 'replayed positions cannot create a new fix', + ); assert.equal(militaryFlightsLayer.getStats().lastUpdate, receivedAt); assert.equal(militaryFlightsLayer.getStats().stale, status === 'STALE'); assert.equal(militaryFlightsLayer.getStats().error, null); @@ -319,14 +344,18 @@ test('real military track path creates no native label and publishes every cache trackedEntityChanged, clock: { currentTime: now }, camera: { - cancelFlight() { cancelledFlights += 1; }, + cancelFlight() { + cancelledFlights += 1; + }, position: new Cesium.Cartesian3(0, -10_000, 7_000), positionWC: new Cesium.Cartesian3(0, -10_000, 7_000), direction: Cesium.Cartesian3.UNIT_Y, transform: Cesium.Matrix4.IDENTITY, viewMatrix: Cesium.Matrix4.IDENTITY, frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY }, - lookAtTransform() { appliedFrames += 1; }, + lookAtTransform() { + appliedFrames += 1; + }, }, scene: { canvas: { clientWidth: 1600, clientHeight: 900 }, @@ -365,9 +394,12 @@ test('real military track path creates no native label and publishes every cache const realFetch = globalThis.fetch; globalThis.window = new EventTarget(); const selectionEvents = []; - globalThis.window.addEventListener('gev:awareness-subject-selected', (event) => { - selectionEvents.push(event.detail); - }); + globalThis.window.addEventListener( + 'gev:awareness-subject-selected', + (event) => { + selectionEvents.push(event.detail); + }, + ); globalThis.fetch = async () => ({ ok: false }); _setTrackedOverlayHostForTest(host); try { @@ -379,13 +411,15 @@ test('real military track path creates no native label and publishes every cache billboardCollection: { show: true, remove() {} }, viewer, tracked: false, - history: [{ - time: now, - epochMs: Date.now(), - position, - velocity: 231.5, - track: 92.1, - }], + history: [ + { + time: now, + epochMs: Date.now(), + position, + velocity: 231.5, + track: 92.1, + }, + ], meta: { ...FULL_INFO, type: 'C17', @@ -395,34 +429,72 @@ test('real military track path creates no native label and publishes every cache }, }); - assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true); + assert.equal( + militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), + true, + ); const entity = viewer.trackedEntity; - assert.ok(entity instanceof Cesium.Entity, 'trackById must create the real Cesium entity'); + assert.ok( + entity instanceof Cesium.Entity, + 'trackById must create the real Cesium entity', + ); assert.equal(entity.label, undefined); - assert.ok(entities.values.every((candidate) => candidate.label === undefined)); + assert.ok( + entities.values.every((candidate) => candidate.label === undefined), + ); assert.deepEqual(entity.gevLabelModel, { title: 'RCH451', - details: [ - 'C17 · 05-8152', - 'United States Air Force · 28000 ft · 450 kt', - ], + details: ['C17 · 05-8152', 'United States Air Force · 28000 ft · 450 kt'], accent: '#ffd166', }); viewer.scene.preUpdate.raiseEvent(); const initialAppliedFrames = appliedFrames; const initialCancelledFlights = cancelledFlights; - assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true); - assert.equal(cancelledFlights, initialCancelledFlights, 'ordinary repeated tracking stays camera-idempotent'); - assert.equal(selectionEvents.at(-1)?.origin, 'user', 'same-target selection upgrades durable authority'); + assert.equal( + militaryFlightsLayer.trackById(icao24, { origin: 'user' }), + true, + ); + assert.equal( + cancelledFlights, + initialCancelledFlights, + 'ordinary repeated tracking stays camera-idempotent', + ); + assert.equal( + selectionEvents.at(-1)?.origin, + 'user', + 'same-target selection upgrades durable authority', + ); assert.equal(entity.gevSelectionOrigin, 'user'); - assert.equal(militaryFlightsLayer.refocusTrackedById('different-flight'), false); - assert.equal(militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), true); - assert.equal(selectionEvents.at(-1)?.origin, 'voice', 'same-target refocus forwards explicit authority'); + assert.equal( + militaryFlightsLayer.refocusTrackedById('different-flight'), + false, + ); + assert.equal( + militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), + true, + ); + assert.equal( + selectionEvents.at(-1)?.origin, + 'voice', + 'same-target refocus forwards explicit authority', + ); assert.equal(militaryFlightsLayer.refocusTrackedById(icao24), true); viewer.scene.preUpdate.raiseEvent(); - assert.equal(viewer.trackedEntity, entity, 'refocus must retain the exact tracked entity'); - assert.equal(appliedFrames, initialAppliedFrames + 1, 'repeated refocus keeps one camera-frame owner'); - assert.equal(cancelledFlights, initialCancelledFlights + 2, 'only explicit refocus requests cancel camera flights'); + assert.equal( + viewer.trackedEntity, + entity, + 'refocus must retain the exact tracked entity', + ); + assert.equal( + appliedFrames, + initialAppliedFrames + 1, + 'repeated refocus keeps one camera-frame owner', + ); + assert.equal( + cancelledFlights, + initialCancelledFlights + 2, + 'only explicit refocus requests cancel camera flights', + ); const publication = publications.at(-1); assert.equal(publication.sourceId, 'tracked'); const entry = publication.entries[0]; @@ -433,23 +505,40 @@ test('real military track path creates no native label and publishes every cache const display = entity.position.getValue(now); assert.ok(display, 'tracked position callback must seed its frame cache'); - assert.equal(entry.position(), display, 'host must reuse the exact cached display Cartesian'); + assert.equal( + entry.position(), + display, + 'host must reuse the exact cached display Cartesian', + ); militaryFlightsLayer.setParams( { selectedMilitaryTrackingId: 'late001' }, { origin: 'share-restore' }, ); assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late001'); - assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true); - assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'new user selection cancels stale restore'); + assert.equal( + militaryFlightsLayer.trackById(icao24, { origin: 'user' }), + true, + ); + assert.equal( + _pendingMilitaryTrackingRestoreForTest(), + null, + 'new user selection cancels stale restore', + ); _addMilitaryTrackingCandidateForTest({ icao24: 'late001', - billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000) }, + billboard: { + ...billboard, + position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000), + }, meta: { ...FULL_INFO, callsign: 'LATE1', rawLat: 31.1, rawLon: -96.9 }, history: [], }); _applyPendingMilitaryTrackingRestoreForTest(); - assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, icao24); + assert.equal( + militaryFlightsLayer.getParams().selectedMilitaryTrackingId, + icao24, + ); militaryFlightsLayer.stopTracking(); militaryFlightsLayer.setParams( @@ -458,15 +547,25 @@ test('real military track path creates no native label and publishes every cache ); assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late002'); militaryFlightsLayer.stopTracking({ origin: 'user' }); - assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'explicit clear cancels stale restore'); + assert.equal( + _pendingMilitaryTrackingRestoreForTest(), + null, + 'explicit clear cancels stale restore', + ); _addMilitaryTrackingCandidateForTest({ icao24: 'late002', - billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000) }, + billboard: { + ...billboard, + position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000), + }, meta: { ...FULL_INFO, callsign: 'LATE2', rawLat: 31.2, rawLon: -96.8 }, history: [], }); _applyPendingMilitaryTrackingRestoreForTest(); - assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null); + assert.equal( + militaryFlightsLayer.getParams().selectedMilitaryTrackingId, + null, + ); militaryFlightsLayer.setParams( { selectedMilitaryTrackingId: 'late003' }, @@ -481,12 +580,18 @@ test('real military track path creates no native label and publishes every cache ); _addMilitaryTrackingCandidateForTest({ icao24: 'late003', - billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000) }, + billboard: { + ...billboard, + position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000), + }, meta: { ...FULL_INFO, callsign: 'LATE3', rawLat: 30.6, rawLon: -97.3 }, history: [], }); _applyPendingMilitaryTrackingRestoreForTest(); - assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null); + assert.equal( + militaryFlightsLayer.getParams().selectedMilitaryTrackingId, + null, + ); militaryFlightsLayer.setParams( { selectedMilitaryTrackingId: 'late004' }, @@ -495,18 +600,27 @@ test('real military track path creates no native label and publishes every cache assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late004'); _addMilitaryTrackingCandidateForTest({ icao24: 'late004', - billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000) }, + billboard: { + ...billboard, + position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000), + }, meta: { ...FULL_INFO, callsign: 'LATE4', rawLat: 30.7, rawLon: -97.2 }, history: [], }); assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true); - assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late004'); + assert.equal( + militaryFlightsLayer.getParams().selectedMilitaryTrackingId, + 'late004', + ); militaryFlightsLayer.setParams( { selectedMilitaryTrackingId: 'late005' }, { origin: 'local-restore' }, ); - assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true); + assert.equal( + militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), + true, + ); assert.equal( _pendingMilitaryTrackingRestoreForTest(), 'late005', @@ -514,12 +628,18 @@ test('real military track path creates no native label and publishes every cache ); _addMilitaryTrackingCandidateForTest({ icao24: 'late005', - billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000) }, + billboard: { + ...billboard, + position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000), + }, meta: { ...FULL_INFO, callsign: 'LATE5', rawLat: 31.4, rawLon: -96.6 }, history: [], }); assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true); - assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late005'); + assert.equal( + militaryFlightsLayer.getParams().selectedMilitaryTrackingId, + 'late005', + ); } finally { militaryFlightsLayer.stopTracking(); viewer.scene.preUpdate.raiseEvent(); @@ -564,21 +684,47 @@ test('a callsign-less contact is findable by the tail number the app displays', // The identity analyst_query hands back for this contact. const analystId = mapAnalystRecord(icao24, { - callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01, + callsign: null, + registration: '6606', + rawLat: 40.71, + rawLon: -74.01, }).id; - assert.equal(analystId, '6606', 'the analyst names a callsign-less contact by its tail'); + assert.equal( + analystId, + '6606', + 'the analyst names a callsign-less contact by its tail', + ); const found = militaryFlightsLayer.findByQuery(analystId); - assert.equal(found?.icao24, icao24, 'and the tracker must resolve that same identity'); - assert.equal(militaryFlightsLayer.findByQuery('660')?.icao24, icao24, 'prefix match too'); - assert.equal(militaryFlightsLayer.findByQuery(icao24)?.icao24, icao24, 'hex still wins outright'); + assert.equal( + found?.icao24, + icao24, + 'and the tracker must resolve that same identity', + ); + assert.equal( + militaryFlightsLayer.findByQuery('660')?.icao24, + icao24, + 'prefix match too', + ); + assert.equal( + militaryFlightsLayer.findByQuery(icao24)?.icao24, + icao24, + 'hex still wins outright', + ); }); test('the analyst record carries the hex key the tracker keys on', () => { const record = mapAnalystRecord('ae7f01', { - callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01, + callsign: null, + registration: '6606', + rawLat: 40.71, + rawLon: -74.01, }); - assert.equal(record.icao24, 'ae7f01', 'a resolvable key must ride along with the display label'); + assert.equal( + record.icao24, + 'ae7f01', + 'a resolvable key must ride along with the display label', + ); }); test('a registration never outranks another contact’s exact callsign', () => { @@ -591,18 +737,35 @@ test('a registration never outranks another contact’s exact callsign', () => { show: true, }); const base = { - type: 'C17', operator: 'USAF', altitudeFt: 25000, speedMps: 180, - track: 90, klass: 'widebody', onGround: false, + type: 'C17', + operator: 'USAF', + altitudeFt: 25000, + speedMps: 180, + track: 90, + klass: 'widebody', + onGround: false, }; // Registered FIRST, so insertion order favours it. _addMilitaryTrackingCandidateForTest({ icao24: 'ae0aa1', - meta: { ...base, callsign: null, registration: 'ZULU777', rawLat: 31.0, rawLon: -97.0 }, + meta: { + ...base, + callsign: null, + registration: 'ZULU777', + rawLat: 31.0, + rawLon: -97.0, + }, billboard: billboardAt(-97.0, 31.0), }); _addMilitaryTrackingCandidateForTest({ icao24: 'ae0bb2', - meta: { ...base, callsign: 'ZULU777', registration: '05-9999', rawLat: 31.2, rawLon: -97.2 }, + meta: { + ...base, + callsign: 'ZULU777', + registration: '05-9999', + rawLat: 31.2, + rawLon: -97.2, + }, billboard: billboardAt(-97.2, 31.2), }); @@ -625,24 +788,49 @@ test('two contacts matching at the same strength resolve deterministically', () show: true, }); const base = { - type: 'C130', operator: 'USAF', altitudeFt: 21000, speedMps: 150, - track: 45, klass: 'turboprop', onGround: false, + type: 'C130', + operator: 'USAF', + altitudeFt: 21000, + speedMps: 150, + track: 45, + klass: 'turboprop', + onGround: false, }; // Both registrations begin with QQ7 — a same-tier prefix tie. Registered // highest-hex first so insertion order would pick the wrong one. _addMilitaryTrackingCandidateForTest({ icao24: 'aef002', - meta: { ...base, callsign: null, registration: 'QQ7-222', rawLat: 32.0, rawLon: -98.0 }, + meta: { + ...base, + callsign: null, + registration: 'QQ7-222', + rawLat: 32.0, + rawLon: -98.0, + }, billboard: billboardAt(-98.0, 32.0), }); _addMilitaryTrackingCandidateForTest({ icao24: 'aef001', - meta: { ...base, callsign: null, registration: 'QQ7-111', rawLat: 32.1, rawLon: -98.1 }, + meta: { + ...base, + callsign: null, + registration: 'QQ7-111', + rawLat: 32.1, + rawLon: -98.1, + }, billboard: billboardAt(-98.1, 32.1), }); const first = militaryFlightsLayer.findByQuery('QQ7')?.icao24; const second = militaryFlightsLayer.findByQuery('qq7')?.icao24; - assert.equal(first, 'aef001', 'the stable key (lowest hex) wins, not the feed order'); - assert.equal(second, first, 'and the same query resolves the same way every time'); + assert.equal( + first, + 'aef001', + 'the stable key (lowest hex) wins, not the feed order', + ); + assert.equal( + second, + first, + 'and the same query resolves the same way every time', + ); }); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 49711163f..53524bada 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -673,11 +673,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // referent-only track_entity alternative (kept out of the model-facing // schema, which may not carry top-level anyOf), and Contacts requested-radius // list descriptions; the missions still ride existing tools. - // Street Level adds toggle enum values and the generated alias hint. - assert.equal(block.length, 29802, 'serialized tool schema length drifted'); + // GNSS and emergency/contact fields extend the manifest-generated contract. + assert.equal(block.length, 30208, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'b316ca2e67848eb565b6847ac044a19d1465d8ef8252a5dfbd7d947a2a36d5ed', + 'f226ed9a1d1867b107b39073f527add7f9cb083ef013dbb2111842fcbd343c35', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8'); diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 0714acfc5..c6a55f76c 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -225,7 +225,7 @@ export function createQueries({ * military: boolean, aircraftClass: string|null, originCountry: string|null, * operator: string|null, squawk: string|null, emergency: boolean, * emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null, - * emergencyBroadcast: string|null, observedAtMs: number|null, routeOrigin: string|null, + * emergencyBroadcast: string|null, lastContactAtMs: number|null, routeOrigin: string|null, * routeDestination: string|null}} */ @@ -271,9 +271,9 @@ export function createQueries({ // when the Mode A code names the status, 'ads-b' for the readsb field. emergencySource: text(info?.emergency?.source), emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency), - // Epoch ms of the last transponder message — when this squawk/status - // was heard (OpenSky time_contact; readsb snapshot time minus `seen`). - observedAtMs: num(info?.lastContactEpochMs), + // Latest known general aircraft-message contact, retained if a poll + // has no time. This is not the receipt time of the current status. + lastContactAtMs: num(info?.lastContactEpochMs), routeOrigin: routeOk ? text(info?.route?.origin?.code) : null, routeDestination: routeOk ? text(info?.route?.destination?.code) : null, }; diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 0d418ddc1..b3d1b8cf3 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -162,7 +162,7 @@ export function createQueries({ * military: boolean, aircraftClass: string|null, originCountry: null, * operator: string|null, squawk: string|null, emergency: boolean, * emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null, - * emergencyBroadcast: string|null, observedAtMs: number|null, routeOrigin: null, routeDestination: null}} + * emergencyBroadcast: string|null, lastContactAtMs: number|null, routeOrigin: null, routeDestination: null}} */ function mapAnalystRecord(icao24, info) { @@ -204,9 +204,9 @@ export function createQueries({ // when the Mode A code names the status, 'ads-b' for the readsb field. emergencySource: text(info?.emergency?.source), emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency), - // Epoch ms of the last transponder message — when this squawk/status - // was heard (OpenSky time_contact; readsb snapshot time minus `seen`). - observedAtMs: num(info?.lastContactEpochMs), + // Latest known general aircraft-message contact, retained if a poll + // has no time. This is not the receipt time of the current status. + lastContactAtMs: num(info?.lastContactEpochMs), routeOrigin: null, routeDestination: null, }; diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js index 977801aa7..faa77d08a 100644 --- a/src/sources/live/aircraft.js +++ b/src/sources/live/aircraft.js @@ -87,7 +87,13 @@ export function openSkySnapshot( ) { const admitted = admitRecords( payload?.states, - normalizeOpenSkyAircraft, + (row) => { + const record = normalizeOpenSkyAircraft(row); + const statuses = payload?.emergencyByIcao; + if (record && statuses && Object.hasOwn(statuses, record.id)) + record.emergency = cleanText(statuses[record.id]).toLowerCase() || null; + return record; + }, 'OpenSky', ); const observedAtMs = epoch(payload?.time, 1000); diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index d3b8e8a90..4a8f130a6 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -36,8 +36,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // consolidated tool wording (each policy stated once); model-facing tools // omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves // requested radius and center authority in descriptions only. - // Street Level adds its toggle enum values and generated alias hint. - '9eb113454c5af5e32e27ffcdc7919d0e4600ba1d204f8f175e071152be41ad0f', + // GNSS and emergency/contact fields extend the manifest-generated contract. + '3f18ec8718981cfa42e1462a0f1a9f0102cd90f1f86e0cc25a9c0d0f3b291a53', ); }); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 9e1e183d0..3a80ea8d7 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -4809,7 +4809,10 @@ function compactAnalystItem(r) { if (!present(r[key]) || key in compact) continue; if (spec.type === 'time') { const ms = Number(r[key]); - if (Number.isFinite(ms)) compact.time = new Date(ms).toISOString(); + if (Number.isFinite(ms)) { + if (key === 'lastContactAtMs') compact.lastContactAtMs = ms; + else compact.time = new Date(ms).toISOString(); + } } else compact[key] = r[key]; } if (present(r.ageHours)) compact.ageHours = r.ageHours; diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 1e348ee41..c5b84f3dd 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -34,9 +34,29 @@ const AIRCRAFT_FIELDS = { aircraftClass: ['t'], military: ['f'], onGround: ['f'], + squawk: ['t'], + emergency: ['f'], + emergencyKind: ['t'], + emergencySource: ['t'], + emergencyBroadcast: ['t'], + lastContactAtMs: ['ms'], }; const layers = [ + { + id: 'gnss-interference', + aliases: [ + 'gnss integrity', + 'navigation integrity', + 'low navigation accuracy', + 'gnss interference', + 'gps interference', + 'gps jamming', + 'gnss', + ], + context: false, + noQuery: 'navigation-integrity cells; no countable analyst records', + }, { id: 'flights', track: true, From 45e9c5ab0aff41aa8b48fd22a1fbee81afb45be1 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Tue, 13 Oct 2026 17:56:41 +0200 Subject: [PATCH 32/86] test: exercise emergency highlighting through current feed routes --- scripts/qa-emergency-squawk.mjs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/qa-emergency-squawk.mjs b/scripts/qa-emergency-squawk.mjs index 88f1adf37..8b5e59bf1 100644 --- a/scripts/qa-emergency-squawk.mjs +++ b/scripts/qa-emergency-squawk.mjs @@ -3,7 +3,7 @@ * qa-emergency-squawk.mjs — headless proof for transponder emergency highlighting. * * Drives the REAL app in headless Chromium against a dev server (keyless). - * `/api/opensky` and `/api/adsblol/mil` are stubbed in the page so the codes + * `/api/flights` and `/api/military` are stubbed in the page so the codes * are deterministic; everything downstream (normalizers, records, rendering, * Cockpit HUD) is production code. * @@ -103,8 +103,12 @@ function installStubs() { const url = typeof input === 'string' ? input : input?.url || ''; const now = Date.now(); const { civil, mil } = window.__squawkQaFixtures; - if (url.includes('/api/opensky-track')) return json({ path: [] }); - if (url.includes('/api/opensky')) { + if ( + url.includes('/api/flights/track') || + url.includes('/api/military/track') + ) + return json({ path: [] }); + if (url.includes('/api/flights')) { const t = Math.floor(now / 1000); return json({ time: t, @@ -116,7 +120,7 @@ function installStubs() { }), }); } - if (url.includes('/api/adsblol/mil')) { + if (url.includes('/api/military')) { const cleared = window.__squawkQaPhase === 'cleared'; return json({ msg: 'No error', From c349a89bb285c9b7244e454a818014bc55ae1149 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Tue, 13 Oct 2026 18:11:41 +0200 Subject: [PATCH 33/86] fix: keep fallback emergency status paired with admitted aircraft --- src/data/adsbLolFallback.js | 38 ++++++++++++++-------- src/data/aircraftEmergencyAnalyst.test.mjs | 19 +++++++++++ 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/data/adsbLolFallback.js b/src/data/adsbLolFallback.js index 2ae17fbe6..e83cf118b 100644 --- a/src/data/adsbLolFallback.js +++ b/src/data/adsbLolFallback.js @@ -43,7 +43,14 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) { .toLowerCase(); const latitude = finiteNumber(aircraft?.lat); const longitude = finiteNumber(aircraft?.lon); - if (!hex || latitude === null || longitude === null) return null; + if ( + !hex || + latitude === null || + longitude === null || + Math.abs(latitude) > 90 || + Math.abs(longitude) > 180 + ) + return null; const seenPosition = Math.max( 0, @@ -95,21 +102,24 @@ export function normalizeAdsbLolPointResponse(payload) { responseNow > 10_000_000_000 ? responseNow / 1000 : responseNow, ); const aircraft = Array.isArray(payload?.ac) ? payload.ac : []; - const states = aircraft - .map((aircraft) => normalizeAdsbLolAircraftState(aircraft, nowSeconds)) - .filter(Boolean); + const states = []; // Keep the OpenSky vectors unchanged. The regional fallback's extra readsb // status travels alongside them, keyed only to admitted aircraft. - const admitted = new Set(states.map((row) => row[0])); - const emergencyByIcao = Object.fromEntries( - aircraft.flatMap((row) => { - const id = String(row?.hex || '') - .trim() - .toLowerCase(); - if (!admitted.has(id) || typeof row?.emergency !== 'string') return []; - return [[id, row.emergency.trim().toLowerCase()]]; - }), - ); + const admitted = new Set(); + const statuses = []; + for (const row of aircraft) { + const state = normalizeAdsbLolAircraftState(row, nowSeconds); + if (!state) continue; + states.push(state); + // The live contract admits the first valid vector for each ICAO. Its + // status must come from that same row, never an invalid or later duplicate. + const id = state[0]; + if (admitted.has(id)) continue; + admitted.add(id); + if (typeof row.emergency === 'string') + statuses.push([id, row.emergency.trim().toLowerCase()]); + } + const emergencyByIcao = Object.fromEntries(statuses); return { time: nowSeconds, states, diff --git a/src/data/aircraftEmergencyAnalyst.test.mjs b/src/data/aircraftEmergencyAnalyst.test.mjs index 2bb9ede1b..9e4a6e2fe 100644 --- a/src/data/aircraftEmergencyAnalyst.test.mjs +++ b/src/data/aircraftEmergencyAnalyst.test.mjs @@ -16,6 +16,25 @@ import { createGevActionRunner } from '../voice/gevActions.js'; const NOW_MS = 1800000000000; +test('fallback status belongs to the first valid admitted aircraft row', () => { + const valid = { hex: 'a1b2c3', lat: 30, lon: -97, squawk: '1200' }; + for (const status of [undefined, 'general', 'none']) { + const payload = normalizeAdsbLolPointResponse({ + now: NOW_MS, + ac: [ + { ...valid, lat: 100, emergency: 'general' }, + { ...valid, emergency: status }, + { ...valid, lat: null, emergency: 'general' }, + { ...valid, emergency: status === 'general' ? 'none' : 'general' }, + ], + }); + const snapshot = openSkySnapshot(payload, { now: NOW_MS }); + assert.equal(snapshot.records.length, 1); + assert.equal(snapshot.records[0].emergency, status ?? null); + assert.equal(snapshot.records[0].latitude, 30); + } +}); + function civilStore() { return new FlightRecords({ geoidHeight: () => 0, From 96534df80717b79dbda80269b561cc0d49c747c4 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Tue, 13 Oct 2026 21:17:10 +0200 Subject: [PATCH 34/86] fix: refresh GNSS sampling automatically as the camera moves --- CHANGELOG.md | 5 +- DATA_SOURCES.md | 2 +- docs/CURRENT-STATE.md | 4 +- scripts/qa-gnss.mjs | 85 ++++++++++++++++++++ src/layers/gnss/index.js | 117 ++++++++++++++++++++++++++-- src/layers/gnss/ownership.test.mjs | 121 ++++++++++++++++++++++++++++- 6 files changed, 319 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7bf0a65a..d81467a25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -189,7 +189,10 @@ are navigation-integrity anomalies; suspected GNSS interference is a secondary, unvalidated interpretation. The layer and the proxy expose machine-readable provenance (`provenance`, `classifier: gev-nic-nacp-v1`). - Keyless, worldwide wherever adsb.lol has receivers; reads + Automatically refreshes after camera pan/zoom without toggling, coalesces + nearby views, and uses the Earth point under the screen centre at globe scale. + Keyless regional sampling wherever adsb.lol has receivers; the row labels + the 250 nm radius, which remains regional when zoomed out. Reads a capped 250 nm snapshot around the camera through `/api/gnss-integrity`, which returns integrity fields only and serves the last snapshot (with its original observation time and `ageMs`, for at most 30 minutes) when the diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 9afeec60b..da262b43a 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -78,7 +78,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. -- **GNSS integrity (adsb.lol integrity fields).** The GNSS Integrity layer (id `gnss-interference`) reads the same keyless 250 nm point snapshot around the camera (anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. The method has three separately sourced parts, exposed as machine-readable `provenance` on the layer and its stats: (1) a GEV-defined per-aircraft classifier (`gev-nic-nacp-v1`): an aircraft has low navigation accuracy when it reports NIC < 7 or NACp < 8 or readsb flags GPS loss. The numbers are borrowed from the 14 CFR 91.227(c) ADS-B Out performance minima, a US regulation, not a published interference classifier; gpsjam.org does not publish its per-aircraft threshold ([FAQ](https://gpsjam.org/faq)), and this one is not yet validated against known events. (2) gpsjam.org's published cell formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %). (3) A GEV-defined window: at least 3 aircraft per 0.5° cell over a rolling 30 minutes around the views the user visits. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is a secondary interpretation, not a confirmed source or location: avionics faults also report low values. Verified 2026-09-29. +- **GNSS integrity (adsb.lol integrity fields).** The GNSS Integrity layer (id `gnss-interference`) reads the same keyless 250 nm point snapshot around the camera (Earth point under the screen centre, anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). Pan/zoom triggers a debounced automatic refresh, bounded to one read per 2.5 s; same-region movement shares the cache. This is a regional public point feed, not a worldwide snapshot at globe zoom. The layer row labels the sampling radius. The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. The method has three separately sourced parts, exposed as machine-readable `provenance` on the layer and its stats: (1) a GEV-defined per-aircraft classifier (`gev-nic-nacp-v1`): an aircraft has low navigation accuracy when it reports NIC < 7 or NACp < 8 or readsb flags GPS loss. The numbers are borrowed from the 14 CFR 91.227(c) ADS-B Out performance minima, a US regulation, not a published interference classifier; gpsjam.org does not publish its per-aircraft threshold ([FAQ](https://gpsjam.org/faq)), and this one is not yet validated against known events. (2) gpsjam.org's published cell formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %). (3) A GEV-defined window: at least 3 aircraft per 0.5° cell over a rolling 30 minutes around the views the user visits. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is a secondary interpretation, not a confirmed source or location: avionics faults also report low values. Verified 2026-09-29. - **Transponder emergencies.** Emergency highlighting reads fields the flight feeds already return: OpenSky state vectors' `squawk` and adsb.lol/readsb's `squawk` and `emergency`. It makes no extra request. A broadcast code states what the transponder sends, not a verified incident; OpenSky has no ADS-B emergency field, so genuine OpenSky highlighting relies on squawk 7500/7600/7700 alone. The civil adsb.lol fallback preserves its additional ADS-B status beside the OpenSky-compatible vectors, without another request. - **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. - **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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c894d49de..d62e733e1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3098,10 +3098,10 @@ its criteria cannot be silently ignored. | 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) | | Street Level 📷 | Street-level imagery; Mapillary is the first provider (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, cached 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-interference`) | 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` | 60s (server cache 60 s; 30 min rolling window) | +| GNSS Integrity 📡 (`gnss-interference`) | 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) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | -GNSS Integrity fetches a capped adsb.lol snapshot around the view anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Only DO-260A/B (ADS-B versions 1 and 2) direct 1090ES positions with a known `seen_pos` of 0–60 s count. Presence and degraded evidence age separately: a degraded report keeps the aircraft degraded in its cell for 30 minutes after that report, however many healthy reports follow, and an aircraft unheard for 30 minutes starts fresh. Rows are dated by the snapshot's observation time, which the proxy keeps on cached and stale answers and reports as `ageMs` on its own clock (so browser clock skew does not move the window); a replayed snapshot never restarts the window; the proxy stops serving a stale snapshot once it is older than 30 minutes, the source refuses an undated one, and cells age out on schedule during fetch failures or while there is no view anchor. +GNSS Integrity automatically refreshes after camera pan/zoom (600 ms debounce, minimum 2.5 s between reads, shared whole-degree cache keys) through the manager refresh lane, so counts and loading/error state update without toggling. The anchor is the Earth point under the screen centre, including at globe scale where the visible rectangle can cover the entire world. It fetches a capped 250-nautical-mile-radius adsb.lol snapshot around that anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band and visibly labels the regional 250 nm sampling radius. Zooming out does not turn this public point feed into worldwide coverage; blank areas can lie outside the sampled region or have fewer than three qualifying aircraft. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Only DO-260A/B (ADS-B versions 1 and 2) direct 1090ES positions with a known `seen_pos` of 0–60 s count. Presence and degraded evidence age separately: a degraded report keeps the aircraft degraded in its cell for 30 minutes after that report, however many healthy reports follow, and an aircraft unheard for 30 minutes starts fresh. Rows are dated by the snapshot's observation time, which the proxy keeps on cached and stale answers and reports as `ageMs` on its own clock (so browser clock skew does not move the window); a replayed snapshot never restarts the window; the proxy stops serving a stale snapshot once it is older than 30 minutes, the source refuses an undated one, and cells age out on schedule during fetch failures or while there is no view anchor. Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index 12cab5ebd..bce30bad7 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -243,11 +243,13 @@ async function stubRoute(page, { ageMs = 0 } = {}) { await page.evaluateOnNewDocument( (payload, age) => { window.__gnssQaAgeMs = age; + window.__gnssQaRequests = []; const realFetch = window.fetch.bind(window); window.__gnssQaMode = 'fixture'; window.fetch = (input, init) => { const url = typeof input === 'string' ? input : input?.url || ''; if (!url.includes('/api/gnss-integrity')) return realFetch(input, init); + window.__gnssQaRequests.push(url); const ok = window.__gnssQaMode === 'fixture'; const body = ok ? { @@ -480,6 +482,86 @@ async function main() { ); await shoot(page, 'synthetic-bands'); + console.log('\nCAMERA — automatic regional refresh without toggling...'); + const beforeMove = await page.evaluate( + () => window.__gnssQaRequests.length, + ); + await page.evaluate(() => { + const { viewer } = window.__godsEyeView; + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (139.7 * Math.PI) / 180, + latitude: (35.7 * Math.PI) / 180, + height: 1_100_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + viewer.scene.requestRender(); + }); + await page.waitForFunction( + () => { + const url = window.__gnssQaRequests.at(-1); + return ( + url && + Math.abs( + Number(new URL(url, location.origin).searchParams.get('lon')) - + 139.7, + ) < 1 + ); + }, + { timeout: 15000 }, + ); + await sleep(3500); + const moved = await page.evaluate( + (id) => ({ + requests: window.__gnssQaRequests.length, + enabled: window.__godsEyeView.dataManager.isEnabled(id), + coverage: window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats().coverage, + info: document.querySelector( + `[data-layer-id="${id}"] .data-toggle-controls-info`, + )?.textContent, + }), + LAYER_ID, + ); + record( + 'CAMERA: moving to Tokyo automatically refreshes the new region and updates the row', + moved.enabled && + moved.requests > beforeMove && + moved.requests <= beforeMove + 2 && + moved.coverage?.longitude === 140 && + /Regional sample.*250 nm radius/.test(moved.info), + JSON.stringify(moved), + ); + await shoot(page, 'camera-refresh'); + await page.evaluate(() => { + const { viewer } = window.__godsEyeView; + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (18.6 * Math.PI) / 180, + latitude: (54.6 * Math.PI) / 180, + height: 450_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + viewer.scene.requestRender(); + }); + await page.waitForFunction( + () => { + const url = window.__gnssQaRequests.at(-1); + return ( + url && + Math.abs( + Number(new URL(url, location.origin).searchParams.get('lon')) - + 18.6, + ) < 1 + ); + }, + { timeout: 15000 }, + ); + await sleep(3500); + // ── (iii) FAILURE ───────────────────────────────────────────────────── console.log('\n(iii) FAILURE — /api/gnss-integrity stubbed to 502...'); await page.evaluate(() => { @@ -550,6 +632,8 @@ async function main() { rowRight: Math.round(rect.right), overflow: row.scrollWidth - row.clientWidth, legendItems: legend.length, + coverageInfo: row.querySelector('.data-toggle-controls-info') + ?.textContent, legendInside: legend.every((item) => { const r = item.getBoundingClientRect(); return ( @@ -564,6 +648,7 @@ async function main() { fit.rowRight <= fit.width && fit.overflow <= 0 && fit.legendItems === 3 && + /Regional sample.*250 nm radius/.test(fit.coverageInfo) && fit.legendInside, JSON.stringify(fit), ); diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index 7be808d9f..5ad764d16 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -23,12 +23,23 @@ const LEVEL_LABELS = Object.freeze({ /** Degrees at the centre of the current view, or null before the camera settles. */ export function defaultGnssViewAnchor(viewer) { - const rectangle = viewer?.camera?.computeViewRectangle?.( - viewer.scene?.globe?.ellipsoid, - ); - const carto = rectangle - ? Cesium.Rectangle.center(rectangle) - : viewer?.camera?.positionCartographic; + const canvas = viewer?.scene?.canvas; + const ellipsoid = viewer?.scene?.globe?.ellipsoid; + // At globe scale the visible rectangle can be the whole world, whose + // midpoint is unrelated to the place under the screen centre. + const centre = + canvas?.clientWidth > 0 && + canvas.clientHeight > 0 && + viewer.camera?.pickEllipsoid?.( + new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2), + ellipsoid, + ); + const rectangle = viewer?.camera?.computeViewRectangle?.(ellipsoid); + const carto = centre + ? (ellipsoid || Cesium.Ellipsoid.WGS84).cartesianToCartographic(centre) + : rectangle + ? Cesium.Rectangle.center(rectangle) + : viewer?.camera?.positionCartographic; if (!carto) return null; const latitude = Cesium.Math.toDegrees(carto.latitude); const longitude = Cesium.Math.toDegrees(carto.longitude); @@ -60,9 +71,60 @@ export function createGnssIntegrityLayer({ let _lastUpdate = null; let _lastError = null; let _stale = false; + let _manager = null; + let _cameraTimer = null; + let _cameraRefresh = null; + let _lastQueryKey = null; + let _lastRequestAt = -Infinity; + let _removeCameraEnd = null; + let _removeCameraChanged = null; + let _coverage = null; const _observations = new Map(); const provenance = gnssProvenance({ windowMs }); + const queryKey = (anchor) => + anchor + ? `${Math.round(anchor.latitude)},${Math.round(anchor.longitude)}` + : null; + + function scheduleCameraRefresh() { + if (!_enabled || !_dataSource) return; + const key = queryKey(viewAnchor(_viewer)); + if (!key || key === _lastQueryKey) return; + clearTimeout(_cameraTimer); + // Debounce drags and bound moving/follow cameras to at most 24 requests + // per minute. Nearby views share the proxy's whole-degree cache key. + _cameraTimer = setTimeout( + async () => { + _cameraTimer = null; + if (!_enabled || _cameraRefresh) return; + const key = queryKey(viewAnchor(_viewer)); + if (!key || key === _lastQueryKey) return; + const controller = new AbortController(); + _cameraRefresh = controller; + try { + if (_manager) { + // Use the normal refresh lane so counts, loading/errors and render + // demand settle together; wait out an older periodic request. + await _manager.refreshLayer(layer.id, { + signal: controller.signal, + }); + } else { + await layer.update(undefined, { signal: controller.signal }); + } + } finally { + if (_cameraRefresh === controller) { + _cameraRefresh = null; + // Movement during an in-flight request is folded into one update + // for the latest view, rather than lost until the minute tick. + scheduleCameraRefresh(); + } + } + }, + Math.max(600, 2500 - (now() - _lastRequestAt)), + ); + } + /** Age the store without new evidence, so expired cells leave the globe. */ function expire() { pruneGnssObservations(_observations, now(), { windowMs }); @@ -100,6 +162,7 @@ export function createGnssIntegrityLayer({ }), ); } + _viewer?.scene?.requestRender?.(); } const layer = { @@ -111,6 +174,10 @@ export function createGnssIntegrityLayer({ provenance, updateInterval: 60000, + attachDataManager(manager) { + _manager = manager; + }, + init(viewer) { if (_viewer) throw new Error('GNSS integrity layer is already initialized'); @@ -118,6 +185,12 @@ export function createGnssIntegrityLayer({ _dataSource = new Cesium.CustomDataSource('gnss-interference'); _dataSource.show = false; viewer.dataSources.add(_dataSource); + _removeCameraEnd = viewer.camera?.moveEnd?.addEventListener( + scheduleCameraRefresh, + ); + _removeCameraChanged = viewer.camera?.changed?.addEventListener( + scheduleCameraRefresh, + ); }, enable() { @@ -130,14 +203,18 @@ export function createGnssIntegrityLayer({ }, disable() { + clearTimeout(_cameraTimer); + _cameraTimer = null; + _cameraRefresh?.abort(); + _cameraRefresh = null; _request?.abort(); _request = null; _enabled = false; if (_dataSource) _dataSource.show = false; }, - async update() { - if (!_enabled || !_dataSource) return false; + async update(_viewerArg, { signal } = {}) { + if (!_enabled || !_dataSource || signal?.aborted) return false; const anchor = viewAnchor(_viewer); // No anchor yet (camera still settling) is not a failure: returning // false would make the manager reject the enable. The next interval @@ -149,6 +226,16 @@ export function createGnssIntegrityLayer({ _request?.abort(); const request = new AbortController(); _request = request; + const onAbort = () => request.abort(signal.reason); + signal?.addEventListener('abort', onAbort, { once: true }); + _lastQueryKey = queryKey(anchor); + _lastRequestAt = now(); + _coverage = { + kind: 'regional', + latitude: Math.round(anchor.latitude), + longitude: Math.round(anchor.longitude), + radiusNm: 250, + }; try { const snapshot = await source.getSnapshot(anchor, { signal: request.signal, @@ -178,11 +265,18 @@ export function createGnssIntegrityLayer({ expire(); return false; } finally { + signal?.removeEventListener('abort', onAbort); if (_request === request) _request = null; + scheduleCameraRefresh(); } }, destroy(viewer = _viewer) { + layer.disable(); + _removeCameraEnd?.(); + _removeCameraChanged?.(); + _removeCameraEnd = null; + _removeCameraChanged = null; _request?.abort(); _request = null; _enabled = false; @@ -197,11 +291,17 @@ export function createGnssIntegrityLayer({ _lastUpdate = null; _lastError = null; _stale = false; + _coverage = null; + _lastQueryKey = null; + _manager = null; }, getRowControls() { return { chips: [], + info: 'Regional sample · 250 nm radius · pan to sample another area', + infoTitle: + 'Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells have insufficient observations.', legend: ['high', 'medium', 'low'].map((level, index) => ({ label: LEVEL_LABELS[level], color: GNSS_LEVEL_COLORS[level], @@ -222,6 +322,7 @@ export function createGnssIntegrityLayer({ error: _lastError, stale: _stale, provenance, + coverage: _coverage, // Load-bearing: without an explicit boolean, layerFeedState reads the // adsb.lol source name as a flights fallback (src/data/feedState.js). fallback: false, diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index 122489fa2..5996d5e2b 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createGnssIntegrityLayer } from './index.js'; +import * as Cesium from 'cesium'; +import { createGnssIntegrityLayer, defaultGnssViewAnchor } from './index.js'; +import { LayerLifecycle } from '../../data/lifecycle.js'; const row = (hex, lat, degraded) => ({ hex, @@ -14,11 +16,15 @@ const row = (hex, lat, degraded) => ({ function harness( getSnapshot, - { anchor: initialAnchor = { latitude: 50, longitude: 30 } } = {}, + { + anchor: initialAnchor = { latitude: 50, longitude: 30 }, + initialize = true, + } = {}, ) { let anchor = initialAnchor; const sources = []; const viewer = { + camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() }, dataSources: { add(value) { sources.push(value); @@ -44,11 +50,12 @@ function harness( }, now: () => clock, }); - layer.init(viewer); + if (initialize) layer.init(viewer); return { layer, sources, anchors, + viewer, advance(ms) { clock += ms; }, @@ -66,6 +73,114 @@ const threeHealthy = () => [ row('a3', 1.3, false), ]; +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +test('a globe-wide rectangle uses the visible screen centre as the query anchor', () => { + const anchor = defaultGnssViewAnchor({ + scene: { + canvas: { clientWidth: 1000, clientHeight: 800 }, + globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, + }, + camera: { + pickEllipsoid(pixel) { + assert.deepEqual(pixel, new Cesium.Cartesian2(500, 400)); + return Cesium.Cartesian3.fromDegrees(-97, 30); + }, + computeViewRectangle: () => Cesium.Rectangle.MAX_VALUE, + }, + }); + assert.ok(Math.abs(anchor.latitude - 30) < 1e-8); + assert.ok(Math.abs(anchor.longitude + 97) < 1e-8); +}); + +test('pan and zoom refresh through the manager without toggling; nearby changes are coalesced', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + let calls = 0; + const h = harness( + async () => { + calls++; + return { rows: threeHealthy() }; + }, + { initialize: false }, + ); + const manager = new LayerLifecycle(h.viewer); + manager.register(h.layer); + h.layer.attachDataManager(manager); + const changes = []; + manager.subscribe((change) => changes.push(change.type)); + await manager.setEnabled(h.layer.id, true); + h.setAnchor({ latitude: 54, longitude: 19 }); + h.viewer.camera.changed.raiseEvent(); + h.setAnchor({ latitude: 55, longitude: 20 }); + h.viewer.camera.moveEnd.raiseEvent(); + h.advance(2500); + t.mock.timers.tick(2500); + await flush(); + assert.equal(calls, 2, 'one initial read and one read for the latest view'); + assert.deepEqual(h.anchors.at(-1), { latitude: 55, longitude: 20 }); + assert.ok(changes.includes('refresh-transition')); + assert.ok(changes.includes('refresh')); + assert.equal(manager.isEnabled(h.layer.id), true); + assert.deepEqual(h.layer.getStats().coverage, { + kind: 'regional', + latitude: 55, + longitude: 20, + radiusNm: 250, + }); + assert.match(h.layer.getRowControls().info, /Regional sample.*250 nm radius/); + h.setAnchor({ latitude: 55.1, longitude: 20.1 }); + h.viewer.camera.changed.raiseEvent(); + h.advance(5000); + t.mock.timers.tick(5000); + await flush(); + assert.equal(calls, 2, 'nearby views reuse the same query region'); + await manager.destroyAll(); + assert.equal(h.viewer.camera.moveEnd.numberOfListeners, 0); + assert.equal(h.viewer.camera.changed.numberOfListeners, 0); +}); + +test('movement during a pending refresh follows up at the newest view; disable cancels queued work', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const calls = []; + let resolvePending; + const h = harness((anchor) => { + calls.push(anchor); + if (calls.length === 2) + return new Promise((resolve) => { + resolvePending = resolve; + }); + return { rows: threeHealthy() }; + }); + h.layer.enable(); + await h.layer.update(); + h.setAnchor({ latitude: 54, longitude: 19 }); + h.viewer.camera.moveEnd.raiseEvent(); + h.advance(2500); + t.mock.timers.tick(2500); + await flush(); + h.setAnchor({ latitude: 35, longitude: 140 }); + h.viewer.camera.changed.raiseEvent(); + h.advance(2500); + t.mock.timers.tick(2500); + await flush(); + assert.equal(calls.length, 2, 'only one camera request in flight'); + resolvePending({ rows: threeHealthy() }); + await flush(); + h.advance(600); + t.mock.timers.tick(600); + await flush(); + assert.equal(calls.length, 3); + assert.deepEqual(calls.at(-1), { latitude: 35, longitude: 140 }); + h.setAnchor({ latitude: 10, longitude: 10 }); + h.viewer.camera.moveEnd.raiseEvent(); + h.layer.disable(); + h.advance(5000); + t.mock.timers.tick(5000); + await flush(); + assert.equal(calls.length, 3, 'disable drains the camera timer'); + h.layer.destroy(); +}); + test('the layer requires a snapshot source and cannot be initialized twice', () => { assert.throws(() => createGnssIntegrityLayer({}), TypeError); const { layer } = harness(async () => ({ rows: [] })); From 70664c300b25a6c431601ed5017fa46cfbde3b2f Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Tue, 13 Oct 2026 21:19:08 +0200 Subject: [PATCH 35/86] test: verify GNSS query center at globe scale --- scripts/qa-gnss.mjs | 47 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index bce30bad7..bebed56aa 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -535,6 +535,53 @@ async function main() { JSON.stringify(moved), ); await shoot(page, 'camera-refresh'); + await page.evaluate(() => { + const { viewer } = window.__godsEyeView; + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (12.5 * Math.PI) / 180, + latitude: (41.9 * Math.PI) / 180, + height: 16_000_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + viewer.scene.requestRender(); + }); + await page.waitForFunction( + () => { + const url = window.__gnssQaRequests.at(-1); + const params = url && new URL(url, location.origin).searchParams; + return ( + params && + Math.abs(Number(params.get('lon')) - 12.5) < 1 && + Math.abs(Number(params.get('lat')) - 41.9) < 1 + ); + }, + { timeout: 15000 }, + ); + const world = await page.evaluate((id) => { + const { viewer, dataManager } = window.__godsEyeView; + const bounds = viewer.camera.computeViewRectangle( + viewer.scene.globe.ellipsoid, + ); + return { + enabled: dataManager.isEnabled(id), + height: viewer.camera.positionCartographic.height, + boundsMidLongitude: + bounds && ((bounds.west + bounds.east) * 90) / Math.PI, + coverage: dataManager.layers.get(id).module.getStats().coverage, + }; + }, LAYER_ID); + record( + 'GLOBE: zoomed-out sampling stays centred on Rome and exposes its regional radius', + world.enabled && + world.height > 15_000_000 && + world.coverage?.longitude === 13 && + world.coverage?.latitude === 42 && + world.coverage?.radiusNm === 250, + JSON.stringify(world), + ); + await shoot(page, 'globe-regional'); await page.evaluate(() => { const { viewer } = window.__godsEyeView; viewer.camera.setView({ From a1786f162ccb753758724409b613ffdc22dd644f Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Tue, 13 Oct 2026 21:49:25 +0200 Subject: [PATCH 36/86] fix: recover GNSS feed startup and honor retry cooldowns --- CHANGELOG.md | 4 + docs/CURRENT-STATE.md | 2 +- scripts/qa-gnss.mjs | 96 ++++++++++++++++++++- src/layers/gnss/index.js | 113 +++++++++++++++++++------ src/layers/gnss/ownership.test.mjs | 129 ++++++++++++++++++++++++++++- src/layers/gnss/source.js | 30 ++++++- src/layers/gnss/source.test.mjs | 35 ++++++++ 7 files changed, 376 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d81467a25..8f7adf929 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -189,6 +189,10 @@ are navigation-integrity anomalies; suspected GNSS interference is a secondary, unvalidated interpretation. The layer and the proxy expose machine-readable provenance (`provenance`, `classifier: gev-nic-nacp-v1`). + Transient feed failures no longer reject GNSS activation: the layer stays + enabled, explains unavailable/degraded feed health and retries automatically. + Provider Retry-After deadlines survive movement and toggling; other failures + back off for 30/60/120 seconds. Disable/destroy cancels retry work. Automatically refreshes after camera pan/zoom without toggling, coalesces nearby views, and uses the Earth point under the screen centre at globe scale. Keyless regional sampling wherever adsb.lol has receivers; the row labels diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d62e733e1..f96d4a805 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3101,7 +3101,7 @@ its criteria cannot be silently ignored. | GNSS Integrity 📡 (`gnss-interference`) | 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) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | -GNSS Integrity automatically refreshes after camera pan/zoom (600 ms debounce, minimum 2.5 s between reads, shared whole-degree cache keys) through the manager refresh lane, so counts and loading/error state update without toggling. The anchor is the Earth point under the screen centre, including at globe scale where the visible rectangle can cover the entire world. It fetches a capped 250-nautical-mile-radius adsb.lol snapshot around that anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band and visibly labels the regional 250 nm sampling radius. Zooming out does not turn this public point feed into worldwide coverage; blank areas can lie outside the sampled region or have fewer than three qualifying aircraft. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Only DO-260A/B (ADS-B versions 1 and 2) direct 1090ES positions with a known `seen_pos` of 0–60 s count. Presence and degraded evidence age separately: a degraded report keeps the aircraft degraded in its cell for 30 minutes after that report, however many healthy reports follow, and an aircraft unheard for 30 minutes starts fresh. Rows are dated by the snapshot's observation time, which the proxy keeps on cached and stale answers and reports as `ageMs` on its own clock (so browser clock skew does not move the window); a replayed snapshot never restarts the window; the proxy stops serving a stale snapshot once it is older than 30 minutes, the source refuses an undated one, and cells age out on schedule during fetch failures or while there is no view anchor. +GNSS display activation stays enabled through transient feed failures, including the first read. The row reports unavailable/degraded source health, a specific HTTP cause and the next automatic retry time; successful recovery clears the warning. Retry-After is honored in full for provider cooldowns, with 30/60/120 s bounded backoff otherwise. Camera movement, polling and toggling cannot bypass the deadline; disable/destroy cancels retries. Genuine non-retryable requests and renderer failures remain lifecycle failures. GNSS Integrity automatically refreshes after camera pan/zoom (600 ms debounce, minimum 2.5 s between reads, shared whole-degree cache keys) through the manager refresh lane, so counts and loading/error state update without toggling. The anchor is the Earth point under the screen centre, including at globe scale where the visible rectangle can cover the entire world. It fetches a capped 250-nautical-mile-radius adsb.lol snapshot around that anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band and visibly labels the regional 250 nm sampling radius. Zooming out does not turn this public point feed into worldwide coverage; blank areas can lie outside the sampled region or have fewer than three qualifying aircraft. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Only DO-260A/B (ADS-B versions 1 and 2) direct 1090ES positions with a known `seen_pos` of 0–60 s count. Presence and degraded evidence age separately: a degraded report keeps the aircraft degraded in its cell for 30 minutes after that report, however many healthy reports follow, and an aircraft unheard for 30 minutes starts fresh. Rows are dated by the snapshot's observation time, which the proxy keeps on cached and stale answers and reports as `ageMs` on its own clock (so browser clock skew does not move the window); a replayed snapshot never restarts the window; the proxy stops serving a stale snapshot once it is older than 30 minutes, the source refuses an undated one, and cells age out on schedule during fetch failures or while there is no view anchor. Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index bebed56aa..bf265b9e4 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -239,23 +239,37 @@ function fixturePayload() { } /** Answer /api/gnss-integrity in-page from `fixturePayload()`. */ -async function stubRoute(page, { ageMs = 0 } = {}) { +async function stubRoute(page, { ageMs = 0, initialFailures = 0, stale } = {}) { await page.evaluateOnNewDocument( - (payload, age) => { + (payload, age, failures, staleOverride) => { window.__gnssQaAgeMs = age; window.__gnssQaRequests = []; + window.__gnssQaFailures = failures; const realFetch = window.fetch.bind(window); window.__gnssQaMode = 'fixture'; window.fetch = (input, init) => { const url = typeof input === 'string' ? input : input?.url || ''; if (!url.includes('/api/gnss-integrity')) return realFetch(input, init); window.__gnssQaRequests.push(url); + if (window.__gnssQaFailures > 0) { + window.__gnssQaFailures--; + return Promise.resolve( + Response.json( + { error: 'rate_limited' }, + { + status: 429, + headers: { 'Retry-After': '5' }, + }, + ), + ); + } const ok = window.__gnssQaMode === 'fixture'; const body = ok ? { ...payload, fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0), ageMs: window.__gnssQaAgeMs || 0, + stale: staleOverride ?? payload.stale, } : { error: 'gnss_integrity_unavailable' }; return Promise.resolve( @@ -268,6 +282,8 @@ async function stubRoute(page, { ageMs = 0 } = {}) { }, fixturePayload(), ageMs, + initialFailures, + stale, ); } @@ -702,6 +718,82 @@ async function main() { await phone.screenshot({ path: path.join(SHOTS_DIR, 'gnss-phone.png') }); await phone.close(); + console.log( + '\nSTARTUP — initial rate limit stays enabled and recovers automatically...', + ); + const retry = await browser.newPage(); + await retry.setViewport({ width: 1440, height: 900 }); + retry.on('pageerror', (e) => errors.push(e.message)); + await stubRoute(retry, { initialFailures: 1, stale: false }); + await boot(retry, { lon: 18.6, lat: 54.6, height: 450_000 }); + await showLayerRow(retry); + await retry.evaluate((id) => { + window.__gnssQaFailedStarts = 0; + window.__godsEyeView.dataManager.subscribe((change) => { + if (change.layerId === id && change.type === 'visibility-failed') + window.__gnssQaFailedStarts++; + }); + }, LAYER_ID); + const initial = await enableAndSettle(retry); + const limited = await retry.evaluate((id) => { + const dm = window.__godsEyeView.dataManager; + const row = document.querySelector(`[data-layer-id="${id}"]`); + return { + enabled: dm.isEnabled(id), + failedStarts: window.__gnssQaFailedStarts, + label: row.querySelector('.data-toggle-btn').textContent, + info: row.querySelector('.data-toggle-controls-info').textContent, + }; + }, LAYER_ID); + record( + 'STARTUP: first 429 keeps the layer enabled and shows automatic retry timing', + limited.enabled && + limited.failedStarts === 0 && + limited.label === 'UNAVAILABLE' && + initial.stats.count === 0 && + initial.stats.retryInSec > 0 && + /rate limited.*Retrying automatically at/.test(limited.info), + JSON.stringify(limited), + ); + await retry.screenshot({ + path: path.join(SHOTS_DIR, 'gnss-startup-retrying.png'), + }); + await retry.waitForFunction( + (id) => { + const layer = window.__godsEyeView.dataManager.layers.get(id); + return ( + layer.enabled && + layer.module.getStats().error === null && + layer.module.getStats().count === 4 + ); + }, + { timeout: 20000 }, + LAYER_ID, + ); + const recovered = await retry.evaluate( + (id) => ({ + requests: window.__gnssQaRequests.length, + enabled: window.__godsEyeView.dataManager.isEnabled(id), + label: document.querySelector( + `[data-layer-id="${id}"] .data-toggle-btn`, + ).textContent, + info: document.querySelector( + `[data-layer-id="${id}"] .data-toggle-controls-info`, + ).textContent, + }), + LAYER_ID, + ); + record( + 'STARTUP: one automatic retry recovers to ON without toggling and clears the warning', + recovered.enabled && + recovered.requests === 2 && + recovered.label === 'ON' && + !/Retrying/.test(recovered.info), + JSON.stringify(recovered), + ); + await shoot(retry, 'startup-recovered'); + await retry.close(); + // ── (vii) AGE ───────────────────────────────────────────────────────── console.log( '\n(vii) AGE — a replayed stale snapshot keeps its observation time...', diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index 5ad764d16..85146d832 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -73,12 +73,15 @@ export function createGnssIntegrityLayer({ let _stale = false; let _manager = null; let _cameraTimer = null; - let _cameraRefresh = null; + let _backgroundRefresh = null; let _lastQueryKey = null; let _lastRequestAt = -Infinity; let _removeCameraEnd = null; let _removeCameraChanged = null; let _coverage = null; + let _retryTimer = null; + let _retryAt = 0; + let _failureCount = 0; const _observations = new Map(); const provenance = gnssProvenance({ windowMs }); @@ -89,6 +92,7 @@ export function createGnssIntegrityLayer({ function scheduleCameraRefresh() { if (!_enabled || !_dataSource) return; + if (now() < _retryAt) return; const key = queryKey(viewAnchor(_viewer)); if (!key || key === _lastQueryKey) return; clearTimeout(_cameraTimer); @@ -97,34 +101,48 @@ export function createGnssIntegrityLayer({ _cameraTimer = setTimeout( async () => { _cameraTimer = null; - if (!_enabled || _cameraRefresh) return; + if (!_enabled || _backgroundRefresh) return; const key = queryKey(viewAnchor(_viewer)); if (!key || key === _lastQueryKey) return; - const controller = new AbortController(); - _cameraRefresh = controller; - try { - if (_manager) { - // Use the normal refresh lane so counts, loading/errors and render - // demand settle together; wait out an older periodic request. - await _manager.refreshLayer(layer.id, { - signal: controller.signal, - }); - } else { - await layer.update(undefined, { signal: controller.signal }); - } - } finally { - if (_cameraRefresh === controller) { - _cameraRefresh = null; - // Movement during an in-flight request is folded into one update - // for the latest view, rather than lost until the minute tick. - scheduleCameraRefresh(); - } - } + await refreshInBackground(); }, Math.max(600, 2500 - (now() - _lastRequestAt)), ); } + async function refreshInBackground() { + if (!_enabled || _backgroundRefresh) return; + const controller = new AbortController(); + _backgroundRefresh = controller; + try { + if (_manager) + await _manager.refreshLayer(layer.id, { signal: controller.signal }); + else await layer.update(undefined, { signal: controller.signal }); + } finally { + if (_backgroundRefresh === controller) { + _backgroundRefresh = null; + scheduleCameraRefresh(); + scheduleRetry(); + } + } + } + + function scheduleRetry() { + clearTimeout(_retryTimer); + _retryTimer = null; + if (!_enabled || !_retryAt) return; + // Long provider cooldowns keep their full deadline. Browser timers cannot + // exceed ~24 days, so wake in bounded chunks without fetching early. + _retryTimer = setTimeout( + () => { + _retryTimer = null; + void refreshInBackground(); + }, + Math.min(2147483647, Math.max(0, _retryAt - now())), + ); + _retryTimer?.unref?.(); + } + /** Age the store without new evidence, so expired cells leave the globe. */ function expire() { pruneGnssObservations(_observations, now(), { windowMs }); @@ -205,8 +223,10 @@ export function createGnssIntegrityLayer({ disable() { clearTimeout(_cameraTimer); _cameraTimer = null; - _cameraRefresh?.abort(); - _cameraRefresh = null; + clearTimeout(_retryTimer); + _retryTimer = null; + _backgroundRefresh?.abort(); + _backgroundRefresh = null; _request?.abort(); _request = null; _enabled = false; @@ -215,6 +235,14 @@ export function createGnssIntegrityLayer({ async update(_viewerArg, { signal } = {}) { if (!_enabled || !_dataSource || signal?.aborted) return false; + if (now() < _retryAt) { + expire(); + scheduleRetry(); + return true; + } + _retryAt = 0; + clearTimeout(_retryTimer); + _retryTimer = null; const anchor = viewAnchor(_viewer); // No anchor yet (camera still settling) is not a failure: returning // false would make the manager reject the enable. The next interval @@ -236,12 +264,14 @@ export function createGnssIntegrityLayer({ longitude: Math.round(anchor.longitude), radiusNm: 250, }; + let received = false; try { const snapshot = await source.getSnapshot(anchor, { signal: request.signal, }); if (request.signal.aborted || _request !== request || !_enabled) return false; + received = true; // Date the rows by when the proxy observed them: a cached or stale // replay keeps its original age instead of restarting the window. // The proxy reports that age, so client and server clocks never mix. @@ -255,19 +285,37 @@ export function createGnssIntegrityLayer({ _stale = snapshot.stale === true; _lastUpdate = now(); _lastError = null; + _retryAt = 0; + _failureCount = 0; return true; } catch (e) { if (request.signal.aborted || _request !== request || !_enabled) return false; + // A renderer/programming failure remains a real lifecycle failure. + // Only acquisition failures are kept enabled for recovery. + if (received) throw e; console.warn('[Data:GNSS] Fetch error:', e); _lastError = e?.message || 'GNSS integrity source unavailable'; // Keep the last cells only while their evidence is inside the window. expire(); - return false; + if (e?.retryable === false) return false; + _failureCount = Math.min(3, _failureCount + 1); + const delay = + Number.isFinite(e?.retryAfterMs) && e.retryAfterMs > 0 + ? e.retryAfterMs + : Math.min(120000, 30000 * 2 ** (_failureCount - 1)); + _retryAt = Math.max( + _retryAt, + Math.min(Number.MAX_VALUE, now() + delay), + ); + // Enabling the display succeeded. Stats still report an unavailable + // or degraded feed; the manager keeps its normal refresh failure lane. + return true; } finally { signal?.removeEventListener('abort', onAbort); if (_request === request) _request = null; scheduleCameraRefresh(); + scheduleRetry(); } }, @@ -292,6 +340,8 @@ export function createGnssIntegrityLayer({ _lastError = null; _stale = false; _coverage = null; + _retryAt = 0; + _failureCount = 0; _lastQueryKey = null; _manager = null; }, @@ -299,7 +349,15 @@ export function createGnssIntegrityLayer({ getRowControls() { return { chips: [], - info: 'Regional sample · 250 nm radius · pan to sample another area', + info: + 'Regional sample · 250 nm radius · pan to sample another area' + + (_lastError && _retryAt + ? ` · ${_lastError} · Retrying automatically${ + _retryAt <= 8640000000000000 + ? ` at ${new Date(_retryAt).toISOString().slice(11, 19)} UTC` + : ' after provider cooldown' + }` + : ''), infoTitle: 'Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells have insufficient observations.', legend: ['high', 'medium', 'low'].map((level, index) => ({ @@ -320,6 +378,9 @@ export function createGnssIntegrityLayer({ count: _cells.length, lastUpdate: _lastUpdate, error: _lastError, + retryInSec: _retryAt + ? Math.max(0, Math.ceil((_retryAt - now()) / 1000)) + : null, stale: _stale, provenance, coverage: _coverage, diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index 5996d5e2b..a26afa1e6 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test'; import * as Cesium from 'cesium'; import { createGnssIntegrityLayer, defaultGnssViewAnchor } from './index.js'; import { LayerLifecycle } from '../../data/lifecycle.js'; +import { layerFeedState } from '../../data/feedState.js'; const row = (hex, lat, degraded) => ({ hex, @@ -281,7 +282,7 @@ test('a failed refresh keeps the last cells and reports the error', async () => await layer.update(); assert.equal(layer.getStats().stale, true); fail = true; - assert.equal(await layer.update(), false); + assert.equal(await layer.update(), true); assert.equal(sources[0].entities.values.length, 1); assert.equal(layer.getStats().error, 'adsb.lol HTTP 502'); assert.equal(layer.getStats().count, 1); @@ -376,10 +377,10 @@ test('cells expire during prolonged fetch failures', async () => { await layer.update(); fail = true; advance(20 * MIN); - assert.equal(await layer.update(), false); + assert.equal(await layer.update(), true); assert.equal(layer.getStats().count, 1, 'still inside the window'); advance(11 * MIN); - assert.equal(await layer.update(), false); + assert.equal(await layer.update(), true); assert.equal(layer.getStats().count, 0); assert.equal(sources[0].entities.values.length, 0); assert.equal(layer.getStats().error, 'adsb.lol HTTP 502'); @@ -432,3 +433,125 @@ test('an identical snapshot leaves the drawn entities untouched', async () => { assert.equal(sources[0].entities.values.length, 1); assert.equal(sources[0].entities.values[0], first); }); + +test('an initial feed failure stays enabled, reports unavailable, and recovers automatically', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + let calls = 0; + const h = harness( + async () => { + calls++; + if (calls === 1) throw new Error('adsb.lol HTTP 502'); + return { rows: threeHealthy() }; + }, + { initialize: false }, + ); + const manager = new LayerLifecycle(h.viewer); + manager.register(h.layer); + h.layer.attachDataManager(manager); + const changes = []; + manager.subscribe((c) => changes.push(c.type)); + assert.equal(await manager.setEnabled(h.layer.id, true), true); + assert.equal(manager.isEnabled(h.layer.id), true); + assert.equal(layerFeedState(h.layer.getStats()), 'unavailable'); + assert.equal(h.layer.getStats().retryInSec, 30); + assert.match( + h.layer.getRowControls().info, + /HTTP 502.*Retrying automatically at/, + ); + assert.equal(changes.includes('visibility-failed'), false); + assert.equal( + await manager.refreshLayer(h.layer.id), + false, + 'feed remains honestly unhealthy during cooldown', + ); + assert.equal(calls, 1, 'a manual refresh cannot bypass cooldown'); + h.advance(30000); + t.mock.timers.tick(30000); + await flush(); + assert.equal(calls, 2); + assert.equal(manager.isEnabled(h.layer.id), true); + assert.equal(layerFeedState(h.layer.getStats()), 'nominal'); + assert.equal(h.layer.getStats().count, 1); + assert.equal(h.layer.getStats().retryInSec, null); + assert.doesNotMatch(h.layer.getRowControls().info, /Retrying/); + await manager.destroyAll(); +}); + +test('provider cooldown is preserved across movement and toggling; disable cancels retry work', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let calls = 0; + const h = harness(async () => { + calls++; + if (calls === 1) + throw Object.assign(new Error('Rate limited (HTTP 429)'), { + retryAfterMs: 3600000, + }); + return { rows: threeHealthy() }; + }); + h.layer.enable(); + await h.layer.update(); + h.setAnchor({ latitude: 10, longitude: 20 }); + h.viewer.camera.moveEnd.raiseEvent(); + h.advance(30000); + t.mock.timers.tick(30000); + await flush(); + await h.layer.update(); + assert.equal(calls, 1); + assert.equal(h.layer.getStats().retryInSec, 3570); + h.layer.disable(); + h.advance(30000); + t.mock.timers.tick(30000); + await flush(); + h.layer.enable(); + await h.layer.update(); + assert.equal(calls, 1, 'reenabling does not erase a provider deadline'); + h.advance(3540000); + t.mock.timers.tick(3540000); + await flush(); + assert.equal(calls, 2); + assert.deepEqual(h.anchors.at(-1), { latitude: 10, longitude: 20 }); + h.layer.destroy(); + + const stopped = harness(async () => { + throw new Error('network unavailable'); + }); + stopped.layer.enable(); + await stopped.layer.update(); + stopped.layer.disable(); + stopped.advance(30000); + t.mock.timers.tick(30000); + await flush(); + assert.equal(stopped.anchors.length, 1, 'disabled layers do not retry'); + stopped.layer.destroy(); +}); + +test('repeated acquisition failures back off, while genuine non-retryable failures reject startup', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let calls = 0; + const h = harness(async () => { + calls++; + throw new Error('network unavailable'); + }); + h.layer.enable(); + await h.layer.update(); + assert.equal(h.layer.getStats().retryInSec, 30); + h.advance(30000); + t.mock.timers.tick(30000); + await flush(); + assert.equal(h.layer.getStats().retryInSec, 60); + h.advance(60000); + t.mock.timers.tick(60000); + await flush(); + assert.equal(h.layer.getStats().retryInSec, 120); + assert.equal(calls, 3); + h.layer.destroy(); + const fatal = harness(async () => { + throw Object.assign(new Error('invalid request (HTTP 400)'), { + retryable: false, + }); + }); + fatal.layer.enable(); + assert.equal(await fatal.layer.update(), false); + assert.equal(fatal.layer.getStats().retryInSec, null); + fatal.layer.destroy(); +}); diff --git a/src/layers/gnss/source.js b/src/layers/gnss/source.js index ae56e45c5..bfe7eaf17 100644 --- a/src/layers/gnss/source.js +++ b/src/layers/gnss/source.js @@ -1,8 +1,18 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js'; +function retryDelay(raw, nowMs) { + if (!raw) return null; + const seconds = Number(raw); + const ms = seconds * 1000; + if (Number.isFinite(ms) && seconds > 0) return Math.max(5000, ms); + const at = Date.parse(raw); + return Number.isFinite(at) && at > nowMs ? Math.max(5000, at - nowMs) : null; +} + /** Request integrity facts around a view anchor through the same-origin adsb.lol proxy. */ export function createAdsbGnssSource({ fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), } = {}) { return { async getSnapshot({ latitude, longitude } = {}, { signal } = {}) { @@ -16,7 +26,25 @@ export function createAdsbGnssSource({ const response = await fetchImpl(`/api/gnss-integrity?${params}`, { signal, }); - if (!response.ok) throw new Error(`adsb.lol HTTP ${response.status}`); + if (!response.ok) { + const limited = response.status === 429; + const retryAfterMs = retryDelay( + response.headers.get('retry-after'), + now(), + ); + throw Object.assign( + new Error( + limited + ? 'adsb.lol rate limited (HTTP 429)' + : `adsb.lol feed unavailable (HTTP ${response.status})`, + ), + { + status: response.status, + retryable: limited || response.status >= 500, + retryAfterMs: retryAfterMs ?? (limited ? 60000 : null), + }, + ); + } const payload = await readResponseJsonCapped( response, 4 * 1024 * 1024, diff --git a/src/layers/gnss/source.test.mjs b/src/layers/gnss/source.test.mjs index 7bab0d7d5..aba3f3e9a 100644 --- a/src/layers/gnss/source.test.mjs +++ b/src/layers/gnss/source.test.mjs @@ -74,3 +74,38 @@ test('the source reports stale proxy data and rejects bad input or responses', a ), ); }); + +test('HTTP failures distinguish rate limiting, preserve Retry-After and classify retryability', async () => { + const at = Date.UTC(2026, 9, 13, 12); + for (const [status, header, delay, retryable] of [ + [429, '3600', 3600000, true], + [429, new Date(at + 3600000).toUTCString(), 3600000, true], + [429, null, 60000, true], + [429, 'Infinity', 60000, true], + [502, '5', 5000, true], + [502, null, null, true], + [400, null, null, false], + ]) { + const source = createAdsbGnssSource({ + now: () => at, + fetchImpl: async () => + Response.json( + {}, + { status, headers: header ? { 'Retry-After': header } : {} }, + ), + }); + await assert.rejects( + source.getSnapshot({ latitude: 50, longitude: 20 }), + (e) => { + assert.equal(e.status, status); + assert.equal(e.retryAfterMs, delay); + assert.equal(e.retryable, retryable); + assert.match( + e.message, + status === 429 ? /rate limited/ : /feed unavailable/, + ); + return true; + }, + ); + } +}); From a922d71120e768b50ab0694f224a83525dc05625 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Tue, 13 Oct 2026 21:51:41 +0200 Subject: [PATCH 37/86] test: isolate GNSS first-fetch recovery from persisted fixtures --- DATA_SOURCES.md | 2 +- scripts/qa-gnss.mjs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index da262b43a..4550ce99f 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -78,7 +78,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. -- **GNSS integrity (adsb.lol integrity fields).** The GNSS Integrity layer (id `gnss-interference`) reads the same keyless 250 nm point snapshot around the camera (Earth point under the screen centre, anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). Pan/zoom triggers a debounced automatic refresh, bounded to one read per 2.5 s; same-region movement shares the cache. This is a regional public point feed, not a worldwide snapshot at globe zoom. The layer row labels the sampling radius. The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. The method has three separately sourced parts, exposed as machine-readable `provenance` on the layer and its stats: (1) a GEV-defined per-aircraft classifier (`gev-nic-nacp-v1`): an aircraft has low navigation accuracy when it reports NIC < 7 or NACp < 8 or readsb flags GPS loss. The numbers are borrowed from the 14 CFR 91.227(c) ADS-B Out performance minima, a US regulation, not a published interference classifier; gpsjam.org does not publish its per-aircraft threshold ([FAQ](https://gpsjam.org/faq)), and this one is not yet validated against known events. (2) gpsjam.org's published cell formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %). (3) A GEV-defined window: at least 3 aircraft per 0.5° cell over a rolling 30 minutes around the views the user visits. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is a secondary interpretation, not a confirmed source or location: avionics faults also report low values. Verified 2026-09-29. +- **GNSS integrity (adsb.lol integrity fields).** The GNSS Integrity layer (id `gnss-interference`) reads the same keyless 250 nm point snapshot around the camera (Earth point under the screen centre, anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). Pan/zoom triggers a debounced automatic refresh, bounded to one read per 2.5 s; same-region movement shares the cache. This is a regional public point feed, not a worldwide snapshot at globe zoom. The layer row labels the sampling radius. Transient acquisition failures keep the layer enabled for automatic recovery: the browser respects Retry-After, uses bounded 30/60/120 s backoff otherwise, and shows feed-health errors with the next retry time. Movement and toggling do not bypass cooldowns. The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. The method has three separately sourced parts, exposed as machine-readable `provenance` on the layer and its stats: (1) a GEV-defined per-aircraft classifier (`gev-nic-nacp-v1`): an aircraft has low navigation accuracy when it reports NIC < 7 or NACp < 8 or readsb flags GPS loss. The numbers are borrowed from the 14 CFR 91.227(c) ADS-B Out performance minima, a US regulation, not a published interference classifier; gpsjam.org does not publish its per-aircraft threshold ([FAQ](https://gpsjam.org/faq)), and this one is not yet validated against known events. (2) gpsjam.org's published cell formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %). (3) A GEV-defined window: at least 3 aircraft per 0.5° cell over a rolling 30 minutes around the views the user visits. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is a secondary interpretation, not a confirmed source or location: avionics faults also report low values. Verified 2026-09-29. - **Transponder emergencies.** Emergency highlighting reads fields the flight feeds already return: OpenSky state vectors' `squawk` and adsb.lol/readsb's `squawk` and `emergency`. It makes no extra request. A broadcast code states what the transponder sends, not a verified incident; OpenSky has no ADS-B emergency field, so genuine OpenSky highlighting relies on squawk 7500/7600/7700 alone. The civil adsb.lol fallback preserves its additional ADS-B status beside the OpenSky-compatible vectors, without another request. - **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. - **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. diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index bf265b9e4..3387ac5da 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -724,6 +724,9 @@ async function main() { const retry = await browser.newPage(); await retry.setViewport({ width: 1440, height: 900 }); retry.on('pageerror', (e) => errors.push(e.message)); + // Pages share the disposable test context's storage. Start this one cold, + // so a previous fixture cannot auto-enable GNSS before the START test. + await retry.evaluateOnNewDocument(() => localStorage.clear()); await stubRoute(retry, { initialFailures: 1, stale: false }); await boot(retry, { lon: 18.6, lat: 54.6, height: 450_000 }); await showLayerRow(retry); From 01a8ad7aa72a14250f2ecc84215c139c542b021f Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Wed, 14 Oct 2026 17:43:13 +0200 Subject: [PATCH 38/86] fix: address GNSS integrity and aircraft emergency review findings --- CHANGELOG.md | 69 ++---- CONTRIBUTING.md | 6 +- DATA_SOURCES.md | 39 +++- README.md | 4 +- docs/CODE-BOUNDARIES.md | 13 +- docs/CURRENT-STATE.md | 78 ++++--- scripts/package-boundaries.json | 2 + scripts/qa-emergency-squawk.mjs | 96 ++++++++- scripts/qa-gnss.mjs | 207 ++++++++++++++---- server/providers/gnssIntegrity.js | 183 ++++++++++------ server/providers/openai/toolDescriptions.js | 4 +- src/app/constructCatalog.js | 2 +- src/app/constructCatalog.test.mjs | 2 +- src/app/sourceComposition.js | 2 +- src/data/adsbLolFallback.js | 8 +- src/data/aircraftEmergency.js | 14 +- src/data/aircraftEmergency.test.mjs | 11 + src/data/aircraftEmergencyAnalyst.test.mjs | 180 +++++++++++++++- src/data/aircraftRecession.js | 7 +- src/data/aircraftRecession.test.mjs | 12 ++ src/data/gnssIntegrityProxy.test.mjs | 122 ++++++++++- src/data/layerState.js | 2 +- src/data/layerState.test.mjs | 10 +- src/data/layerStateTokenLedger.test.mjs | 2 +- src/data/layerStateTokenReservations.json | 2 +- src/firstRunExperience.test.mjs | 6 +- src/layers/flights/queries.js | 1 + src/layers/flights/records.js | 14 +- src/layers/gnss/index.js | 132 +++++++----- src/layers/gnss/ownership.test.mjs | 219 ++++++++++++++++---- src/layers/gnss/records.js | 55 +++-- src/layers/gnss/records.test.mjs | 101 ++++++++- src/layers/gnss/source.js | 15 +- src/layers/gnss/source.test.mjs | 6 +- src/layers/military/queries.js | 1 + src/layers/military/snapshotRenderer.js | 6 + src/sources/live/aircraft.js | 7 +- src/sources/live/contract.test.mjs | 4 +- src/sources/reference.js | 2 +- src/sources/reference.test.mjs | 6 +- src/ui/layerPanel.js | 2 +- src/voice/actionSchemas.test.mjs | 4 +- src/voice/gevActions.test.mjs | 10 +- src/voice/layerManifest.js | 2 +- 44 files changed, 1282 insertions(+), 388 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f5481754..99089f5f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,28 +106,16 @@ ## [Unreleased] -- Preserve full GNSS upstream retry deadlines and concurrent cooldowns; keep - published Street Level token `0` and assign GNSS token `3`. Live QA requires - a successful dated snapshot even in empty airspace. -- Civil Flights fallback retains ADS-B-only emergency/priority status and clears - it on later polls. Analyst/voice results label the retained general aircraft - contact time as `lastContactAtMs`, not the time of the current status. - GNSS Integrity and aircraft emergency highlighting contributed by - [Leo Pechnicki](https://github.com/leopechnicki) (#855, #856). +- Flights and Military Flights highlight emergency transponder broadcasts in + fleet billboards, 3D models and Cockpit contacts. Colors update when a status + starts or clears. Emergency squawks outrank ADS-B priority statuses; GEV + reports minimum fuel and lifeguard without red tint. Cockpit names the code + or ADS-B status, and analyst/voice results retain broadcast provenance and + the latest known contact time. One omitted ADS-B status poll during a source + switch may retain the previous status if the squawk is unchanged; an explicit + clear takes effect immediately. Broadcasts are not confirmed incidents. + Contributed by [Leo Pechnicki](https://github.com/leopechnicki) (#856). -- Flights and Military Flights highlight aircraft broadcasting a transponder - emergency. Squawk 7500 (unlawful interference), 7600 (radio failure) and - 7700 (general emergency) — or adsb.lol's ADS-B `emergency` status, which - also reports downed aircraft — tint the aircraft red on the globe, in - Cockpit contacts and in snapshots, and name the code on the Cockpit HUD. - Minimum-fuel and lifeguard statuses are reported but not tinted. The status - follows the latest poll instead of sticking, so a cleared code stops - highlighting on the next refresh. The analyst and voice query engine can - filter by `squawk`, `emergency`, `emergencyKind` and `emergencySource` - (whether the squawk or the ADS-B field named the status), and each result - says when the transponder was last heard, so answers read "broadcasting - squawk 7700" or "ADS-B reports general emergency". No new source or - request: OpenSky already carries the squawk and adsb.lol carries both. - Street Level: KartaView is the third provider, keyless. Light its chip (purple; off by default, one provider at a time) and zoom in below about 6 km: the lines are the streets KartaView has photos of (z13–14 tiles from @@ -211,35 +199,16 @@ largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed under NLOD 2.0. -- GNSS Integrity layer (Events group, share id `gnss-interference`): cells of - low navigation accuracy reported by ADS-B aircraft. The method is a GEV - threshold plus gpsjam.org aggregation, not "the gpsjam method": an aircraft - counts as low when it reports NIC < 7 or NACp < 8 (borrowed from the US - 14 CFR 91.227 ADS-B Out minima, not a published interference classifier) or - readsb's GPS-loss flag; aircraft are binned into 0.5° cells over a - GEV-defined rolling 30-minute window around visited views and scored with - gpsjam.org's published formula and 2% / 10% bands. Medium and high cells - are navigation-integrity anomalies; suspected GNSS interference is a - secondary, unvalidated interpretation. The layer and the proxy expose - machine-readable provenance (`provenance`, `classifier: gev-nic-nacp-v1`). - Transient feed failures no longer reject GNSS activation: the layer stays - enabled, explains unavailable/degraded feed health and retries automatically. - Provider Retry-After deadlines survive movement and toggling; other failures - back off for 30/60/120 seconds. Disable/destroy cancels retry work. - Automatically refreshes after camera pan/zoom without toggling, coalesces - nearby views, and uses the Earth point under the screen centre at globe scale. - Keyless regional sampling wherever adsb.lol has receivers; the row labels - the 250 nm radius, which remains regional when zoomed out. Reads - a capped 250 nm snapshot around the camera through `/api/gnss-integrity`, - which returns integrity fields only and serves the last snapshot (with its - original observation time and `ageMs`, for at most 30 minutes) when the - upstream fails. - Only ADS-B versions 1 and 2 with a known position age of 0-60 s count. A - degraded report keeps an aircraft degraded for 30 minutes after that report, - not after its latest healthy one; replayed snapshots keep their age, and - cells expire on schedule during fetch failures or without a view anchor. - -## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) +- GNSS Integrity (Events, voice-addressable, share token `3`) draws navigation + accuracy scores from a keyless regional adsb.lol sample; aircraft-level analyst + queries are unavailable for these aggregate cells. Cells do not intercept + map clicks. Pan/zoom refreshes automatically; feed failures retain the display + and explain the next retry. Aircraft-reported quality includes MLAT-positioned + contacts after GPS loss, excludes TIS-B/MLAT-derived quality, and locates GPS-loss + evidence at the last good GNSS position when available. Scores discount one + bad aircraft per cell and retain 30 minutes of visited-view evidence; they + do not confirm jamming. See [method and limits](DATA_SOURCES.md#gnss-integrity). + Contributed by [Leo Pechnicki](https://github.com/leopechnicki) (#855). - Ship each bundled data pack once. The region, marine, admin-boundary, county, military-name and neighborhood packs were emitted twice by the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63f83e4c2..cde8582f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,8 +79,8 @@ if its layer is later removed. New layers first use the remaining unreserved single-character digits (`0` through `9`), then the two-character base-36 namespace (`00` through `zz`). On the -current published ledger, `0`, `1` and `2` are already owned, so the next -choices are `3` through `9`, then `00`. After rebasing onto the latest `main`, run +current published ledger, `0`, `1`, `2` and `3` are already owned, so the next +choices are `4` through `9`, then `00`. After rebasing onto the latest `main`, run `npm run layer-token:next -- `, add that result to both the permanent JSON reservation ledger and registry entry, and keep the exact registered-layer count assertion up to date. The token is assigned against the merge-time base, not @@ -98,7 +98,7 @@ remove only your still-unpublished layer's registry entry and ledger row so the local registry is valid again, then rerun `npm run layer-token:next -- `. For example, if PR A publishes `0` while PR B still provisionally uses `0`, PR B removes only its own provisional entry, reruns the helper, and receives -`3`. Put `3` on PR B's registry entry and ledger row, update its pinned count +`4`. Put `4` on PR B's registry entry and ledger row, update its pinned count and token tests, then rerun `npm run layer-token:check -- --base-ref origin/main`. An invalid duplicate ledger can fail during module import before the helper prints anything; never fix that by renaming or deleting PR A's published token. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 84c4901c3..d0897a04f 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -26,7 +26,7 @@ How to read this: | **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org | -| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Integrity layer (per-aircraft NIC/NACp integrity only, 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 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 | | **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) | @@ -80,8 +80,41 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. -- **GNSS integrity (adsb.lol integrity fields).** The GNSS Integrity layer (id `gnss-interference`) reads the same keyless 250 nm point snapshot around the camera (Earth point under the screen centre, anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). Pan/zoom triggers a debounced automatic refresh, bounded to one read per 2.5 s; same-region movement shares the cache. This is a regional public point feed, not a worldwide snapshot at globe zoom. The layer row labels the sampling radius. Transient acquisition failures keep the layer enabled for automatic recovery: the browser respects Retry-After, uses bounded 30/60/120 s backoff otherwise, and shows feed-health errors with the next retry time. Movement and toggling do not bypass cooldowns. The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. The method has three separately sourced parts, exposed as machine-readable `provenance` on the layer and its stats: (1) a GEV-defined per-aircraft classifier (`gev-nic-nacp-v1`): an aircraft has low navigation accuracy when it reports NIC < 7 or NACp < 8 or readsb flags GPS loss. The numbers are borrowed from the 14 CFR 91.227(c) ADS-B Out performance minima, a US regulation, not a published interference classifier; gpsjam.org does not publish its per-aircraft threshold ([FAQ](https://gpsjam.org/faq)), and this one is not yet validated against known events. (2) gpsjam.org's published cell formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %). (3) A GEV-defined window: at least 3 aircraft per 0.5° cell over a rolling 30 minutes around the views the user visits. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is a secondary interpretation, not a confirmed source or location: avionics faults also report low values. Verified 2026-09-29. -- **Transponder emergencies.** Emergency highlighting reads fields the flight feeds already return: OpenSky state vectors' `squawk` and adsb.lol/readsb's `squawk` and `emergency`. It makes no extra request. A broadcast code states what the transponder sends, not a verified incident; OpenSky has no ADS-B emergency field, so genuine OpenSky highlighting relies on squawk 7500/7600/7700 alone. The civil adsb.lol fallback preserves its additional ADS-B status beside the OpenSky-compatible vectors, without another request. + + +- **GNSS integrity (adsb.lol).** A separate keyless point request samples a 250 nm + circle around the screen centre, snapped once to whole degrees. This does not + reuse the Flights fallback cache. Cache lifetime is 60 s; 429 backoff is capped + at one hour and other upstream failures cool down for 15 s. Future access may + require feeder keys under the [API terms](https://github.com/adsblol/api). + Airborne decoded ADS-B v1/v2 aircraft with a known 0–60 s position age qualify, + including MLAT-positioned aircraft still broadcasting their own quality. + TIS-B/MLAT-derived NIC and NACp are ignored. GPS-loss evidence uses + `gpsOkLat/gpsOkLon` when present, otherwise the current known position. + The proxy strips identity metadata and tags its response with classifier + `gev-nic-nacp-v2`; rows carry only integrity facts. + + The method has three separately attributed parts: + 1. **GEV classifier (unvalidated):** GPS-loss flag, aircraft-reported NACp < 8, + or aircraft-reported NIC < 7 except NIC 0 with NACp ≥ 10 (unknown containment). + The numerical minima come from US 14 CFR 91.227(c), not a published jamming + classifier. Readsb's [JSON contract](https://github.com/wiedehopf/readsb/blob/dev/README-json.md) + describes the quality field origins and GPS-loss flag. + 2. **GPSJAM aggregation:** score = `100 × max(0, bad − 1) / total`, with percent + bands < 2, 2–10, > 10. One bad aircraft is discounted. This is not the raw + share of bad aircraft. See the [GPSJAM FAQ](https://gpsjam.org/faq). + 3. **GEV window:** 30 minutes over visited views, at least three aircraft per + 0.5° cell. These bands were developed for daily totals; with fewer than ten + observations a cell can skip the middle band. Source timestamps date evidence + even through cache replays, and degraded evidence expires independently. + + Medium/high cells report navigation-integrity anomalies. Interference is one + possible explanation, not a detected or located emitter. Avionics faults and + manoeuvring military trainers can also lower reported accuracy. Blank areas + can be outside coverage or lack enough observations. Attribution: adsb.lol + 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. - **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. diff --git a/README.md b/README.md index 4029db402..4c636151c 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,9 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | | 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search | | ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) | -| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 | +| 🎖️ **Military Flights** | ADS-B military traffic in amber, emergency broadcasts in red | adsb.lol | 🟢 | +| 📡 **GNSS Integrity** | Regional navigation accuracy scores; anomalies do not confirm interference | adsb.lol, GPSJAM aggregation | 🟢 | +| 🔥 **Fire Perimeters** | Incident boundaries and reported containment | WFIGS / NIFC | 🟢 | | 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | | 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 | | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index a0a2325ad..19a5ee372 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -65,11 +65,10 @@ check, not a JavaScript sandbox. Common voice controls cannot depend on a Realtime protocol implementation. Negative fixtures cover indirect helpers, self-package imports, symlinks and unreachable files. -Source factories have dedicated exports for ALPR, bikeshare, CCTV, earthquakes, -FIRMS, installations, launches, radio, satellites and traffic. They preserve the -same factory implementations without loading layer rendering. ALPR/earthquake -record normalization and CCTV source endpoint policy have plain owners separate -from geometry/cards. Source exports do not start acquisition at import time. +Source-only factories use dedicated `layers//source` exports where +available, preserving the same factory implementation without loading rendering. +Record normalization and endpoint policies have plain owners separate from +geometry/cards. Source exports do not start acquisition at import time. Other layer `ingestion.js` files may still coordinate Cesium resources; the portable contract applies to the explicitly reviewed graphs above. Their layer @@ -630,9 +629,9 @@ explicit operations. Both exports exclude Cesium, DOM and application assembly. The snapshot renderer applies record changes; rendering owns a weak map of geometry and billboard resources used by cards, picking and trails. -`gods-eye-view/sources/reference` constructs fresh earthquake and bundled cable +`gods-eye-view/sources/reference` constructs fresh earthquake, GNSS integrity and bundled cable source instances independently of standalone setup. Individual sources remain -available through `layers/earthquakes/source` and `layers/submarine-cables/source`. +available through their `layers//source` exports, including `layers/gnss/source`. The latter retains the bundled dataset’s attribution and licensing requirements. ## Geocoding and feature-query providers diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3b41f7e9a..70ac44705 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -817,31 +817,26 @@ camera, terrain floor, trail, selection and measured model-size policies remain. ## Transponder emergency highlighting `src/data/aircraftEmergency.js` derives one status per poll from the Mode A -squawk (7500 unlawful interference, 7600 radio failure, 7700 general emergency; -ICAO-standard worldwide) and, where the feed carries it, the readsb ADS-B -`emergency` field (adsb.lol; OpenSky state vectors carry the squawk only). An -explicit ADS-B status wins over the squawk. `general`, `unlawful`, `nordo` and -`downed` are emergencies; `minfuel` and `lifeguard` are priorities. +squawk (7500 unlawful interference, 7600 radio failure, 7700 general emergency) +and the readsb ADS-B emergency/priority field where present. Higher severity +wins, with ADS-B taking a tie. GEV treats `minfuel` and `lifeguard` as priorities; +`general`, `unlawful`, `nordo` and `downed` are emergency statuses. -Both aircraft layers keep `squawk` and `emergency` on each record and replace -them on every poll, so the status is never sticky. Civil and military fleet -billboards and Cockpit far-contact dots are tinted `EMERGENCY_TINT_CSS` red -only for emergencies; priorities keep the normal tint. The military layer uses -the same tint when a billboard is first created and when a tracked contact is -released. While a broadcasting aircraft is tracked, the Cockpit aircraft-meta -line reads `SQUAWK 7700 · GENERAL EMERGENCY` when the code names the status, -else `ADS-B EMERGENCY · …` or `ADS-B PRIORITY · …`, in red. Analyst records and -the voice query fields expose `squawk`, `emergency` (flag), `emergencyKind` and -`emergencySource` (`squawk` when the Mode A code names the status, `ads-b` for -the readsb field), plus `emergencyBroadcast` (`broadcasting squawk 7700 (general -emergency)` vs `ADS-B reports general emergency`) and `lastContactAtMs`, the latest known general aircraft-message contact in epoch -ms. Missing contact times retain the preceding known contact, or stay null if -none is known. This does not timestamp the current emergency broadcast. Civil -adsb.lol fallback preserves the readsb status in `emergencyByIcao` alongside -unchanged OpenSky-compatible vectors, including clearing on subsequent polls. -Compact voice results retain the emergency flag, source, phrase and named -last-contact time; they do not label that time as the status receipt. -`scripts/qa-emergency-squawk.mjs` proves the tint, HUD line and clearing. +Civil and military fleet billboards, 3D models and Cockpit dots turn red for +emergencies and repaint when the broadcast changes. Priorities retain normal +colors; tracked map colors keep their existing selection treatment. The Cockpit +meta line names the squawk or ADS-B status (only emergencies are red), replacing +COURSE ALIGNED while a status is present. + +Analyst and tracked voice results describe broadcasts, not confirmed incidents. +Fields include `squawk`, `emergency`, `emergencyKind`, `emergencySource` and +`emergencyBroadcast`. `lastContactAtMs` is the last known transponder contact, +not the arrival time of this status; missing contact times retain known contact. +Civil fallback preserves status alongside unchanged OpenSky vectors. One poll +omitting ADS-B status may retain the previous status when the squawk is unchanged; +explicit clears and changed squawks take effect immediately. The next omitted +poll expires that retention. `scripts/qa-emergency-squawk.mjs` checks billboard +and model transitions, Cockpit HUD, clearing and tracking release. ## Browser live-source observations @@ -3129,10 +3124,33 @@ its criteria cannot be silently ignored. | 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) | | Street Level 📷 | Street-level imagery; Mapillary is the first provider (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, cached 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-interference`) | 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) | +| 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) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | -GNSS display activation stays enabled through transient feed failures, including the first read. The row reports unavailable/degraded source health, a specific HTTP cause and the next automatic retry time; successful recovery clears the warning. Retry-After is honored in full for provider cooldowns, with 30/60/120 s bounded backoff otherwise. Camera movement, polling and toggling cannot bypass the deadline; disable/destroy cancels retries. Genuine non-retryable requests and renderer failures remain lifecycle failures. GNSS Integrity automatically refreshes after camera pan/zoom (600 ms debounce, minimum 2.5 s between reads, shared whole-degree cache keys) through the manager refresh lane, so counts and loading/error state update without toggling. The anchor is the Earth point under the screen centre, including at globe scale where the visible rectangle can cover the entire world. It fetches a capped 250-nautical-mile-radius adsb.lol snapshot around that anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band and visibly labels the regional 250 nm sampling radius. Zooming out does not turn this public point feed into worldwide coverage; blank areas can lie outside the sampled region or have fewer than three qualifying aircraft. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Only DO-260A/B (ADS-B versions 1 and 2) direct 1090ES positions with a known `seen_pos` of 0–60 s count. Presence and degraded evidence age separately: a degraded report keeps the aircraft degraded in its cell for 30 minutes after that report, however many healthy reports follow, and an aircraft unheard for 30 minutes starts fresh. Rows are dated by the snapshot's observation time, which the proxy keeps on cached and stale answers and reports as `ageMs` on its own clock (so browser clock skew does not move the window); a replayed snapshot never restarts the window; the proxy stops serving a stale snapshot once it is older than 30 minutes, the source refuses an undated one, and cells age out on schedule during fetch failures or while there is no view anchor. +GNSS Integrity samples a 250 nm circle around the Earth point under the screen +centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap; +nearby views share whole-degree cache keys, and a minute poll refreshes data. +Counts and bands cover visited views over 30 minutes, including regions now off +screen. Non-pickable ground primitives preserve Street Level and perimeter +selection. The legend describes a low-accuracy score, not the raw aircraft share; +`getStats().levels` exposes nominal, medium and high counts separately. + +Transient source failures keep activation enabled and show unavailable/degraded +health with the next automatic retry. Upstream 429 deadlines are capped at one +hour, with the longest concurrent deadline retained; 5xx, timeout and malformed +responses impose a 15 s server cooldown. Cache hits and shared reads do not spend +upstream admission. Cache age and cooldown use a monotonic clock; observation +age comes from the upstream snapshot and is preserved in the row and voice stats. +Snapshots older than 30 minutes or without a source timestamp are refused. +Disable/destroy cancels pending requests, timers, subscriptions and primitives. + +The GEV classifier remains unvalidated; anomalies do not confirm interference. +See [method, provenance and limits](../DATA_SOURCES.md#gnss-integrity), including +MLAT-positioned GPS loss, derived-field exclusions and the NIC 0 exception. +`scripts/qa-gnss.mjs` covers live freshness, bands, built primitives, camera/globe +anchors, recovery, keyboard and mobile states; emergency repainting is covered +by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot. + Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. @@ -4482,7 +4500,7 @@ are omitted rather than framing the wrong part of the globe. - **The TRACKED contact's 2D↔3D handoff is DEFAULT behaviour (2026-08-19), driven by camera distance alone.** It does NOT consult the DISPLAY-rail `3D` toggle, which continues to own the FLEET (the un-instanced draw-call budget stays the operator's decision). Policy lives in `src/data/trackedModelRegime.js` and is shared by both layers: enter below `TRACKED_MODEL_ENTER_ALT_M` = 150,000 m and hand back to the billboard only above `TRACKED_MODEL_EXIT_ALT_M` = 172,500 m. **The swap distance was set by playtesting on 2026-08-20:** a first pass at 1,000,000 m switched too early; 2D reads correctly at ~600 km and the handoff belongs at ~150 km. **Consequence, recorded on purpose:** the tracked contact now enters 3D NEARER than the FLEET does (`MODEL_ALT_CEIL_M` = 800,000 m, unchanged), so with the DISPLAY-rail `3D` toggle on, 150–800 km draws surrounding contacts as models while the selected one is still a glyph. Nothing double-draws (the fleet pass skips the tracked icao) and aligning the two is a fleet-side decision, deliberately out of scope. **The two thresholds are asymmetric on purpose:** a single threshold makes a tracked orbit sitting ON the boundary strobe billboard↔model as the camera's altitude wobbles across it. Do not collapse them. The latch is scoped per selection, so a new target re-evaluates against the ENTER ceiling rather than inheriting the previous target's exit band. Exactly ONE model is involved; it loads on demand when the regime opens, is HIDDEN (not released) on regime exit so re-entry has no load gap, and is released by the existing teardown on deselect/re-track/destroy. Cockpit and TR-3B suppression are unchanged. **Two invariants around it:** (a) the hysteresis latch AND the load-failure latch are per-selection state cleared by `_resetTrackedSelectionState()` in the tracking lifecycle (deselect / re-track / cross-layer / init / destroy) — the predicate's icao-change guard is defence only, since it needs a drawn frame while nothing is selected and the render governor's idle mode does not promise one; (b) on-demand loading is bounded at 3 attempts per selection with a 1.5 s backoff and one console warning naming the asset — the driver runs every `scene.preUpdate`, so an unbounded catch means a missing GLB spins load→reject at frame rate. The billboard stays the visual throughout a failed load. - **Grounded 3D handoff is terrain-validity gated (2026-08-23).** A ready civilian or military glTF does not own the visual until `groundSnap` (`src/data/groundSnap.js`) can answer with a MEASURED photoreal-surface height. On success the layer writes `height + the model's measured belly offset` before revealing the model. Cache movement is measured on the WGS84 surface, not across altitude, so a stationary contact keeps its snap through poll-time vertical-datum changes. Model existence or GPU readiness alone never suppresses the billboard floor, and ordinary zoom/style/deselect transitions preserve a valid snap cache. **Two states, and the difference is the whole design.** COLD — nothing has ever resolved for this icao (tiles still streaming on first sight, sample failure, backoff after a first miss): there is no evidence of where the ground is, `heightFor` returns null, the model stays hidden and the depth-test-free 2D billboard remains opaque and floored. WARM — a snap resolved and then a >`MOVE_INVALIDATE_M` (50 m) taxi move stopped it answering directly: the measurement is DEMOTED to a bounded last-known rather than deleted, and it keeps answering while the resample is outstanding, so a taxiing aircraft does not pop 3D→2D→3D across a 2–30 s retry backoff. The bound is `HELD_SNAP_MAX_DRIFT_M` = 250 m from the spot the value was measured at, past which the hold is dropped rather than stretched and the contact is COLD again. It is spatial with no timer beside it (ground under a contact that has not moved does not change; what invalidates the value is the contact MOVING) and deliberately a quarter of the billboard chain's `HELD_FLOOR_MAX_DRIFT_KM` — that hold only ever RAISES a sprite, while a held snap IS the model's placement, so its error shows in both directions. A fresh sample releases the hold, and so does a ground flip (which already calls `forget`). **A loading model is HIDDEN, never zero-scaled:** admission sets `show = false` (Cesium's default is `true`, and an unplaced primitive would claim the visual at the identity matrix), and ownership is `ready && show` — Cesium 1.138's `Model.update` has no `show` guard, so hiding a primitive costs its load nothing. -- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, always flat amber). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. +- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, flat amber or emergency red). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. - The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged. - **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design. - **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed. @@ -5336,9 +5354,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - -GNSS integration reserves share token `3`, retaining published Street Level `0`. -It uses the catalog source contract and voice layer manifest. Upstream 429 -Retry-After seconds/HTTP dates retain their full deadlines; overlapping requests -keep the longest cooldown. Live GNSS QA requires a successful dated response -even when there are no qualifying cells. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 13ebe3836..ec8f56022 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -781,6 +781,7 @@ "live-observations": { "exports": ["./sources/live"], "modules": [ + "src/data/aircraftEmergency.js", "src/sources/live/aircraft.js", "src/sources/live/contract.js", "src/sources/live/index.js", @@ -3505,6 +3506,7 @@ "src/sources/featureGeometry.js", "src/sources/live/standalone.js", "src/sources/live/contract.js", + "src/data/aircraftEmergency.js", "src/sources/live/aircraft.js", "src/sources/live/vessels.js", "src/tools/places.js", diff --git a/scripts/qa-emergency-squawk.mjs b/scripts/qa-emergency-squawk.mjs index 8b5e59bf1..f877ea659 100644 --- a/scripts/qa-emergency-squawk.mjs +++ b/scripts/qa-emergency-squawk.mjs @@ -10,6 +10,8 @@ * (i) TINT — a civil 7600 and a military ADS-B `general` emergency are * drawn red; a normal civil flight stays white, a normal military * flight stays amber, and a military `minfuel` priority stays amber. + * POLL/MODELS verify existing untracked billboards and 3D models + * repaint when emergency status starts and clears. * (ii) HUD — tracking the military emergency and entering Cockpit names * the broadcast code on the aircraft-meta line, in red. * (iii) CLEAR — the next poll drops the code: the HUD line returns to its @@ -20,6 +22,7 @@ * Screenshots saved to qa-shots/emergency-*.png (gitignored). * * Run: node scripts/qa-emergency-squawk.mjs --url http://localhost:4173 + * Optional: --timeout-ms 180000 for a slow boot. * Exits non-zero on any FAIL. Does not commit anything. */ @@ -39,6 +42,7 @@ const getOpt = (name, dflt) => { }; const APP_URL = getOpt('--url', 'http://localhost:4173'); const HEADFUL = argv.includes('--headful'); +const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000')); const CENTER = { lon: 4.0, lat: 53.5 }; const results = []; @@ -232,7 +236,7 @@ async function main() { civilRow('4847a2', 'KLM1234 ', lon - 0.6, lat - 0.25, '2000'), ], mil: [ - milRow('ae7700', 'RCH7700', lon + 0.6, lat + 0.25, '7700', 'general'), + milRow('ae7700', 'RCH7700', lon + 0.6, lat + 0.25, '1200', 'general'), milRow('ae0f01', 'RCH0F01', lon + 0.6, lat - 0.25, '1234', 'minfuel'), milRow('ae0001', 'RCH0001', lon, lat - 0.5, '4520', 'none'), ], @@ -243,11 +247,11 @@ async function main() { await page.evaluateOnNewDocument(installStubs); await page.goto(`${APP_URL}/?welcome=0`, { waitUntil: 'domcontentloaded', - timeout: 60000, + timeout: TIMEOUT_MS, }); await page.waitForFunction( () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, - { timeout: 60000 }, + { timeout: TIMEOUT_MS }, ); await sleep(5000); await page.keyboard.press('Escape'); @@ -287,7 +291,7 @@ async function main() { const probe = `(${billboardColors})(${JSON.stringify(ids)})`; await page .waitForFunction(`Object.keys(${probe}).length === ${ids.length}`, { - timeout: 60000, + timeout: TIMEOUT_MS, polling: 500, }) .catch(() => {}); @@ -321,6 +325,79 @@ async function main() { ); await shoot(page, 'globe-tint'); + // Existing untracked billboards and models must repaint in both directions. + await page.evaluate(async () => { + window.__squawkQaPhase = 'cleared'; + await window.__godsEyeView.dataManager.refreshLayer('military'); + }); + record( + 'POLL: existing military billboard clears to amber', + isAmber((await page.evaluate(probe)).ae7700), + ); + await page.evaluate(async () => { + window.__squawkQaPhase = 'emergency'; + await window.__godsEyeView.dataManager.refreshLayer('military'); + }); + record( + 'POLL: existing military billboard turns red again', + isRed((await page.evaluate(probe)).ae7700), + ); + const modelProbe = () => { + const found = {}; + const visit = (items) => { + for (let i = 0; i < items.length; i++) { + const item = items.get(i); + if (item.ready && item.color && typeof item.id === 'string') + found[item.id] = { + r: Math.round(item.color.red * 255), + g: Math.round(item.color.green * 255), + b: Math.round(item.color.blue * 255), + }; + else if (item.get) visit(item); + } + }; + visit(window.__godsEyeView.viewer.scene.primitives); + return found; + }; + await page.waitForFunction( + `(${modelProbe})()['4847a1'] && (${modelProbe})().ae7700`, + { timeout: TIMEOUT_MS, polling: 250 }, + ); + const beforeModels = await page.evaluate(modelProbe); + record( + 'MODELS: civil and military emergency models are red', + isRed(beforeModels['4847a1']) && isRed(beforeModels.ae7700), + JSON.stringify(beforeModels), + ); + await page.evaluate(async () => { + window.__squawkQaFixtures.civil[0][14] = '2000'; + window.__squawkQaFixtures.civil[1][14] = '7600'; + window.__squawkQaPhase = 'cleared'; + const dm = window.__godsEyeView.dataManager; + await dm.refreshLayer('flights'); + await dm.refreshLayer('military'); + }); + await page.waitForFunction( + `(${modelProbe})()['4847a1']?.g > 220 && (${modelProbe})()['4847a2']?.g < 110 && (${modelProbe})().ae7700?.g > 110`, + { timeout: TIMEOUT_MS, polling: 250 }, + ); + const afterModels = await page.evaluate(modelProbe); + record( + 'MODELS: existing civil models start and clear, military clears', + isWhite(afterModels['4847a1']) && + isRed(afterModels['4847a2']) && + isAmber(afterModels.ae7700), + JSON.stringify(afterModels), + ); + await page.evaluate(async () => { + window.__squawkQaFixtures.civil[0][14] = '7600'; + window.__squawkQaFixtures.civil[1][14] = '2000'; + window.__squawkQaFixtures.mil[0].squawk = '7700'; + window.__squawkQaPhase = 'emergency'; + const dm = window.__godsEyeView.dataManager; + await dm.refreshLayer('flights'); + await dm.refreshLayer('military'); + }); // ── (ii) HUD ────────────────────────────────────────────────────────── console.log('\n(ii) HUD — track the emergency and enter Cockpit...'); const tracked = await page.evaluate(() => @@ -385,6 +462,17 @@ async function main() { hud.emergencyClass && /255,\s*68,\s*68/.test(hud.color || ''), `class=${hud.emergencyClass} color=${hud.color}`, ); + const trackedBroadcast = await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('military') + .module.getTrackedInfo().emergencyBroadcast, + ); + record( + 'TRACKED: voice-facing status quotes the broadcast', + trackedBroadcast === 'broadcasting squawk 7700 (general emergency)', + trackedBroadcast, + ); await shoot(page, 'cockpit-hud'); // ── (iii) CLEAR ─────────────────────────────────────────────────────── diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index 3387ac5da..629316ce8 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * qa-gnss.mjs — headless proof for the GNSS Integrity layer - * (id `gnss-interference`): low navigation accuracy reported by ADS-B. + * (id `gnss-integrity`): low navigation accuracy reported by ADS-B. * * Drives the REAL app in headless Chromium against a dev server (keyless). * The live section reads adsb.lol through `/api/gnss-integrity` (60 s server @@ -9,10 +9,10 @@ * stub that route in the page so they are deterministic. * * (i) LIVE — over Poland and the southern Baltic the layer settles with no error, - * one entity per cell and legend band counts that sum to the cell + * one geometry per cell and legend band counts that sum to the cell * count; cells are required only when the returned rows, binned * with the layer's own rules, reach the per-cell minimum (live - * traffic varies by hour). + * traffic varies by hour). Requires a non-stale response no older than 60 s. * (ii) BANDS (synthetic) — a 10-aircraft cell with 5 degraded is high, * a 30-aircraft cell with 2 degraded is medium, a 3-aircraft cell * with 1 degraded is low (first bad discounted), a clean cell at sea @@ -20,9 +20,11 @@ * getStats. Every band is drawn and low is shown over land and sea. * The row reads as low navigation accuracy, and stats carry the * machine-readable provenance (GEV classifier, gpsjam bands, window). + * PICK verifies actual perimeter and empty-ground clicks under GNSS. + * CAMERA/GLOBE verify automatic sampling and the screen-centre anchor. * (iii) FAILURE — the route stubbed to 502: stats.error is set and the * cells already on the globe are kept. - * (iv) DISABLE — the layer's data source is hidden. + * (iv) DISABLE — owned ground primitives are hidden. * (v) KEYBOARD — the row's toggle button, focused, turns the layer on * with Enter and off with Space, keeps focus and names its state. * (vi) VIEWPORT — at a 390x844 phone viewport the open row and its legend @@ -39,6 +41,7 @@ * Run: node scripts/qa-gnss.mjs --url http://localhost:4173 * (also against a built preview: npm run build && npm run preview, * then pass the preview URL) + * Optional: --timeout-ms 180000 for a slow boot. * Exits non-zero on any FAIL. Does not commit anything. */ @@ -62,7 +65,8 @@ const getOpt = (name, dflt) => { }; const APP_URL = getOpt('--url', 'http://localhost:4173'); const HEADFUL = argv.includes('--headful'); -const LAYER_ID = 'gnss-interference'; +const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000')); +const LAYER_ID = 'gnss-integrity'; const results = []; function record(name, ok, detail) { @@ -76,11 +80,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function boot(page, { lon, lat, height }) { await page.goto(`${APP_URL}/?welcome=0`, { waitUntil: 'domcontentloaded', - timeout: 60000, + timeout: TIMEOUT_MS, }); await page.waitForFunction( () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, - { timeout: 60000 }, + { timeout: TIMEOUT_MS }, ); await sleep(5000); await page.keyboard.press('Escape'); @@ -118,26 +122,24 @@ async function enableAndSettle(page, { timeoutS = 30, refresh = false } = {}) { const layer = dm.layers.get(id).module; if (again) await dm.refreshLayer(id); else await dm.setEnabled(id, true); - let stats = null; - for (let i = 0; i < tS; i++) { - stats = layer.getStats(); - if (stats.count > 0 || stats.error) break; - await new Promise((r) => setTimeout(r, 1000)); - } + const stats = layer.getStats(); const rowControls = layer.getRowControls().legend; const legend = rowControls.map(({ label, count }) => ({ label, count })); const blurb = rowControls[0]?.blurb ?? ''; const rowName = document.querySelector(`[data-layer-id="${id}"] .data-name`) ?.textContent ?? ''; - const ds = window.__godsEyeView.viewer.dataSources.getByName(id)[0]; + const all = window.__godsEyeView.viewer.scene.groundPrimitives; + const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find( + (item) => item.id === id, + ); return { stats, legend, blurb, rowName, name: layer.name, - entities: ds?.entities.values.length ?? -1, + entities: ds?.get(0)?.geometryInstances.length ?? 0, shown: ds?.show ?? null, }; }, @@ -164,7 +166,7 @@ async function shoot(page, name) { await page .waitForFunction( () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, - { timeout: 60000 }, + { timeout: TIMEOUT_MS }, ) .catch(() => {}); await showLayerRow(page); @@ -174,15 +176,12 @@ async function shoot(page, name) { /** Bounding-sphere state of every GNSS cell: DONE = 0, PENDING = 1, FAILED = 2. */ function cellStates(id) { const { viewer } = window.__godsEyeView; - const ds = viewer.dataSources.getByName(id)[0]; - const sphere = {}; - return ds.entities.values.map((entity) => { - try { - return viewer.dataSourceDisplay.getBoundingSphere(entity, false, sphere); - } catch { - return 1; // not yet seen by the geometry visualizer - } - }); + const all = viewer.scene.groundPrimitives; + const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find( + (item) => item.id === id, + ); + if (!ds?.length) return []; + return [ds.get(0).ready ? 0 : 1]; } /** Wait until every GNSS cell's ground rectangle has finished building. */ @@ -371,7 +370,7 @@ async function main() { if (res.url().includes('/api/gnss-integrity') && res.ok()) liveBodies.push(res.json()); }); - await boot(live, { lon: 19.5, lat: 52.5, height: 1_100_000 }); + await boot(live, { lon: 19.6, lat: 52.6, height: 1_100_000 }); const l = await enableAndSettle(live); const legendTotal = l.legend.reduce((sum, { count }) => sum + count, 0); // Bin what adsb.lol actually returned with the layer's own rules: live @@ -388,7 +387,9 @@ async function main() { Array.isArray(body.rows) && Number.isFinite(body.ageMs) && body.ageMs >= 0 && - body.classifier === 'gev-nic-nacp-v1', + body.ageMs <= 60000 && + body.stale !== true && + body.classifier === 'gev-nic-nacp-v2', ), `successful responses=${liveSnapshots.length}`, ); @@ -405,7 +406,7 @@ async function main() { } const expectedCells = binGnssCells(expectedStore).length; record( - 'LIVE: no error, one entity per cell', + 'LIVE: no error, one geometry per cell', !l.stats.error && l.entities === l.stats.count, `cells=${l.stats.count} entities=${l.entities} error=${JSON.stringify(l.stats.error)}`, ); @@ -456,17 +457,19 @@ async function main() { ); record( 'BANDS: 5/10 is high, 2/30 is medium, 1/3 and 0/4 are low', - band('Over') === 1 && band('2') === 1 && band('Under') === 2, + band('Low-accuracy score >') === 1 && + band('Low-accuracy score 2') === 1 && + band('Low-accuracy score <') === 2, JSON.stringify(f.legend), ); const prov = f.stats.provenance; record( 'PROVENANCE: GEV classifier, gpsjam bands and visited-view window are machine-readable', - prov?.classifier?.id === 'gev-nic-nacp-v1' && + prov?.classifier?.id === 'gev-nic-nacp-v2' && prov.classifier.definedBy === 'GEV' && prov.classifier.validated === false && prov.aggregation?.source === 'https://gpsjam.org/faq' && - JSON.stringify(prov.aggregation.bands) === '[0.02,0.1]' && + JSON.stringify(prov.aggregation.bands) === '[2,10]' && prov.window?.minutes === 30 && prov.window.scope === 'visited-view' && prov.interpretation?.validated === false, @@ -477,12 +480,9 @@ async function main() { f.name === 'GNSS Integrity' && f.rowName.includes('GNSS Integrity') && !/interference/i.test(f.rowName) && - f.legend.every(({ label }) => label.endsWith('low accuracy')) && - f.blurb.startsWith( - 'Share of ADS-B aircraft reporting low navigation accuracy', - ) && - /GEV threshold/.test(f.blurb) && - /Suspected jamming or spoofing is one possible cause/.test(f.blurb), + f.legend.every(({ label }) => label.startsWith('Low-accuracy score')) && + /one aircraft is discounted/.test(f.blurb) && + /not a detection/.test(f.blurb), `name=${JSON.stringify(f.name)}`, ); record( @@ -498,6 +498,128 @@ async function main() { ); await shoot(page, 'synthetic-bands'); + // GNSS fills must not own map input, including over a real perimeter. + await page.evaluate(async () => { + const original = window.fetch.bind(window); + window.fetch = (input, init) => { + const url = typeof input === 'string' ? input : input.url; + if (url.includes('/api/fire-perimeters/inciweb')) + return Promise.resolve(Response.json({ rows: [] })); + if (url.includes('/api/fire-perimeters')) + return Promise.resolve( + Response.json({ + rows: [ + { + stableId: 'gnss-pick-qa', + name: 'GNSS PICK QA FIRE', + containedPct: 0, + acres: 100, + polygons: [ + [ + [ + [18.247, 54.247], + [18.253, 54.247], + [18.253, 54.253], + [18.247, 54.253], + [18.247, 54.247], + ], + ], + ], + }, + ], + }), + ); + return original(input, init); + }; + const { viewer, dataManager } = window.__godsEyeView; + viewer.camera.cancelFlight(); + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (18.25 * Math.PI) / 180, + latitude: (54.25 * Math.PI) / 180, + height: 2500, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + await dataManager.setEnabled('fire-perimeters', true); + viewer.scene.requestRender(); + }); + await page.waitForFunction( + () => { + const v = window.__godsEyeView.viewer, + p = v.scene.pick({ + x: v.scene.canvas.clientWidth / 2, + y: v.scene.canvas.clientHeight / 2, + }); + return String(p?.id?.id ?? p?.id ?? '').startsWith( + 'fire-perimeter:gnss-pick-qa:', + ); + }, + { timeout: TIMEOUT_MS, polling: 250 }, + ); + const pickProbe = await page.evaluate(() => { + const v = window.__godsEyeView.viewer, + c = v.scene.canvas; + const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 }; + const empty = { x: centre.x + 300, y: centre.y }; + const picked = v.scene.pick(empty), + box = c.getBoundingClientRect(); + return { + centre: { x: centre.x + box.left, y: centre.y + box.top }, + empty: { x: empty.x + box.left, y: empty.y + box.top }, + surface: !picked || picked.id === undefined, + height: v.camera.positionCartographic.height, + }; + }); + record('PICK: a perimeter remains selectable under GNSS', true); + record( + 'PICK: street ground remains a surface pick under GNSS', + pickProbe.surface, + ); + await page.mouse.click(pickProbe.centre.x, pickProbe.centre.y); + await page.waitForFunction( + () => + (window.__gevWorldOverlay.getDiagnostics().paintedBySource[ + 'fire-perimeters' + ] ?? 0) > 0, + { timeout: 10000 }, + ); + record('PICK: actual perimeter click opens its incident card', true); + await page.screenshot({ + path: path.join(SHOTS_DIR, 'gnss-perimeter-selected.png'), + }); + await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y); + await sleep(500); + record( + 'PICK: empty ground clears the perimeter card', + await page.evaluate( + () => + (window.__gevWorldOverlay.getDiagnostics().entriesBySource[ + 'fire-perimeters' + ] ?? 0) === 0, + ), + ); + await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y, { + clickCount: 2, + }); + await sleep(1000); + record( + 'PICK: empty double-click does not zoom to a GNSS cell', + await page.evaluate( + (height) => + Math.abs( + window.__godsEyeView.viewer.camera.positionCartographic.height - + height, + ) < 200, + pickProbe.height, + ), + ); + await page.evaluate(async () => { + await window.__godsEyeView.dataManager.setEnabled( + 'fire-perimeters', + false, + ); + }); console.log('\nCAMERA — automatic regional refresh without toggling...'); const beforeMove = await page.evaluate( () => window.__gnssQaRequests.length, @@ -555,7 +677,7 @@ async function main() { const { viewer } = window.__godsEyeView; viewer.camera.setView({ destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: (12.5 * Math.PI) / 180, + longitude: (12.4 * Math.PI) / 180, latitude: (41.9 * Math.PI) / 180, height: 16_000_000, }), @@ -569,7 +691,7 @@ async function main() { const params = url && new URL(url, location.origin).searchParams; return ( params && - Math.abs(Number(params.get('lon')) - 12.5) < 1 && + Math.abs(Number(params.get('lon')) - 12.4) < 1 && Math.abs(Number(params.get('lat')) - 41.9) < 1 ); }, @@ -592,7 +714,7 @@ async function main() { 'GLOBE: zoomed-out sampling stays centred on Rome and exposes its regional radius', world.enabled && world.height > 15_000_000 && - world.coverage?.longitude === 13 && + world.coverage?.longitude === 12 && world.coverage?.latitude === 42 && world.coverage?.radiusNm === 250, JSON.stringify(world), @@ -642,7 +764,10 @@ async function main() { console.log('\n(iv) DISABLE...'); const shown = await page.evaluate(async (id) => { await window.__godsEyeView.dataManager.setEnabled(id, false); - return window.__godsEyeView.viewer.dataSources.getByName(id)[0]?.show; + const all = window.__godsEyeView.viewer.scene.groundPrimitives; + return Array.from({ length: all.length }, (_, i) => all.get(i)).find( + (item) => item.id === id, + )?.show; }, LAYER_ID); record('DISABLE: data source hidden', shown === false, `show=${shown}`); await shoot(page, 'disabled'); diff --git a/server/providers/gnssIntegrity.js b/server/providers/gnssIntegrity.js index 4b27bff4f..adf28cea5 100644 --- a/server/providers/gnssIntegrity.js +++ b/server/providers/gnssIntegrity.js @@ -16,10 +16,13 @@ import { makeRateLimiter, clientKey } from './common/rate-limit.js'; const RADIUS_NM = 250; const CACHE_MS = 60_000; const CACHE_MAX = 64; -/** Cooldown after a 429 or 5xx when upstream sends no usable Retry-After. */ +/** Default 429 cooldown when upstream sends no usable Retry-After. */ const COOLDOWN_MS = 60_000; -/** Minimum backoff; a valid longer upstream deadline is never shortened. */ +/** Bound sane upstream deadlines to one hour; never shorten a deadline within that range. */ const COOLDOWN_MIN_MS = 5_000; +const COOLDOWN_MAX_MS = 60 * 60_000; +const FAILURE_COOLDOWN_MS = 15_000; +const MAX_IN_FLIGHT = 8; const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; const TIMEOUT_MS = 10_000; @@ -36,13 +39,14 @@ export function gnssIntegrityAnchor(url) { /** Cooldown (ms) an upstream 429 earns, honouring Retry-After when sane. */ export function gnssRetryCooldownMs(raw, nowMs) { if (raw) { - const seconds = Number(raw); + const seconds = /^\d+(?:\.\d+)?$/.test(raw.trim()) ? Number(raw) : NaN; const ms = seconds * 1000; - if (Number.isFinite(ms) && seconds > 0) - return Math.max(COOLDOWN_MIN_MS, ms); - const at = Date.parse(raw); + if (Number.isFinite(seconds) && seconds > 0) + return Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, ms)); + const at = + Number.isNaN(seconds) && /[a-z]/i.test(raw) ? Date.parse(raw) : NaN; if (Number.isFinite(at) && at > nowMs) - return Math.max(COOLDOWN_MIN_MS, at - nowMs); + return Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, at - nowMs)); } return COOLDOWN_MS; } @@ -51,77 +55,123 @@ export function gnssRetryCooldownMs(raw, nowMs) { export function gnssIntegrityProxy({ fetchImpl = (...args) => globalThis.fetch(...args), now = () => Date.now(), + monotonicNow = () => performance.now(), + warn = (...args) => console.warn(...args), } = {}) { const cache = new Map(); const inFlight = new Map(); const allow = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 600 }); let cooldownUntil = 0; + let cooldownStatus = 429; async function load({ lat, lon }) { - if (now() < cooldownUntil) - throw Object.assign(new Error('cooling_down'), { status: 429 }); + if (monotonicNow() < cooldownUntil) + throw Object.assign(new Error('cooling_down'), { + status: cooldownStatus, + }); const signal = AbortSignal.timeout(TIMEOUT_MS); - const response = await fetchImpl( - `https://api.adsb.lol/v2/lat/${lat}/lon/${lon}/dist/${RADIUS_NM}`, - { - signal, - redirect: 'error', - headers: { - Accept: 'application/json', - 'User-Agent': 'gods-eye-view-gnss-integrity/1.0', + try { + const response = await fetchImpl( + `https://api.adsb.lol/v2/lat/${lat}/lon/${lon}/dist/${RADIUS_NM}`, + { + signal, + redirect: 'error', + headers: { + Accept: 'application/json', + 'User-Agent': 'gods-eye-view-gnss-integrity/1.0', + }, }, - }, - ); - if (!response.ok) { - await response.body?.cancel(); - if (response.status === 429) { - const at = now(); - cooldownUntil = Math.max( - cooldownUntil, - at + gnssRetryCooldownMs(response.headers.get('retry-after'), at), - ); - throw Object.assign(new Error('upstream_rate_limited'), { - status: 429, - }); + ); + if (!response.ok) { + await response.body?.cancel(); + if (response.status === 429) { + const at = monotonicNow(); + const raw = response.headers.get('retry-after'); + const delay = gnssRetryCooldownMs(raw, now()); + if (delay === COOLDOWN_MAX_MS) + warn('[gnss-integrity] upstream cooldown capped at one hour'); + cooldownStatus = 429; + cooldownUntil = Math.max(cooldownUntil, at + delay); + throw Object.assign(new Error('upstream_rate_limited'), { + status: 429, + }); + } + throw new Error('upstream_unavailable'); } - throw new Error('upstream_unavailable'); + const payload = await readResponseJsonCapped( + response, + MAX_RESPONSE_BYTES, + signal, + ); + const rows = normalizeGnssAircraft(payload); + if (!rows) throw new Error('invalid_snapshot'); + const sourceNow = payload.now; + if (!Number.isFinite(sourceNow) || sourceNow <= 0) + throw new Error('undated_snapshot'); + const fetchedAt = Math.min( + now(), + sourceNow > 10_000_000_000 ? sourceNow : sourceNow * 1000, + ); + if (now() - fetchedAt > GNSS_WINDOW_MS) + throw new Error('expired_snapshot'); + return { + fetchedAt, + anchor: { lat, lon }, + radiusNm: RADIUS_NM, + // `degraded` on each row is this classifier's verdict, not gpsjam's. + classifier: GNSS_CLASSIFIER.id, + rows, + }; + } catch (error) { + if (error.status !== 429) { + const until = monotonicNow() + FAILURE_COOLDOWN_MS; + if (until > cooldownUntil) { + cooldownUntil = until; + cooldownStatus = 502; + } + } + throw error; } - const payload = await readResponseJsonCapped( - response, - MAX_RESPONSE_BYTES, - signal, - ); - const rows = normalizeGnssAircraft(payload); - if (!rows) throw new Error('invalid_snapshot'); - return { - fetchedAt: now(), - anchor: { lat, lon }, - radiusNm: RADIUS_NM, - // `degraded` on each row is this classifier's verdict, not gpsjam's. - classifier: GNSS_CLASSIFIER.id, - rows, - }; } - async function acquire(anchor) { + function age(entry) { + if (!entry) return Infinity; + const elapsed = monotonicNow() - entry.savedAt; + // A backwards injected clock cannot rejuvenate a snapshot. + return elapsed < 0 ? Infinity : entry.ageAtReceipt + elapsed; + } + + async function acquire(anchor, peer) { const key = `${anchor.lat},${anchor.lon}`; const previous = cache.get(key); - if (previous && now() - previous.fetchedAt < CACHE_MS) - return { value: previous, stale: false }; + if (age(previous) < CACHE_MS) + return { value: previous.value, ageMs: age(previous), stale: false }; try { + if (!inFlight.has(key) && inFlight.size >= MAX_IN_FLIGHT) + throw Object.assign(new Error('busy'), { status: 429, local: true }); const { promise } = coalesceProxyRequest(inFlight, key, async () => { + if (monotonicNow() >= cooldownUntil && !allow(peer)) + throw Object.assign(new Error('rate_limited'), { + status: 429, + local: true, + }); const value = await load(anchor); + const entry = { + value, + savedAt: monotonicNow(), + ageAtReceipt: Math.max(0, now() - value.fetchedAt), + }; cache.delete(key); - cache.set(key, value); + cache.set(key, entry); while (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value); - return value; + return entry; }); - return { value: await promise, stale: false }; + const entry = await promise; + const ageMs = age(entry); + return { value: entry.value, ageMs, stale: ageMs >= CACHE_MS }; } catch (error) { - // A stale answer keeps its original fetchedAt so the layer can age it; - // past the layer's window it is no evidence at all, so it is not served. - if (previous && now() - previous.fetchedAt <= GNSS_WINDOW_MS) - return { value: previous, stale: true }; + if (age(previous) <= GNSS_WINDOW_MS) + return { value: previous.value, ageMs: age(previous), stale: true }; throw error; } } @@ -133,7 +183,9 @@ export function gnssIntegrityProxy({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...(status === 405 ? { Allow: 'GET' } : {}), - ...(status === 429 ? { 'Retry-After': String(retryAfterS) } : {}), + ...([429, 502].includes(status) + ? { 'Retry-After': String(retryAfterS) } + : {}), ...(stale ? { 'X-Data-Stale': 'true' } : {}), }); res.end(JSON.stringify(value)); @@ -144,28 +196,33 @@ export function gnssIntegrityProxy({ return json(404, { error: 'unknown_route' }); const anchor = gnssIntegrityAnchor(req.url); if (!anchor) return json(400, { error: 'invalid_anchor' }); - if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' }); try { - const { value, stale } = await acquire(anchor); + const { value, stale, ageMs } = await acquire(anchor, clientKey(req)); // ageMs is measured on this server's clock, so the browser can date // the rows without comparing its clock to ours. - const ageMs = Math.max(0, now() - value.fetchedAt); json(200, { ...value, ageMs, ...(stale ? { stale: true } : {}) }, stale); } catch (error) { if (error.status === 429) { // Tell the browser how long the shared upstream cooldown has left. const retryAfterS = Math.max( 1, - Math.ceil((cooldownUntil - now()) / 1000), + error.local ? 60 : Math.ceil((cooldownUntil - monotonicNow()) / 1000), ); return json( 429, - { error: 'gnss_integrity_rate_limited' }, + { + error: error.local ? error.message : 'gnss_integrity_rate_limited', + }, false, retryAfterS, ); } - json(502, { error: 'gnss_integrity_unavailable' }); + json( + 502, + { error: 'gnss_integrity_unavailable' }, + false, + Math.max(1, Math.ceil((cooldownUntil - monotonicNow()) / 1000)), + ); } } diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 3504be9a1..18de877db 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -179,7 +179,7 @@ export const ACTION_DESCRIPTIONS = { }, get_current_view_state: { description: - 'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action.', + 'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action. Tracked aircraft emergency status is a crowd-sourced transponder broadcast, not a confirmed incident: quote emergencyBroadcast and never infer a hijack or an incident ending from a code change.', $position: 1, }, set_hud: { @@ -543,7 +543,7 @@ export const ACTION_DESCRIPTIONS = { }, analyst_query: { description: - 'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values). Aircraft emergency fields describe broadcasts, not confirmed incidents: narrate emergencyBroadcast with emergencySource. lastContactAtMs is retained general aircraft-message contact and never a status-specific receipt timestamp.', + 'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values). Aircraft emergency fields describe broadcasts, not confirmed incidents: narrate emergencyBroadcast with emergencySource. emergencyKind values are general, unlawful, nordo, downed, minfuel and lifeguard; GEV treats the last two as priorities (emergency:false). lastContactAtMs says when the transponder was last heard, not when the status arrived.', $position: 1, parameters: { properties: { diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index f78d4d5eb..184b51f17 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -122,7 +122,7 @@ export function createApplicationCatalog({ source: sources['fire-perimeters'], }), createApplicationGnssIntegrity({ - source: sources['gnss-interference'], + source: sources['gnss-integrity'], }), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index d5a1fff63..f8ebb1e73 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -60,7 +60,7 @@ test('catalogs construct distinct layers and classification from their supplied '2026-09-21T12:00:00.000Z', ); assert.ok(first.get('fire-perimeters')); - assert.ok(first.get('gnss-interference')); + assert.ok(first.get('gnss-integrity')); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); assert.deepEqual( diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 826b90b1f..0b516f5b4 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -46,7 +46,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ cyclones: contract(['weather-cyclones'], ['getSnapshot']), earthquakes: contract(['earthquakes'], ['getSnapshot']), 'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']), - 'gnss-interference': contract(['gnss-interference'], ['getSnapshot']), + 'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), }); diff --git a/src/data/adsbLolFallback.js b/src/data/adsbLolFallback.js index e83cf118b..40dd2bbc0 100644 --- a/src/data/adsbLolFallback.js +++ b/src/data/adsbLolFallback.js @@ -54,7 +54,7 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) { const seenPosition = Math.max( 0, - finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? 0, + finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? NaN, ); const seen = Math.max(0, finiteNumber(aircraft?.seen) ?? seenPosition); const onGround = aircraft?.alt_baro === 'ground'; @@ -69,8 +69,10 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) { hex, String(aircraft?.flight || aircraft?.r || '').trim() || null, null, - Math.max(0, nowSeconds - seenPosition), - Math.max(0, nowSeconds - seen), + Number.isFinite(seenPosition) + ? Math.max(0, nowSeconds - seenPosition) + : null, + Number.isFinite(seen) ? Math.max(0, nowSeconds - seen) : null, longitude, latitude, barometricFeet === null ? null : barometricFeet * FOOT_TO_M, diff --git a/src/data/aircraftEmergency.js b/src/data/aircraftEmergency.js index 3a555399e..78bd4308f 100644 --- a/src/data/aircraftEmergency.js +++ b/src/data/aircraftEmergency.js @@ -1,4 +1,3 @@ -// src/data/aircraftEmergency.js /** * Transponder emergency status for one aircraft, from the Mode A squawk and * the ADS-B emergency/priority field. @@ -10,8 +9,8 @@ * squawk only. * * The status is what the transponder broadcast in THIS poll, never a finding: - * crews pass through 7x00 codes while dialling, and a real emergency clears - * when the code does. Callers keep it per poll (not sticky). + * crews pass through 7x00 codes while dialling. Highlighting follows the latest + * broadcast, which does not confirm an incident or its resolution. */ /** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */ @@ -39,7 +38,8 @@ export function normalizeSquawk(value) { } /** - * Emergency status from one observation. An explicit ADS-B status wins; a + * Emergency status from one observation. Higher severity wins; an ADS-B + * emergency wins a tie. A * 7x00 squawk counts even when the ADS-B field says `none`. * @param {{squawk?: unknown, emergency?: unknown}} [observation] * @returns {{kind: string, label: string, severity: 'emergency'|'priority', @@ -53,7 +53,11 @@ export function aircraftEmergency({ squawk, emergency } = {}) { .trim() .toLowerCase(); const fromAdsb = Object.hasOwn(KINDS, reported); - const kind = fromAdsb ? reported : (SQUAWK_KINDS[code] ?? null); + const squawkKind = SQUAWK_KINDS[code] ?? null; + const kind = + fromAdsb && (KINDS[reported].severity === 'emergency' || !squawkKind) + ? reported + : squawkKind; if (!kind) return null; return { kind, diff --git a/src/data/aircraftEmergency.test.mjs b/src/data/aircraftEmergency.test.mjs index 4304dcb14..6646aa6bd 100644 --- a/src/data/aircraftEmergency.test.mjs +++ b/src/data/aircraftEmergency.test.mjs @@ -82,3 +82,14 @@ test('the broadcast phrase quotes a naming code, else attributes ADS-B', () => { assert.equal(emergencyBroadcastPhrase(null), null); assert.equal(emergencyBroadcastPhrase({ kind: 'reserved' }), null); }); + +test('an emergency squawk outranks an ADS-B priority', () => { + assert.equal( + aircraftEmergency({ squawk: '7700', emergency: 'lifeguard' }).kind, + 'general', + ); + assert.equal( + aircraftEmergency({ squawk: '7500', emergency: 'minfuel' }).kind, + 'unlawful', + ); +}); diff --git a/src/data/aircraftEmergencyAnalyst.test.mjs b/src/data/aircraftEmergencyAnalyst.test.mjs index 9e4a6e2fe..27deb2b46 100644 --- a/src/data/aircraftEmergencyAnalyst.test.mjs +++ b/src/data/aircraftEmergencyAnalyst.test.mjs @@ -1,8 +1,11 @@ -// src/data/aircraftEmergencyAnalyst.test.mjs -// Source-to-analyst fixture: an OpenSky state vector squawking 7700 and an -// adsb.lol (readsb) aircraft whose ADS-B `emergency` field says `general` -// on an ordinary code must reach the analyst with the same kind but distinct -// provenance, so a summary never flattens one into the other. +import * as Cesium from 'cesium'; +import { TRACKED_ICON_COLOR } from '../layers/military/policy.js'; +import { createRendering as civilRendering } from '../layers/flights/rendering.js'; +import { createRendering as militaryRendering } from '../layers/military/rendering.js'; +import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js'; +import { aircraftEmergency } from './aircraftEmergency.js'; +// Source-to-voice provenance, status retention/clearing, contact-time contracts, +// and civil/military billboard, Cockpit and model tint regressions. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { openSkySnapshot, readsbSnapshot } from '../sources/live/index.js'; @@ -30,7 +33,7 @@ test('fallback status belongs to the first valid admitted aircraft row', () => { }); const snapshot = openSkySnapshot(payload, { now: NOW_MS }); assert.equal(snapshot.records.length, 1); - assert.equal(snapshot.records[0].emergency, status ?? null); + assert.equal(snapshot.records[0].emergency, status); assert.equal(snapshot.records[0].latitude, 30); } }); @@ -354,3 +357,168 @@ test('military status changes retain only the latest known general contact', () assert.equal(changed.emergencyKind, 'nordo'); assert.equal(changed.lastContactAtMs, NOW_MS - 1500); }); + +test('fallback missing contact age retains known contact through the real converter', () => { + const store = civilStore(); + const row = { + hex: 'a1b2c3', + lat: 30, + lon: -97, + squawk: '1200', + emergency: 'general', + seen: 3, + seen_pos: 1, + }; + const receive = (row) => + store.receive( + openSkySnapshot( + normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }), + { now: NOW_MS }, + ).records[0], + receiveContext, + ).meta; + const first = receive(row); + const second = receive({ ...row, seen: undefined, seen_pos: undefined }); + assert.equal(second.lastContactEpochMs, first.lastContactEpochMs); +}); + +test('one omitted ADS-B status poll is retained; explicit none and changed squawk clear immediately', () => { + const store = civilStore(); + const row = { + hex: 'a1b2c3', + lat: 30, + lon: -97, + squawk: '1200', + emergency: 'general', + seen: 3, + }; + const receive = (row) => + store.receive( + openSkySnapshot( + normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }), + { now: NOW_MS }, + ).records[0], + receiveContext, + ).meta; + assert.equal(receive(row).emergency.kind, 'general'); + assert.equal( + receive({ ...row, emergency: undefined }).emergency?.kind, + 'general', + ); + assert.equal(receive({ ...row, emergency: undefined }).emergency, null); + receive(row); + assert.equal(receive({ ...row, emergency: 'none' }).emergency, null); + receive(row); + assert.equal( + receive({ ...row, emergency: undefined, squawk: '2000' }).emergency, + null, + ); +}); + +function renderHarness(factory) { + const flightState = { records: { data: new Map() }, _trackedIcao: null }; + const rendering = factory({ + flightState, + services: { + militaryRegistry: { isMilitaryIcao: (id) => id === 'mil' }, + aircraftPresentation: {}, + camera: {}, + focus: {}, + recession: {}, + }, + parts: {}, + }); + return { flightState, rendering }; +} + +test('tint priorities cover normal, priority, emergency, tracking and Cockpit dots', () => { + for (const factory of [civilRendering, militaryRendering]) { + const { flightState, rendering } = renderHarness(factory); + const normal = rendering._fleetBillboardColor('civil', {}); + assert.equal( + rendering._fleetBillboardColor('civil', { + emergency: aircraftEmergency({ emergency: 'minfuel' }), + }), + normal, + ); + const red = rendering._fleetBillboardColor('civil', { + emergency: aircraftEmergency({ squawk: '7700' }), + }); + assert.ok(red.red > 0.9 && red.green < 0.5); + flightState.records.data.set('civil', { + emergency: aircraftEmergency({ squawk: '7700' }), + }); + assert.equal(rendering._modelColor('civil'), red); + if (rendering._cockpitContactColor) + assert.equal(rendering._cockpitContactColor('civil'), red); + flightState._trackedIcao = 'civil'; + assert.equal( + rendering._modelColor('civil'), + factory === civilRendering ? Cesium.Color.CYAN : TRACKED_ICON_COLOR, + ); + flightState._trackedIcao = null; + flightState.records.data.set('civil', { emergency: null }); + assert.equal(rendering._modelColor('civil'), normal); + } +}); + +test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => { + const { flightState, rendering } = renderHarness(militaryRendering); + const bb = { + color: rendering._fleetBillboardColor('abc123').withAlpha(0.65), + }; + Object.assign(flightState, { + _billboards: new Map([['abc123', bb]]), + _models: new Map(), + _positionHistory: new Map(), + _cullPositions: new Map(), + _groundSnap: { forget() {} }, + _cockpitContactMode: false, + }); + const records = new MilitaryFlightRecords({ + geoidHeight: () => 0, + cachedGroundFloor: () => null, + floorAltitudeM: (value) => value, + }); + flightState.records = records; + const apply = createMilitarySnapshotRenderer({ + flightState, + records, + groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null }, + meshFloor: { sampleMeshFloorCells() {} }, + militaryRegistry: { registerMilitaryIcaos() {} }, + rendering: { + ...rendering, + _syncModelToClass() {}, + _applyFleetBillboardPresentation() {}, + }, + motion: {}, + tracking: {}, + queries: {}, + }); + for (const [emergency, red] of [ + ['general', true], + ['none', false], + ['general', true], + ]) { + const snapshot = readsbSnapshot( + { + ac: [ + { + hex: 'abc123', + lat: 30, + lon: -97, + alt_baro: 10000, + squawk: '1200', + emergency, + seen_pos: 1, + }, + ], + }, + { observedAtMs: NOW_MS }, + ); + apply(snapshot, {}); + assert.equal(bb.color.green < 0.5, red); + assert.equal(bb.color.alpha, 0.65); + } +}); diff --git a/src/data/aircraftRecession.js b/src/data/aircraftRecession.js index b260c5f1b..04c41e604 100644 --- a/src/data/aircraftRecession.js +++ b/src/data/aircraftRecession.js @@ -242,7 +242,7 @@ export function applyAircraftBillboardTreatment({ } /** - * Apply the already-composed aircraft alpha to an ambient glTF model without + * Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without * disturbing Cesium's existing MIX/colorBlendAmount presentation semantics. * @param {object} input * @param {object} input.model @@ -261,7 +261,10 @@ export function applyAircraftModelTreatment({ if (!model || !baseColor || !Number.isFinite(alpha)) return 0; if ( Number.isFinite(model.color?.alpha) && - Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon + Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon && + model.color.red === baseColor.red && + model.color.green === baseColor.green && + model.color.blue === baseColor.blue ) return 0; model.color = baseColor.withAlpha(alpha); diff --git a/src/data/aircraftRecession.test.mjs b/src/data/aircraftRecession.test.mjs index 29bce05c5..23b8ceffb 100644 --- a/src/data/aircraftRecession.test.mjs +++ b/src/data/aircraftRecession.test.mjs @@ -142,3 +142,15 @@ test('ambient model presentation receives composed alpha without changing blend assert.equal(model.colorBlendAmount, 0.9); assert.equal(applyAircraftModelTreatment({ model, baseColor: color, alpha: 0.2, params }), 0); }); + + +test('model hue changes at constant alpha, then clears without redundant writes', () => { + const color = (red,green,blue) => ({red,green,blue,withAlpha(alpha){return {red,green,blue,alpha};}}); + const model = {color:color(1,1,1).withAlpha(0.65)}; + assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),1); + assert.equal(model.color.green,0.2); + assert.equal(model.color.alpha,0.65); + assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),0); + assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,1,1),alpha:0.65}),1); + assert.equal(model.color.green,1); +}); diff --git a/src/data/gnssIntegrityProxy.test.mjs b/src/data/gnssIntegrityProxy.test.mjs index 76849c8aa..457878b18 100644 --- a/src/data/gnssIntegrityProxy.test.mjs +++ b/src/data/gnssIntegrityProxy.test.mjs @@ -7,7 +7,7 @@ import { } from '../../server/providers/gnssIntegrity.js'; const snapshot = { - now: 1, + now: 4_000_000_000_000, ac: [ { hex: '4ca7b5', @@ -28,7 +28,7 @@ const snapshot = { function install(options = {}, hook = 'configureServer') { let handler; - const plugin = gnssIntegrityProxy(options); + const plugin = gnssIntegrityProxy({ monotonicNow: options.now, ...options }); assert.equal(plugin.name, 'gnss-integrity'); plugin[hook]({ middlewares: { @@ -89,7 +89,7 @@ for (const hook of ['configureServer', 'configurePreviewServer']) { ageMs: 0, anchor: { lat: 50, lon: 20 }, radiusNm: 250, - classifier: 'gev-nic-nacp-v1', + classifier: 'gev-nic-nacp-v2', rows: [ { hex: '4ca7b5', @@ -205,7 +205,7 @@ test('an upstream 429 starts a cooldown with no further upstream calls', async ( assert.equal(calls, 2); }); -test('Retry-After accepts seconds or an HTTP date, with a 5 s minimum and no truncated deadline', () => { +test('Retry-After accepts sane seconds or HTTP dates up to one hour', () => { const nowMs = Date.parse('2026-09-29T12:00:00Z'); assert.equal(gnssRetryCooldownMs('30', nowMs), 30_000); assert.equal(gnssRetryCooldownMs('1', nowMs), 5_000); @@ -309,9 +309,121 @@ test('the route rejects other methods, paths, bad anchors, a malformed feed and assert.equal((await request('/other?lat=1&lon=1')).status, 404); assert.equal((await request('/?lat=1')).status, 400); assert.equal((await request('/?lat=1&lon=1')).status, 502); + const flood = install({ fetchImpl: async () => Response.json(snapshot) }); let limited = 0; for (let i = 0; i < 40; i += 1) - if ((await request('/?lat=1&lon=1', 'GET', 'flood')).status === 429) + if ((await flood(`/?lat=${i}&lon=1`, 'GET', 'flood')).status === 429) limited += 1; assert.ok(limited > 0); }); + +test('GERMAN: pathological retry deadlines are bounded', () => { + const at = Date.parse('2026-10-14T12:00:00Z'); + assert.ok(gnssRetryCooldownMs('1e305', at) <= 3600000); + assert.equal(gnssRetryCooldownMs('9999999999999999999999', at), 3600000); + assert.equal( + gnssRetryCooldownMs('Wed, 14 Oct 2099 12:00:00 GMT', at), + 3600000, + ); + assert.equal(gnssRetryCooldownMs('0x10', at), 60000); +}); + +test('GERMAN: 5xx, network timeouts and malformed JSON share a short cooldown', async () => { + for (const fail of [ + async () => new Response('', { status: 503 }), + async () => { + throw new DOMException('timeout', 'TimeoutError'); + }, + async () => new Response('bad JSON'), + ]) { + let clock = 0, + calls = 0; + const request = install({ + now: () => clock, + fetchImpl: async () => { + calls++; + return fail(); + }, + }); + assert.equal((await request()).status, 502); + clock = 14000; + assert.equal((await request('/?lat=1&lon=1')).status, 502); + assert.equal(calls, 1); + clock = 15001; + await request('/?lat=2&lon=2'); + assert.equal(calls, 2); + } +}); + +test('GERMAN: source timestamp dates evidence; frozen old snapshots are rejected', async () => { + const now = 1800000000000; + const request = install({ + now: () => now, + fetchImpl: async () => + Response.json({ ...snapshot, now: (now - 120000) / 1000 }), + }); + const res = await request(); + assert.equal(res.body.ageMs, 120000); + assert.equal(res.body.stale, true); + for (const sourceNow of [(now - 1800001) / 1000, undefined, NaN]) { + const missing = install({ + now: () => now, + fetchImpl: async () => Response.json({ ...snapshot, now: sourceNow }), + }); + assert.equal((await missing()).status, 502); + } +}); + +test('GERMAN: cache age and cooldown remain stable across a wall-clock rollback', async () => { + let wall = 1800000000000, + mono = 0, + calls = 0; + const request = install({ + now: () => wall, + monotonicNow: () => mono, + fetchImpl: async () => { + calls++; + return calls === 1 + ? Response.json({ ...snapshot, now: wall / 1000 }) + : new Response('', { status: 429, headers: { 'Retry-After': '60' } }); + }, + }); + await request(); + wall -= 3600000; + mono = 61000; + const stale = await request(); + assert.equal(stale.body.ageMs, 61000); + assert.equal(stale.body.stale, true); + mono += 1000; + assert.equal((await request('/?lat=1&lon=1')).headers['Retry-After'], '59'); + assert.equal(calls, 2); +}); + +test('GERMAN: cache hits and coalesced reads do not consume upstream admission', async () => { + let calls = 0; + const request = install({ + now: () => 1800000000000, + fetchImpl: async () => { + calls++; + return Response.json(snapshot); + }, + }); + for (let i = 0; i < 100; i++) assert.equal((await request()).status, 200); + assert.equal((await request('/?lat=1&lon=1')).status, 200); + assert.equal(calls, 2); +}); + +test('GERMAN: distinct concurrent upstream reads are bounded', async () => { + const pending = []; + const request = install({ + fetchImpl: () => new Promise((resolve) => pending.push(resolve)), + }); + const jobs = Array.from({ length: 30 }, (_, i) => + request(`/?lat=${i}&lon=1`), + ); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(pending.length <= 8); + pending.forEach((resolve) => resolve(Response.json(snapshot))); + const results = await Promise.all(jobs); + assert.ok(results.some((result) => result.status === 429)); +}); diff --git a/src/data/layerState.js b/src/data/layerState.js index 72a3351b9..1db10494b 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -608,7 +608,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ optionOwner: 'flights', }), Object.freeze({ - id: 'gnss-interference', + id: 'gnss-integrity', token: '3', disposition: 'enabled-only', }), diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index b782de5e2..30f251b7b 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.throws( () => validateLayerStateAllocations( - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3' }, - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3', competing: '3' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4', competing: '4' }, ), - /next free token 4/, + /next free token 5/, ); assert.throws( () => validateLayerStateAllocations({ future: '00' }, { future: '01' }), @@ -2643,11 +2643,11 @@ test('GNSS interference uses digit 3 without colliding with other digit tokens', const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2.1')); assert.deepEqual(decoded.enabledLayerIds, [ 'fire-perimeters', - 'gnss-interference', + 'gnss-integrity', 'recent-imagery', ]); assert.equal( - LAYER_STATE_REGISTRY.find(({ id }) => id === 'gnss-interference').token, + LAYER_STATE_REGISTRY.find(({ id }) => id === 'gnss-integrity').token, '3', ); assert.deepEqual( diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 9d7544109..a38a43d51 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -134,7 +134,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0', - 'gnss-interference': '3', + 'gnss-integrity': '3', }, ); assert.throws( diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index 13a5d136b..34ba725b5 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -9,7 +9,7 @@ ["earthquakes", "e"], ["fire-perimeters", "2"], ["flights", "f"], - ["gnss-interference", "3"], + ["gnss-integrity", "3"], ["local-dams", "q"], ["local-datacenters", "d"], ["local-firms", "w"], diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 53524bada..a75ac91fd 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -673,11 +673,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // referent-only track_entity alternative (kept out of the model-facing // schema, which may not carry top-level anyOf), and Contacts requested-radius // list descriptions; the missions still ride existing tools. - // GNSS and emergency/contact fields extend the manifest-generated contract. - assert.equal(block.length, 30208, 'serialized tool schema length drifted'); + // Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft. + assert.equal(block.length, 30511, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'f226ed9a1d1867b107b39073f527add7f9cb083ef013dbb2111842fcbd343c35', + '62d5df9cedf413403e4fd0a7cbe94bb2cd13736c3d8fedc84bcccd63ad253eb6', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8'); diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index c6a55f76c..9c206a335 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -171,6 +171,7 @@ export function createQueries({ // This poll's transponder broadcast (not sticky; see records.js). squawk: info?.squawk ?? null, emergency: info?.emergency ? { ...info.emergency } : null, + emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency), airline: info?.airline ?? null, // CLASS label follows the TR-3B conversion so every downstream card // (cockpit, Contacts, analyst) agrees with the triangle on screen. diff --git a/src/layers/flights/records.js b/src/layers/flights/records.js index 4b665d773..2b6f48dfb 100644 --- a/src/layers/flights/records.js +++ b/src/layers/flights/records.js @@ -230,7 +230,19 @@ export class FlightRecords { // Per poll, NOT sticky: an emergency code clears when the crew resets // it, so a blank squawk must not hold a stale 7700 on screen. squawk: observation.squawk ?? null, - emergency: aircraftEmergency(observation), + // One source-switch omission may retain an ADS-B status, while an + // explicit clear or changed squawk always takes effect immediately. + emergency: + observation.emergency === undefined && + observation.squawk === prevMeta?.squawk && + prevMeta?.emergency?.source === 'ads-b' && + (prevMeta?.missingEmergencyPolls ?? 0) < 1 + ? prevMeta.emergency + : aircraftEmergency(observation), + missingEmergencyPolls: + observation.emergency === undefined + ? (prevMeta?.missingEmergencyPolls ?? 0) + 1 + : 0, // OpenSky distinguishes the last position epoch from the last // transponder message. The fleet coast horizon uses this actual // contact time so a temporarily old position does not hard-freeze diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index 85146d832..bd63b600f 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -2,8 +2,6 @@ import * as Cesium from 'cesium'; import { GNSS_LEVEL_COLORS, GNSS_MIN_AIRCRAFT, - GNSS_NACP_THRESHOLD, - GNSS_NIC_THRESHOLD, GNSS_WINDOW_MS, accumulateGnssObservations, binGnssCells, @@ -13,12 +11,23 @@ import { export * from './records.js'; export { createAdsbGnssSource } from './source.js'; +/** + * GroundPrimitive allowPicking:false suppresses ids but still submits stencil + * commands in Cesium's pick pass, erasing picks of overlapping ground geometry. + * This fill participates only in rendering, so sibling polygons remain pickable. + */ +class GnssGroundFill extends Cesium.GroundPrimitive { + update(frameState) { + if (!frameState.passes.pick) super.update(frameState); + } +} + /** Low stays visible over land so coverage reads apart from no data. */ const FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 }); const LEVEL_LABELS = Object.freeze({ - low: 'Under 2% low accuracy', - medium: '2–10% low accuracy', - high: 'Over 10% low accuracy', + low: 'Low-accuracy score < 2%', + medium: 'Low-accuracy score 2–10%', + high: 'Low-accuracy score > 10%', }); /** Degrees at the centre of the current view, or null before the camera settles. */ @@ -34,7 +43,8 @@ export function defaultGnssViewAnchor(viewer) { new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2), ellipsoid, ); - const rectangle = viewer?.camera?.computeViewRectangle?.(ellipsoid); + const rectangle = + !centre && viewer?.camera?.computeViewRectangle?.(ellipsoid); const carto = centre ? (ellipsoid || Cesium.Ellipsoid.WGS84).cartesianToCartographic(centre) : rectangle @@ -50,20 +60,20 @@ export function defaultGnssViewAnchor(viewer) { /** * Own one GNSS navigation-integrity display: rolling integrity observations - * around the views the user visits, binned into cells coloured by the share - * of aircraft reporting low navigation accuracy. The layer id keeps its - * original `gnss-interference` spelling because share links depend on it. + * around the views the user visits, binned into cells coloured by the + * gpsjam low-accuracy score. Share links retain token 3. */ export function createGnssIntegrityLayer({ source, viewAnchor = defaultGnssViewAnchor, now = () => Date.now(), + monotonicNow = () => performance.now(), windowMs = GNSS_WINDOW_MS, } = {}) { if (typeof source?.getSnapshot !== 'function') throw new TypeError('GNSS integrity layer requires a snapshot source'); let _viewer = null; - let _dataSource = null; + let _primitives = null; let _request = null; let _enabled = false; let _cells = []; @@ -91,7 +101,7 @@ export function createGnssIntegrityLayer({ : null; function scheduleCameraRefresh() { - if (!_enabled || !_dataSource) return; + if (!_enabled || !_primitives) return; if (now() < _retryAt) return; const key = queryKey(viewAnchor(_viewer)); if (!key || key === _lastQueryKey) return; @@ -101,12 +111,12 @@ export function createGnssIntegrityLayer({ _cameraTimer = setTimeout( async () => { _cameraTimer = null; - if (!_enabled || _backgroundRefresh) return; + if (!_enabled || _backgroundRefresh || _request) return; const key = queryKey(viewAnchor(_viewer)); if (!key || key === _lastQueryKey) return; await refreshInBackground(); }, - Math.max(600, 2500 - (now() - _lastRequestAt)), + Math.max(600, 2500 - (monotonicNow() - _lastRequestAt)), ); } @@ -157,26 +167,40 @@ export function createGnssIntegrityLayer({ ); if (signature === _signature) return; _signature = signature; - _dataSource.entities.removeAll(); - for (const cell of _cells) { - const color = Cesium.Color.fromCssColorString( - GNSS_LEVEL_COLORS[cell.level], - ); - _dataSource.entities.add( - new Cesium.Entity({ - id: `gnss-interference:${cell.id}`, - rectangle: { - coordinates: Cesium.Rectangle.fromDegrees( - cell.west, - cell.south, - cell.east, - cell.north, - ), - material: new Cesium.ColorMaterialProperty( - color.withAlpha(FILL_ALPHA[cell.level]), - ), - classificationType: Cesium.ClassificationType.BOTH, - }, + _primitives.removeAll(); + if (_cells.length) { + _primitives.add( + new GnssGroundFill({ + geometryInstances: _cells.map( + (cell) => + new Cesium.GeometryInstance({ + id: `${layer.id}:${cell.id}`, + geometry: new Cesium.RectangleGeometry({ + rectangle: Cesium.Rectangle.fromDegrees( + cell.west, + cell.south, + cell.east, + cell.north, + ), + vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT, + }), + attributes: { + color: Cesium.ColorGeometryInstanceAttribute.fromColor( + Cesium.Color.fromCssColorString( + GNSS_LEVEL_COLORS[cell.level], + ).withAlpha(FILL_ALPHA[cell.level]), + ), + }, + }), + ), + appearance: new Cesium.PerInstanceColorAppearance({ + flat: true, + translucent: true, + }), + classificationType: Cesium.ClassificationType.BOTH, + allowPicking: false, + // Retain the compact rectangle descriptors for inspection, not Entity visualizers. + releaseGeometryInstances: false, }), ); } @@ -184,7 +208,7 @@ export function createGnssIntegrityLayer({ } const layer = { - id: 'gnss-interference', + id: 'gnss-integrity', name: 'GNSS Integrity', icon: '📡', source: 'adsb.lol', @@ -200,9 +224,10 @@ export function createGnssIntegrityLayer({ if (_viewer) throw new Error('GNSS integrity layer is already initialized'); _viewer = viewer; - _dataSource = new Cesium.CustomDataSource('gnss-interference'); - _dataSource.show = false; - viewer.dataSources.add(_dataSource); + _primitives = new Cesium.PrimitiveCollection(); + _primitives.id = layer.id; + _primitives.show = false; + viewer.scene.groundPrimitives.add(_primitives); _removeCameraEnd = viewer.camera?.moveEnd?.addEventListener( scheduleCameraRefresh, ); @@ -214,9 +239,9 @@ export function createGnssIntegrityLayer({ enable() { _enabled = true; // Cells left from before a long disable must not reappear past the window. - if (_dataSource) { + if (_primitives) { expire(); - _dataSource.show = true; + _primitives.show = true; } }, @@ -230,11 +255,11 @@ export function createGnssIntegrityLayer({ _request?.abort(); _request = null; _enabled = false; - if (_dataSource) _dataSource.show = false; + if (_primitives) _primitives.show = false; }, async update(_viewerArg, { signal } = {}) { - if (!_enabled || !_dataSource || signal?.aborted) return false; + if (!_enabled || !_primitives || signal?.aborted) return false; if (now() < _retryAt) { expire(); scheduleRetry(); @@ -257,7 +282,7 @@ export function createGnssIntegrityLayer({ const onAbort = () => request.abort(signal.reason); signal?.addEventListener('abort', onAbort, { once: true }); _lastQueryKey = queryKey(anchor); - _lastRequestAt = now(); + _lastRequestAt = monotonicNow(); _coverage = { kind: 'regional', latitude: Math.round(anchor.latitude), @@ -283,7 +308,7 @@ export function createGnssIntegrityLayer({ _cells = binGnssCells(_observations); render(); _stale = snapshot.stale === true; - _lastUpdate = now(); + _lastUpdate = at - snapshot.ageMs; _lastError = null; _retryAt = 0; _failureCount = 0; @@ -325,15 +350,12 @@ export function createGnssIntegrityLayer({ _removeCameraChanged?.(); _removeCameraEnd = null; _removeCameraChanged = null; - _request?.abort(); - _request = null; - _enabled = false; _observations.clear(); _cells = []; _signature = null; - if (_dataSource) { - viewer.dataSources.remove(_dataSource, true); - _dataSource = null; + if (_primitives) { + viewer.scene.groundPrimitives.remove(_primitives); + _primitives = null; } _viewer = null; _lastUpdate = null; @@ -350,7 +372,7 @@ export function createGnssIntegrityLayer({ return { chips: [], info: - 'Regional sample · 250 nm radius · pan to sample another area' + + 'Regional sample · 250 nm radius · cells from visited views over 30 min' + (_lastError && _retryAt ? ` · ${_lastError} · Retrying automatically${ _retryAt <= 8640000000000000 @@ -359,14 +381,14 @@ export function createGnssIntegrityLayer({ }` : ''), infoTitle: - 'Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells have insufficient observations.', + 'Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.', legend: ['high', 'medium', 'low'].map((level, index) => ({ label: LEVEL_LABELS[level], color: GNSS_LEVEL_COLORS[level], count: _cells.filter((cell) => cell.level === level).length, ...(index === 0 ? { - blurb: `Share of ADS-B aircraft reporting low navigation accuracy over the last ${provenance.window.minutes} minutes, around the views you visit. An aircraft counts when it reports NIC < ${GNSS_NIC_THRESHOLD} or NACp < ${GNSS_NACP_THRESHOLD} or GPS loss: a GEV threshold borrowed from US ADS-B Out minima, not a published interference test. Cells use gpsjam.org's formula and 2% / 10% bands. Cells with fewer than ${GNSS_MIN_AIRCRAFT} aircraft are not drawn, so blank areas mean no data, not clean GNSS. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is one possible cause, not a detection: avionics faults also report low values.`, + blurb: `Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need ${GNSS_MIN_AIRCRAFT} aircraft over ${provenance.window.minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.`, } : {}), })), @@ -376,6 +398,12 @@ export function createGnssIntegrityLayer({ getStats() { return { count: _cells.length, + levels: Object.fromEntries( + ['low', 'medium', 'high'].map((level) => [ + level, + _cells.filter((cell) => cell.level === level).length, + ]), + ), lastUpdate: _lastUpdate, error: _lastError, retryInSec: _retryAt diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index a26afa1e6..713e44889 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -26,12 +26,16 @@ function harness( const sources = []; const viewer = { camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() }, - dataSources: { - add(value) { - sources.push(value); - }, - remove(value) { - sources.splice(sources.indexOf(value), 1); + scene: { + globe: { show: true }, + groundPrimitives: { + add(value) { + sources.push(value); + }, + remove(value) { + sources.splice(sources.indexOf(value), 1); + value.destroy(); + }, }, }, }; @@ -50,6 +54,7 @@ function harness( return anchor; }, now: () => clock, + monotonicNow: () => clock, }); if (initialize) layer.init(viewer); return { @@ -87,7 +92,9 @@ test('a globe-wide rectangle uses the visible screen centre as the query anchor' assert.deepEqual(pixel, new Cesium.Cartesian2(500, 400)); return Cesium.Cartesian3.fromDegrees(-97, 30); }, - computeViewRectangle: () => Cesium.Rectangle.MAX_VALUE, + computeViewRectangle: () => { + throw new Error('centre hit must not compute a rectangle'); + }, }, }); assert.ok(Math.abs(anchor.latitude - 30) < 1e-8); @@ -208,10 +215,11 @@ test('an enabled refresh bins the snapshot into coloured ground cells', async () layer.enable(); assert.equal(sources[0].show, true); assert.equal(await layer.update(), true); - const entities = sources[0].entities.values; + const entities = sources[0].get(0).geometryInstances; assert.equal(entities.length, 1, 'the one-aircraft cell is withheld'); - assert.equal(entities[0].id, 'gnss-interference:280:420'); - assert.ok(entities[0].rectangle); + assert.equal(entities[0].id, 'gnss-integrity:280:420'); + assert.ok(entities[0].geometry instanceof Cesium.RectangleGeometry); + assert.equal(sources[0].get(0).allowPicking, false); const stats = layer.getStats(); assert.equal(stats.count, 1); assert.equal(stats.error, null); @@ -222,35 +230,32 @@ test('an enabled refresh bins the snapshot into coloured ground cells', async () [1, 0, 0], ); assert.match(legend[0].blurb, /not a detection/); - assert.match(legend[0].blurb, /fewer than 3 aircraft are not drawn/); + assert.match(legend[0].blurb, /need 3 aircraft/); }); test('the visible layer leads with low navigation accuracy and exposes its provenance', async () => { const { layer } = harness(async () => ({ rows: [], stale: false })); - assert.equal(layer.id, 'gnss-interference', 'the share-link id is unchanged'); + assert.equal( + layer.id, + 'gnss-integrity', + 'the unpublished id describes the measured state', + ); assert.equal(layer.name, 'GNSS Integrity'); assert.doesNotMatch(layer.name, /interference|jamming/i); const { legend } = layer.getRowControls(); assert.deepEqual( legend.map(({ label }) => label), - ['Over 10% low accuracy', '2–10% low accuracy', 'Under 2% low accuracy'], + [ + 'Low-accuracy score > 10%', + 'Low-accuracy score 2–10%', + 'Low-accuracy score < 2%', + ], ); const { blurb } = legend[0]; - assert.match( - blurb, - /^Share of ADS-B aircraft reporting low navigation accuracy/, - ); - assert.match(blurb, /NIC < 7 or NACp < 8/); - assert.match(blurb, /GEV threshold/); - assert.match(blurb, /not a published interference test/); - assert.match(blurb, /gpsjam\.org's formula and 2% \/ 10% bands/); - assert.match(blurb, /navigation-integrity anomalies/); - // Interference appears only as the secondary, hedged reading. - assert.ok( - blurb.indexOf('navigation-integrity anomalies') < - blurb.indexOf('Suspected jamming or spoofing'), - ); - assert.equal(layer.provenance.classifier.id, 'gev-nic-nacp-v1'); + assert.match(blurb, /one aircraft is discounted/); + assert.match(blurb, /not the raw share/); + assert.match(blurb, /not a detection/); + assert.equal(layer.provenance.classifier.id, 'gev-nic-nacp-v2'); assert.equal(layer.provenance.classifier.definedBy, 'GEV'); assert.equal(layer.provenance.classifier.validated, false); assert.equal(layer.provenance.aggregation.source, 'https://gpsjam.org/faq'); @@ -283,7 +288,7 @@ test('a failed refresh keeps the last cells and reports the error', async () => assert.equal(layer.getStats().stale, true); fail = true; assert.equal(await layer.update(), true); - assert.equal(sources[0].entities.values.length, 1); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1); assert.equal(layer.getStats().error, 'adsb.lol HTTP 502'); assert.equal(layer.getStats().count, 1); }); @@ -310,7 +315,7 @@ test('observations expire with the window, and disable aborts the request in fli pending.resolve({ rows: [] }); assert.equal(await result, true); assert.equal(layer.getStats().count, 0); - assert.equal(sources[0].entities.values.length, 0); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); result = layer.update(); layer.disable(); @@ -364,7 +369,7 @@ test('a replayed stale snapshot keeps its observation age and expires on time', 0, 'the replay did not restart the window', ); - assert.equal(sources[0].entities.values.length, 0); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); }); test('cells expire during prolonged fetch failures', async () => { @@ -382,7 +387,7 @@ test('cells expire during prolonged fetch failures', async () => { advance(11 * MIN); assert.equal(await layer.update(), true); assert.equal(layer.getStats().count, 0); - assert.equal(sources[0].entities.values.length, 0); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); assert.equal(layer.getStats().error, 'adsb.lol HTTP 502'); }); @@ -402,7 +407,7 @@ test('cells expire while no view anchor is available', async () => { assert.equal(await layer.update(), true); assert.equal(calls, 1, 'no anchor means no request'); assert.equal(layer.getStats().count, 0); - assert.equal(sources[0].entities.values.length, 0); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); assert.equal(layer.getStats().error, null); }); @@ -412,12 +417,12 @@ test('re-enabling after a long disable does not show cells past the window', asy })); layer.enable(); await layer.update(); - assert.equal(sources[0].entities.values.length, 1); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1); layer.disable(); advance(31 * MIN); layer.enable(); assert.equal(layer.getStats().count, 0); - assert.equal(sources[0].entities.values.length, 0); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); }); const threeCells = () => ({ @@ -428,10 +433,10 @@ test('an identical snapshot leaves the drawn entities untouched', async () => { const { layer, sources } = harness(async () => threeCells()); layer.enable(); await layer.update(); - const [first] = sources[0].entities.values; + const first = sources[0].get(0); await layer.update(); - assert.equal(sources[0].entities.values.length, 1); - assert.equal(sources[0].entities.values[0], first); + assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1); + assert.equal(sources[0].get(0), first); }); test('an initial feed failure stays enabled, reports unavailable, and recovers automatically', async (t) => { @@ -555,3 +560,141 @@ test('repeated acquisition failures back off, while genuine non-retryable failur assert.equal(fatal.layer.getStats().retryInSec, null); fatal.layer.destroy(); }); + +test('GERMAN: stale snapshot keeps the source observation age in row stats', async () => { + const h = harness(async () => ({ + rows: threeHealthy(), + ageMs: 120000, + stale: true, + })); + h.layer.enable(); + await h.layer.update(); + assert.equal(h.layer.getStats().lastUpdate, h.now() - 120000); + h.layer.destroy(); +}); + +test('GERMAN: camera debounce and minimum request gap are pinned', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let calls = 0; + const h = harness(async () => { + calls++; + return { rows: [] }; + }); + h.layer.enable(); + await h.layer.update(); + h.setAnchor({ latitude: 51, longitude: 31 }); + h.viewer.camera.changed.raiseEvent(); + h.advance(2499); + t.mock.timers.tick(2499); + await flush(); + assert.equal(calls, 1); + h.advance(1); + t.mock.timers.tick(1); + await flush(); + assert.equal(calls, 2); + h.advance(3000); + h.setAnchor({ latitude: 52, longitude: 32 }); + h.viewer.camera.changed.raiseEvent(); + h.advance(599); + t.mock.timers.tick(599); + await flush(); + assert.equal(calls, 2); + h.advance(1); + t.mock.timers.tick(1); + await flush(); + assert.equal(calls, 3); + h.layer.destroy(); +}); + +test('camera movement during initial activation waits for acquisition before following up', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const calls = []; + let resolve; + const h = harness((anchor) => { + calls.push(anchor); + return calls.length === 1 + ? new Promise((r) => (resolve = r)) + : Promise.resolve({ rows: threeHealthy() }); + }); + h.layer.enable(); + const initial = h.layer.update(); + h.setAnchor({ latitude: 51, longitude: 31 }); + h.viewer.camera.changed.raiseEvent(); + h.advance(2500); + t.mock.timers.tick(2500); + await flush(); + assert.equal(calls.length, 1); + resolve({ rows: threeHealthy() }); + assert.equal(await initial, true); + h.advance(600); + t.mock.timers.tick(600); + await flush(); + assert.equal(calls.length, 2); + h.layer.destroy(); +}); + +test('monotonic camera scheduling is unaffected by wall-clock rollback', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(async () => ({ rows: [] })); + h.layer.destroy(); + let wall = 1800000000000, + mono = 0, + anchor = { latitude: 50, longitude: 30 }, + calls = 0; + const layer = createGnssIntegrityLayer({ + source: { + getSnapshot: async () => { + calls++; + return { rows: [], ageMs: 0 }; + }, + }, + viewAnchor: () => anchor, + now: () => wall, + monotonicNow: () => mono, + }); + layer.init(h.viewer); + layer.enable(); + await layer.update(); + wall -= 3600000; + mono = 3000; + anchor = { latitude: 51, longitude: 31 }; + h.viewer.camera.changed.raiseEvent(); + mono += 600; + t.mock.timers.tick(600); + await flush(); + assert.equal(calls, 2); + layer.destroy(); +}); + +test('GNSS fill emits no pick-pass commands but keeps normal rendering', async (t) => { + const h = harness(async () => ({ rows: threeHealthy() })); + h.layer.enable(); + await h.layer.update(); + const fill = h.sources[0].get(0); + let updates = 0; + t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++); + fill.update({ passes: { pick: true } }); + assert.equal(updates, 0); + fill.update({ passes: { render: true, pick: false } }); + assert.equal(updates, 1); + h.layer.destroy(); + assert.equal(fill.isDestroyed(), true); +}); + +test('view anchor falls back to rectangle, then nadir, then no camera', () => { + assert.equal(defaultGnssViewAnchor({}), null); + const viewer = { + camera: { + computeViewRectangle: () => Cesium.Rectangle.fromDegrees(20, 40, 22, 42), + }, + }; + const rectangleAnchor = defaultGnssViewAnchor(viewer); + assert.ok(Math.abs(rectangleAnchor.latitude - 41) < 1e-10); + assert.ok(Math.abs(rectangleAnchor.longitude - 21) < 1e-10); + viewer.camera = { + positionCartographic: Cesium.Cartographic.fromDegrees(21, 41), + }; + const nadirAnchor = defaultGnssViewAnchor(viewer); + assert.ok(Math.abs(nadirAnchor.latitude - 41) < 1e-10); + assert.ok(Math.abs(nadirAnchor.longitude - 21) < 1e-10); +}); diff --git a/src/layers/gnss/records.js b/src/layers/gnss/records.js index b76f10e5a..ec425d5ee 100644 --- a/src/layers/gnss/records.js +++ b/src/layers/gnss/records.js @@ -11,7 +11,8 @@ * state. * * Three separately sourced parts make up the method (see `gnssProvenance`): - * - Per-aircraft classifier, defined by GEV: NIC < 7 or NACp < 8 (borrowed + * - Per-aircraft classifier, defined by GEV: aircraft-reported NIC < 7 + * (except unknown NIC 0 with NACp >= 10) or NACp < 8 (borrowed * from the US 14 CFR 91.227(c) ADS-B Out performance minima, which are not * a published interference classifier) or the readsb/tar1090 `gpsOkBefore` * flag. gpsjam.org does not publish its per-aircraft threshold. @@ -19,9 +20,9 @@ * - Window: a GEV-defined rolling 30 minutes over the views the user visits. */ -/** Direct 1090ES ADS-B only: MLAT, TIS-B and ADS-R positions are not self-reported. */ -const QUALIFYING_TYPES = new Set(['adsb_icao']); -/** DO-260A (1) and DO-260B (2) define NIC and NACp; version 0 and unknown versions do not. */ +/** Position source can switch to MLAT after GPS loss; quality must remain aircraft-reported. */ +const QUALIFYING_TYPES = new Set(['adsb_icao', 'mlat']); +/** Restrict to decoded DO-260A/B quality fields; readsb does not decode v3 quality as v3. */ const QUALIFYING_VERSIONS = new Set([1, 2]); /** NIC below 7: containment radius of 0.2 NM or more (below the US 91.227 ADS-B Out minimum). GEV threshold. */ export const GNSS_NIC_THRESHOLD = 7; @@ -31,7 +32,7 @@ export const GNSS_NACP_THRESHOLD = 8; const MAX_POSITION_AGE_S = 60; /** Cell edge in degrees: roughly 55 km north-south, similar in area to an H3 res-4 cell. */ export const GNSS_CELL_DEG = 0.5; -/** Fewer aircraft than this in a cell cannot distinguish one faulty transponder from interference. */ +/** Hide sparse one- and two-aircraft cells; the aggregation separately discounts one bad report. */ export const GNSS_MIN_AIRCRAFT = 3; /** Rolling window over which aircraft seen in a cell are counted. */ export const GNSS_WINDOW_MS = 30 * 60_000; @@ -47,8 +48,8 @@ export const GNSS_INTERPRETATION = 'suspected-gnss-interference'; /** The per-aircraft rule; GEV-defined and not validated against known events. */ export const GNSS_CLASSIFIER = Object.freeze({ - id: 'gev-nic-nacp-v1', - rule: `gpsOkBefore set || NIC < ${GNSS_NIC_THRESHOLD} || NACp < ${GNSS_NACP_THRESHOLD}`, + id: 'gev-nic-nacp-v2', + rule: `gpsOkBefore set || aircraft-reported NACp < ${GNSS_NACP_THRESHOLD} || (aircraft-reported NIC < ${GNSS_NIC_THRESHOLD} && !(NIC === 0 && NACp >= 10))`, basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)', definedBy: 'GEV', validated: false, @@ -66,10 +67,7 @@ export function gnssProvenance({ windowMs = GNSS_WINDOW_MS } = {}) { aggregation: Object.freeze({ id: 'gpsjam-cell-bands', formula: '100 * max(0, bad - 1) / total', - bands: Object.freeze([ - GNSS_MEDIUM_PERCENT / 100, - GNSS_HIGH_PERCENT / 100, - ]), + bands: Object.freeze([GNSS_MEDIUM_PERCENT, GNSS_HIGH_PERCENT]), source: 'https://gpsjam.org/faq', definedBy: 'gpsjam.org', }), @@ -104,7 +102,7 @@ const finite = (value) => typeof value === 'number' && Number.isFinite(value); * facts the layer needs. Returns null when the payload has no aircraft array. * * @param {{ac?: object[], now?: number}} payload - readsb-style snapshot. - * @returns {{hex: string, lat: number, lon: number, nic: number, nacp: number, gpsLost: boolean, degraded: boolean}[]|null} + * @returns {{hex: string, lat: number, lon: number, nic: number|null, nacp: number|null, gpsLost: boolean, degraded: boolean}[]|null} */ export function normalizeGnssAircraft(payload) { if (!Array.isArray(payload?.ac)) return null; @@ -117,18 +115,33 @@ export function normalizeGnssAircraft(payload) { if (!QUALIFYING_TYPES.has(aircraft.type)) continue; if (!QUALIFYING_VERSIONS.has(aircraft.version)) continue; if (aircraft.alt_baro === 'ground' || !finite(aircraft.alt_baro)) continue; - const { lat, lon, nic, nac_p: nacp } = aircraft; + let { lat, lon } = aircraft; + const derived = (field) => + aircraft.tisb?.includes(field) || aircraft.mlat?.includes(field); + const nic = finite(aircraft.nic) && !derived('nic') ? aircraft.nic : null; + const nacp = + finite(aircraft.nac_p) && !derived('nac_p') ? aircraft.nac_p : null; if (!finite(lat) || Math.abs(lat) > 90) continue; if (!finite(lon) || Math.abs(lon) > 180) continue; - if (!finite(nic) || !finite(nacp)) continue; // Only a position confirmed within the last minute is current evidence; a // missing, invalid or negative age confirms nothing. const age = aircraft.seen_pos; if (!finite(age) || age < 0 || age > MAX_POSITION_AGE_S) continue; - // readsb sets gpsOkBefore when an aircraft's integrity collapses and keeps - // it for roughly 15 minutes after GPS recovers, so it can outlast the - // current NIC/NACp; the rolling window treats that as recent evidence. - const gpsLost = aircraft.gpsOkBefore != null; + // readsb exposes the flag while GPS is lost (15 s–15 min after the + // last good position), and removes it on recovery. Locate that evidence + // at the last good GNSS position, rather than a later MLAT estimate. + const gpsLost = finite(aircraft.gpsOkBefore) && aircraft.gpsOkBefore > 0; + if (nic === null && nacp === null && !gpsLost) continue; + if ( + gpsLost && + finite(aircraft.gpsOkLat) && + Math.abs(aircraft.gpsOkLat) <= 90 && + finite(aircraft.gpsOkLon) && + Math.abs(aircraft.gpsOkLon) <= 180 + ) { + lat = aircraft.gpsOkLat; + lon = aircraft.gpsOkLon; + } seen.add(hex); rows.push({ hex, @@ -138,7 +151,11 @@ export function normalizeGnssAircraft(payload) { nacp, gpsLost, degraded: - gpsLost || nic < GNSS_NIC_THRESHOLD || nacp < GNSS_NACP_THRESHOLD, + gpsLost || + (nacp !== null && nacp < GNSS_NACP_THRESHOLD) || + (nic !== null && + nic < GNSS_NIC_THRESHOLD && + !(nic === 0 && nacp !== null && nacp >= 10)), }); } return rows; diff --git a/src/layers/gnss/records.test.mjs b/src/layers/gnss/records.test.mjs index 593f3e6f3..2f1493544 100644 --- a/src/layers/gnss/records.test.mjs +++ b/src/layers/gnss/records.test.mjs @@ -26,16 +26,21 @@ const aircraft = (overrides = {}) => ({ ...overrides, }); -test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', () => { +test('normalization keeps airborne decoded ADS-B v1/v2 quality with current positions', () => { const rows = normalizeGnssAircraft({ ac: [ aircraft(), - aircraft({ hex: '000001', type: 'mlat', nic: 0 }), + aircraft({ + hex: '000001', + type: 'mlat', + nic: undefined, + nac_p: undefined, + }), aircraft({ hex: '000002', type: 'tisb_icao' }), aircraft({ hex: '000003', version: 0 }), aircraft({ hex: '000004', alt_baro: 'ground' }), aircraft({ hex: '000005', seen_pos: 120 }), - aircraft({ hex: '000006', nic: undefined }), + aircraft({ hex: '000006', nic: undefined, nac_p: undefined }), aircraft({ hex: '000007', lat: 95 }), aircraft({ hex: '000008', version: undefined }), // Only DO-260A/B (versions 1 and 2) define NIC and NACp. @@ -166,8 +171,8 @@ test('the primary state is a navigation-integrity anomaly; interference is only test('provenance separates the GEV classifier from the gpsjam aggregation and the GEV window', () => { assert.deepEqual(GNSS_PROVENANCE.classifier, { - id: 'gev-nic-nacp-v1', - rule: 'gpsOkBefore set || NIC < 7 || NACp < 8', + id: 'gev-nic-nacp-v2', + rule: 'gpsOkBefore set || aircraft-reported NACp < 8 || (aircraft-reported NIC < 7 && !(NIC === 0 && NACp >= 10))', basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)', definedBy: 'GEV', validated: false, @@ -176,7 +181,7 @@ test('provenance separates the GEV classifier from the gpsjam aggregation and th assert.deepEqual(GNSS_PROVENANCE.aggregation, { id: 'gpsjam-cell-bands', formula: '100 * max(0, bad - 1) / total', - bands: [0.02, 0.1], + bands: [2, 10], source: 'https://gpsjam.org/faq', definedBy: 'gpsjam.org', }); @@ -197,9 +202,9 @@ test('provenance separates the GEV classifier from the gpsjam aggregation and th assert.ok(Object.isFrozen(GNSS_PROVENANCE.aggregation.bands)); // The bands in provenance are the ones the level function applies. const [medium, high] = GNSS_PROVENANCE.aggregation.bands; - assert.equal(gnssIntegrityLevel(100, 1 + 100 * medium).level, 'medium'); - assert.equal(gnssIntegrityLevel(100, 1 + 100 * high).level, 'medium'); - assert.equal(gnssIntegrityLevel(100, 2 + 100 * high).level, 'high'); + assert.equal(gnssIntegrityLevel(100, 1 + medium).level, 'medium'); + assert.equal(gnssIntegrityLevel(100, 1 + high).level, 'medium'); + assert.equal(gnssIntegrityLevel(100, 2 + high).level, 'high'); assert.equal(gnssProvenance({ windowMs: 10 * 60_000 }).window.minutes, 10); }); @@ -406,3 +411,81 @@ test('cells below the minimum aircraft count are withheld', () => { assert.equal(cell.level, 'high'); assert.equal(cell.percentDegraded, 50); }); + +test('MLAT GPS-loss evidence uses the last good GNSS location and ignores MLAT NIC', () => { + const [row] = normalizeGnssAircraft({ + ac: [ + aircraft({ + type: 'mlat', + nac_p: 0, + nic: 0, + mlat: ['lat', 'lon', 'nic'], + gpsOkBefore: 1800000000, + gpsOkLat: 54.2, + gpsOkLon: 19.3, + }), + ], + }); + assert.ok(row); + assert.equal(row.lat, 54.2); + assert.equal(row.lon, 19.3); + assert.equal(row.nic, null); + assert.equal(row.degraded, true); + const [healthy] = normalizeGnssAircraft({ + ac: [aircraft({ type: 'mlat', nic: 0, mlat: ['nic'] })], + }); + assert.equal(healthy.degraded, false); +}); + +test('TIS-B accuracy fields and unknown containment do not manufacture anomalies', () => { + const rows = normalizeGnssAircraft({ + ac: [ + aircraft({ hex: 'a00001', nac_p: 0, tisb: ['nac_p'] }), + aircraft({ hex: 'a00002', nic: 0, tisb: ['nic'] }), + aircraft({ hex: 'a00003', nic: 0, nac_p: 10 }), + aircraft({ hex: 'a00004', nic: 0, nac_p: 8 }), + aircraft({ hex: 'a00005', nic: 6, nac_p: 10 }), + ], + }); + assert.deepEqual( + rows.map((r) => r.degraded), + [false, false, false, true, true], + ); +}); + +// Quality-field subsets captured from public adsb.lol US point responses on +// 2026-10-14 (34,-118 and 41,-73, radius 250 nm), ODbL 1.0 / adsb.lol contributors. +// Only field provenance is reused; the airborne ADS-B envelopes are synthetic. +test('public US quality-field fixtures do not turn rebroadcast accuracy into own accuracy', () => { + const tisbQuality = { + nic: 0, + nac_p: 0, + tisb: [ + 'altitude', + 'alt_baro', + 'gs', + 'track', + 'geom_rate', + 'lat', + 'lon', + 'nic', + 'rc', + 'nac_p', + 'nac_v', + 'sil', + 'sil_type', + ], + mlat: [], + }; + assert.deepEqual(normalizeGnssAircraft({ ac: [aircraft(tisbQuality)] }), []); + const unknownContainment = { + nic: 0, + nac_p: 10, + tisb: ['callsign'], + mlat: [], + }; + assert.equal( + normalizeGnssAircraft({ ac: [aircraft(unknownContainment)] })[0].degraded, + false, + ); +}); diff --git a/src/layers/gnss/source.js b/src/layers/gnss/source.js index bfe7eaf17..af67aa18b 100644 --- a/src/layers/gnss/source.js +++ b/src/layers/gnss/source.js @@ -4,9 +4,12 @@ function retryDelay(raw, nowMs) { if (!raw) return null; const seconds = Number(raw); const ms = seconds * 1000; - if (Number.isFinite(ms) && seconds > 0) return Math.max(5000, ms); + if (Number.isFinite(ms) && seconds > 0) + return Math.min(3600000, Math.max(5000, ms)); const at = Date.parse(raw); - return Number.isFinite(at) && at > nowMs ? Math.max(5000, at - nowMs) : null; + return Number.isFinite(at) && at > nowMs + ? Math.min(3600000, Math.max(5000, at - nowMs)) + : null; } /** Request integrity facts around a view anchor through the same-origin adsb.lol proxy. */ @@ -20,8 +23,8 @@ export function createAdsbGnssSource({ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error('GNSS snapshot needs a view anchor'); const params = new URLSearchParams({ - lat: latitude.toFixed(2), - lon: longitude.toFixed(2), + lat: String(Math.round(latitude)), + lon: String(Math.round(longitude)), }); const response = await fetchImpl(`/api/gnss-integrity?${params}`, { signal, @@ -35,8 +38,8 @@ export function createAdsbGnssSource({ throw Object.assign( new Error( limited - ? 'adsb.lol rate limited (HTTP 429)' - : `adsb.lol feed unavailable (HTTP ${response.status})`, + ? 'GNSS feed rate limited (HTTP 429)' + : `GNSS feed unavailable (HTTP ${response.status})`, ), { status: response.status, diff --git a/src/layers/gnss/source.test.mjs b/src/layers/gnss/source.test.mjs index aba3f3e9a..3d27d15c6 100644 --- a/src/layers/gnss/source.test.mjs +++ b/src/layers/gnss/source.test.mjs @@ -11,7 +11,7 @@ test('the source asks the same-origin proxy for the view anchor', async () => { return Response.json({ fetchedAt: 5, ageMs: 0, - classifier: 'gev-nic-nacp-v1', + classifier: 'gev-nic-nacp-v2', rows: [{ hex: 'abc123' }], }); }, @@ -20,13 +20,13 @@ test('the source asks the same-origin proxy for the view anchor', async () => { { latitude: 50.06143, longitude: 19.93658 }, { signal: new AbortController().signal }, ); - assert.deepEqual(calls, ['/api/gnss-integrity?lat=50.06&lon=19.94']); + assert.deepEqual(calls, ['/api/gnss-integrity?lat=50&lon=20']); assert.deepEqual(snapshot, { rows: [{ hex: 'abc123' }], ageMs: 0, fetchedAt: 5, stale: false, - classifier: 'gev-nic-nacp-v1', + classifier: 'gev-nic-nacp-v2', }); }); diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index b3d1b8cf3..ee11f811f 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -137,6 +137,7 @@ export function createQueries({ // This poll's transponder broadcast (not sticky; see records.js). squawk: info?.squawk ?? null, emergency: info?.emergency ? { ...info.emergency } : null, + emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency), }; } diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index b9e54c173..6d619bf72 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { turnRateFromFixHistory, @@ -120,6 +121,11 @@ export function createMilitarySnapshotRenderer({ if (flightState._billboards.has(icao24)) { const bb = flightState._billboards.get(icao24); + if (!isTracked) { + bb.color = rendering + ._fleetBillboardColor(icao24, meta) + .withAlpha(cyberSonarBaseAlpha(bb)); + } // Position AND rotation are owned by the fleet pass (_fleetTick); // course changes land on the next rotation pass (forced below). // Ground flips (landing/takeoff) restyle this SAME billboard in diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js index faa77d08a..ebc27f559 100644 --- a/src/sources/live/aircraft.js +++ b/src/sources/live/aircraft.js @@ -1,3 +1,4 @@ +import { normalizeSquawk } from '../../data/aircraftEmergency.js'; import { admitRecords, cleanText, @@ -30,9 +31,9 @@ export function normalizeOpenSkyAircraft(row) { typeCode: null, registration: null, operator: null, - squawk: cleanText(row[14]) || null, + squawk: normalizeSquawk(row[14]), // State vectors carry no ADS-B emergency field; the squawk stands alone. - emergency: null, + emergency: undefined, }; } @@ -71,7 +72,7 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) { typeCode: cleanText(row.t), registration: cleanText(row.r), operator: cleanText(row.ownOp), - squawk: cleanText(row.squawk) || null, + squawk: normalizeSquawk(row.squawk), emergency: cleanText(row.emergency).toLowerCase() || null, }; } diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index 36fa9b95b..1bba6e28f 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -314,11 +314,11 @@ test('identity lookup validates its response and honors body-parse cancellation' }); }); -test('transponder squawk and ADS-B emergency status are carried, blanks stay null', () => { +test('squawks are validated and omitted OpenSky status differs from an explicit ADS-B clear', () => { const squawking = [...aircraft, '7700']; const civil = openSkySnapshot({ states: [squawking] }, { now }).records[0]; assert.equal(civil.squawk, '7700'); - assert.equal(civil.emergency, null); + assert.equal(civil.emergency, undefined); assert.equal(openSkySnapshot({ states: [aircraft] }).records[0].squawk, null); const mil = normalizeReadsbAircraft( { hex: 'abc123', lat: 30, lon: -97, squawk: '7600', emergency: 'NORDO' }, diff --git a/src/sources/reference.js b/src/sources/reference.js index 40870c1f7..8631d3ad2 100644 --- a/src/sources/reference.js +++ b/src/sources/reference.js @@ -9,6 +9,6 @@ export function createReferenceSources() { earthquakes: createUsgsEarthquakeSource(), 'fire-perimeters': createWfigsPerimeterSource(), cables: createBundledCableSource(), - 'gnss-interference': createAdsbGnssSource(), + 'gnss-integrity': createAdsbGnssSource(), }; } diff --git a/src/sources/reference.test.mjs b/src/sources/reference.test.mjs index fea1cb4f2..9f9cc9d92 100644 --- a/src/sources/reference.test.mjs +++ b/src/sources/reference.test.mjs @@ -16,15 +16,15 @@ test('reference factories retain compatibility without starting acquisition or s 'earthquakes', 'fire-perimeters', 'cables', - 'gnss-interference', + 'gnss-integrity', ]); assert.notEqual(first.earthquakes, second.earthquakes); assert.notEqual(first['fire-perimeters'], second['fire-perimeters']); assert.notEqual(first.cables, second.cables); - assert.notEqual(first['gnss-interference'], second['gnss-interference']); + assert.notEqual(first['gnss-integrity'], second['gnss-integrity']); assert.equal(typeof first.earthquakes.getSnapshot, 'function'); assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function'); assert.equal(typeof first.cables.fetch, 'function'); - assert.equal(typeof first['gnss-interference'].getSnapshot, 'function'); + assert.equal(typeof first['gnss-integrity'].getSnapshot, 'function'); assert.equal(requests, 0); }); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index c91771dca..6fe0fe8d5 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -54,7 +54,7 @@ const PANEL_GROUPS = [ 'earthquakes', 'local-firms', 'fire-perimeters', - 'gnss-interference', + 'gnss-integrity', ], }, { diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 4a8f130a6..c57b4a1ab 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -36,8 +36,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // consolidated tool wording (each policy stated once); model-facing tools // omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves // requested radius and center authority in descriptions only. - // GNSS and emergency/contact fields extend the manifest-generated contract. - '3f18ec8718981cfa42e1462a0f1a9f0102cd90f1f86e0cc25a9c0d0f3b291a53', + // Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts. + '9398815a573ab29ecc3ce527f6e1a2ad883804e7696e0444aebb11f973700d4b', ); }); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 55daa80e9..32b9ac8b2 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3271,18 +3271,18 @@ test('GNSS integrity and interference common names toggle only the GNSS layer th const calls = []; let enabled = false; const dataManager = { - layers: new Map([['gnss-interference', { module: {} }]]), - getAll: () => [{ id: 'gnss-interference', name: 'GNSS Integrity' }], + layers: new Map([['gnss-integrity', { module: {} }]]), + getAll: () => [{ id: 'gnss-integrity', name: 'GNSS Integrity' }], isEnabled: () => enabled, setEnabled: async (id, value) => { calls.push([id, value]); enabled = value; return true; }, }; const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager }); - for (const alias of ['gnss-interference', 'gnss integrity', 'navigation integrity', 'low navigation accuracy', 'gnss interference', 'gps interference', 'gps jamming', 'gnss']) { + for (const alias of ['gnss-integrity', 'gnss integrity', 'navigation integrity', 'low navigation accuracy', 'gnss interference', 'gps interference', 'gps jamming', 'gnss']) { for (const value of [true, false]) { const result = await runner('set_layer_visibility', { layerId: alias, enabled: value }); assert.equal(result.ok, true); - assert.equal(result.layerId, 'gnss-interference'); - assert.deepEqual(calls.at(-1), ['gnss-interference', value]); + assert.equal(result.layerId, 'gnss-integrity'); + assert.deepEqual(calls.at(-1), ['gnss-integrity', value]); } } }); diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index c5b84f3dd..286b8a5d8 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -44,7 +44,7 @@ const AIRCRAFT_FIELDS = { const layers = [ { - id: 'gnss-interference', + id: 'gnss-integrity', aliases: [ 'gnss integrity', 'navigation integrity', From 6c362bb0bd427797c9444201509aa79b6cc5de69 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Wed, 14 Oct 2026 17:53:19 +0200 Subject: [PATCH 39/86] test: verify GNSS map clicks with production canvas pixels --- scripts/qa-gnss.mjs | 50 +++++++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index 629316ce8..41933141e 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -115,9 +115,9 @@ async function boot(page, { lon, lat, height }) { } /** Enable (or refresh) the layer and poll until it settles. */ -async function enableAndSettle(page, { timeoutS = 30, refresh = false } = {}) { +async function enableAndSettle(page, { refresh = false } = {}) { return page.evaluate( - async (id, tS, again) => { + async (id, again) => { const dm = window.__godsEyeView.dataManager; const layer = dm.layers.get(id).module; if (again) await dm.refreshLayer(id); @@ -144,7 +144,6 @@ async function enableAndSettle(page, { timeoutS = 30, refresh = false } = {}) { }; }, LAYER_ID, - timeoutS, refresh, ); } @@ -162,6 +161,18 @@ async function showLayerRow(page) { await sleep(800); } +/** Actual card pixels on the shared canvas, available in dev and production. */ +function paintedOverlayPixels() { + const canvas = document.getElementById('world-overlay-canvas'); + if (!canvas) return -1; + const pixels = canvas + .getContext('2d') + .getImageData(0, 0, canvas.width, canvas.height).data; + let count = 0; + for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++; + return count; +} + async function shoot(page, name) { await page .waitForFunction( @@ -576,28 +587,31 @@ async function main() { 'PICK: street ground remains a surface pick under GNSS', pickProbe.surface, ); + const beforeCardPixels = await page.evaluate(paintedOverlayPixels); await page.mouse.click(pickProbe.centre.x, pickProbe.centre.y); await page.waitForFunction( - () => - (window.__gevWorldOverlay.getDiagnostics().paintedBySource[ - 'fire-perimeters' - ] ?? 0) > 0, - { timeout: 10000 }, + `(${paintedOverlayPixels})() > ${beforeCardPixels + 100}`, + { timeout: 10000, polling: 250 }, + ); + const selectedCardPixels = await page.evaluate(paintedOverlayPixels); + record( + 'PICK: actual perimeter click paints its incident card', + selectedCardPixels > beforeCardPixels + 100, + `${beforeCardPixels} → ${selectedCardPixels} pixels`, ); - record('PICK: actual perimeter click opens its incident card', true); await page.screenshot({ path: path.join(SHOTS_DIR, 'gnss-perimeter-selected.png'), }); await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y); - await sleep(500); + await page.waitForFunction( + `(${paintedOverlayPixels})() <= ${beforeCardPixels}`, + { timeout: 10000, polling: 250 }, + ); + const clearedCardPixels = await page.evaluate(paintedOverlayPixels); record( 'PICK: empty ground clears the perimeter card', - await page.evaluate( - () => - (window.__gevWorldOverlay.getDiagnostics().entriesBySource[ - 'fire-perimeters' - ] ?? 0) === 0, - ), + clearedCardPixels <= beforeCardPixels, + `${selectedCardPixels} → ${clearedCardPixels} pixels`, ); await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y, { clickCount: 2, @@ -752,7 +766,7 @@ async function main() { await page.evaluate(() => { window.__gnssQaMode = 'error'; }); - const e = await enableAndSettle(page, { refresh: true, timeoutS: 1 }); + const e = await enableAndSettle(page, { refresh: true }); record( 'FAILURE: error surfaced, cells kept', Boolean(e.stats.error) && e.stats.count === 4 && e.entities === 4, @@ -931,7 +945,7 @@ async function main() { aged.on('pageerror', (e) => errors.push(e.message)); await stubRoute(aged, { ageMs: 31 * 60_000 }); await boot(aged, { lon: 18.6, lat: 54.6, height: 450_000 }); - const old = await enableAndSettle(aged, { timeoutS: 5 }); + const old = await enableAndSettle(aged, {}); record( 'AGE: a snapshot observed 31 min ago adds no cells and is not an error', old.stats.count === 0 && old.entities === 0 && !old.stats.error, From 4838a643730353af0f9d810d522ab393766c0fce Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 17:13:17 +0100 Subject: [PATCH 40/86] docs: restore NYC CCTV changelog entry below the Bing note The merge from main replaced the NYC DOT opt-in pack entry instead of adding the Bing entry above it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 775566c12..7acdedf6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ their ion-backed providers, retaining ion and per-tile attribution without widening the Content Security Policy. +- Add New York City traffic cameras from the NYC DOT Traffic Management Center + as an opt-in CCTV pack. NYC DOT requires a data-sharing agreement for the + camera feed, so the pack is off unless `CCTV_NYC_ENABLED=1` is set by an + operator who holds one; otherwise no request reaches NYC DOT. NYC DOT's + requested disclaimer is in the Data attribution popover. The camera list is one keyless JSON request to + `webcams.nyctmc.org/api/cameras`; frames are stills synthesized from each + camera UUID on that same host, so a catalog field cannot steer the proxy + off-host. Offline cameras are dropped. Once enabled, the 250 cameras nearest + the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is + unchanged; extra cameras share it round-robin with the other packs. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. From 6f0d1bb35f121ec69b64d136901e60416a339a05 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 11:07:18 -0700 Subject: [PATCH 41/86] fix(ui): keep panels the right rail hides itself in its layout Exclusive presentation hides collapsed right-rail panels with display: none. Skipping every display: none panel also skipped those, so panels the rail had auto-collapsed stayed collapsed after the HUD was hidden or left Tactical. Only panels hidden by other CSS are skipped now. Adds the changelog and current-state notes. --- CHANGELOG.md | 4 ++++ docs/CURRENT-STATE.md | 3 +++ src/ui/panelRails.test.mjs | 26 ++++++++++++++++++++++++++ src/ui/rightPanelRail.js | 9 +++++++-- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..7c7ada1b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- The right rail ignores panels that other CSS hides, such as during an event + view, so they no longer force one-panel-at-a-time layout or collapse visible + panels. Panels the rail collapses itself are still restored. + - Bing Aerial and Bing Labels request HTTPS tiles on HTTP localhost through their ion-backed providers, retaining ion and per-tile attribution without widening the Content Security Policy. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..59ba91202 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1047,6 +1047,9 @@ Automatic collapse remains presentation only, prioritizes the latest explicit panel, and honors keyboard focus on the right. Mobile layout still releases desktop allocation styles. Stable Display allocation avoids unnecessary style writes. This extraction does not change panel positions or layout defaults. +The right rail skips panels hidden by other CSS (computed `display: none`), but +keeps the collapsed panels its own exclusive presentation hides, so automatic +collapse can still be undone. ## Surface keyboard lifecycle diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index de5b3997b..7015c89e2 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -723,6 +723,32 @@ test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity ); }); +test('right layout still restores panels that exclusive presentation hides', () => { + const f = fixture('right'); + // Model layers.css: an exclusive rail hides its collapsed panels. + f.options.windowRef.getComputedStyle = (node) => ({ + ...node.computed, + display: + f.stack.classList.contains('layout-exclusive') && + node.classList.contains('collapsed') + ? 'none' + : node.computed.display, + }); + f.expand(f.first, 250); + f.second.classList.add('layout-auto-collapsed'); + f.stack.classList.add('layout-exclusive'); + + f.run(); + assert.equal(f.stack.classList.contains('layout-exclusive'), true); + assert.equal(f.second.getAttribute('aria-hidden'), 'true'); + + f.options.hud.visible = false; + f.run(); + assert.equal(f.second.classList.contains('collapsed'), false); + assert.equal(f.second.classList.contains('layout-auto-collapsed'), false); + assert.deepEqual(f.collapsed, ['second']); +}); + test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => { const f = fixture('right'); const imagery = element('recent-imagery-panel', { height: 0 }); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index b62be511c..be49999fd 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -69,12 +69,17 @@ export function layoutRightPanelRail({ // A collapse may schedule one follow-up, which only measures and allocates. const isCollapseRetry = pendingCollapseRetries.delete(stack); - // A floating (lifted-out) panel is not laid out by the rail. + // A floating (lifted-out) panel is not laid out by the rail. Neither is one + // that other CSS hides, such as an event view. Exclusive presentation hides + // collapsed panels itself; those stay in the rail so they can be restored. + const hiddenByRail = (panel) => + stack.classList.contains('layout-exclusive') && + panel.classList.contains('collapsed'); const panels = [...stack.children].filter( (panel) => panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden && - getComputedStyle(panel).display !== 'none', + (hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'), ); if (!hud.visible || hud.variant !== 'tactical') { for (const panel of panels.filter((item) => From 632005d221f2a5c60045769208c4b442d69cf3e5 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:33:27 -0400 Subject: [PATCH 42/86] fix(director): seek camera longitude across the shortest dateline arc --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 ++++ src/director/director-dateline-seek.test.mjs | 26 ++++++++++++++++++++ src/director/timeline.js | 8 +++++- 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/director/director-dateline-seek.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..2075effa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Director timeline seeks cross the dateline along the short longitude arc. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..cb0b43c54 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(director): seek camera longitude across the shortest dateline arc + +Interpolate longitude with the existing shortest-angle easing, normalize intermediate longitudes, and preserve exact authored endpoints at progress zero and one. diff --git a/src/director/director-dateline-seek.test.mjs b/src/director/director-dateline-seek.test.mjs new file mode 100644 index 000000000..f950814f3 --- /dev/null +++ b/src/director/director-dateline-seek.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { cameraAtProgress } from './timeline.js'; +const camera = (lon) => ({ + lat: 10, + lon, + alt: 1000, + heading: 0, + pitch: -40, + roll: 0, +}); +test('director seek follows the short dateline arc in both directions', () => { + for (const [a, b] of [ + [179, -179], + [-179, 179], + ]) { + assert.equal( + Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon), + 180, + ); + assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a); + assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b); + assert.ok(Math.abs(cameraAtProgress(camera(a), camera(b), 0.25).lon) > 179); + } + assert.equal(cameraAtProgress(camera(20), camera(24), 0.5).lon, 22); +}); diff --git a/src/director/timeline.js b/src/director/timeline.js index 7fd4cbbae..1da08d498 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -45,7 +45,13 @@ export function cameraAtProgress(fromCamera, toCamera, progress) { }; return { lat: lerp(source.lat, target.lat), - lon: lerp(source.lon, target.lon), + lon: + t === 0 + ? Number(source.lon) + : t === 1 + ? Number(target.lon) + : ((((lerpAngle(source.lon, target.lon) + 180) % 360) + 360) % 360) - + 180, alt: lerp(source.alt, target.alt), heading: lerpAngle(source.heading, target.heading), pitch: lerp(source.pitch, target.pitch), From 3b08bd74872a4a7c2dac103139a05ce660fd71f0 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:42 -0400 Subject: [PATCH 43/86] fix: address review feedback for PR #1023 --- docs/CURRENT-STATE.md | 6 +----- src/director/camera.js | 7 ++++++- src/director/timeline.js | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cb0b43c54..37a9e6c0a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -399,6 +399,7 @@ camera moves with easing, duration and holds. Playback and seek share one coordinate sampler; navigation/manual input revokes authored motion and pending holds. Anchor heights and explicit endpoints require the WGS84 ellipsoid reference. Legacy projects retain their ordinary flights, content and edits. +Seeking between ordinary camera poses uses the shortest longitude arc across the dateline while preserving both authored endpoint longitudes. See [camera directions](DIRECTOR-CAMERA.md). Realtime voice composes separate connection, response/tool, Radio handoff, input @@ -5310,8 +5311,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(director): seek camera longitude across the shortest dateline arc - -Interpolate longitude with the existing shortest-angle easing, normalize intermediate longitudes, and preserve exact authored endpoints at progress zero and one. diff --git a/src/director/camera.js b/src/director/camera.js index 059c7a873..e009c2cba 100644 --- a/src/director/camera.js +++ b/src/director/camera.js @@ -28,6 +28,11 @@ export function resolveCameraMove(scene, shot) { }; } +/** Wrap an interpolated camera longitude to WGS84 without changing authored endpoints. */ +export function normalizeCameraLongitude(longitude) { + return ((((longitude + 180) % 360) + 360) % 360) - 180; +} + /** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */ export function sampleCameraMove(move, progress) { const t = Math.max(0, Math.min(1, Number(progress) || 0)); @@ -45,7 +50,7 @@ export function sampleCameraMove(move, progress) { const { from, to } = move; return { lat: lerp(from.lat, to.lat), - lon: ((((angle(from.lon, to.lon) + 180) % 360) + 360) % 360) - 180, + lon: normalizeCameraLongitude(angle(from.lon, to.lon)), alt: lerp(from.alt, to.alt), heading: angle(from.heading, to.heading), pitch: lerp(from.pitch, to.pitch), diff --git a/src/director/timeline.js b/src/director/timeline.js index 1da08d498..fbaca4301 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -2,6 +2,7 @@ import { resolveCameraPose, resolveCameraMove, sampleCameraMove, + normalizeCameraLongitude, } from './camera.js'; const DEFAULT_SHOT_DURATION_SEC = 4; @@ -50,8 +51,7 @@ export function cameraAtProgress(fromCamera, toCamera, progress) { ? Number(source.lon) : t === 1 ? Number(target.lon) - : ((((lerpAngle(source.lon, target.lon) + 180) % 360) + 360) % 360) - - 180, + : normalizeCameraLongitude(lerpAngle(source.lon, target.lon)), alt: lerp(source.alt, target.alt), heading: lerpAngle(source.heading, target.heading), pitch: lerp(source.pitch, target.pitch), From 2c5fb039ce1da3b0ce7ba9e177499b16c14c4656 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:33:43 -0400 Subject: [PATCH 44/86] fix(tools): enforce string schema lengths by Unicode code point --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++++ src/tools/schema.js | 5 +++-- src/tools/tool-schema-codepoints.test.mjs | 20 ++++++++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 src/tools/tool-schema-codepoints.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..f7607fd02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..7e1970768 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(tools): enforce string schema lengths by Unicode code point + +Count code points for minLength and maxLength, preserving ASCII behavior and regular-expression validation. diff --git a/src/tools/schema.js b/src/tools/schema.js index 7c40c3eb5..cdb63cfc1 100644 --- a/src/tools/schema.js +++ b/src/tools/schema.js @@ -106,9 +106,10 @@ export function validateValue(schema, value, path = 'arguments') { fail('must be a string'); break; } - if (schema.minLength != null && value.length < schema.minLength) + const length = [...value].length; + if (schema.minLength != null && length < schema.minLength) fail(`must be at least ${schema.minLength} characters`); - if (schema.maxLength != null && value.length > schema.maxLength) + if (schema.maxLength != null && length > schema.maxLength) fail(`must be at most ${schema.maxLength} characters`); if ( schema.pattern !== undefined && diff --git a/src/tools/tool-schema-codepoints.test.mjs b/src/tools/tool-schema-codepoints.test.mjs new file mode 100644 index 000000000..c379b77e9 --- /dev/null +++ b/src/tools/tool-schema-codepoints.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { validateValue } from './schema.js'; +test('tool string schemas count Unicode characters rather than UTF-16 units', () => { + const value = '𠮷野家😀'; + assert.deepEqual(validateValue({ type: 'string', maxLength: 4 }, value), []); + assert.equal( + validateValue({ type: 'string', minLength: 5 }, value).length, + 1, + ); + assert.equal( + validateValue({ type: 'string', maxLength: 3 }, value).length, + 1, + ); + assert.deepEqual( + validateValue({ type: 'string', minLength: 4, maxLength: 4 }, 'abcd'), + [], + ); + assert.equal(validateValue({ type: 'string', minLength: 2 }, '𠮷').length, 1); +}); From 615ba9a00826a50c2907184b9193c6e6d51bdc0b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:53 -0400 Subject: [PATCH 45/86] fix: address review feedback for PR #1026 --- docs/CURRENT-STATE.md | 7 ++----- src/tools/schema.js | 3 ++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7e1970768..e66800fd5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -73,6 +73,8 @@ Windows/Claude Desktop Cowork confirmation remains outstanding (#927). ## Tools and local MCP server — October 1, 2026 +Tool argument string lengths follow JSON Schema Unicode code-point counts, so supplementary characters count once rather than as two UTF-16 units. + `gods-eye-view/tools` defines queries that answer questions from the app's data, and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client, @@ -5310,8 +5312,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(tools): enforce string schema lengths by Unicode code point - -Count code points for minLength and maxLength, preserving ASCII behavior and regular-expression validation. diff --git a/src/tools/schema.js b/src/tools/schema.js index cdb63cfc1..b6e8e2fd0 100644 --- a/src/tools/schema.js +++ b/src/tools/schema.js @@ -106,7 +106,8 @@ export function validateValue(schema, value, path = 'arguments') { fail('must be a string'); break; } - const length = [...value].length; + let length = 0; + for (const character of value) length += 1; if (schema.minLength != null && length < schema.minLength) fail(`must be at least ${schema.minLength} characters`); if (schema.maxLength != null && length > schema.maxLength) From c86c1a4ccedb0468bc622c00ee151c1224bab71a Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:35:40 -0400 Subject: [PATCH 46/86] fix(weather): keep absent observations unknown instead of reporting clear skies --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 ++++ src/data/regionalModel.js | 23 +++++++++++++++++-- .../weather-missing-observations.test.mjs | 20 ++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/data/weather-missing-observations.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..d3a8f9027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Weather records keep missing conditions unknown and refuse summaries without a measured temperature. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..c066ab182 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(weather): keep absent observations unknown instead of reporting clear skies + +Refuse absent or boolean primary temperatures and keep absent/boolean weather codes explicitly CONDITIONS UNKNOWN while retaining valid numeric strings and real zero observations. diff --git a/src/data/regionalModel.js b/src/data/regionalModel.js index 08b4e9336..90eeac1fd 100644 --- a/src/data/regionalModel.js +++ b/src/data/regionalModel.js @@ -90,9 +90,22 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) { /** Normalize Open-Meteo current conditions into a small source-stamped record. */ export function normalizeRegionalWeather(payload) { const current = payload?.current; - if (!current || !Number.isFinite(Number(current.temperature_2m))) return null; + if ( + !current || + current.temperature_2m == null || + typeof current.temperature_2m === 'boolean' || + (typeof current.temperature_2m === 'string' && + !current.temperature_2m.trim()) || + !Number.isFinite(Number(current.temperature_2m)) + ) + return null; const numberOrNull = (value) => { - if (value === null || value === undefined || value === '') return null; + if ( + value == null || + typeof value === 'boolean' || + (typeof value === 'string' && !value.trim()) + ) + return null; return Number.isFinite(Number(value)) ? Number(value) : null; }; // Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC, @@ -119,6 +132,12 @@ export function normalizeRegionalWeather(payload) { /** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */ export function weatherCodeLabel(code) { + if ( + code == null || + typeof code === 'boolean' || + (typeof code === 'string' && !code.trim()) + ) + return 'CONDITIONS UNKNOWN'; const value = Number(code); if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN'; if (value === 0) return 'CLEAR'; diff --git a/src/data/weather-missing-observations.test.mjs b/src/data/weather-missing-observations.test.mjs new file mode 100644 index 000000000..4e89dcedd --- /dev/null +++ b/src/data/weather-missing-observations.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeRegionalWeather, weatherCodeLabel } from './regionalModel.js'; +test('missing weather observations never assert clear conditions', () => { + for (const value of [null, undefined, '', ' ', false, true]) { + assert.equal(weatherCodeLabel(value), 'CONDITIONS UNKNOWN'); + assert.equal( + normalizeRegionalWeather({ current: { temperature_2m: value } }), + null, + ); + } + assert.equal(weatherCodeLabel(0), 'CLEAR'); + assert.equal(weatherCodeLabel('0'), 'CLEAR'); + const record = normalizeRegionalWeather({ + current: { temperature_2m: 0, weather_code: null, wind_speed_10m: false }, + }); + assert.equal(record.temperatureC, 0); + assert.equal(record.weatherCode, null); + assert.equal(record.windKph, null); +}); From 7126555acb42d8a2c9bb7e60a14fc569a628bccf Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:54 -0400 Subject: [PATCH 47/86] fix: address review feedback for PR #1027 --- docs/CURRENT-STATE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c066ab182..076603256 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4625,6 +4625,8 @@ are omitted rather than framing the wrong part of the globe. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. +Missing local weather observations remain unknown rather than being converted to zero or clear skies; a missing temperature drops the whole local weather record, including its wind and condition. + - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. - **Aircraft course slew:** civilian and military 3D models retain the 60°/s course limiter, but each rendered frame can consume at most 250 ms of accumulated slew time. A long tile/render stall therefore catches up over multiple visible frames instead of turning one delayed frame into a heading snap. @@ -5310,8 +5312,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(weather): keep absent observations unknown instead of reporting clear skies - -Refuse absent or boolean primary temperatures and keep absent/boolean weather codes explicitly CONDITIONS UNKNOWN while retaining valid numeric strings and real zero observations. From a140c52f847b4b3cf7f9bfe1b6b4597afd25747f Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:36:07 -0400 Subject: [PATCH 48/86] fix(directions): report truncation only when a maneuver was actually omitted --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++++ src/data/route-cap-truthfulness.test.mjs | 24 ++++++++++++++++++++++++ src/data/routeSteps.js | 2 +- 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 src/data/route-cap-truthfulness.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..e1518ce6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Direction instruction limits report complete routes honestly and retain a final roundabout exit within the limit. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..d6222d72e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(directions): report truncation only when a maneuver was actually omitted + +Keep processing foldable exits after the cap and report truncation only when the next admissible independent instruction would exceed it. The returned list remains bounded to 200 steps. diff --git a/src/data/route-cap-truthfulness.test.mjs b/src/data/route-cap-truthfulness.test.mjs new file mode 100644 index 000000000..7d5d80153 --- /dev/null +++ b/src/data/route-cap-truthfulness.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeOsrmSteps, ROUTE_STEPS_MAX } from './routeSteps.js'; +const step = (type) => ({ + maneuver: { type, location: [10, 20] }, + distance: 10, + duration: 1, +}); +test('exact-cap routes remain complete and boundary exits retain their distance', () => { + const steps = Array.from({ length: ROUTE_STEPS_MAX }, () => step('turn')); + assert.equal(normalizeOsrmSteps({ legs: [{ steps }] }).truncated, false); + assert.equal( + normalizeOsrmSteps({ legs: [{ steps: [...steps, step('turn')] }] }) + .truncated, + true, + ); + steps[steps.length - 1] = step('roundabout'); + const result = normalizeOsrmSteps({ + legs: [{ steps: [...steps, step('exit roundabout')] }], + }); + assert.equal(result.truncated, false); + assert.equal(result.steps.length, ROUTE_STEPS_MAX); + assert.equal(result.steps.at(-1).distanceM, 20); +}); diff --git a/src/data/routeSteps.js b/src/data/routeSteps.js index 5ee11602a..e3144bdf7 100644 --- a/src/data/routeSteps.js +++ b/src/data/routeSteps.js @@ -185,9 +185,9 @@ export function normalizeOsrmSteps(route) { } continue; } + if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true }; step.instruction = instructionFor(step); out.push(step); - if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true }; } } return { steps: out, truncated: false }; From 69815a92bec2f3ceaf41aecf919b6f78edc8818a Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:02 -0400 Subject: [PATCH 49/86] docs: address review scope and feature placement for #1030 --- docs/CURRENT-STATE.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d6222d72e..607449cf8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3317,6 +3317,10 @@ from fixtures (`providers/mapillary/coverageFixture.mjs`, photo flow. CI runs the fixture gate and `qa:panel-resize` against a production build served by `vite preview` with a dummy token. +Directions retains up to 200 turn instructions. Its truncation notice appears +only when another independent instruction is omitted; a complete route with +exactly 200 turns does not show the notice. + Directions is a keyless front end to the routing the voice agent already uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the profile; SET A and SET B arm the next globe click (Escape or a second press @@ -5310,8 +5314,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(directions): report truncation only when a maneuver was actually omitted - -Keep processing foldable exits after the cap and report truncation only when the next admissible independent instruction would exceed it. The returned list remains bounded to 200 steps. From ba58a5ec50286ce4b06ac483b2eff9ddcc12aa91 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:40:05 -0400 Subject: [PATCH 50/86] fix(street-level): deduplicate overlapping dateline tile halves --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++++ .../street-level-unique-tiles.test.mjs | 15 +++++++++++++++ src/layers/streetLevel/tileMath.js | 7 ++++++- 4 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/layers/streetLevel/street-level-unique-tiles.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..0814f7e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Street Level requests count each tile once when dateline bounds overlap at coarse zoom levels. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..d0014227f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(street-level): deduplicate overlapping dateline tile halves + +Deduplicate tile addresses before ranking, truncating and reporting total coverage. Dateline ordering and the existing focus policy remain in control. diff --git a/src/layers/streetLevel/street-level-unique-tiles.test.mjs b/src/layers/streetLevel/street-level-unique-tiles.test.mjs new file mode 100644 index 000000000..f96f0b892 --- /dev/null +++ b/src/layers/streetLevel/street-level-unique-tiles.test.mjs @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { tilesForBbox } from './tileMath.js'; +test('coarse dateline coverage counts each tile exactly once', () => { + for (const z of [0, 1, 2]) { + const result = tilesForBbox([170, -1, -170, 1], z); + const keys = result.tiles.map((t) => `${t.x}/${t.y}/${t.z}`); + assert.equal(new Set(keys).size, keys.length); + assert.equal(result.total, keys.length); + const limited = tilesForBbox([170, -1, -170, 1], z, { limit: 1 }); + assert.equal(limited.total, keys.length); + assert.equal(limited.truncated, keys.length > 1); + assert.equal(limited.tiles.length, 1); + } +}); diff --git a/src/layers/streetLevel/tileMath.js b/src/layers/streetLevel/tileMath.js index fe3c3ec18..ca7b09cd5 100644 --- a/src/layers/streetLevel/tileMath.js +++ b/src/layers/streetLevel/tileMath.js @@ -147,7 +147,12 @@ export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) { return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2; }; // Each tile's distance is computed once; ties keep their row order. - const ranked = tiles + const unique = [ + ...new Map( + tiles.map((tile) => [`${tile.x}/${tile.y}/${tile.z}`, tile]), + ).values(), + ]; + const ranked = unique .map((tile) => ({ tile, d: distance2(tile) })) .sort((a, b) => a.d - b.d) .map(({ tile }) => tile); From f67888be00327c45bb2f0a651979ea9eb77489f4 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:01 -0400 Subject: [PATCH 51/86] docs: address review scope and feature placement for #1032 --- CHANGELOG.md | 3 ++ docs/CURRENT-STATE.md | 9 +++--- .../street-level-unique-tiles.test.mjs | 29 ++++++++++++------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0814f7e7c..1d76a1963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ - Street Level requests count each tile once when dateline bounds overlap at coarse zoom levels. +- Street Level counts unique tiles before ranking so overlapping dateline halves + in polar views do not waste slots in the 16-tile overview budget. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d0014227f..c2291829e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3122,6 +3122,10 @@ sequence lines below 60 km; the proxy strips the unused `image` layer from z14 tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent requests for a tile. +Mapillary coverage ranks unique tile addresses before applying the 16-tile +overview budget. Overlapping dateline halves in a polar view therefore do not +waste overview slots on the same tile; loading already skips duplicate keys. + On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out above 1,800 m) the surface mode switches from `draped` to `terrain`: draped lines land on roofs and tree tops, so `groundCast.js` instead places lines, @@ -5310,8 +5314,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(street-level): deduplicate overlapping dateline tile halves - -Deduplicate tile addresses before ranking, truncating and reporting total coverage. Dateline ordering and the existing focus policy remain in control. diff --git a/src/layers/streetLevel/street-level-unique-tiles.test.mjs b/src/layers/streetLevel/street-level-unique-tiles.test.mjs index f96f0b892..224de523b 100644 --- a/src/layers/streetLevel/street-level-unique-tiles.test.mjs +++ b/src/layers/streetLevel/street-level-unique-tiles.test.mjs @@ -1,15 +1,22 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { tilesForBbox } from './tileMath.js'; -test('coarse dateline coverage counts each tile exactly once', () => { - for (const z of [0, 1, 2]) { - const result = tilesForBbox([170, -1, -170, 1], z); - const keys = result.tiles.map((t) => `${t.x}/${t.y}/${t.z}`); - assert.equal(new Set(keys).size, keys.length); - assert.equal(result.total, keys.length); - const limited = tilesForBbox([170, -1, -170, 1], z, { limit: 1 }); - assert.equal(limited.total, keys.length); - assert.equal(limited.truncated, keys.length > 1); - assert.equal(limited.tiles.length, 1); - } + +test('reachable polar dateline coverage ranks 24 distinct tiles before overview truncation', () => { + // coverage uses the actual view bbox above zoom 1; polar views can wrap. + const bbox = [-10, 60, -20, 85]; + const result = tilesForBbox(bbox, 3); + const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`); + assert.equal(keys.length, 24); + assert.equal(new Set(keys).size, 24); + assert.equal(result.total, 24); + assert.equal(result.truncated, false); + const overview = tilesForBbox(bbox, 3, { limit: 16 }); + assert.equal(overview.total, 24); + assert.equal(overview.tiles.length, 16); + assert.equal( + new Set(overview.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`)).size, + 16, + ); + assert.equal(overview.truncated, true); }); From 750f3da6eba3b115399ab50d8fadb9144fb7a846 Mon Sep 17 00:00:00 2001 From: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:48:51 +0900 Subject: [PATCH 52/86] fix(styles): keep Snow wind sway bounded over time (#782) The horizontal offset multiplied elapsed time by a sine, so flakes swung faster the longer the app ran. It is now the integral of the intended sway velocity, which stays bounded at any session age; Wind still scales the amplitude. Co-authored-by: Sameh Khamis --- CHANGELOG.md | 3 +++ docs/CURRENT-STATE.md | 2 ++ src/styles/snow.js | 6 ++++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69a7f6823..1ef811a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +- Snow flakes sway within a fixed range instead of drifting sideways faster the + longer the app runs; the Wind setting still scales the sway. + - The right rail ignores panels that other CSS hides, such as during an event view, so they no longer force one-panel-at-a-time layout or collapse visible panels. Panels the rail collapses itself are still restored. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9281f4a40..ebf7495f5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1013,6 +1013,8 @@ and sharpen. Display supplies actions and renders settings; effect execution has no DOM dependency. Existing presets and the 500 ms transition remain unchanged. Stopping the controller revokes animation before asynchronous UI teardown; final destruction removes its stages and restores the borrowed bloom configuration. +Snow's sideways sway stays within a fixed range at any session age; Wind scales +its amplitude. ## Display control ownership diff --git a/src/styles/snow.js b/src/styles/snow.js index e2ada6ad7..b23f9bd71 100644 --- a/src/styles/snow.js +++ b/src/styles/snow.js @@ -29,11 +29,13 @@ export const snowShader = { float depth = 0.5 + layer * 0.5; float speed = 0.4 + layer * 0.3; float size = mix(0.01, 0.025, layer); - float windForce = sin(time * 0.5 + layer * 3.14) * (0.05 + wind * 0.2); + float windForce = 0.05 + wind * 0.2; vec2 snowUV = uv * vec2(1.0, 0.5) * (4.0 + layer * 4.0); snowUV.y += time * speed; - snowUV.x += time * windForce; + // Offset is the integral of a swaying velocity sin(time * 0.5 + phase) * windForce, + // so the sway stays bounded however long the app has been running. + snowUV.x -= 2.0 * cos(time * 0.5 + layer * 3.14) * windForce; vec2 cell = floor(snowUV); vec2 f = fract(snowUV); From 85754bcf50c7b52fe3c753d34c653b4ae6826960 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 11:59:14 -0700 Subject: [PATCH 53/86] test: say which CI job and which files the discovery guard covers --- scripts/run-unit-tests.mjs | 9 +++++---- src/unitTestRunner.test.mjs | 11 ++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 7024ea38c..6cc6aaa94 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -25,10 +25,11 @@ export function assertNode24AllocationRuntime(version = process.versions.node) { * Directories this runner searches for `*.test.mjs`, in stable order. * * `server/` belongs here because provider modules own tests of their own: a - * suite placed beside the module it covers must run, and `npm test` is the only - * thing CI invokes, so a directory missing from this list is a suite that never - * runs anywhere. `unitTestRunner.test.mjs` asserts the list covers every test - * file in the repository, so adding a tree without adding it here fails. + * suite placed beside the module it covers must run, and CI's unit-test job runs + * `npm test`, so a directory missing from this list is a suite that never runs + * there. `unitTestRunner.test.mjs` asserts the list covers every `*.test.mjs` + * outside dependencies, build output and dot-directories, so adding a tree + * without adding it here fails. */ export const UNIT_TEST_ROOTS = Object.freeze(['src', 'server']); diff --git a/src/unitTestRunner.test.mjs b/src/unitTestRunner.test.mjs index 201f6ed8e..80d9091e0 100644 --- a/src/unitTestRunner.test.mjs +++ b/src/unitTestRunner.test.mjs @@ -64,11 +64,12 @@ test('unit runner serializes only GC-bracketed allocation microbenchmarks', () = }); test('the runner discovers every test file in the repository', () => { - // `npm test` is the only suite CI runs, and it runs exactly what this - // function returns. A `*.test.mjs` outside the searched roots therefore never - // executes anywhere, while still reading — in review and to its author — like - // covered code. Compare the two sets rather than their sizes, so the failure - // names the files instead of only disagreeing about a count. + // CI's unit-test job runs `npm test`, which runs exactly what this function + // returns. A `*.test.mjs` outside the searched roots therefore never runs + // there, while still reading — in review and to its author — like covered + // code. Compare the two sets rather than their sizes, so the failure names the + // files instead of only disagreeing about a count. The final equality also + // catches the reverse: files the runner finds that this walk skips. const discovered = discoverUnitTestFiles(repositoryRoot); const onDisk = testFilesOnDisk(repositoryRoot); const undiscovered = onDisk.filter((file) => !discovered.includes(file)); From afb3a92914b4d1bc19fa62d708456a331e0db274 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 11:59:51 -0700 Subject: [PATCH 54/86] docs: say what the connection reads from the voice credential It reads the token, model, transcription model and tier for diagnostics and cost accounting, so the credential is not opaque to it. Also lists requestToken's authMode option in APPLICATION.md. --- docs/APPLICATION.md | 2 +- docs/VOICE-OWNERSHIP.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index deafe9168..c5a2642e6 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -158,7 +158,7 @@ routing and regional caches. Request parameters cannot override these endpoints. Voice controls accept an action runner and a controller factory through `createVoiceCommands` (`./voice/commands`). The default Realtime controller owns microphone tracks, playback, push-to-talk, tool cancellation and radio handoff. -Its backend supplies `requestToken({ tier, signal })` and +Its backend supplies `requestToken({ tier, authMode, signal })` and `negotiate({ offerSdp, credential, signal })`. The Realtime-compatible adapter (`./voice/realtime-backend`) accepts separate token and connection transports and endpoints. Only the short-lived client secret reaches the SDP endpoint. diff --git a/docs/VOICE-OWNERSHIP.md b/docs/VOICE-OWNERSHIP.md index 98b6b0c5c..bb28eb3e9 100644 --- a/docs/VOICE-OWNERSHIP.md +++ b/docs/VOICE-OWNERSHIP.md @@ -26,8 +26,8 @@ The backend is replaceable per deployment: `realtimeController.js` accepts it as `backend` option and falls back to `createRealtimeBackend()`. Its two method signatures live with [application construction](APPLICATION.md); what belongs here is the ownership. The connection is the backend's only consumer in the application. -The resolved credential is opaque to the connection and is handed to `negotiate` -whole. Client-secret expiry is refused inside the backend, not by the connection. +The connection reads credential metadata for diagnostics and cost accounting, +then passes the complete credential unchanged to `negotiate`. Client-secret expiry is refused inside the backend, not by the connection. `realtimeOwners.test.mjs` drives whole sessions through a substitute that implements only those two methods. From 4cd7200fe445200968109b2a65cb6a98a485deb3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 12:00:16 -0700 Subject: [PATCH 55/86] docs: attribute the Pinokio stutter report accurately The workaround came from one Pinokio 8.2.0 user who did not state an OS, and a single report suggests rather than isolates the embedded-browser cause. --- README.md | 10 +++++----- docs/PERFORMANCE.md | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6c6b1c238..50c5a4003 100644 --- a/README.md +++ b/README.md @@ -119,11 +119,11 @@ Version 8.2 fixes the launcher installation issue; **Pinokio opens but the globe is very choppy?** Before treating that as a GPU compatibility or minimum-hardware problem, copy the local server URL shown by -Pinokio and open the same URL in your normal hardware-accelerated browser. A -Pinokio 8.2.0 Linux report reproduced severe stutter in Pinokio's embedded view -while the same running server was smooth in an external browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). -That isolates the browser/compositor path from the app server and your machine; -it does not establish a minimum GPU requirement. +Pinokio and open the same URL in your normal hardware-accelerated browser. One +Pinokio 8.2.0 user reported severe stutter in Pinokio's embedded view that went +away when the same running server was opened in an external browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). +That points to the embedded browser rather than the app server or your +hardware; it does not establish a minimum GPU requirement. ### Path 2 — Terminal / coding agent diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 873ef8768..65d0abae9 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -129,11 +129,11 @@ Use the same controls before attributing a difference to the application: 7. Treat a live-source outage as missing coverage, not as evidence of low client rendering cost. 8. Record whether the app is running in Pinokio's embedded view or in an - external browser. The same Pinokio 8.2.0 Linux server was reported as - severely stuttery in the embedded view and smooth when its local URL was - opened directly in a normal browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). - Treat that as a browser/compositor-path difference until the GPU renderer - proves otherwise. + external browser. One Pinokio 8.2.0 user reported severe stutter in the + embedded view that went away when the same local URL was opened in a normal + browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). + Do not compare embedded-view and external-browser measurements directly; + compare the reported GPU renderer and matched scene settings first. ## What is not established yet From 4adcb3f7274951bd5c763cc59e190ef559c169de Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 15:18:17 -0400 Subject: [PATCH 56/86] docs: drop superseded CHANGELOG bullet The review-driven rewrite added a narrower entry but left the original bullet in place, so the PR carried two entries for one change. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d76a1963..69748384f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,6 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. -- Street Level requests count each tile once when dateline bounds overlap at coarse zoom levels. - - Street Level counts unique tiles before ranking so overlapping dateline halves in polar views do not waste slots in the 16-tile overview budget. From 91ac5c119084fdb200362b2c6dd07cb2324fb081 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:23:49 -0700 Subject: [PATCH 57/86] test(codex): match the installed-executable stub on Windows (#997) The test's access stub matched a forward-slash suffix, so on Windows the resolver's backslash path never matched and it fell back to PATH. The stub and the expected value now use the same path.join result. The Windows CI job also runs this test file now. Co-authored-by: Sameh Khamis --- .github/workflows/ci.yml | 2 +- src/codexOauthRealtime.test.mjs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 375bc9077..491f2468d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,7 +150,7 @@ jobs: run: npm run check:boundaries - name: Run focused onboarding tests - run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs + run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs src/codexOauthRealtime.test.mjs - name: Build production bundle run: npm run build diff --git a/src/codexOauthRealtime.test.mjs b/src/codexOauthRealtime.test.mjs index 07e1a7dcc..4a577ac70 100644 --- a/src/codexOauthRealtime.test.mjs +++ b/src/codexOauthRealtime.test.mjs @@ -145,17 +145,20 @@ test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => { const checked = []; + // Built with path.join so the stub matches the resolver on Windows too, + // where the separators are backslashes. + const installed = path.join('/home/fixture', '.local', 'bin', 'codex'); assert.equal( resolveCodexExecutable({ environment: {}, home: '/home/fixture', access(candidate) { checked.push(candidate); - if (candidate.endsWith('/.local/bin/codex')) return; + if (candidate === installed) return; throw new Error('missing'); }, }), - path.join('/home/fixture', '.local', 'bin', 'codex'), + installed, ); assert.equal(checked.length, 1); From 8daaa065d2b410380e18a5702112d7cad7e2e900 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 14:16:30 -0700 Subject: [PATCH 58/86] qa: log the Recent Imagery gate's stability mode and document its flag --- docs/CURRENT-STATE.md | 2 +- scripts/qa-recent-imagery.mjs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ebf7495f5..91ba81591 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4451,7 +4451,7 @@ are omitted rather than framing the wrong part of the globe. - **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape. - **Panel.** `#recent-imagery-panel` is static rail markup in `src/ui/templates/context.html` beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`. - **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off). -- Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect across the transitions within 1 px; that measurement is the real layout-shift check. +- Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect, relative to the panel, across the transitions within 1 px; that measurement is the real layout-shift check. `QA_RECENT_IMAGERY_SLOW_RENDER=1` gives loaded software renderers a 4 s zoom deadline and an 8-sample stability tail; the default stays 2 s and 12 samples. ### 3D Aircraft + Tracking (June 2026) diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 9c1d6700e..5d42279dc 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -383,6 +383,9 @@ try { const initialStack = await page.evaluate(() => window.__riQa.activeStack()); const photoreal = initialStack === 'photoreal'; console.log(`initial map stack: ${initialStack}`); + console.log( + `stability: ${SLOW_RENDER ? 'slow-render' : 'default'} (zoom ${ZOOM_TIMEOUT_MS} ms, last ${HEIGHT_TAIL_SAMPLES} samples)`, + ); const onPhotorealOnly = (name, passed, detail) => photoreal ? check(name, passed, detail) From 4cacd4cc0021858c009d38a977095fac73a49136 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:48:53 -0700 Subject: [PATCH 59/86] fix(qa): exit QA gates gracefully on Windows (#1064) process.exit(2) after a successful fetch trips a libuv assertion on Windows and turns the exit code into 127. The four gates set process.exitCode and return instead, and discard the unread reachability response so an early exit does not wait for the server to finish the body. Co-authored-by: Sameh Khamis --- scripts/qa-firms.mjs | 8 ++++++-- scripts/qa-traffic-jamviz-ab.mjs | 8 ++++++-- scripts/qa-traffic-preset-ab.mjs | 8 ++++++-- scripts/qa-traffic.mjs | 8 ++++++-- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/scripts/qa-firms.mjs b/scripts/qa-firms.mjs index d32ede533..27c8663bf 100644 --- a/scripts/qa-firms.mjs +++ b/scripts/qa-firms.mjs @@ -230,16 +230,20 @@ async function main() { try { const res = await fetch(APP_URL); + // Only the status matters; an unread body can hold the socket open. + await res.body?.cancel(); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`); - process.exit(2); + process.exitCode = 2; + return; } const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null); if (!FIXTURES && !status?.hasKey) { console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m'); - process.exit(2); + process.exitCode = 2; + return; } fs.mkdirSync(SHOTS_DIR, { recursive: true }); diff --git a/scripts/qa-traffic-jamviz-ab.mjs b/scripts/qa-traffic-jamviz-ab.mjs index 73d28ca60..b236ab0b1 100644 --- a/scripts/qa-traffic-jamviz-ab.mjs +++ b/scripts/qa-traffic-jamviz-ab.mjs @@ -139,15 +139,19 @@ async function main() { try { const res = await fetch(APP_URL); + // Only the status matters; an unread body can hold the socket open. + await res.body?.cancel(); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`); - process.exit(2); + process.exitCode = 2; + return; } const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); if (!status?.hasKey) { console.error('Server has no TomTom key — the A/B needs live flow.'); - process.exit(2); + process.exitCode = 2; + return; } fs.mkdirSync(OUT_DIR, { recursive: true }); diff --git a/scripts/qa-traffic-preset-ab.mjs b/scripts/qa-traffic-preset-ab.mjs index 44319af0d..d1b4950fd 100644 --- a/scripts/qa-traffic-preset-ab.mjs +++ b/scripts/qa-traffic-preset-ab.mjs @@ -145,15 +145,19 @@ async function main() { try { const res = await fetch(APP_URL); + // Only the status matters; an unread body can hold the socket open. + await res.body?.cancel(); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`); - process.exit(2); + process.exitCode = 2; + return; } const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); if (!status?.hasKey) { console.error('Server has no TomTom key — the A/B needs live flow.'); - process.exit(2); + process.exitCode = 2; + return; } fs.mkdirSync(OUT_DIR, { recursive: true }); diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 2a04d5595..424f3486e 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -113,17 +113,21 @@ async function main() { try { const res = await fetch(APP_URL); + // Only the status matters; an unread body can hold the socket open. + await res.body?.cancel(); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`); - process.exit(2); + process.exitCode = 2; + return; } const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.'); if (!statusBefore?.hasKey) { console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m'); - process.exit(2); + process.exitCode = 2; + return; } fs.mkdirSync(SHOTS_DIR, { recursive: true }); From 504d56ba2b3d73dcad45c14cd0715764c8cb2955 Mon Sep 17 00:00:00 2001 From: Ian Blenke Date: Wed, 14 Oct 2026 17:58:53 -0400 Subject: [PATCH 60/86] test: fix flaky timing and timeout guards (#969) Clears the timeout guards once each race settles, measures the 15,000-vehicle transit ingestion with main-thread CPU time instead of wall time, and times the region-timeout case from the boundary lookup with a 2 s ceiling, below the 3 s default budget, so an ignored budget still fails. Co-authored-by: Sameh Khamis --- server/providers/transitHistory.test.mjs | 16 +++++++++------- src/annotations/regionRing.test.mjs | 7 +++++-- src/data/manager.test.mjs | 14 ++++++++++---- src/sdr/controller.test.mjs | 9 +++++---- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/server/providers/transitHistory.test.mjs b/server/providers/transitHistory.test.mjs index 6ea9209f1..cb06c182a 100644 --- a/server/providers/transitHistory.test.mjs +++ b/server/providers/transitHistory.test.mjs @@ -142,9 +142,11 @@ test('three identical-order 15000-vehicle snapshots retain paths within a bounde const rows = Array.from({ length: 15000 }, (_, i) => row(now, `vehicle-${i}`), ); - const start = performance.now(); + // Main-thread CPU: excludes scheduler waits and parallel GC helper threads. + const start = process.threadCpuUsage(); h.ingest(feed, rows, now); - times.push(performance.now() - start); + const usage = process.threadCpuUsage(start); + times.push((usage.user + usage.system) / 1000); } assert.equal(h.diagnostics().keys, 15000); for (let i = 0; i < 15000; i++) { @@ -154,12 +156,12 @@ test('three identical-order 15000-vehicle snapshots retain paths within a bounde } assert.ok(h.diagnostics().allocatedBytes < 16 * 1024 * 1024); t.diagnostic( - `15000-vehicle ingestion ms: ${times.map((n) => n.toFixed(1)).join('/')}`, + `15000-vehicle ingestion CPU ms: ${times.map((n) => n.toFixed(1)).join('/')}`, ); - // A ceiling that says "not quadratic", not a benchmark: 15,000 rows in - // well under two seconds on a loaded laptop or a slow CI runner. The - // per-run figure above is the diagnostic to read. - assert.ok(Math.max(...times) < 2000, `ingestion ms: ${times}`); + // CPU time measures ingestion work without counting scheduler delays on + // loaded runners. Keep the two-second ceiling as a guard against quadratic + // work, not a benchmark; the per-poll figures above show any slowdown. + assert.ok(Math.max(...times) < 2000, `ingestion CPU ms: ${times}`); }); test('snapshot capacity pressure preserves members not yet visited and discloses recreated history', (t) => { diff --git a/src/annotations/regionRing.test.mjs b/src/annotations/regionRing.test.mjs index 09814247b..cd8ad6447 100644 --- a/src/annotations/regionRing.test.mjs +++ b/src/annotations/regionRing.test.mjs @@ -51,9 +51,11 @@ test('region ring: Natural Earth names resolve without a geocoder', async () => test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => { let adminSignal; let release; + let lookupStarted; const { resolveRegionRingForQuery } = createAnnotationResolver({ featureSource: featureSource({ getAdministrativeAreas(_point, { signal }) { + lookupStarted = Date.now(); adminSignal = signal; return new Promise((resolve) => { release = resolve; @@ -61,7 +63,6 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th }, }), }); - const started = Date.now(); const region = await resolveRegionRingForQuery( 'Atlantis', undefined, @@ -73,8 +74,10 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th ring: null, error: 'region-timeout', }); + // Timed from the boundary lookup, so the bundled pack scans before it do not + // count. Under the 3 s default budget, so an ignored budgetMs still fails. assert.ok( - Date.now() - started < 1000, + Date.now() - lookupStarted < 2_000, 'returns at the budget, not the lookup', ); // The lookup is left running so it can fill the boundary cache. diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index fce64ae8f..23641c962 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -2398,10 +2398,13 @@ test('a newer absolute intent aborts a hung guard-compensation instead of starvi removeGuard(); // The newest intent must be able to abort the hung compensation and run. const pendingFinalOn = mgr.setEnabled('flights', true, { origin: 'user' }); + let giveUpTimer; const finalOn = await Promise.race([ pendingFinalOn, - new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)), - ]); + new Promise((resolve) => { + giveUpTimer = setTimeout(() => resolve('starved'), 2000); + }), + ]).finally(() => clearTimeout(giveUpTimer)); assert.notEqual(finalOn, 'starved', 'newest intent must not starve behind the compensation'); assert.equal(compensationAborted, true, 'the hung compensation was aborted'); assert.equal(await pendingBlockedOn, false, 'the guard-blocked request stays unfulfilled'); @@ -2451,10 +2454,13 @@ test('re-entrant setEnabled from a blocked-adoption listener supersedes the comp releaseFirstDisable(); const blockedOn = await pendingBlockedOn; + let giveUpTimer; const reentrant = await Promise.race([ (async () => reentrantResult === null ? 'never-fired' : await reentrantResult)(), - new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)), - ]); + new Promise((resolve) => { + giveUpTimer = setTimeout(() => resolve('starved'), 2000); + }), + ]).finally(() => clearTimeout(giveUpTimer)); await pendingOff; await mgr.waitForLayerSettled('flights'); unsubscribe(); diff --git a/src/sdr/controller.test.mjs b/src/sdr/controller.test.mjs index 95b1eb5f2..f8078dbe5 100644 --- a/src/sdr/controller.test.mjs +++ b/src/sdr/controller.test.mjs @@ -357,10 +357,11 @@ test('WebUSB picker remains available when no authorized receiver matches', asyn /** Resolve with the promise's value, or 'timed out' after `ms`. */ function within(promise, ms = 1_000) { - return Promise.race([ - promise, - new Promise((resolve) => setTimeout(() => resolve('timed out'), ms)), - ]); + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve('timed out'), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } /** From cbefd529f9ca08d55e874240142d495cdf1d342f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 15:05:05 -0700 Subject: [PATCH 61/86] fix(director): turn heading and roll the short way for any authored angle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single % made the seek delta negative when the target angle was more than 540° below the start, so 350° to -350° turned 340° through 180°. Uses the same double modulo as playback, and adds tests for exact ±180° endpoints and a near-antipodal seek. --- docs/CURRENT-STATE.md | 4 ++- src/director/director-dateline-seek.test.mjs | 27 ++++++++++++++++++++ src/director/timeline.js | 4 ++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fa48b7bfe..3a7f7b94f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -399,7 +399,9 @@ camera moves with easing, duration and holds. Playback and seek share one coordinate sampler; navigation/manual input revokes authored motion and pending holds. Anchor heights and explicit endpoints require the WGS84 ellipsoid reference. Legacy projects retain their ordinary flights, content and edits. -Seeking between ordinary camera poses uses the shortest longitude arc across the dateline while preserving both authored endpoint longitudes. +Seeking between ordinary camera poses uses the shortest longitude arc across the +dateline while preserving both authored endpoint longitudes, and turns heading +and roll the short way for any authored angles. See [camera directions](DIRECTOR-CAMERA.md). Realtime voice composes separate connection, response/tool, Radio handoff, input diff --git a/src/director/director-dateline-seek.test.mjs b/src/director/director-dateline-seek.test.mjs index f950814f3..b0dd2b24b 100644 --- a/src/director/director-dateline-seek.test.mjs +++ b/src/director/director-dateline-seek.test.mjs @@ -24,3 +24,30 @@ test('director seek follows the short dateline arc in both directions', () => { } assert.equal(cameraAtProgress(camera(20), camera(24), 0.5).lon, 22); }); + +test('director seek keeps exact ±180 endpoints and picks the short arc near the antipode', () => { + for (const [a, b] of [ + [180, -180], + [-180, 180], + ]) { + assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a); + assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b); + assert.equal( + Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon), + 180, + ); + } + // 10°E to 169.9°W is 179.9° westward through Greenwich, not 180.1° east. + const mid = cameraAtProgress(camera(10), camera(-169.9), 0.5).lon; + assert.ok(mid > -81 && mid < -79, `short arc midpoint, got ${mid}`); +}); + +test('director seek turns heading and roll the short way for any authored angles', () => { + const pose = (heading, roll) => ({ ...camera(0), heading, roll }); + const mid = cameraAtProgress(pose(350, 350), pose(-350, -350), 0.5); + // 350° and -350° are 20° apart through 0°/360°, not 340° through 180°. + for (const value of [mid.heading, mid.roll]) { + const wrapped = ((value % 360) + 360) % 360; + assert.ok(wrapped < 1e-9 || 360 - wrapped < 1e-9, `got ${value}`); + } +}); diff --git a/src/director/timeline.js b/src/director/timeline.js index fbaca4301..f7cffcece 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -41,7 +41,9 @@ export function cameraAtProgress(fromCamera, toCamera, progress) { const lerp = (from, to) => Number(from) + (Number(to) - Number(from)) * eased; const lerpAngle = (from, to) => { const start = Number(from) || 0; - const delta = ((Number(to) - start + 540) % 360) - 180; + // Double modulo keeps the delta in [-180, 180) for any authored angles, + // as sampleCameraMove does; a single % goes negative below -540. + const delta = ((((Number(to) - start + 540) % 360) + 360) % 360) - 180; return start + delta * eased; }; return { From c12938cd05b843ca00e8bf0ac4be5d5c5770acaa Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 15:05:40 -0700 Subject: [PATCH 62/86] perf(tools): stop counting string code points once the bounds are decided Strings without length bounds are not counted, and counting stops at the larger bound, so a huge tool argument costs no more than its limit. --- src/tools/schema.js | 20 ++++++++++++++------ src/tools/tool-schema-codepoints.test.mjs | 13 +++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/tools/schema.js b/src/tools/schema.js index b6e8e2fd0..f857825a1 100644 --- a/src/tools/schema.js +++ b/src/tools/schema.js @@ -106,12 +106,20 @@ export function validateValue(schema, value, path = 'arguments') { fail('must be a string'); break; } - let length = 0; - for (const character of value) length += 1; - if (schema.minLength != null && length < schema.minLength) - fail(`must be at least ${schema.minLength} characters`); - if (schema.maxLength != null && length > schema.maxLength) - fail(`must be at most ${schema.maxLength} characters`); + if (schema.minLength != null || schema.maxLength != null) { + // Code points, as JSON Schema counts. Counting stops once both bounds + // are decided, so a huge argument costs no more than its limit. + const limit = Math.max( + schema.minLength ?? 0, + schema.maxLength != null ? schema.maxLength + 1 : 0, + ); + let length = 0; + for (const _ of value) if (++length >= limit) break; + if (schema.minLength != null && length < schema.minLength) + fail(`must be at least ${schema.minLength} characters`); + if (schema.maxLength != null && length > schema.maxLength) + fail(`must be at most ${schema.maxLength} characters`); + } if ( schema.pattern !== undefined && !new RegExp(schema.pattern, 'u').test(value) diff --git a/src/tools/tool-schema-codepoints.test.mjs b/src/tools/tool-schema-codepoints.test.mjs index c379b77e9..27af78fa3 100644 --- a/src/tools/tool-schema-codepoints.test.mjs +++ b/src/tools/tool-schema-codepoints.test.mjs @@ -18,3 +18,16 @@ test('tool string schemas count Unicode characters rather than UTF-16 units', () ); assert.equal(validateValue({ type: 'string', minLength: 2 }, '𠮷').length, 1); }); + +test('tool string bounds hold at their edges and on huge arguments', () => { + const string = (bounds) => ({ type: 'string', ...bounds }); + assert.deepEqual(validateValue(string({ maxLength: 3 }), '😀😀😀'), []); + assert.equal(validateValue(string({ maxLength: 3 }), '😀😀😀😀').length, 1); + assert.deepEqual(validateValue(string({ minLength: 3 }), '😀😀😀'), []); + assert.deepEqual(validateValue(string({}), 'x'.repeat(1e6)), []); + // Counting all of it takes roughly 0.7 s; stopping at the limit takes none. + const huge = 'x'.repeat(1e8); + const started = performance.now(); + assert.equal(validateValue(string({ maxLength: 60 }), huge).length, 1); + assert.ok(performance.now() - started < 200, 'stops counting past the limit'); +}); From 5be2185dc79353cf76969a13c4372b20b15428d2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 15:06:52 -0700 Subject: [PATCH 63/86] fix(weather): treat a missing code as unavailable in cloud effects too Number(null) is 0, so the cockpit cloud profile treated a missing weather code as clear but available. The briefing's three checks now share one helper, and the current-state note is its own bullet instead of folding into the route one. --- docs/CURRENT-STATE.md | 3 +-- src/data/regionalModel.js | 41 ++++++++++++--------------------- src/weatherEffectsMath.js | 8 ++++++- src/weatherEffectsMath.test.mjs | 9 ++++++++ 4 files changed, 32 insertions(+), 29 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f62c77bab..0ec9548ae 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4632,8 +4632,7 @@ are omitted rather than framing the wrong part of the globe. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. -Missing local weather observations remain unknown rather than being converted to zero or clear skies; a missing temperature drops the whole local weather record, including its wind and condition. - +- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. - **Aircraft course slew:** civilian and military 3D models retain the 60°/s course limiter, but each rendered frame can consume at most 250 ms of accumulated slew time. A long tile/render stall therefore catches up over multiple visible frames instead of turning one delayed frame into a heading snap. diff --git a/src/data/regionalModel.js b/src/data/regionalModel.js index 90eeac1fd..3a6d79f14 100644 --- a/src/data/regionalModel.js +++ b/src/data/regionalModel.js @@ -87,27 +87,22 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) { return articles; } +/** A reported number, or null when the observation is missing or not numeric. */ +function observedNumber(value) { + if ( + value == null || + typeof value === 'boolean' || + (typeof value === 'string' && !value.trim()) + ) + return null; + return Number.isFinite(Number(value)) ? Number(value) : null; +} + /** Normalize Open-Meteo current conditions into a small source-stamped record. */ export function normalizeRegionalWeather(payload) { const current = payload?.current; - if ( - !current || - current.temperature_2m == null || - typeof current.temperature_2m === 'boolean' || - (typeof current.temperature_2m === 'string' && - !current.temperature_2m.trim()) || - !Number.isFinite(Number(current.temperature_2m)) - ) - return null; - const numberOrNull = (value) => { - if ( - value == null || - typeof value === 'boolean' || - (typeof value === 'string' && !value.trim()) - ) - return null; - return Number.isFinite(Number(value)) ? Number(value) : null; - }; + if (!current || observedNumber(current.temperature_2m) == null) return null; + const numberOrNull = observedNumber; // Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC, // but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly. const observedRaw = @@ -132,14 +127,8 @@ export function normalizeRegionalWeather(payload) { /** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */ export function weatherCodeLabel(code) { - if ( - code == null || - typeof code === 'boolean' || - (typeof code === 'string' && !code.trim()) - ) - return 'CONDITIONS UNKNOWN'; - const value = Number(code); - if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN'; + const value = observedNumber(code); + if (value == null) return 'CONDITIONS UNKNOWN'; if (value === 0) return 'CLEAR'; if ([1, 2].includes(value)) return 'PARTLY CLOUDY'; if (value === 3) return 'OVERCAST'; diff --git a/src/weatherEffectsMath.js b/src/weatherEffectsMath.js index 0c1276bab..ec06741aa 100644 --- a/src/weatherEffectsMath.js +++ b/src/weatherEffectsMath.js @@ -13,7 +13,13 @@ function range01(value, min, max) { * Missing weather fails clear instead of inventing conditions. */ export function deriveWeatherEffectProfile(weather) { - if (!weather || !Number.isFinite(Number(weather.weatherCode))) { + const reported = weather?.weatherCode; + // Number(null) and Number('') are 0 (clear), so a missing code is checked first. + if ( + reported == null || + reported === '' || + !Number.isFinite(Number(reported)) + ) { return { available: false, cloud: 0, diff --git a/src/weatherEffectsMath.test.mjs b/src/weatherEffectsMath.test.mjs index 6ca4387bd..bed3d1cd4 100644 --- a/src/weatherEffectsMath.test.mjs +++ b/src/weatherEffectsMath.test.mjs @@ -10,6 +10,15 @@ test('missing weather fails clear instead of inventing effects', () => { assert.equal(profile.storm, 0); }); +test('a missing weather code is unavailable, not clear', () => { + for (const weatherCode of [null, undefined, '']) { + const profile = deriveWeatherEffectProfile({ weatherCode, cloudCoverPct: 90 }); + assert.equal(profile.available, false, `weatherCode ${weatherCode}`); + assert.equal(profile.cloud, 0); + } + assert.equal(deriveWeatherEffectProfile({ weatherCode: 0 }).available, true); +}); + test('clear weather follows observed cloud cover without precipitation', () => { const profile = deriveWeatherEffectProfile({ weatherCode: 1, From e50bc45674c4e7f940dce07f3c67b7c1ed83fb67 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:36:35 -0700 Subject: [PATCH 64/86] fix(qa): run the macOS-only gates on Windows and Linux (#1061) Seven QA scripts hard-coded macOS Google Chrome. They now share chromeExecutablePath(): PUPPETEER_EXECUTABLE_PATH when set, then macOS Chrome when installed, otherwise Puppeteer's managed browser. The cables screenshot path is built with fileURLToPath so it works on Windows. qa-overlay-baseline keeps its fixed Chrome because its baselines record the executable. Co-authored-by: Sameh Khamis --- scripts/format-scope.json | 2 ++ scripts/precompute-cctv-heights.mjs | 4 ++-- scripts/qa-cables-shot.mjs | 5 ++++- scripts/qa-chrome.mjs | 20 ++++++++++++++++++++ scripts/qa-directions.mjs | 4 ++-- scripts/qa-draw-tool.mjs | 4 ++-- scripts/qa-recent-imagery.mjs | 4 ++-- scripts/qa-transit-heading.mjs | 4 ++-- scripts/qa-transit-recovery.mjs | 4 ++-- scripts/qa-transit.mjs | 4 ++-- src/qaChrome.test.mjs | 27 +++++++++++++++++++++++++++ 11 files changed, 67 insertions(+), 15 deletions(-) create mode 100644 scripts/qa-chrome.mjs create mode 100644 src/qaChrome.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1a880b284..296880561 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -934,6 +934,8 @@ "scripts/qa-street-level.mjs", "src/tooling/streetLevelQa.test.mjs", "scripts/qa-browserEvidence.mjs", + "scripts/qa-chrome.mjs", + "src/qaChrome.test.mjs", "scripts/fixtures/street-level/mapillaryGraph.mjs", "scripts/fixtures/street-level/kartaView.mjs", "src/layers/streetLevel/filter.test.mjs", diff --git a/scripts/precompute-cctv-heights.mjs b/scripts/precompute-cctv-heights.mjs index 24813b5e7..648768341 100644 --- a/scripts/precompute-cctv-heights.mjs +++ b/scripts/precompute-cctv-heights.mjs @@ -1,6 +1,7 @@ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { chromeExecutablePath } from './qa-chrome.mjs'; import { planeSupportPoints, poseHash, @@ -389,8 +390,7 @@ async function main() { const { default: puppeteer } = await import('puppeteer'); browser = await puppeteer.launch({ headless: false, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), protocolTimeout: 600000, args: [ '--no-sandbox', diff --git a/scripts/qa-cables-shot.mjs b/scripts/qa-cables-shot.mjs index 12af9beb8..3e3008e03 100644 --- a/scripts/qa-cables-shot.mjs +++ b/scripts/qa-cables-shot.mjs @@ -6,6 +6,7 @@ */ import puppeteer from 'puppeteer'; import { mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; const argv = process.argv; const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214'; @@ -65,7 +66,9 @@ try { requestAnimationFrame(tick); })); await new Promise((r) => setTimeout(r, 1_000)); - const path = new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url).pathname; + const path = fileURLToPath( + new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url), + ); await page.screenshot({ path }); console.log(`saved ${path}`); } diff --git a/scripts/qa-chrome.mjs b/scripts/qa-chrome.mjs new file mode 100644 index 000000000..05c4dbff0 --- /dev/null +++ b/scripts/qa-chrome.mjs @@ -0,0 +1,20 @@ +/** + * The browser for gates that drive an installed Chrome. These gates were + * written against macOS Google Chrome, which Windows and Linux do not have. + */ +import { existsSync } from 'node:fs'; + +export const MACOS_CHROME = + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + +/** + * PUPPETEER_EXECUTABLE_PATH when set, then macOS Google Chrome when it is + * installed. Otherwise undefined, so Puppeteer launches the browser it manages. + */ +export function chromeExecutablePath({ + env = process.env, + exists = existsSync, +} = {}) { + if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH; + return exists(MACOS_CHROME) ? MACOS_CHROME : undefined; +} diff --git a/scripts/qa-directions.mjs b/scripts/qa-directions.mjs index 9501690f9..ca271026f 100644 --- a/scripts/qa-directions.mjs +++ b/scripts/qa-directions.mjs @@ -10,6 +10,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const args = process.argv.slice(2); @@ -35,8 +36,7 @@ const check = (name, passed, detail = '') => { const browser = await puppeteer.launch({ headless: false, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: [ '--no-sandbox', '--disable-background-timer-throttling', diff --git a/scripts/qa-draw-tool.mjs b/scripts/qa-draw-tool.mjs index a5a16af1d..adb29f3af 100644 --- a/scripts/qa-draw-tool.mjs +++ b/scripts/qa-draw-tool.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/draw'; @@ -22,8 +23,7 @@ const browser = await puppeteer.launch({ headless: !HEADFUL, ...(HEADFUL ? { - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), } : {}), args: [ diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 5d42279dc..89d60d96f 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -20,6 +20,7 @@ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery'; @@ -54,8 +55,7 @@ const browser = await puppeteer.launch({ headless: !HEADFUL, ...(HEADFUL ? { - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), } : {}), args: [ diff --git a/scripts/qa-transit-heading.mjs b/scripts/qa-transit-heading.mjs index a16bab9bc..c229be96c 100644 --- a/scripts/qa-transit-heading.mjs +++ b/scripts/qa-transit-heading.mjs @@ -1,11 +1,11 @@ import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; /** Prove parked and moving headings in the real scene, without live feeds. */ export async function runTransitHeadingRegression(base, check) { const browser = await puppeteer.launch({ headless: true, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: ['--no-sandbox', '--disable-background-timer-throttling'], }); try { diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index e5fadda49..5f6404b41 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -1,5 +1,6 @@ /** Camera-jump regression against an existing server; never starts a server. */ import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; import { mkdir, writeFile } from 'node:fs/promises'; import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; @@ -8,8 +9,7 @@ const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery'; await mkdir(shots, { recursive: true }); const browser = await puppeteer.launch({ headless: false, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: [ '--disable-background-timer-throttling', '--disable-renderer-backgrounding', diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index aac1d8f60..7d83d77a4 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -36,6 +36,7 @@ import { */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; import { createTransitHistory } from '../server/providers/transitHistory.js'; import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js'; import { transitModeTier } from '../src/data/transitPresetStyle.js'; @@ -262,8 +263,7 @@ await mkdir(SHOTS, { recursive: true }); const browser = await puppeteer.launch({ headless: false, protocolTimeout: 150000, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: [ '--no-sandbox', '--disable-background-timer-throttling', diff --git a/src/qaChrome.test.mjs b/src/qaChrome.test.mjs new file mode 100644 index 000000000..4414f3c93 --- /dev/null +++ b/src/qaChrome.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs'; + +test('gates keep macOS Chrome where it is installed', () => { + assert.equal( + chromeExecutablePath({ env: {}, exists: (p) => p === MACOS_CHROME }), + MACOS_CHROME, + ); +}); + +test('gates fall back to Puppeteer’s browser without macOS Chrome', () => { + assert.equal( + chromeExecutablePath({ env: {}, exists: () => false }), + undefined, + ); +}); + +test('an explicit executable path wins', () => { + assert.equal( + chromeExecutablePath({ + env: { PUPPETEER_EXECUTABLE_PATH: 'C:\\Chrome\\chrome.exe' }, + exists: () => true, + }), + 'C:\\Chrome\\chrome.exe', + ); +}); From 51d335b1db56a767ceab9aed3238889aaccebcdd Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 16:35:10 -0700 Subject: [PATCH 65/86] test: check the route cap at exactly 200 turns The existing "route that fits" case used 199 steps, which is how the off-by-one went unnoticed. Folds the docs note into the existing cap sentence and makes the changelog entry specific. --- CHANGELOG.md | 3 ++- docs/CURRENT-STATE.md | 11 ++++------- src/data/routeSteps.test.mjs | 4 ++-- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31fa3b502..a770fbd35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. -- Direction instruction limits report complete routes honestly and retain a final roundabout exit within the limit. +- Directions no longer shows a truncation notice for a route with exactly 200 + turns, and a roundabout at the 200th turn keeps its exit distance. - Weather records keep missing conditions unknown and refuse summaries without a measured temperature. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c98b2f667..1f794a5b5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3327,10 +3327,6 @@ from fixtures (`providers/mapillary/coverageFixture.mjs`, photo flow. CI runs the fixture gate and `qa:panel-resize` against a production build served by `vite preview` with a dummy token. -Directions retains up to 200 turn instructions. Its truncation notice appears -only when another independent instruction is omitted; a complete route with -exactly 200 turns does not show the notice. - Directions is a keyless front end to the routing the voice agent already uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the profile; SET A and SET B arm the next globe click (Escape or a second press @@ -3403,9 +3399,10 @@ rate limit from the routing service is reported as one instead of as "no route found". `src/data/routeSteps.js` turns OSRM maneuver type / modifier / exit / road name into one plain-English sentence per decision, folding `exit roundabout` steps into the roundabout they leave, and caps one route at -200 maneuvers — a route past that cap is reported as cut off, in the row -summary and as the last line of the list, because the arrival step is among -the ones dropped. +200 maneuvers — a route with more is reported as cut off, in the row summary +and as the last line of the list, because the arrival step is among the ones +dropped. A route of exactly 200 is complete, and a roundabout exit after the +200th keeps folding into it. A step's card opens from its dot on the globe or from its line in the list, and the list covers every step including departure and arrival, which have no dot. diff --git a/src/data/routeSteps.test.mjs b/src/data/routeSteps.test.mjs index a3c8cf227..6bf74dd08 100644 --- a/src/data/routeSteps.test.mjs +++ b/src/data/routeSteps.test.mjs @@ -206,9 +206,9 @@ test('the step list is capped, and the cap is reported rather than hidden', () = // would present a route that simply stops in the middle of a road. assert.equal(cut.truncated, true); const exact = normalizeOsrmSteps({ - legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX - 1) }], + legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX) }], }); - assert.equal(exact.steps.length, ROUTE_STEPS_MAX - 1); + assert.equal(exact.steps.length, ROUTE_STEPS_MAX); assert.equal(exact.truncated, false, 'a route that fits is not cut'); }); From 84714030c86ca42a770aa04e19ae52fabf457dc8 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 16:35:47 -0700 Subject: [PATCH 66/86] test: show a capped overview no longer holds repeat tiles Adds a case that fails before de-duplication (a 24-slot overview held repeats and reported itself truncated) and says why the date-line halves overlap. --- .../streetLevel/street-level-unique-tiles.test.mjs | 9 +++++++++ src/layers/streetLevel/tileMath.js | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/layers/streetLevel/street-level-unique-tiles.test.mjs b/src/layers/streetLevel/street-level-unique-tiles.test.mjs index 224de523b..52e7b6aea 100644 --- a/src/layers/streetLevel/street-level-unique-tiles.test.mjs +++ b/src/layers/streetLevel/street-level-unique-tiles.test.mjs @@ -20,3 +20,12 @@ test('reachable polar dateline coverage ranks 24 distinct tiles before overview ); assert.equal(overview.truncated, true); }); + +test('a limit that fits every distinct tile is not spent on repeats', () => { + // Before de-duplication this box yielded 27 entries for 24 tiles, so a + // 24-slot overview held repeats and still reported itself truncated. + const result = tilesForBbox([-10, 60, -20, 85], 3, { limit: 24 }); + const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`); + assert.equal(new Set(keys).size, 24); + assert.equal(result.truncated, false); +}); diff --git a/src/layers/streetLevel/tileMath.js b/src/layers/streetLevel/tileMath.js index ca7b09cd5..350188200 100644 --- a/src/layers/streetLevel/tileMath.js +++ b/src/layers/streetLevel/tileMath.js @@ -146,7 +146,10 @@ export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) { const dx = Math.abs(tile.x - centre.x); return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2; }; - // Each tile's distance is computed once; ties keep their row order. + // The date-line halves share a column when the gap between east and west is + // narrower than a tile; count that column once so a capped overview does not + // spend its slots on repeats. Each tile's distance is computed once; ties keep + // their row order. const unique = [ ...new Map( tiles.map((tile) => [`${tile.x}/${tile.y}/${tile.z}`, tile]), From c5ac24b6005d4c070378a99544fd78d6bc21ef09 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:40:12 -0400 Subject: [PATCH 67/86] fix(recent-imagery): reject absent box edges instead of inventing zero degrees --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 +++++ .../imagery-box-missing-edge.test.mjs | 16 ++++++++++++++++ src/layers/recentImagery/model.js | 17 +++++++++++------ 4 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 src/layers/recentImagery/imagery-box-missing-edge.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..95a92c46f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. +- Recent Imagery refuses missing bounding-box edges instead of interpreting them as zero coordinates. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..1ad836aba 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(recent-imagery): reject absent box edges instead of inventing zero degrees + +Reject absent, blank and boolean edges before numeric conversion while retaining valid finite numbers and numeric strings. Invalid boxes remain explicit invalid results instead of false geometry. diff --git a/src/layers/recentImagery/imagery-box-missing-edge.test.mjs b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs new file mode 100644 index 000000000..3e5a8e8a5 --- /dev/null +++ b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { validateBox, boxCentre } from './model.js'; +test('missing imagery edges do not turn into invented zero coordinates', () => { + for (const value of [null, undefined, '', ' ', false, true]) { + const box = { west: value, south: 0, east: 1, north: 1 }; + assert.equal(validateBox(box).ok, false); + assert.equal(validateBox(box).reason, 'invalid'); + assert.equal(boxCentre(box), null); + } + assert.equal(validateBox({ west: 0, south: 0, east: 1, north: 1 }).ok, true); + assert.equal( + validateBox({ west: '0', south: '0', east: '1', north: '1' }).ok, + true, + ); +}); diff --git a/src/layers/recentImagery/model.js b/src/layers/recentImagery/model.js index 23f503e9e..63c5ec16c 100644 --- a/src/layers/recentImagery/model.js +++ b/src/layers/recentImagery/model.js @@ -77,12 +77,17 @@ const pad2 = (value) => String(value).padStart(2, '0'); */ function normalizeBox(box) { if (!box || typeof box !== 'object') return null; - const [west, south, east, north] = [ - box.west, - box.south, - box.east, - box.north, - ].map(Number); + const edges = [box.west, box.south, box.east, box.north]; + if ( + edges.some( + (value) => + value == null || + typeof value === 'boolean' || + (typeof value === 'string' && !value.trim()), + ) + ) + return null; + const [west, south, east, north] = edges.map(Number); if (![west, south, east, north].every(Number.isFinite)) return null; return { west, From 9c9c80a7ed489c5ba1bb4a7159e87103e7ae25d2 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:03 -0400 Subject: [PATCH 68/86] docs: address review scope and feature placement for #1036 --- CHANGELOG.md | 3 +++ docs/CURRENT-STATE.md | 10 +++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95a92c46f..dddcdeef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ - Director timeline seeks cross the dateline along the short longitude arc. - Recent Imagery refuses missing bounding-box edges instead of interpreting them as zero coordinates. +- Recent Imagery’s exported `setBox` and `searchHls` reject missing, blank or + boolean programmatic box edges instead of interpreting them as zero coordinates. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1ad836aba..054aff963 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4449,6 +4449,11 @@ are omitted rather than framing the wrong part of the globe. ### Recent Imagery (September 2026) +Programmatic boxes passed to the exported `setBox` and `searchHls` operations +reject missing, blank or boolean edges instead of interpreting them as zero +coordinates. Valid finite numeric edges remain accepted; the app’s drag tool, +pins and share links already supply complete boxes. + - The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room. - DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes. - **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height. @@ -5326,8 +5331,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(recent-imagery): reject absent box edges instead of inventing zero degrees - -Reject absent, blank and boolean edges before numeric conversion while retaining valid finite numbers and numeric strings. Invalid boxes remain explicit invalid results instead of false geometry. From 6ffbf7f5244ccaaf8959118ad857efe8761e58a1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 16:36:29 -0700 Subject: [PATCH 69/86] fix(imagery): refuse blank box edges at every conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share-link integers and Cesium radians were converted before normalizeBox checked them, so a blank edge still became 0°. Edges are now accepted only as numbers or non-blank numeric strings, which also refuses [] and {}. Tests cover all four edges through validateBox, dequantizeBox, boxFromRectangle and searchHls. --- CHANGELOG.md | 1 - .../imagery-box-missing-edge.test.mjs | 34 +++++++++++++++- src/layers/recentImagery/model.js | 40 +++++++++++-------- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dddcdeef8..9ab3b03cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,6 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. -- Recent Imagery refuses missing bounding-box edges instead of interpreting them as zero coordinates. - Recent Imagery’s exported `setBox` and `searchHls` reject missing, blank or boolean programmatic box edges instead of interpreting them as zero coordinates. diff --git a/src/layers/recentImagery/imagery-box-missing-edge.test.mjs b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs index 3e5a8e8a5..698bcaee5 100644 --- a/src/layers/recentImagery/imagery-box-missing-edge.test.mjs +++ b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs @@ -1,6 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { validateBox, boxCentre } from './model.js'; +import { + boxCentre, + boxFromRectangle, + dequantizeBox, + validateBox, +} from './model.js'; +import { searchHls } from './catalog.js'; + test('missing imagery edges do not turn into invented zero coordinates', () => { for (const value of [null, undefined, '', ' ', false, true]) { const box = { west: value, south: 0, east: 1, north: 1 }; @@ -14,3 +21,28 @@ test('missing imagery edges do not turn into invented zero coordinates', () => { true, ); }); + +test('every edge and every box entry point refuses a missing edge', async () => { + const blanks = [null, undefined, '', ' ', false, [], {}]; + for (const edge of ['west', 'south', 'east', 'north']) { + for (const value of blanks) { + const box = { west: 1, south: 1, east: 2, north: 2, [edge]: value }; + assert.equal(validateBox(box).ok, false, `${edge}=${String(value)}`); + // Share-link integers and Cesium radians are converted per edge. + assert.equal(dequantizeBox(box), null, `share ${edge}`); + assert.equal(boxFromRectangle(box), null, `radians ${edge}`); + } + } + let fetched = false; + await assert.rejects( + searchHls({ + box: { west: ' ', south: 1, east: 2, north: 2 }, + fetchImpl: async () => { + fetched = true; + }, + }), + TypeError, + ); + assert.equal(fetched, false); + assert.ok(dequantizeBox({ west: 0, south: 0, east: 100000, north: 100000 })); +}); diff --git a/src/layers/recentImagery/model.js b/src/layers/recentImagery/model.js index 63c5ec16c..24ef54fc6 100644 --- a/src/layers/recentImagery/model.js +++ b/src/layers/recentImagery/model.js @@ -75,20 +75,23 @@ const pad2 = (value) => String(value).padStart(2, '0'); * east are not swapped: west > east crosses the dateline and `validateBox` * rejects it. Null when any edge is nonfinite. */ +/** A box edge as a number, or null when it is missing, blank or not numeric. */ +function edgeNumber(value) { + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value !== 'string' || !value.trim()) return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + function normalizeBox(box) { if (!box || typeof box !== 'object') return null; - const edges = [box.west, box.south, box.east, box.north]; - if ( - edges.some( - (value) => - value == null || - typeof value === 'boolean' || - (typeof value === 'string' && !value.trim()), - ) - ) - return null; - const [west, south, east, north] = edges.map(Number); - if (![west, south, east, north].every(Number.isFinite)) return null; + const [west, south, east, north] = [ + box.west, + box.south, + box.east, + box.north, + ].map(edgeNumber); + if ([west, south, east, north].some((edge) => edge == null)) return null; return { west, south: Math.min(south, north), @@ -216,11 +219,16 @@ export function boxFromPin(lon, lat, sideKm = PIN_BOX_SIDE_KM) { function mapBox(box, fn) { if (!box || typeof box !== 'object') return null; + // Converting first would turn a blank edge into 0 before normalizeBox sees it. + const edge = (value) => { + const number = edgeNumber(value); + return number == null ? null : fn(number); + }; return normalizeBox({ - west: fn(box.west), - south: fn(box.south), - east: fn(box.east), - north: fn(box.north), + west: edge(box.west), + south: edge(box.south), + east: edge(box.east), + north: edge(box.north), }); } From 9f56e9df981eeb6f090c757d36bd081415edb34d Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:47:56 -0400 Subject: [PATCH 70/86] fix(terrain): refresh cache recency when resolving warm batches --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 ++++ src/services/terrain-warm-batch-lru.test.mjs | 24 ++++++++++++++++++++ src/services/terrainHeights.js | 2 +- 4 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 src/services/terrain-warm-batch-lru.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..e3075234c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. +- Frequently reused terrain batches retain their cached heights ahead of unused cells. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..246f3067c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(terrain): refresh cache recency when resolving warm batches + +Use the existing LRU read path for batch lookups; provider requests, fallback cooldowns and bounded residency retain their current contracts. diff --git a/src/services/terrain-warm-batch-lru.test.mjs b/src/services/terrain-warm-batch-lru.test.mjs new file mode 100644 index 000000000..ac718b998 --- /dev/null +++ b/src/services/terrain-warm-batch-lru.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createTerrainHeights } from './terrainHeights.js'; +test('warm batch consumers retain their terrain cells ahead of unused cells', async () => { + let calls = 0; + const terrain = createTerrainHeights({ + maxCacheEntries: 2, + source: { + async getHeights(points) { + calls++; + return points.map((p) => ({ ellipsoid: 100 + p.lon })); + }, + }, + }); + const p = (lon) => ({ lat: 0, lon }); + await terrain.resolveEllipsoidalGround([p(1)]); + await terrain.resolveEllipsoidalGround([p(2)]); + await terrain.resolveEllipsoidalGround([p(1)]); + assert.equal(calls, 2); + await terrain.resolveEllipsoidalGround([p(3)]); + assert.equal(terrain.cachedEllipsoidalGround(0, 1), 101); + assert.equal(terrain.cachedEllipsoidalGround(0, 2), null); + assert.equal(calls, 3); +}); diff --git a/src/services/terrainHeights.js b/src/services/terrainHeights.js index a6502aeb5..124777150 100644 --- a/src/services/terrainHeights.js +++ b/src/services/terrainHeights.js @@ -273,7 +273,7 @@ export function createTerrainHeights({ const seenKeys = new Set(); const now = Date.now(); for (const item of work) { - const entry = cache.get(item.key); + const entry = cacheTouch(item.key); const fallbackCooling = entry?.source === 'geoid-fallback' && Number.isFinite(entry.retryAt) && From 07757a3cae25956f362525432bf2b45bb1908bd3 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:14:26 -0400 Subject: [PATCH 71/86] docs: address maintainer review for PR #1040 --- docs/CURRENT-STATE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 246f3067c..fcb2f3b3f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1192,6 +1192,8 @@ The browser layers and their rendering remain in their existing modules. ## Terrain height cache bound +Warm batch lookups also promote their cached entries in the least-recently-used eviction order. + The client terrain-height resolver's in-memory cache is bounded at 20 000 entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through `createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated @@ -5326,8 +5328,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(terrain): refresh cache recency when resolving warm batches - -Use the existing LRU read path for batch lookups; provider requests, fallback cooldowns and bounded residency retain their current contracts. From b4aa61d6ef53982ed2cf34f0eea65cabf181c86d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 16:37:30 -0700 Subject: [PATCH 72/86] fix(terrain): keep a batch's warm points when its own fetches evict them A warm entry could be evicted by the same call's later fetches and then reported unresolved. The call now remembers warm hits as it does fetched ones. Moves the batch LRU test beside the existing cache tests and folds the docs note into the section's eviction paragraph. --- docs/CURRENT-STATE.md | 10 ++--- src/services/terrain-warm-batch-lru.test.mjs | 24 ------------ src/services/terrainHeights.js | 20 +++++----- src/services/terrainHeights.test.mjs | 41 ++++++++++++++++++++ 4 files changed, 54 insertions(+), 41 deletions(-) delete mode 100644 src/services/terrain-warm-batch-lru.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fcb2f3b3f..d28fee7e1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1192,19 +1192,17 @@ The browser layers and their rendering remain in their existing modules. ## Terrain height cache bound -Warm batch lookups also promote their cached entries in the least-recently-used eviction order. - The client terrain-height resolver's in-memory cache is bounded at 20 000 entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through `createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated visits to one place share a key but does not bound how many distinct places a session visits, and the cache previously had no eviction, so a long session retained every coordinate it ever resolved. Eviction is least-recently-used: -a consumer read promotes its entry, so the cell being watched is not dropped for -having been resolved early. The bound sits far above a city-scale session, so +a consumer read, including a warm hit in a batch lookup, promotes its entry, so +the cell being watched is not dropped for having been resolved early. The bound sits far above a city-scale session, so normal use never evicts and issues no additional proxy requests. A batch's -results are assembled from what that call resolved, so eviction during a large -batch cannot report a just-resolved point as unresolved. Re:Earth and +results are assembled from what that call resolved or found warm, so eviction +during a large batch cannot report one of its points as unresolved. Re:Earth and geoid-fallback entry semantics, the fallback cooldown and the abort-time flush are unchanged. diff --git a/src/services/terrain-warm-batch-lru.test.mjs b/src/services/terrain-warm-batch-lru.test.mjs deleted file mode 100644 index ac718b998..000000000 --- a/src/services/terrain-warm-batch-lru.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createTerrainHeights } from './terrainHeights.js'; -test('warm batch consumers retain their terrain cells ahead of unused cells', async () => { - let calls = 0; - const terrain = createTerrainHeights({ - maxCacheEntries: 2, - source: { - async getHeights(points) { - calls++; - return points.map((p) => ({ ellipsoid: 100 + p.lon })); - }, - }, - }); - const p = (lon) => ({ lat: 0, lon }); - await terrain.resolveEllipsoidalGround([p(1)]); - await terrain.resolveEllipsoidalGround([p(2)]); - await terrain.resolveEllipsoidalGround([p(1)]); - assert.equal(calls, 2); - await terrain.resolveEllipsoidalGround([p(3)]); - assert.equal(terrain.cachedEllipsoidalGround(0, 1), 101); - assert.equal(terrain.cachedEllipsoidalGround(0, 2), null); - assert.equal(calls, 3); -}); diff --git a/src/services/terrainHeights.js b/src/services/terrainHeights.js index 124777150..f84a4413f 100644 --- a/src/services/terrainHeights.js +++ b/src/services/terrainHeights.js @@ -272,28 +272,26 @@ export function createTerrainHeights({ const uncached = []; const seenKeys = new Set(); const now = Date.now(); + // What this call resolved or found warm, so the assembly below can report a + // point even if a later chunk's writes pushed it past CACHE_MAX_ENTRIES. + // Without it a batch big enough to evict its own entries would report + // points as unresolved that it had in fact resolved. + const resolvedThisCall = new Map(); for (const item of work) { const entry = cacheTouch(item.key); const fallbackCooling = entry?.source === 'geoid-fallback' && Number.isFinite(entry.retryAt) && now < entry.retryAt; - if ( - (entry && entry.source === 'reearth') || - fallbackCooling || - seenKeys.has(item.key) - ) + if ((entry && entry.source === 'reearth') || fallbackCooling) { + resolvedThisCall.set(item.key, entry); continue; + } + if (seenKeys.has(item.key)) continue; seenKeys.add(item.key); uncached.push(item); } - // What this call resolved, so the assembly below can report a point even - // if a later chunk's writes pushed it past CACHE_MAX_ENTRIES. Without it a - // batch big enough to evict its own earlier chunks would report points as - // unresolved that it had in fact just resolved. - const resolvedThisCall = new Map(); - // Resolve the network path in sequential <=CHUNK_SIZE chunks. Each chunk's // failure is isolated to that chunk's points (geoid fallback), so a single // bad chunk doesn't lose results for the rest of a large batch. diff --git a/src/services/terrainHeights.test.mjs b/src/services/terrainHeights.test.mjs index c9884e89c..5e80b9be2 100644 --- a/src/services/terrainHeights.test.mjs +++ b/src/services/terrainHeights.test.mjs @@ -114,6 +114,47 @@ test('a batch larger than the cache bound still reports every point it resolved' assert.equal(warm.length, 4); }); +test('warm batch consumers retain their terrain cells ahead of unused cells', async () => { + let calls = 0; + const terrain = createTerrainHeights({ + maxCacheEntries: 2, + source: { + async getHeights(points) { + calls++; + return points.map((p) => ({ ellipsoid: 100 + p.lon })); + }, + }, + }); + const p = (lon) => ({ lat: 0, lon }); + await terrain.resolveEllipsoidalGround([p(1)]); + await terrain.resolveEllipsoidalGround([p(2)]); + await terrain.resolveEllipsoidalGround([p(1)]); + assert.equal(calls, 2); + await terrain.resolveEllipsoidalGround([p(3)]); + assert.equal(terrain.cachedEllipsoidalGround(0, 1), 101); + assert.equal(terrain.cachedEllipsoidalGround(0, 2), null); + assert.equal(calls, 3); +}); + +test('a batch still reports its warm points after its own fetches evict them', async () => { + const terrain = createTerrainHeights({ + source: stubSource(), + maxCacheEntries: 2, + }); + await terrain.resolveEllipsoidalGround([pointAt(0)]); + + const results = await terrain.resolveEllipsoidalGround([ + pointAt(0), + pointAt(1), + pointAt(2), + ]); + + results.forEach((result, i) => { + assert.equal(result.source, 'reearth', `point ${i} reported resolved`); + assert.equal(result.ellipsoid, heightAt(lonAt(i))); + }); +}); + test('an out-of-range cache bound falls back to the default instead of disabling the cache', async () => { for (const maxCacheEntries of [0, -1, Number.NaN, undefined]) { const terrain = createTerrainHeights({ From d861f24d3e4456de567590ba253cd6a5b84a68f3 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:11:57 -0400 Subject: [PATCH 73/86] docs: separate CHANGELOG bullets after rebase Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3075234c..80d2a4544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. + - Frequently reused terrain batches retain their cached heights ahead of unused cells. - Treat explicitly current, complete empty vessel coverage as a successful update, From 8b68ff8412747986895811912ea4d5e26c4c0a9c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:49:44 -0400 Subject: [PATCH 74/86] fix(firms): reject missing and out-of-range detection coordinates --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 ++++ src/data/firms-coordinate-evidence.test.mjs | 31 +++++++++++++++++++++ src/data/firmsAdapt.js | 13 ++++++++- src/data/firmsCsv.js | 10 ++++++- 5 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 src/data/firms-coordinate-evidence.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..446192c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. +- Fire detections with missing or impossible positions are skipped while genuine zero coordinates remain valid. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..967fbda64 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(firms): reject missing and out-of-range detection coordinates + +Require explicit numeric coordinate evidence and WGS84 ranges in both ingestion paths, preserving genuine zero coordinates and sequential admitted detection indices. diff --git a/src/data/firms-coordinate-evidence.test.mjs b/src/data/firms-coordinate-evidence.test.mjs new file mode 100644 index 000000000..494b38c18 --- /dev/null +++ b/src/data/firms-coordinate-evidence.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseFirmsCsv } from './firmsCsv.js'; +import { adaptFirmsRecords } from './firmsAdapt.js'; +test('missing positions do not become fire detections at zero coordinates', () => { + const csv = + 'latitude,longitude,acq_date,acq_time,confidence,frp\n,1,2026-07-16,1200,n,1\n1, ,2026-07-16,1200,n,1\n95,1,2026-07-16,1200,n,1\n1,181,2026-07-16,1200,n,1\n0,0,2026-07-16,1200,n,1\n-90,-180,2026-07-16,1200,n,1'; + assert.deepEqual( + parseFirmsCsv(csv).map((r) => [r.lat, r.lon]), + [ + [0, 0], + [-90, -180], + ], + ); + const records = [ + { lat: null, lon: 1 }, + { lat: '', lon: 1 }, + { lat: false, lon: 1 }, + { lat: 95, lon: 1 }, + { lat: 1, lon: 181 }, + { lat: '0', lon: 0 }, + { lat: -90, lon: -180 }, + ]; + assert.deepEqual( + adaptFirmsRecords(records).map((r) => [r.index, r.lat, r.lon]), + [ + [0, 0, 0], + [1, -90, -180], + ], + ); +}); diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index 2fc909bce..b8f07ec2d 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -21,7 +21,18 @@ export function adaptFirmsRecords(records) { for (const record of records) { const lat = Number(record?.lat); const lon = Number(record?.lon); - if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + if ( + ![record?.lat, record?.lon].every( + (value) => + typeof value === 'number' || + (typeof value === 'string' && value.trim() !== ''), + ) || + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) + continue; fires.push({ index: fires.length, lat, diff --git a/src/data/firmsCsv.js b/src/data/firmsCsv.js index 7f9019e18..c89d10a0a 100644 --- a/src/data/firmsCsv.js +++ b/src/data/firmsCsv.js @@ -105,7 +105,15 @@ export function parseFirmsCsv(text) { if (parts.length < header.length) continue; // malformed row — skip const lat = Number(parts[iLat]); const lon = Number(parts[iLon]); - if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + if ( + !parts[iLat].trim() || + !parts[iLon].trim() || + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) + continue; records.push({ lat, From 5a14a06a52ca70517b1a6199177d1b4ea2a47f0c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:14:28 -0400 Subject: [PATCH 75/86] docs: address maintainer review for PR #1044 --- docs/CURRENT-STATE.md | 7 ++----- src/data/firmsAdapt.js | 14 +++++++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 967fbda64..4c33b1744 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1429,6 +1429,8 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## FIRMS source status +FIRMS ingestion and adaptation require explicit finite coordinates within WGS84 ranges; genuine zero coordinates remain valid and admitted detection indices stay sequential. + The FIRMS proxy records source success after appending its rows. If aggregation throws, that source reports failure without a contradictory success entry. The existing per-record append continues to support large feeds; sequential @@ -5326,8 +5328,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(firms): reject missing and out-of-range detection coordinates - -Require explicit numeric coordinate evidence and WGS84 ranges in both ingestion paths, preserving genuine zero coordinates and sequential admitted detection indices. diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index b8f07ec2d..3e3f27631 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -22,11 +22,8 @@ export function adaptFirmsRecords(records) { const lat = Number(record?.lat); const lon = Number(record?.lon); if ( - ![record?.lat, record?.lon].every( - (value) => - typeof value === 'number' || - (typeof value === 'string' && value.trim() !== ''), - ) || + !hasCoordinateValue(record?.lat) || + !hasCoordinateValue(record?.lon) || !Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || @@ -121,3 +118,10 @@ function finiteNumber(value) { const number = Number(value); return Number.isFinite(number) ? number : 0; } + +function hasCoordinateValue(value) { + return ( + typeof value === 'number' || + (typeof value === 'string' && value.trim() !== '') + ); +} From e26a715b04968cac14646735232ce75b32716793 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:12:33 -0400 Subject: [PATCH 76/86] docs: separate CHANGELOG bullets after rebase Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 446192c88..077d7c5c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. + - Fire detections with missing or impossible positions are skipped while genuine zero coordinates remain valid. - Treat explicitly current, complete empty vessel coverage as a successful update, From cd7c23b36fcee32f646723456bd1a7d163b86d9b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:49:47 -0400 Subject: [PATCH 77/86] fix(director): reject cancellation during the final asset stream read --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 +++ .../packs/asset-stream-cancellation.test.mjs | 42 +++++++++++++++++++ src/director/packs/source.js | 1 + 4 files changed, 49 insertions(+) create mode 100644 src/director/packs/asset-stream-cancellation.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..a7f8ca53d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. +- Director assets cancelled during the final stream read are discarded with reader cleanup. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..20e346aad 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(director): reject cancellation during the final asset stream read + +Re-check caller cancellation immediately after every awaited read, including done reads, while retaining reader cancellation and lock cleanup. diff --git a/src/director/packs/asset-stream-cancellation.test.mjs b/src/director/packs/asset-stream-cancellation.test.mjs new file mode 100644 index 000000000..cd4f2490d --- /dev/null +++ b/src/director/packs/asset-stream-cancellation.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createAssetDirectorySource } from './source.js'; +test('cancellation during the terminating read cannot return a scene asset', async () => { + for (const cancel of [true, false]) { + const controller = new AbortController(); + let reads = 0, + cancelled = 0, + released = 0; + const source = createAssetDirectorySource({ + baseUrl: 'https://fixture.example/assets/', + fetchImpl: async () => ({ + ok: true, + headers: new Headers({ 'content-type': 'application/octet-stream' }), + body: { + getReader() { + return { + async read() { + reads++; + if (reads === 1) + return { done: false, value: new Uint8Array([1, 2]) }; + if (cancel) controller.abort(); + return { done: true }; + }, + async cancel() { + cancelled++; + }, + releaseLock() { + released++; + }, + }; + }, + }, + }), + }); + const pending = source({ path: 'scene.bin', signal: controller.signal }); + if (cancel) await assert.rejects(pending, { name: 'AbortError' }); + else assert.deepEqual([...(await pending).bytes], [1, 2]); + assert.equal(cancelled, 1); + assert.equal(released, 1); + } +}); diff --git a/src/director/packs/source.js b/src/director/packs/source.js index 4828827af..052581a70 100644 --- a/src/director/packs/source.js +++ b/src/director/packs/source.js @@ -41,6 +41,7 @@ export function createAssetDirectorySource({ for (;;) { signal?.throwIfAborted(); const { done, value } = await reader.read(); + signal?.throwIfAborted(); if (done) break; length += value.byteLength; if (length > maxBytes) throw new Error('Asset exceeds byte limit'); From 04b712910d3e4793180f4bd66b1f198ebe704419 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:14:30 -0400 Subject: [PATCH 78/86] docs: address maintainer review for PR #1046 --- docs/CURRENT-STATE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 20e346aad..fae0cc8bf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -396,6 +396,8 @@ and geographic placement remain distinct; Stop/replacement releases resources. Import acquires nothing. See [data packs](DIRECTOR-DATA-PACKS.md). Existing scene content, assets and credit remain unchanged. +The asset directory source rejects with the caller's abort reason if cancellation arrives during any read, including the final one. + Director version-4 documents add named geographic anchors and optional explicit camera moves with easing, duration and holds. Playback and seek share one coordinate sampler; navigation/manual input revokes authored motion and pending @@ -5326,8 +5328,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(director): reject cancellation during the final asset stream read - -Re-check caller cancellation immediately after every awaited read, including done reads, while retaining reader cancellation and lock cleanup. From 5889d353272723a75e8b0bfaacbc2085873e15f7 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:51:10 -0400 Subject: [PATCH 79/86] fix(maps): stop delivering settled state to detached subscribers --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 ++ src/maps/controller.js | 1 + src/maps/map-subscription-ownership.test.mjs | 55 ++++++++++++++++++++ 4 files changed, 62 insertions(+) create mode 100644 src/maps/map-subscription-ownership.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..362eb93e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. +- Map state updates stop delivering to subscribers removed during the same notification. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..558963496 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(maps): stop delivering settled state to detached subscribers + +Recheck listener ownership and controller lifetime before each settled-state callback. Still-active subscribers keep their ordering and exception isolation. diff --git a/src/maps/controller.js b/src/maps/controller.js index e595ede26..d51b67082 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -142,6 +142,7 @@ export class MapSourceController { if (this._destroyed || !this._subscribers.size) return; const state = this.getState(); for (const listener of [...this._subscribers]) { + if (this._destroyed || !this._subscribers.has(listener)) continue; try { listener(state); } catch (error) { diff --git a/src/maps/map-subscription-ownership.test.mjs b/src/maps/map-subscription-ownership.test.mjs new file mode 100644 index 000000000..638de65a2 --- /dev/null +++ b/src/maps/map-subscription-ownership.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { MapSourceController } from './controller.js'; +function fixture() { + return new MapSourceController( + { + scene: { + globe: { show: true }, + requestRender() {}, + frameState: { + creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} }, + }, + primitives: { add() {}, remove() {} }, + }, + imageryLayers: { add() {}, remove() {} }, + }, + { + registry: { + defaultId: 'fixture', + sources: [ + { + descriptor: { id: 'fixture', label: 'Fixture', kind: 'imagery' }, + imagery: async () => ({}), + }, + ], + }, + createImageryLayer: (provider) => ({ provider }), + }, + ); +} +test('an unsubscribed owner is skipped in the same map settlement delivery', async () => { + const controller = fixture(); + const calls = []; + let detach; + controller.subscribe(() => { + calls.push('first'); + detach(); + }); + detach = controller.subscribe(() => calls.push('detached')); + controller.subscribe(() => calls.push('last')); + await controller.setStack('fixture'); + assert.deepEqual(calls, ['first', 'last']); + controller.destroy(); +}); +test('destroying the controller during delivery stops remaining listeners', async () => { + const controller = fixture(); + const calls = []; + controller.subscribe(() => { + calls.push('destroy'); + controller.destroy(); + }); + controller.subscribe(() => calls.push('detached')); + await controller.setStack('fixture'); + assert.deepEqual(calls, ['destroy']); +}); From bbbdd0aeba19f9f73a0ba43db4a0a2d7c84b5b35 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:14:31 -0400 Subject: [PATCH 80/86] docs: address maintainer review for PR #1047 --- docs/CURRENT-STATE.md | 7 ++----- src/maps/controller.js | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 558963496..ef3720370 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5200,6 +5200,8 @@ the project's MIT license; see `DATA_SOURCES.md`. ## Map source factories and coordination +A subscriber removed, or a controller destroyed, during a settled-state delivery receives no further callbacks from that delivery. + Map selection is composed from separate imagery, terrain and 3D factories. The default registry retains Google 3D, Bing Aerial/Labels, Esri Satellite and OSM, including their setup guidance and existing preset/share IDs. Esri still @@ -5326,8 +5328,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(maps): stop delivering settled state to detached subscribers - -Recheck listener ownership and controller lifetime before each settled-state callback. Still-active subscribers keep their ordering and exception isolation. diff --git a/src/maps/controller.js b/src/maps/controller.js index d51b67082..6dd2720f7 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -142,7 +142,7 @@ export class MapSourceController { if (this._destroyed || !this._subscribers.size) return; const state = this.getState(); for (const listener of [...this._subscribers]) { - if (this._destroyed || !this._subscribers.has(listener)) continue; + if (!this._subscribers.has(listener)) continue; try { listener(state); } catch (error) { From e225f0851b63f014f40233f9e611c79af215e394 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:13:15 -0400 Subject: [PATCH 81/86] docs: separate CHANGELOG bullets after rebase Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 362eb93e7..168d5b9db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. + - Map state updates stop delivering to subscribers removed during the same notification. - Treat explicitly current, complete empty vessel coverage as a successful update, From 1f83897d7719977a5405045e9761e8554ff84315 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:13:26 -0400 Subject: [PATCH 82/86] docs: separate CHANGELOG bullets after rebase Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7f8ca53d..44a6e5908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. + - Director assets cancelled during the final stream read are discarded with reader cleanup. - Treat explicitly current, complete empty vessel coverage as a successful update, From 6a7b89e436f282356375f6dff667ec8ffb0c1a30 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 19:21:57 -0700 Subject: [PATCH 83/86] test(firms): cover both coordinate bounds and blank values on each axis Documents the adapter's skip rule and cross-references the two checks, which sit in separate packages (provider parser and fire layer). --- src/data/firms-coordinate-evidence.test.mjs | 38 +++++++++++++++++++++ src/data/firmsAdapt.js | 5 +-- src/data/firmsCsv.js | 2 ++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/data/firms-coordinate-evidence.test.mjs b/src/data/firms-coordinate-evidence.test.mjs index 494b38c18..d60a7ee37 100644 --- a/src/data/firms-coordinate-evidence.test.mjs +++ b/src/data/firms-coordinate-evidence.test.mjs @@ -29,3 +29,41 @@ test('missing positions do not become fire detections at zero coordinates', () = ], ); }); + +test('both axes keep their exact bounds and drop values just past them', () => { + const row = (lat, lon) => `${lat},${lon},2026-07-16,1200,n,1`; + const csv = [ + 'latitude,longitude,acq_date,acq_time,confidence,frp', + row(90, 180), + row(-90, -180), + row(90.000001, 0), + row(-90.000001, 0), + row(0, 180.000001), + row(0, -180.000001), + row(' ', 5), + row(5, '\t'), + ].join('\n'); + assert.deepEqual( + parseFirmsCsv(csv).map((r) => [r.lat, r.lon]), + [ + [90, 180], + [-90, -180], + ], + ); + const kept = adaptFirmsRecords([ + { lat: 90, lon: 180 }, + { lat: '-90', lon: '-180' }, + { lat: -90.000001, lon: 0 }, + { lat: 0, lon: -180.000001 }, + { lat: 5, lon: ' ' }, + { lat: 5, lon: undefined }, + { lat: [5], lon: 5 }, + ]); + assert.deepEqual( + kept.map((r) => [r.lat, r.lon]), + [ + [90, 180], + [-90, -180], + ], + ); +}); diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index 3e3f27631..312838e80 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -7,8 +7,8 @@ */ /** - * Map proxy fire records into internal fire records. Records with - * non-finite coordinates are skipped; `index` is the post-skip position + * Map proxy fire records into internal fire records. Records with missing, + * non-numeric or out-of-range (WGS84) coordinates are skipped; `index` is the post-skip position * (it keys pick ids and context-store ids, so it must stay sequential). * `contextEntity`/`position` start null and are lazily filled by the layer. * @param {?Array} records - /api/firms `fires` array. @@ -119,6 +119,7 @@ function finiteNumber(value) { return Number.isFinite(number) ? number : 0; } +// Same rule as parseFirmsCsv: a blank or non-numeric value is not a zero. function hasCoordinateValue(value) { return ( typeof value === 'number' || diff --git a/src/data/firmsCsv.js b/src/data/firmsCsv.js index c89d10a0a..aafeac50b 100644 --- a/src/data/firmsCsv.js +++ b/src/data/firmsCsv.js @@ -105,6 +105,8 @@ export function parseFirmsCsv(text) { if (parts.length < header.length) continue; // malformed row — skip const lat = Number(parts[iLat]); const lon = Number(parts[iLon]); + // Blank or out-of-range positions are dropped, never read as 0 (see + // hasCoordinateValue in firmsAdapt.js for the proxy-record side). if ( !parts[iLat].trim() || !parts[iLon].trim() || From bf4edb5bdb7697372109cf262c377c6b95c98512 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 19:23:12 -0700 Subject: [PATCH 84/86] fix(director): discard an asset cancelled during reader cleanup The finally block awaits reader.cancel(), so a cancel arriving there still let the bytes return. Rechecks the signal after cleanup, with a test that keeps the caller's abort reason, and wraps the current-state note. --- docs/CURRENT-STATE.md | 4 +- .../packs/asset-stream-cancellation.test.mjs | 37 +++++++++++++++++++ src/director/packs/source.js | 2 + 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8a70f30e9..de4ca26ca 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -396,7 +396,9 @@ and geographic placement remain distinct; Stop/replacement releases resources. Import acquires nothing. See [data packs](DIRECTOR-DATA-PACKS.md). Existing scene content, assets and credit remain unchanged. -The asset directory source rejects with the caller's abort reason if cancellation arrives during any read, including the final one. +The asset directory source rejects with the caller's abort reason if +cancellation arrives during any read, including the final one, or while the +reader is being released. Director version-4 documents add named geographic anchors and optional explicit camera moves with easing, duration and holds. Playback and seek share one diff --git a/src/director/packs/asset-stream-cancellation.test.mjs b/src/director/packs/asset-stream-cancellation.test.mjs index cd4f2490d..eb67244ff 100644 --- a/src/director/packs/asset-stream-cancellation.test.mjs +++ b/src/director/packs/asset-stream-cancellation.test.mjs @@ -40,3 +40,40 @@ test('cancellation during the terminating read cannot return a scene asset', asy assert.equal(released, 1); } }); + +test('cancellation during reader cleanup cannot return a scene asset', async () => { + const controller = new AbortController(); + const reason = new DOMException('scene changed', 'AbortError'); + let released = 0; + const source = createAssetDirectorySource({ + baseUrl: 'https://fixture.example/assets/', + fetchImpl: async () => ({ + ok: true, + headers: new Headers(), + body: { + getReader() { + let reads = 0; + return { + async read() { + reads++; + return reads === 1 + ? { done: false, value: new Uint8Array([1]) } + : { done: true }; + }, + async cancel() { + controller.abort(reason); + }, + releaseLock() { + released++; + }, + }; + }, + }, + }), + }); + await assert.rejects( + source({ path: 'scene.bin', signal: controller.signal }), + (error) => error === reason, + ); + assert.equal(released, 1); +}); diff --git a/src/director/packs/source.js b/src/director/packs/source.js index 052581a70..0ad920670 100644 --- a/src/director/packs/source.js +++ b/src/director/packs/source.js @@ -51,6 +51,8 @@ export function createAssetDirectorySource({ await reader.cancel().catch(() => {}); reader.releaseLock(); } + // Cleanup awaits too; a cancel during it must still discard the asset. + signal?.throwIfAborted(); const bytes = new Uint8Array(length); let offset = 0; for (const chunk of chunks) { From b9fa0eedcc2367a018f501688a9bd6d4e86bb956 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 19:23:46 -0700 Subject: [PATCH 85/86] fix(maps): stop all delivery once the controller is destroyed A listener could destroy the controller and re-subscribe one still pending in the snapshot, which then ran. Delivery now stops at destruction and a destroyed controller takes no listeners. Adds ordering tests for adds, duplicates and re-adds during delivery, and moves the current-state note after the section intro. --- docs/CURRENT-STATE.md | 6 ++-- src/maps/controller.js | 4 ++- src/maps/map-subscription-ownership.test.mjs | 34 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8ecd0eae8..8474257b6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5209,8 +5209,6 @@ the project's MIT license; see `DATA_SOURCES.md`. ## Map source factories and coordination -A subscriber removed, or a controller destroyed, during a settled-state delivery receives no further callbacks from that delivery. - Map selection is composed from separate imagery, terrain and 3D factories. The default registry retains Google 3D, Bing Aerial/Labels, Esri Satellite and OSM, including their setup guidance and existing preset/share IDs. Esri still @@ -5218,6 +5216,10 @@ falls back to OSM on construction failure or two active tile failures, and credits follow the source actually displayed. Terrain remains lazy while the photoreal globe is hidden. +Map-stack subscribers hear each settled activation once. A subscriber removed +during a delivery, or every subscriber once the controller is destroyed, gets +no further callback from it, and a destroyed controller accepts no new ones. + Map credentials are passed to each source constructor rather than changing SDK-wide Google or ion defaults. Bing Aerial and Bing Labels resolve their ion imagery endpoint with the supplied diff --git a/src/maps/controller.js b/src/maps/controller.js index 6dd2720f7..2b2ea462f 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -131,7 +131,8 @@ export class MapSourceController { * @returns {() => void} Unsubscribe. */ subscribe(listener) { - if (typeof listener !== 'function') return () => {}; + // A destroyed controller never settles again, so it takes no listeners. + if (typeof listener !== 'function' || this._destroyed) return () => {}; this._subscribers.add(listener); return () => { this._subscribers.delete(listener); @@ -142,6 +143,7 @@ export class MapSourceController { if (this._destroyed || !this._subscribers.size) return; const state = this.getState(); for (const listener of [...this._subscribers]) { + if (this._destroyed) return; if (!this._subscribers.has(listener)) continue; try { listener(state); diff --git a/src/maps/map-subscription-ownership.test.mjs b/src/maps/map-subscription-ownership.test.mjs index 638de65a2..cf0708c09 100644 --- a/src/maps/map-subscription-ownership.test.mjs +++ b/src/maps/map-subscription-ownership.test.mjs @@ -53,3 +53,37 @@ test('destroying the controller during delivery stops remaining listeners', asyn await controller.setStack('fixture'); assert.deepEqual(calls, ['destroy']); }); + +test('a listener re-added after destroy during delivery is not called', async () => { + const controller = fixture(); + const calls = []; + const later = () => calls.push('later'); + controller.subscribe(() => { + calls.push('destroy'); + controller.destroy(); + controller.subscribe(later); + }); + controller.subscribe(later); + await controller.setStack('fixture'); + assert.deepEqual(calls, ['destroy']); +}); + +test('delivery order: added during delivery waits, duplicates run once, re-added runs', async () => { + const controller = fixture(); + const calls = []; + const twice = () => calls.push('twice'); + const readded = () => calls.push('readded'); + let offReadded; + controller.subscribe(() => { + calls.push('first'); + controller.subscribe(() => calls.push('added-late')); + offReadded(); + controller.subscribe(readded); + }); + controller.subscribe(twice); + controller.subscribe(twice); + offReadded = controller.subscribe(readded); + await controller.setStack('fixture'); + assert.deepEqual(calls, ['first', 'twice', 'readded']); + controller.destroy(); +}); From fd5a1ef8569a313b249af271fe3d2e6f18817cfa Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:54:17 -0400 Subject: [PATCH 86/86] fix(tools): keep results when a timestamp is outside Date's range (#1045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isoTime returned toISOString() for any finite number, which throws past ±8.64e15 ms, so one bad record (e.g. a bike-share last_reported) failed the whole tool answer. Such timestamps are now null. The voice analyst's result rows had the same conversion and get the same guard. Co-authored-by: Sameh Khamis --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 2 + src/tools/results.js | 4 +- src/tools/tool-timestamp-range.test.mjs | 59 +++++++++++++++++++++++++ src/voice/gevActions.js | 5 ++- src/voice/gevActions.test.mjs | 20 +++++++++ 6 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 src/tools/tool-timestamp-range.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6051fb3aa..b72aeefd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Analyst queries preserve useful rows when an upstream timestamp is outside the supported date range. + - Fire detections with missing or impossible positions are skipped while genuine zero coordinates remain valid. - Frequently reused terrain batches retain their cached heights ahead of unused cells. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6ac31dd73..251b71a7e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -75,6 +75,8 @@ Windows/Claude Desktop Cowork confirmation remains outstanding (#927). Tool argument string lengths follow JSON Schema Unicode code-point counts, so supplementary characters count once rather than as two UTF-16 units. +Query timestamps outside the representable Date range are reported as null, preserving the rest of the result row. + `gods-eye-view/tools` defines queries that answer questions from the app's data, and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client, diff --git a/src/tools/results.js b/src/tools/results.js index 51ed82bbb..f9af7af3d 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -24,7 +24,9 @@ export function capRows(rows, limit = DEFAULT_LIMIT) { /** Format an epoch millisecond time as ISO 8601, or null. */ export function isoTime(ms) { - return Number.isFinite(ms) ? new Date(ms).toISOString() : null; + if (!Number.isFinite(ms)) return null; + const date = new Date(ms); + return Number.isFinite(date.getTime()) ? date.toISOString() : null; } /** "1 earthquake", "3 earthquakes". */ diff --git a/src/tools/tool-timestamp-range.test.mjs b/src/tools/tool-timestamp-range.test.mjs new file mode 100644 index 000000000..7aeee737b --- /dev/null +++ b/src/tools/tool-timestamp-range.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isoTime } from './results.js'; +import { getBikeShare } from './queries/mobility.js'; +test('out-of-range finite timestamps are unknown rather than throwing', async () => { + for (const value of [1e30, -1e30, NaN, Infinity, null]) + assert.equal(isoTime(value), null); + assert.equal(isoTime(0), '1970-01-01T00:00:00.000Z'); + assert.equal(isoTime(8640000000000000), '+275760-09-13T00:00:00.000Z'); + assert.equal(isoTime(-8640000000000000), '-271821-04-20T00:00:00.000Z'); + for (const value of [8640000000000001, -8640000000000001, -Infinity]) + assert.equal(isoTime(value), null); + assert.equal(isoTime(-1), '1969-12-31T23:59:59.999Z'); + const station = { + station_id: 's1', + name: 'Fixture', + lat: 0, + lon: 0, + capacity: 4, + }; + const result = await getBikeShare.run( + { area: { bbox: [-1, -1, 1, 1] } }, + { + services: { + bikeshare: { + systems: [ + { + id: 'fixture', + city: 'Fixture', + centerLat: 0, + centerLon: 0, + loadRadiusKm: 30, + stationInformationUrl: 'information', + stationStatusUrl: 'status', + }, + ], + async getStations(url) { + return { + data: { + stations: + url === 'information' + ? [station] + : [ + { + station_id: 's1', + last_reported: 1e30, + num_bikes_available: 2, + }, + ], + }, + }; + }, + }, + }, + }, + ); + assert.equal(result.data.returned, 1); + assert.equal(result.data.rows[0].last_reported, null); +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 9e1e183d0..267f633ff 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -4808,8 +4808,9 @@ function compactAnalystItem(r) { for (const [key, spec] of Object.entries(fields)) { if (!present(r[key]) || key in compact) continue; if (spec.type === 'time') { - const ms = Number(r[key]); - if (Number.isFinite(ms)) compact.time = new Date(ms).toISOString(); + // A finite value can still be past Date's range, where toISOString throws. + const time = new Date(Number(r[key])); + if (Number.isFinite(time.getTime())) compact.time = time.toISOString(); } else compact[key] = r[key]; } if (present(r.ageHours)) compact.ageHours = r.ageHours; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 3d0e35b3e..2a80b829c 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3459,6 +3459,26 @@ test('analyst_query refusals carry their code and the allowed values', async () assert.match(ok.items[0].time, /^\d{4}-\d\d-\d\dT/); }); +test('analyst_query keeps its answer when one record has an out-of-range time', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const now = Date.now(); + const earthquakes = { + getStats: () => ({ count: 2, lastUpdate: now }), + getAnalystRecords: () => [ + { id: 'q', lat: 0, lon: 0, magnitude: 5, timeMs: now }, + { id: 'r', lat: 1, lon: 1, magnitude: 4, timeMs: 8.64e15 + 1 }, + ], + }; + const { runner } = manifestRunner({ earthquakes }, { enabled: ['earthquakes'] }); + const result = await runner('analyst_query', { layers: ['earthquakes'], scope: { kind: 'anywhere' } }); + assert.equal(result.ok, true); + const q = result.items.find((item) => item.id === 'q'); + assert.match(q.time, /^\d{4}-\d\d-\d\dT/); + const r = result.items.find((item) => item.id === 'r'); + assert.ok(r, 'the record is still listed'); + assert.equal(r.time, undefined, 'an unrepresentable time is omitted'); +}); + test('spoken layer names from the manifest reach the newer layers', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const stub = () => ({ getStats: () => ({ count: 0 }) });