From d7b3d451354094643e1efac080ab91cfcf6988b0 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 03:30:19 +0400 Subject: [PATCH] Keep local ADS-B receivers and aircraft history alive across engine changes --- scripts/qa-local-adsb-engine-switch.mjs | 143 ++++++++++++++++++++++++ src/app/constructCatalog.js | 4 +- src/layers/localAdsb/index.js | 86 +++++++++----- src/layers/localAdsb/index.test.mjs | 21 ++++ 4 files changed, 224 insertions(+), 30 deletions(-) create mode 100644 scripts/qa-local-adsb-engine-switch.mjs diff --git a/scripts/qa-local-adsb-engine-switch.mjs b/scripts/qa-local-adsb-engine-switch.mjs new file mode 100644 index 000000000..329049e09 --- /dev/null +++ b/scripts/qa-local-adsb-engine-switch.mjs @@ -0,0 +1,143 @@ +import puppeteer from 'puppeteer'; +import assert from 'node:assert/strict'; +import { blockPaidQaMaps } from './qa-map-policy.mjs'; + +const base = new URL(process.argv[2]); +if ( + !['localhost', '127.0.0.1'].includes(base.hostname) || + ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + base.port, + ) +) + throw new Error('Supply an unreserved local keyless preview URL'); +base.search = '?engine=navara&welcome=0&look=cinematic'; +base.hash = + 'v=2&lat=41.5340&lon=-90.5070&alt=20000&heading=0&pitch=-90&roll=0&map=sentinel2&l='; +const b = await puppeteer.launch({ + headless: true, + args: ['--use-angle=metal', '--enable-gpu'], +}); +try { + const p = await b.newPage(); + await blockPaidQaMaps(p); + await p.setViewport({ width: 1024, height: 768 }); + const errors = []; + p.on('pageerror', (e) => { + errors.push(e.message); + console.log('error', e.message.split('?')[0]); + }); + await p.evaluateOnNewDocument(() => { + const raw = window.fetch; + window.fetch = function (input, ...args) { + const url = new URL(input?.url ?? input, location.href); + if (url.pathname === '/api/local-receivers/aircraft') { + const at = Date.now(); + return Promise.resolve( + Response.json({ + configured: true, + generatedAt: at, + records: [ + { + icao: 'abc123', + callsign: 'LOCAL1', + lat: 41.534, + lon: -90.507, + altitudeFt: 11000, + groundSpeedKt: 180, + trackDeg: 5, + lastPositionAt: at, + lastMessageAt: at, + messageCount: 12, + band: '1090', + source: 'decoder', + }, + ], + }), + ); + } + return raw.call(this, input, ...args); + }; + window.qaSelections = []; + window.addEventListener('gev:entity-selected', ({ detail }) => + window.qaSelections.push(detail.id), + ); + }); + await p.goto(base.href); + const ready = async (kind) => { + await p.waitForFunction( + (k) => + window.__godsEyeView?.engine.kind === k && + !document.querySelector('#globe-engine')?.disabled && + !document.querySelector('.engine-switch-frame') && + document.querySelector('#loading-screen.hidden'), + { timeout: 90000 }, + kind, + ); + await p.evaluate( + () => window.__godsEyeView.styleManager.initialRestorePromise, + ); + }; + await ready('navara'); + await p.evaluate(async () => { + const { dataManager, tileset } = window.__godsEyeView; + if (tileset || window.__GOOGLE_MAPS_API_KEY__) + throw Error('Keyless preview required'); + const mod = dataManager.layers.get('local-adsb').module; + window.qaAdsb = { + mod, + receiver: mod.receiver, + feeds: mod.feeds, + starts: 0, + stops: 0, + closes: 0, + }; + for (const [obj, key, counter] of [ + [mod.receiver, 'destroy', 'closes'], + [mod.feeds, 'start', 'starts'], + [mod.feeds, 'stop', 'stops'], + ]) { + const original = obj[key].bind(obj); + obj[key] = (...args) => { + window.qaAdsb[counter]++; + return original(...args); + }; + } + await dataManager.setEnabled('local-adsb', true, { origin: 'user' }); + }); + await p.waitForFunction( + () => window.qaAdsb.mod.getAllPositions().length === 1, + ); + assert.ok(await p.evaluate(() => window.qaAdsb.mod.selectAircraft('abc123'))); + for (const kind of ['cesium', 'navara']) { + await p.evaluate(() => { + window.qaSelections = []; + }); + await p.select('#globe-engine', kind); + await ready(kind); + await p.waitForFunction(() => + window.qaSelections.includes('local-adsb:abc123'), + ); + const result = await p.evaluate(() => { + const q = window.qaAdsb, + manager = window.__godsEyeView.dataManager; + const mod = manager.layers.get('local-adsb').module; + return [ + manager.isEnabled(mod.id), + q.mod === mod, + q.receiver === mod.receiver, + q.feeds === mod.feeds, + mod.getAllPositions()[0]?.id, + q.starts, + q.stops, + q.closes, + ]; + }); + assert.deepEqual(result, [true, true, true, true, 'abc123', 1, 0, 0]); + } + assert.deepEqual(errors, []); + console.log( + 'PASS retained local decoder, receiver, marker and selection in both scenes', + ); +} finally { + await b.close(); +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 249e41f3d..d8fffe6bb 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -173,7 +173,7 @@ export function createApplicationCatalog({ [ 'local-adsb', () => - createApplicationLocalAdsb({ + sessionModel('local-adsb', () => createApplicationLocalAdsb({ surface, // QA fixture receivers (?qaFeed=local-adsb:fixture, dev only). ...(sources.localAdsbFeeds @@ -182,7 +182,7 @@ export function createApplicationCatalog({ enrichment: sources.flights, displayParams: () => flights().getParams(), ...(resolveAsset ? { resolveAsset } : {}), - }), + })), ], [ 'earthquakes', diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index bffd14660..1ba7f9c8c 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -400,6 +400,10 @@ export function createLocalAdsbLayer({ const newFix = marker.motion.observe(record, at); rejectedFixes += marker.motion.rejectedFixes - rejectedBefore; updateDisplay(marker, at); + if (!renderer) { + refreshClass(marker); + return id; + } if (!marker.entity) { // Magenta glyph, 20 px, ×3 near / ×0.5 far, always on top, not drawn // past 2,000 km (the renderer's display distance). @@ -432,13 +436,13 @@ export function createLocalAdsbLayer({ function refreshSelectedCard() { const marker = selectedId ? markers.get(selectedId) : null; - if (!marker) return; + if (!marker?.entity) return; marker.entity.gevLabelModel = cardModel(marker, now()); services.overlays?.refreshReadout?.(marker.entity); } function updateRenderHold() { - const want = enabled && markers.size > 0; + const want = Boolean(renderer) && enabled && markers.size > 0; if (want === renderHeld) return; renderHeld = want; if (want) holdContinuousRender(RENDER_HOLD_ID); @@ -457,7 +461,6 @@ export function createLocalAdsbLayer({ function sync() { clearTimeout(syncTimer); syncTimer = null; - if (!renderer) return; const at = now(); const live = new Set(); let added = false; @@ -479,8 +482,8 @@ export function createLocalAdsbLayer({ if (removed) models?.retain(markers); if (selectedId && !live.has(selectedId)) clearSelection({ evicted: enabled }); - removeEntityContextsForLayer(LAYER_ID, { retainIds: live }); - if (selectedId) { + if (renderer) removeEntityContextsForLayer(LAYER_ID, { retainIds: live }); + if (renderer && selectedId) { const selected = markers.get(selectedId); if (getSelectedEntityContext()?.id !== selectedId) { selectedId = null; @@ -515,7 +518,7 @@ export function createLocalAdsbLayer({ function selectMarker(id) { const marker = markers.get(id); - if (!marker) return false; + if (!marker?.entity) return false; const reselect = id === selectedId && selectedTrail?.id === id; selectedId = id; enrichment.request(marker.record, { selected: true }); @@ -610,7 +613,22 @@ export function createLocalAdsbLayer({ * without one). * @param {{ engine?: object }} [context] LayerLifecycle's context. */ - init(nextViewer, { engine = null } = {}) { + init(nextViewer, options = {}) { + services.geoid + ?.ensureGeoidReady?.() + ?.then(() => { + geoidReady = true; + for (const marker of markers.values()) marker.geoidN = null; + }) + ?.catch(() => { + /* the baro path stays un-geoid-corrected */ + }); + unsubscribe = receiver.subscribe?.(scheduleSync) || null; + unsubscribeFeeds = feeds?.subscribe?.(requestSync) || null; + return this.attachRenderer(nextViewer, options); + }, + + attachRenderer(nextViewer, { engine = null } = {}) { renderer = services.renderer.create({ viewer: nextViewer, engine, @@ -626,20 +644,41 @@ export function createLocalAdsbLayer({ groundSnap: services.groundSnap?.createGroundSnap?.() || null, }); } - services.geoid - ?.ensureGeoidReady?.() - ?.then(() => { - geoidReady = true; - for (const marker of markers.values()) marker.geoidN = null; - }) - ?.catch(() => { - /* the baro path stays un-geoid-corrected */ - }); - unsubscribe = receiver.subscribe?.(scheduleSync) || null; - unsubscribeFeeds = feeds?.subscribe?.(requestSync) || null; + if (enabled) { + const restore = selectedId; + selectedId = null; + renderer.setVisible(true); + registerPickOwner(LAYER_ID, (id) => markers.has(id)); + installInteraction(); + installFrame(); + sync(); + const selected = getSelectedEntityContext(); + if (restore && (!selected || selected.id === restore)) selectMarker(restore); + } return true; }, + detachRenderer() { + if (!renderer) return; + if (getSelectedEntityContext()?.id !== selectedId) selectedId = null; + releaseInteraction(); + releaseFrame(); + unregisterPickOwner(LAYER_ID); + releaseTrail(); + models?.destroy(); + models = null; + renderer.detach(); + renderer.destroy(); + renderer = null; + for (const marker of markers.values()) { + marker.entity = null; + marker.iconKind = marker.iconScale = null; + marker.modelOwnsVisual = false; + } + removeEntityContextsForLayer(LAYER_ID); + updateRenderHold(); + }, + async enable() { enabled = true; renderer?.setVisible(true); @@ -686,9 +725,6 @@ export function createLocalAdsbLayer({ clearTimeout(syncTimer); tickTimer = null; syncTimer = null; - releaseInteraction(); - releaseFrame(); - unregisterPickOwner(LAYER_ID); unsubscribe?.(); unsubscribe = null; unsubscribeFeeds?.(); @@ -696,14 +732,8 @@ export function createLocalAdsbLayer({ feeds?.destroy?.(); enrichment.destroy(); clearSelection(); - removeEntityContextsForLayer(LAYER_ID); + this.detachRenderer(); markers.clear(); - updateRenderHold(); - models?.destroy(); - models = null; - renderer?.detach(); - renderer?.destroy(); - renderer = null; void receiver.destroy?.(); }, diff --git a/src/layers/localAdsb/index.test.mjs b/src/layers/localAdsb/index.test.mjs index e474c3409..bf3ff33b8 100644 --- a/src/layers/localAdsb/index.test.mjs +++ b/src/layers/localAdsb/index.test.mjs @@ -1165,3 +1165,24 @@ test('a click on a local aircraft under an unowned or trail pick still selects i harness.click(); assert.equal(calls.selected.length, 2, 'the Flights contact is not taken'); }); + +test('globe handoff retains the receiver, feed, trail fixes and selection', async (t) => { + const receiver = fakeReceiver({ aircraft: [record()] }); + const feeds = fakeFeeds(); + let closes = 0; + receiver.destroy = () => { closes++; }; + const clock = { now: 100_000 }; + const { layer, calls } = await enabledLayer(receiver, clock, feeds); + t.after(() => layer.destroy()); + assert.ok(layer.selectAircraft('abc123')); + layer.detachRenderer(); + clock.now += 1000; + receiver.set({ aircraft: [record({ lon: 0.001, lastPositionAt: clock.now, lastMessageAt: clock.now })] }); + await layer.update(); + assert.equal(layer.getAnalystRecords()[0].icao24, 'abc123'); + layer.attachRenderer(fakeViewer().viewer); + assert.deepEqual(receiver.modeCalls, ['adsb']); + assert.deepEqual(feeds.calls, ['start']); + assert.equal(closes, 0); + assert.deepEqual(calls.selected, ['local-adsb:abc123', 'local-adsb:abc123']); +});