Preserve AIS observations, selection and trail across globe changes

This commit is contained in:
German Khokhlov committed 2026-11-03 02:53:10 +04:00
1 parent e08ddd787b
commit 81bcae907f
8 files changed
+207 -52

No files matched your search

+43
View File
@@ -32,6 +32,16 @@ try {
const fetch = window.fetch.bind(window); const fetch = window.fetch.bind(window);
window.fetch = (input, o) => { window.fetch = (input, o) => {
const url = new URL(input.url ?? input, document.baseURI); 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')) { if (url.pathname.startsWith('/api/fire-perimeters/inciweb')) {
return Promise.resolve(Response.json({ rows: [] })); return Promise.resolve(Response.json({ rows: [] }));
} }
@@ -201,6 +211,39 @@ try {
await card(true); await card(true);
console.log('PASS', kind, 'select fire under GNSS, exclude hole, retain incident card'); 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, []); assert.deepEqual(errors, []);
} finally { } finally {
await b.close(); await b.close();
+6 -4
View File
@@ -132,10 +132,12 @@ export function createApplicationCatalog({
})), })),
); );
const vessels = once(() => const vessels = once(() =>
createApplicationVessels({ sessionModel('ais-live-vessels', () =>
source: sources.vessels, createApplicationVessels({
options: vesselOptions, source: sources.vessels,
}), options: vesselOptions,
}),
),
); );
const installations = once(() => const installations = once(() =>
createApplicationInstallations({ createApplicationInstallations({
-2
View File
@@ -8,7 +8,6 @@ import {
unregisterSpriteCollection, unregisterSpriteCollection,
} from './spriteOrder.js'; } from './spriteOrder.js';
import flightsLayer from './flights.js'; import flightsLayer from './flights.js';
import aisLiveVesselsLayer from './aisLiveVessels.js';
import { createFirmsHeatmapLayer } from './firmsHeatmap.js'; import { createFirmsHeatmapLayer } from './firmsHeatmap.js';
const ORDER = ['cctv', 'firms', 'bikeshare', 'ais', 'military', 'flights']; 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'), readFileSync(new URL('../layers/flights/render/cesium.js', import.meta.url), 'utf8'),
/spriteLayer: 'flights'/, /spriteLayer: 'flights'/,
); );
assert.match(aisLiveVesselsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('ais', activeViewer\)/);
assert.match( assert.match(
firmsLayer.enable.toString(), firmsLayer.enable.toString(),
/renderer\.onEnable\(\)/, /renderer\.onEnable\(\)/,
+3 -3
View File
@@ -23,7 +23,7 @@ export function createIngestion({
setSourceLabel, setSourceLabel,
}) { }) {
async function loadLivePositions(viewer) { async function loadLivePositions(viewer) {
if (!viewer || feed.loading) return; if (!feed.enabled || feed.loading) return;
feed.loading = true; feed.loading = true;
feed.loadingLabel = feed.loaded ? 'refreshing...' : 'loading...'; feed.loadingLabel = feed.loaded ? 'refreshing...' : 'loading...';
const requestController = new AbortController(); const requestController = new AbortController();
@@ -41,7 +41,7 @@ export function createIngestion({
]) ])
: requestController.signal; : requestController.signal;
// Ask for the vessels in view; a wide view asks for every vessel. // 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; feed.lastArea = area;
const snapshot = await readSource().getSnapshot( const snapshot = await readSource().getSnapshot(
{ maxRows: getRowLimit(), ...(area ? { area } : {}) }, { maxRows: getRowLimit(), ...(area ? { area } : {}) },
@@ -50,7 +50,7 @@ export function createIngestion({
if (!ownsAisRequest(requestController, requestSessionId)) return; if (!ownsAisRequest(requestController, requestSessionId)) return;
setSourceLabel(snapshot.source); setSourceLabel(snapshot.source);
// Map observations into the existing display store; source fields stop here. // Map observations into the existing display store; source fields stop here.
applyAisFeedSnapshot(viewer, { applyAisFeedSnapshot(readViewer(), {
rows: snapshot.records.map(vesselDisplayRow), rows: snapshot.records.map(vesselDisplayRow),
observedAtMs: snapshot.observedAtMs, observedAtMs: snapshot.observedAtMs,
freshness: snapshot.freshness, freshness: snapshot.freshness,
+70 -29
View File
@@ -142,7 +142,7 @@ export function createLifecycle({
/** Configure the source before initialization; an active layer keeps its owner. */ /** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) { setSource(source) {
// Initialized: a viewer, or an engine's renderer without one. // 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'); throw new Error('Configure the source before layer initialization');
if (typeof source?.getSnapshot !== 'function') if (typeof source?.getSnapshot !== 'function')
throw new TypeError('A snapshot source is required'); throw new TypeError('A snapshot source is required');
@@ -160,19 +160,49 @@ export function createLifecycle({
init(viewer, { engine = null } = {}) { init(viewer, { engine = null } = {}) {
if (typeof vesselState._source?.getSnapshot !== 'function') if (typeof vesselState._source?.getSnapshot !== 'function')
throw new TypeError('A snapshot source is required'); throw new TypeError('A snapshot source is required');
if (viewer || engine) this.attachRenderer(viewer, { engine });
},
attachRenderer(viewer, { engine = null } = {}) {
state.viewer = viewer; state.viewer = viewer;
state.renderer ??= createRenderer(viewer, engine); 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; const scene = viewer || state.renderer?.view || null;
components.rendering.ensureCollections(scene); components.rendering.ensureCollections(scene);
vesselState._vesselOverlayHost.setVisible(
VESSEL_OVERLAY_SOURCE_ID,
false,
);
components.selection.installInteraction(scene); components.selection.installInteraction(scene);
components.rendering.installRuntime(scene); components.rendering.installRuntime(scene);
restoreSpriteOrder(viewer); 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) { enable(viewer) {
@@ -204,27 +234,7 @@ export function createLifecycle({
/* grid failed to load — anchors stay at ellipsoid 0 */ /* grid failed to load — anchors stay at ellipsoid 0 */
}); });
} }
// Pick-ownership (H2): vessel picks carry the record OBJECT as their id; bindVisibleScene(activeViewer, scene);
// 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);
}
return components.ingestion.loadLivePositions(scene); return components.ingestion.loadLivePositions(scene);
}, },
@@ -257,7 +267,7 @@ export function createLifecycle({
unregisterPickOwner('ais-live-vessels'); unregisterPickOwner('ais-live-vessels');
components.selection.clearVesselInspection(); components.selection.clearVesselInspection();
components.tracking.destroySelectedVesselTrail(); components.tracking.destroySelectedVesselTrail();
state.renderer?.fleet.detach(); detachFleet();
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
vesselState._vesselOverlayHost.setVisible( vesselState._vesselOverlayHost.setVisible(
VESSEL_OVERLAY_SOURCE_ID, 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() { function removeViewListener() {
const remove = state.feed.removeViewListener; const remove = state.feed.removeViewListener;
state.feed.removeViewListener = null; state.feed.removeViewListener = null;
+60
View File
@@ -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 () => { test('a vessel bank commits rows arriving after its empty visibility commit and after growth', async () => {
const { createMoverBank } = await import('../aircraft/render/navara.js'); const { createMoverBank } = await import('../aircraft/render/navara.js');
let commits = 0; let commits = 0;
+5 -3
View File
@@ -119,6 +119,8 @@ export function createRendering({
*/ */
function addRecordPrimitives(record, occluder) { function addRecordPrimitives(record, occluder) {
if (!state.renderer) return;
const selected = record === state.selectedRecord;
const visible = const visible =
state.feed.enabled && state.feed.enabled &&
isVisible(getVisual(record).surfacePosition, occluder); isVisible(getVisual(record).surfacePosition, occluder);
@@ -126,8 +128,8 @@ export function createRendering({
getVisual(record).billboard = state.renderer.fleet.add({ getVisual(record).billboard = state.renderer.fleet.add({
position: getVisual(record).position, position: getVisual(record).position,
show: visible, show: visible,
image: shipIcon(record, false), image: shipIcon(record, selected),
scale: shipScale(record), scale: shipScale(record) * (selected ? 1.2 : 1),
// Screen-projected rotation lands on the next visibility/rotation pass. // Screen-projected rotation lands on the next visibility/rotation pass.
rotation: 0, rotation: 0,
id: record, id: record,
@@ -225,7 +227,7 @@ export function createRendering({
} }
function updateVisibility(force = false) { function updateVisibility(force = false) {
if (!state.feed.enabled) return; if (!state.feed.enabled || (!state.renderer && !state.viewer)) return;
if (force) governorRequestRender('ais-vessels-update'); if (force) governorRequestRender('ais-vessels-update');
const now = focusNowMs(performance.now()); const now = focusNowMs(performance.now());
const focusTarget = getFocusTarget(); const focusTarget = getFocusTarget();
+20 -11
View File
@@ -88,6 +88,16 @@ export function createTracking({
state.trailPositions = []; state.trailPositions = [];
const current = vesselTrailPosition(record); const current = vesselTrailPosition(record);
if (current) state.trailPositions.push(current); 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) { if (!state.trail && state.renderer) {
state.trail = state.renderer.trails.create({ state.trail = state.renderer.trails.create({
color: TRAIL_COLOR, color: TRAIL_COLOR,
@@ -95,11 +105,11 @@ export function createTracking({
}); });
} }
if (state.trail) state.trail.setPositions(state.trailPositions); if (state.trail) state.trail.setPositions(state.trailPositions);
backfillVesselTrail( }
record.mmsi,
state.trailBackfillToken, function detachSelectedVesselTrail() {
record.reference, state.trail?.destroy();
); state.trail = null;
} }
/** /**
@@ -165,7 +175,7 @@ export function createTracking({
*/ */
function appendSelectedVesselTrailFix(record) { function appendSelectedVesselTrailFix(record) {
if (!state.trail) return; if (state.trailMmsi !== record.mmsi) return;
const next = vesselTrailPosition(record); const next = vesselTrailPosition(record);
if (!next) return; if (!next) return;
const last = state.trailPositions[state.trailPositions.length - 1]; const last = state.trailPositions[state.trailPositions.length - 1];
@@ -173,7 +183,7 @@ export function createTracking({
state.trailPositions.push(next); state.trailPositions.push(next);
if (state.trailPositions.length > TRAIL_MAX_POINTS) if (state.trailPositions.length > TRAIL_MAX_POINTS)
state.trailPositions.shift(); state.trailPositions.shift();
state.trail.setPositions(state.trailPositions); state.trail?.setPositions(state.trailPositions);
} }
/** /**
@@ -195,16 +205,15 @@ export function createTracking({
function destroySelectedVesselTrail() { function destroySelectedVesselTrail() {
clearSelectedVesselTrail(); clearSelectedVesselTrail();
if (state.trail) { detachSelectedVesselTrail();
state.trail.destroy();
state.trail = null;
}
} }
return { return {
currentGeoidN, currentGeoidN,
vesselTrailPosition, vesselTrailPosition,
refloorVesselRecords, refloorVesselRecords,
startSelectedVesselTrail, startSelectedVesselTrail,
attachSelectedVesselTrail,
detachSelectedVesselTrail,
backfillVesselTrail, backfillVesselTrail,
appendSelectedVesselTrailFix, appendSelectedVesselTrailFix,
clearSelectedVesselTrail, clearSelectedVesselTrail,