From 81bcae907fb2f7f112d0e26fbe46efeea30934f8 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 02:53:10 +0400 Subject: [PATCH] Preserve AIS observations, selection and trail across globe changes --- scripts/qa-ground-layer-switch.mjs | 43 ++++++++++++ src/app/constructCatalog.js | 10 +-- src/data/spriteOrder.test.mjs | 2 - src/layers/vessels/ingestion.js | 6 +- src/layers/vessels/lifecycle.js | 99 ++++++++++++++++++++-------- src/layers/vessels/renderer.test.mjs | 60 +++++++++++++++++ src/layers/vessels/rendering.js | 8 ++- src/layers/vessels/tracking.js | 31 +++++---- 8 files changed, 207 insertions(+), 52 deletions(-) diff --git a/scripts/qa-ground-layer-switch.mjs b/scripts/qa-ground-layer-switch.mjs index 7d4a32299..b1f58133d 100644 --- a/scripts/qa-ground-layer-switch.mjs +++ b/scripts/qa-ground-layer-switch.mjs @@ -32,6 +32,16 @@ try { const fetch = window.fetch.bind(window); window.fetch = (input, o) => { const url = new URL(input.url ?? input, document.baseURI); + if (url.pathname === '/api/vessels/track') { + window.qaVesselHistories = (window.qaVesselHistories ?? 0) + 1; + return Promise.resolve(Response.json({ samples: [{lat: 43.63, lon: -79.38}] })); + } + if (url.pathname === '/api/vessels') { + return Promise.resolve(Response.json({status: 'live', rows: [{ + mmsi: '123456789', name: 'Toronto QA', lat: 43.65, lon: -79.38, + last_position_epoch: Date.now() / 1000, + }]})); + } if (url.pathname.startsWith('/api/fire-perimeters/inciweb')) { return Promise.resolve(Response.json({ rows: [] })); } @@ -201,6 +211,39 @@ try { await card(true); console.log('PASS', kind, 'select fire under GNSS, exclude hole, retain incident card'); } + await p.evaluate(async () => { + const { dataManager } = window.__godsEyeView; + for (const id of ['gnss-integrity', 'fire-perimeters', 'air-quality']) + await dataManager.setEnabled(id, false); + await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' }); + window.qaVesselEntry = dataManager.layers.get('ais-live-vessels'); + }); + await p.waitForFunction(() => window.qaVesselEntry.module.qaDrawn()?.chevrons[0]?.show); + await new Promise((resolve) => setTimeout(resolve, 800)); + await click(-79.38, 43.65); + await p.waitForFunction(() => window.qaVesselEntry.module.getSelectedInfo()?.mmsi === '123456789'); + for (const kind of ['navara', 'cesium', 'navara']) { + if (await p.evaluate(() => window.__godsEyeView.engine.kind) !== kind) { + await p.select('#globe-engine', kind); + await ready(kind); + } + const sample = await p.evaluate(() => { + const entry = window.__godsEyeView.dataManager.layers.get('ais-live-vessels'); + const drawn = entry.module.qaDrawn(); + return { + retained: entry === window.qaVesselEntry, + selected: entry.module.getSelectedInfo()?.mmsi, + chevron: drawn.chevrons.find((c) => c.selected && c.show)?.id, + card: drawn.cards.find((c) => c.selected)?.id, + histories: window.qaVesselHistories, + }; + }); + assert.deepEqual(sample, { + retained: true, selected: '123456789', chevron: '123456789', + card: 'vessel:123456789', histories: 1, + }, kind); + console.log('PASS', kind, 'retained selected AIS vessel, chevron, card and single track request'); + } assert.deepEqual(errors, []); } finally { await b.close(); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 548fc255c..22364b155 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -132,10 +132,12 @@ export function createApplicationCatalog({ })), ); const vessels = once(() => - createApplicationVessels({ - source: sources.vessels, - options: vesselOptions, - }), + sessionModel('ais-live-vessels', () => + createApplicationVessels({ + source: sources.vessels, + options: vesselOptions, + }), + ), ); const installations = once(() => createApplicationInstallations({ diff --git a/src/data/spriteOrder.test.mjs b/src/data/spriteOrder.test.mjs index 438abccdc..3d01a645a 100644 --- a/src/data/spriteOrder.test.mjs +++ b/src/data/spriteOrder.test.mjs @@ -8,7 +8,6 @@ import { unregisterSpriteCollection, } from './spriteOrder.js'; import flightsLayer from './flights.js'; -import aisLiveVesselsLayer from './aisLiveVessels.js'; import { createFirmsHeatmapLayer } from './firmsHeatmap.js'; const ORDER = ['cctv', 'firms', 'bikeshare', 'ais', 'military', 'flights']; @@ -122,7 +121,6 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r readFileSync(new URL('../layers/flights/render/cesium.js', import.meta.url), 'utf8'), /spriteLayer: 'flights'/, ); - assert.match(aisLiveVesselsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('ais', activeViewer\)/); assert.match( firmsLayer.enable.toString(), /renderer\.onEnable\(\)/, diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js index dca9b81ca..ca3501b30 100644 --- a/src/layers/vessels/ingestion.js +++ b/src/layers/vessels/ingestion.js @@ -23,7 +23,7 @@ export function createIngestion({ setSourceLabel, }) { async function loadLivePositions(viewer) { - if (!viewer || feed.loading) return; + if (!feed.enabled || feed.loading) return; feed.loading = true; feed.loadingLabel = feed.loaded ? 'refreshing...' : 'loading...'; const requestController = new AbortController(); @@ -41,7 +41,7 @@ export function createIngestion({ ]) : requestController.signal; // Ask for the vessels in view; a wide view asks for every vessel. - const area = readArea(viewer); + const area = viewer ? readArea(viewer) : feed.lastArea; feed.lastArea = area; const snapshot = await readSource().getSnapshot( { maxRows: getRowLimit(), ...(area ? { area } : {}) }, @@ -50,7 +50,7 @@ export function createIngestion({ if (!ownsAisRequest(requestController, requestSessionId)) return; setSourceLabel(snapshot.source); // Map observations into the existing display store; source fields stop here. - applyAisFeedSnapshot(viewer, { + applyAisFeedSnapshot(readViewer(), { rows: snapshot.records.map(vesselDisplayRow), observedAtMs: snapshot.observedAtMs, freshness: snapshot.freshness, diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index f693cef11..96d5ef1b0 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -142,7 +142,7 @@ export function createLifecycle({ /** Configure the source before initialization; an active layer keeps its owner. */ setSource(source) { // Initialized: a viewer, or an engine's renderer without one. - if (state.viewer || state.renderer?.engine) + if (state.viewer || state.renderer?.engine || state.feed.enabled) throw new Error('Configure the source before layer initialization'); if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required'); @@ -160,19 +160,49 @@ export function createLifecycle({ init(viewer, { engine = null } = {}) { if (typeof vesselState._source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required'); + if (viewer || engine) this.attachRenderer(viewer, { engine }); + }, + + attachRenderer(viewer, { engine = null } = {}) { state.viewer = viewer; state.renderer ??= createRenderer(viewer, engine); - // The scene the layer draws in: the viewer, or the renderer's view on - // an engine without one. const scene = viewer || state.renderer?.view || null; components.rendering.ensureCollections(scene); - vesselState._vesselOverlayHost.setVisible( - VESSEL_OVERLAY_SOURCE_ID, - false, - ); components.selection.installInteraction(scene); components.rendering.installRuntime(scene); restoreSpriteOrder(viewer); + components.rendering.setVisible(state.feed.enabled); + if (!state.feed.enabled) return; + bindVisibleScene(viewer, scene); + const horizon = components.rendering.makeOccluder(); + for (const record of state.records.all) + components.rendering.addRecordPrimitives(record, horizon); + components.tracking.attachSelectedVesselTrail(); + components.rendering.updateVisibility(true); + if (state.selectedRecord) + components.cards.updateSelectedVesselHud(state.selectedRecord); + }, + + detachRenderer() { + removeViewListener(); + components.selection.removeVesselInteraction(); + state.preRenderRemover?.(); + state.preRenderRemover = null; + components.rendering.cancelTrailingVisibilityPass(); + releaseContinuousRender('ais-vessels'); + unregisterPickOwner('ais-live-vessels'); + components.tracking.detachSelectedVesselTrail(); + for (const record of state.records.all) + components.rendering.removeRecordPrimitives(record); + components.rendering.setVisible(false); + vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); + components.cards.resetSelectedVesselHud(); + detachFleet(); + state.renderer?.destroy(); + state.renderer = null; + state.viewer = null; + state.activeFocusCount = 0; + state.activeLabelCount = 0; }, enable(viewer) { @@ -204,27 +234,7 @@ export function createLifecycle({ /* grid failed to load — anchors stay at ellipsoid 0 */ }); } - // Pick-ownership (H2): vessel picks carry the record OBJECT as their id; - // the registry resolver reduces it to the record's mmsi (a string key). - registerPickOwner('ais-live-vessels', (pickedId) => - state.records.byMmsi.has(pickedId), - ); - restoreSpriteOrderOnEnable('ais', activeViewer); - // Vessels are asked for by view, so ask again when the view moves away. - removeViewListener(); - const onMoveEnd = () => { - components.ingestion.refreshIfMoved(scene); - }; - if (state.renderer) - state.feed.removeViewListener = state.renderer.view.on( - 'moveEnd', - onMoveEnd, - ); - else { - const moveEnd = activeViewer?.camera?.moveEnd; - if (typeof moveEnd?.addEventListener === 'function') - state.feed.removeViewListener = moveEnd.addEventListener(onMoveEnd); - } + bindVisibleScene(activeViewer, scene); return components.ingestion.loadLivePositions(scene); }, @@ -257,7 +267,7 @@ export function createLifecycle({ unregisterPickOwner('ais-live-vessels'); components.selection.clearVesselInspection(); components.tracking.destroySelectedVesselTrail(); - state.renderer?.fleet.detach(); + detachFleet(); vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); vesselState._vesselOverlayHost.setVisible( VESSEL_OVERLAY_SOURCE_ID, @@ -282,6 +292,37 @@ export function createLifecycle({ ); } + function bindVisibleScene(activeViewer, scene) { + // Pick-ownership (H2): vessel picks carry the record OBJECT as their id; + // the registry resolver reduces it to the record's mmsi (a string key). + registerPickOwner('ais-live-vessels', (pickedId) => + state.records.byMmsi.has(pickedId), + ); + restoreSpriteOrderOnEnable('ais', activeViewer); + // Vessels are asked for by view, so ask again when the view moves away. + removeViewListener(); + const onMoveEnd = () => { + components.ingestion.refreshIfMoved(scene); + }; + if (state.renderer) + state.feed.removeViewListener = state.renderer.view.on( + 'moveEnd', + onMoveEnd, + ); + else { + const moveEnd = activeViewer?.camera?.moveEnd; + if (typeof moveEnd?.addEventListener === 'function') + state.feed.removeViewListener = moveEnd.addEventListener(onMoveEnd); + } + } + + function detachFleet() { + const fleet = state.renderer?.fleet; + if (fleet?.collection) + services.sprites.unregisterSpriteCollection?.('ais', fleet.collection); + fleet?.detach(); + } + function removeViewListener() { const remove = state.feed.removeViewListener; state.feed.removeViewListener = null; diff --git a/src/layers/vessels/renderer.test.mjs b/src/layers/vessels/renderer.test.mjs index 8cf4dce4d..af3d3b079 100644 --- a/src/layers/vessels/renderer.test.mjs +++ b/src/layers/vessels/renderer.test.mjs @@ -291,6 +291,66 @@ test('vessels on the Navara renderer: one always-on-top chevron bank, MMSI picks } }); +test('a pending AIS poll and selected trail survive replacement of the planet', async (t) => { + const engines = [createFakeEngine(), createFakeEngine()]; + const recorders = engines.map(recordingRenderer); + const output = recorders.map(() => []); + for (const [i, { renderer }] of recorders.entries()) + renderer.trails.create = () => ({ + setPositions: (positions) => output[i].push(positions.slice()), + clear: noop, + destroy: noop, + }); + const injected = services(recorders[0].renderer); + const restored = []; + injected.sprites.restoreSpriteOrderOnEnable = (id) => restored.push(id); + injected.renderer.create = ({ engine }) => recorders[engines.indexOf(engine)].renderer; + const previousDocument = globalThis.document; + globalThis.document = Object.assign(new EventTarget(), { getElementById: () => null }); + t.after(() => { globalThis.document = previousDocument; }); + let polls = 0, histories = 0, deliver; + const snapshot = (records) => ({ + records, source: 'AIS fixture', complete: true, transportStatus: 'live', + rawRowCount: records.length, observedAtMs: 500, freshness: 'current', + }); + const layer = createVesselLayer({ + services: injected, + source: { + getSnapshot: () => ++polls === 1 + ? snapshot([row('111', 40.6, -73.9)]) + : new Promise((resolve) => { deliver = resolve; }), + getTrack: async () => { + histories++; + return { records: [{ latitude: 40.59, longitude: -73.92 }] }; + }, + }, + }); + t.after(() => layer.destroy()); + layer.init(null, { engine: engines[0] }); + await layer.enable(); + const record = [...recorders[0].sprites][0].id; + layer.selectById('111'); + const session = layer.testing._getVesselFeedStateForTest().sessionId; + const pending = layer.update(); + layer.detachRenderer(); + assert.equal(recorders[0].sprites.size, 0); + deliver(snapshot([row('111', 40.61, -73.91), row('222', 40.62, -73.92)])); + await pending; + assert.equal(layer.getSelectedInfo()?.mmsi, '111'); + assert.ok(layer.findByQuery('222').position); + assert.equal(layer.testing._getVesselStateForTest().trailPositionCount, 3); + layer.attachRenderer(null, { engine: engines[1] }); + assert.equal(recorders[1].sprites.size, 2); + const selected = [...recorders[1].sprites].find(({ id }) => id === record); + assert.equal(selected.image, vesselChevronIcon('#ffffff', true)); + assert.equal(selected.scale, 0.72); + assert.equal(output[1].at(-1).length, 3); + assert.equal(layer.testing._getVesselFeedStateForTest().sessionId, session); + assert.equal(polls, 2); + assert.equal(histories, 1); + assert.deepEqual(restored, ['ais', 'ais']); +}); + test('a vessel bank commits rows arriving after its empty visibility commit and after growth', async () => { const { createMoverBank } = await import('../aircraft/render/navara.js'); let commits = 0; diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index 52d8316af..9e9a03002 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -119,6 +119,8 @@ export function createRendering({ */ function addRecordPrimitives(record, occluder) { + if (!state.renderer) return; + const selected = record === state.selectedRecord; const visible = state.feed.enabled && isVisible(getVisual(record).surfacePosition, occluder); @@ -126,8 +128,8 @@ export function createRendering({ getVisual(record).billboard = state.renderer.fleet.add({ position: getVisual(record).position, show: visible, - image: shipIcon(record, false), - scale: shipScale(record), + image: shipIcon(record, selected), + scale: shipScale(record) * (selected ? 1.2 : 1), // Screen-projected rotation lands on the next visibility/rotation pass. rotation: 0, id: record, @@ -225,7 +227,7 @@ export function createRendering({ } function updateVisibility(force = false) { - if (!state.feed.enabled) return; + if (!state.feed.enabled || (!state.renderer && !state.viewer)) return; if (force) governorRequestRender('ais-vessels-update'); const now = focusNowMs(performance.now()); const focusTarget = getFocusTarget(); diff --git a/src/layers/vessels/tracking.js b/src/layers/vessels/tracking.js index a19d5b015..5d5760bb3 100644 --- a/src/layers/vessels/tracking.js +++ b/src/layers/vessels/tracking.js @@ -88,6 +88,16 @@ export function createTracking({ state.trailPositions = []; const current = vesselTrailPosition(record); if (current) state.trailPositions.push(current); + attachSelectedVesselTrail(); + backfillVesselTrail( + record.mmsi, + state.trailBackfillToken, + record.reference, + ); + } + + function attachSelectedVesselTrail() { + if (!state.trailMmsi) return; if (!state.trail && state.renderer) { state.trail = state.renderer.trails.create({ color: TRAIL_COLOR, @@ -95,11 +105,11 @@ export function createTracking({ }); } if (state.trail) state.trail.setPositions(state.trailPositions); - backfillVesselTrail( - record.mmsi, - state.trailBackfillToken, - record.reference, - ); + } + + function detachSelectedVesselTrail() { + state.trail?.destroy(); + state.trail = null; } /** @@ -165,7 +175,7 @@ export function createTracking({ */ function appendSelectedVesselTrailFix(record) { - if (!state.trail) return; + if (state.trailMmsi !== record.mmsi) return; const next = vesselTrailPosition(record); if (!next) return; const last = state.trailPositions[state.trailPositions.length - 1]; @@ -173,7 +183,7 @@ export function createTracking({ state.trailPositions.push(next); if (state.trailPositions.length > TRAIL_MAX_POINTS) state.trailPositions.shift(); - state.trail.setPositions(state.trailPositions); + state.trail?.setPositions(state.trailPositions); } /** @@ -195,16 +205,15 @@ export function createTracking({ function destroySelectedVesselTrail() { clearSelectedVesselTrail(); - if (state.trail) { - state.trail.destroy(); - state.trail = null; - } + detachSelectedVesselTrail(); } return { currentGeoidN, vesselTrailPosition, refloorVesselRecords, startSelectedVesselTrail, + attachSelectedVesselTrail, + detachSelectedVesselTrail, backfillVesselTrail, appendSelectedVesselTrailFix, clearSelectedVesselTrail,