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);
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();
+6 -4
View File
@@ -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({
-2
View File
@@ -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\(\)/,
+3 -3
View File
@@ -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,
+70 -29
View File
@@ -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;
+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 () => {
const { createMoverBank } = await import('../aircraft/render/navara.js');
let commits = 0;
+5 -3
View File
@@ -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();
+20 -11
View File
@@ -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,