mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Preserve AIS observations, selection and trail across globe changes
This commit is contained in:
1 parent
e08ddd787b
commit
81bcae907f
8 files changed
+203
-48
No files matched your search
@@ -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();
|
||||
|
||||
@@ -132,10 +132,12 @@ export function createApplicationCatalog({
|
||||
})),
|
||||
);
|
||||
const vessels = once(() =>
|
||||
sessionModel('ais-live-vessels', () =>
|
||||
createApplicationVessels({
|
||||
source: sources.vessels,
|
||||
options: vesselOptions,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const installations = once(() =>
|
||||
createApplicationInstallations({
|
||||
|
||||
@@ -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\(\)/,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user