Merge branch 'main' into fix/b-vessel-course-north

This commit is contained in:
Sameh Khamis committed 2026-11-01 20:52:20 -08:00
commit 08265d94d4
890 files changed
+73156 -22003

No files matched your search

+12 -17
View File
@@ -13,28 +13,23 @@ export function createCards({
options,
}) {
function updateSelectedVesselHud(record) {
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
// Pinned vessels missing from recent refreshes get a stale marker
const stale = (record.missedRefreshes || 0) > 0;
el.classList.add('active');
el.textContent = [
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
t('{type} SPD: {speed} HDG: {heading}', {
type: trimHudValue(record.type || t('VESSEL'), 24),
speed: formatSpeed(record.speed),
heading: formatHeading(record.heading ?? record.course),
}),
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
].join('\n');
services.readouts?.presentSelectedVessel({
text: [
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
t('{type} SPD: {speed} HDG: {heading}', {
type: trimHudValue(record.type || t('VESSEL'), 24),
speed: formatSpeed(record.speed),
heading: formatHeading(record.heading ?? record.course),
}),
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
].join('\n'),
});
}
function resetSelectedVesselHud() {
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
el.classList.remove('active');
el.textContent = t('AIS: --');
services.readouts?.presentSelectedVessel(null);
}
function trimHudValue(value, maxLength) {
+4 -2
View File
@@ -11,7 +11,7 @@ export function createLifecycle({
}) {
const { state } = vesselState;
const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites;
const { holdContinuousRender, releaseContinuousRender } = services.render;
const { releaseContinuousRender } = services.render;
const { ensureGeoidReady } = services.geoid;
const { registerPickOwner, unregisterPickOwner } = services.picking;
@@ -125,6 +125,7 @@ export function createLifecycle({
state.interactionHandlerFactory = null;
state.interactionKeyTarget = null;
state.preRenderRemover = null;
components.rendering.cancelTrailingVisibilityPass();
state.lastVisibilityUpdate = 0;
state.lastFocusUpdate = 0;
state.activeFocusCount = 0;
@@ -164,7 +165,6 @@ export function createLifecycle({
const wasEnabled = state.feed.enabled;
state.feed.enabled = true;
if (!wasEnabled) beginAisSession();
holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2)
const activeViewer = viewer || state.viewer;
components.rendering.ensureCollections(activeViewer);
components.selection.installInteraction(activeViewer);
@@ -208,6 +208,7 @@ export function createLifecycle({
state.feed.enabled = false;
invalidateAisSession();
releaseContinuousRender('ais-vessels');
components.rendering.cancelTrailingVisibilityPass();
unregisterPickOwner('ais-live-vessels');
components.rendering.setVisible(false);
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
@@ -227,6 +228,7 @@ export function createLifecycle({
const activeViewer = viewer || state.viewer;
invalidateAisSession();
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
components.rendering.cancelTrailingVisibilityPass();
if (state.feed.abort) state.feed.abort.abort();
unregisterPickOwner('ais-live-vessels');
components.selection.clearVesselInspection();
+5 -1
View File
@@ -29,7 +29,11 @@ function services() {
forgetSpriteFocus: noop,
},
worldFocus: {},
render: { releaseContinuousRender: noop },
render: {
holdContinuousRender: noop,
releaseContinuousRender: noop,
governorRequestRender: noop,
},
};
}
function setup(source, options = {}) {
+138
View File
@@ -0,0 +1,138 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createVesselLayer } from './index.js';
import { VISIBILITY_UPDATE_MS } from './policy.js';
// Vessels move only when a poll lands, so the layer must let the scene idle:
// it asks for frames for forced passes and one trailing frame after the camera
// stops, and never schedules a frame from a pass the camera did not cause.
const noop = () => {};
function setup() {
let clock = 10_000;
const requests = [];
const holds = new Set();
const timers = new Map();
let nextTimer = 1;
const layer = createVesselLayer({
source: { getSnapshot: async () => ({ records: [] }) },
services: {
context: {
clearSelectedEntityContextForLayer: noop,
registerEntityContext: () => null,
},
trails: {
createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }),
},
labels: {},
picking: { unregisterPickOwner: noop },
overlay: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
geoid: {},
sprites: {},
focus: {
focusNowMs: () => clock,
getFocusTarget: () => null,
focusPassIsNeeded: () => false,
forgetSpriteFocus: noop,
},
worldFocus: {},
render: {
holdContinuousRender: (id) => holds.add(id),
releaseContinuousRender: (id) => holds.delete(id),
governorRequestRender: (reason) => requests.push(reason),
},
},
});
const camera = {
positionWC: { x: 0, y: 0, z: 7_000_000 },
heading: 0,
pitch: -1.2,
roll: 0,
};
layer.testing._setVesselStateForTest({ viewer: { camera, scene: {} } });
layer.testing._setAisRuntimeForTest({
now: () => clock,
setTimeout(fn, ms) {
const id = nextTimer++;
timers.set(id, { fn, at: clock + ms });
return id;
},
clearTimeout: (id) => timers.delete(id),
});
const advance = (ms) => {
clock += ms;
for (const [id, timer] of [...timers]) {
if (timer.at > clock) continue;
timers.delete(id);
timer.fn();
}
};
const frame = () => layer.testing._runVisibilityPassForTest();
return { layer, camera, requests, holds, timers, advance, frame };
}
test('a parked camera schedules no frames after the cadence pass', () => {
const { requests, holds, timers, advance, frame } = setup();
frame();
for (let i = 0; i < 20; i += 1) {
advance(100);
frame();
}
assert.deepEqual(requests, []);
assert.equal(timers.size, 0);
assert.equal(holds.has('ais-vessels'), false);
});
test('a camera that stops inside the cadence gets exactly one trailing frame', () => {
const { layer, camera, requests, timers, advance, frame } = setup();
frame();
advance(100);
camera.heading = 0.5;
frame();
advance(100);
frame();
assert.equal(timers.size, 1, 'one trailing frame, not one per skipped pass');
assert.equal(layer.testing._hasTrailingVisibilityPassForTest(), true);
advance(VISIBILITY_UPDATE_MS);
assert.deepEqual(requests, ['ais-vessels-visibility']);
// The trailing frame runs the regular pass on the pose the camera stopped
// on. Its own frames must not schedule another one.
frame();
for (let i = 0; i < 20; i += 1) {
advance(100);
frame();
}
assert.deepEqual(requests, ['ais-vessels-visibility']);
assert.equal(timers.size, 0);
});
test('a forced pass requests its own frame', () => {
const { layer, requests } = setup();
layer.testing._runVisibilityPassForTest(true);
assert.deepEqual(requests, ['ais-vessels-update']);
});
test('destroy cancels a pending trailing frame', (t) => {
const originalDocument = globalThis.document;
globalThis.document = { getElementById: () => null };
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
});
const { layer, camera, requests, timers, advance, frame } = setup();
frame();
advance(100);
camera.heading = 0.5;
frame();
assert.equal(timers.size, 1);
layer.destroy();
assert.equal(timers.size, 0);
advance(VISIBILITY_UPDATE_MS);
assert.deepEqual(requests, []);
});
+66 -2
View File
@@ -75,7 +75,13 @@ export function createRendering({
focusPassIsNeeded,
advanceSpriteFocus,
focusAlphaNeedsWrite,
spriteFocusAtRest,
} = services.focus;
const {
holdContinuousRender,
releaseContinuousRender,
governorRequestRender,
} = services.render;
function renderRowLimit() {
const configured = Number(options.maxRows);
@@ -196,6 +202,14 @@ export function createRendering({
return icon;
}
// Vessels move only when a poll lands, so the layer does not hold the
// continuous render loop. Its passes run in preRender, which idle mode fires
// only for rendered frames: a forced pass (new data, selection) requests its
// frame, a frame that moved the camera but fell inside the 800 ms cadence
// leaves a trailing frame so the view the camera stopped on is always
// evaluated, and the continuous hold is kept only while focus emphasis is
// animating. The trailing frame is keyed to camera motion alone: the pass's
// own label publication requests a frame, and scheduling on that would loop.
function installRuntime(viewer) {
if (state.preRenderRemover || !viewer) return;
state.preRenderRemover = viewer.scene.preRender.addEventListener(() =>
@@ -203,8 +217,27 @@ export function createRendering({
);
}
function scheduleTrailingVisibilityPass(now) {
if (state.trailingVisibilityTimer) return;
const waitMs = Math.max(
0,
VISIBILITY_UPDATE_MS - (now - state.lastVisibilityUpdate),
);
state.trailingVisibilityTimer = vesselState._aisRuntime.setTimeout(() => {
state.trailingVisibilityTimer = null;
if (state.feed.enabled) governorRequestRender('ais-vessels-visibility');
}, waitMs);
}
function cancelTrailingVisibilityPass() {
if (!state.trailingVisibilityTimer) return;
vesselState._aisRuntime.clearTimeout(state.trailingVisibilityTimer);
state.trailingVisibilityTimer = null;
}
function updateVisibility(force = false) {
if (!state.feed.enabled) return;
if (force) governorRequestRender('ais-vessels-update');
const now = focusNowMs(performance.now());
const focusTarget = getFocusTarget();
const regularPass =
@@ -212,13 +245,30 @@ export function createRendering({
const focusPass =
focusPassIsNeeded(focusTarget, state.activeFocusCount) &&
(force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS);
if (
!regularPass &&
state.viewer?.camera &&
cameraPoseSignature(state.viewer.camera) !== vesselState._lastCamPoseSig
)
scheduleTrailingVisibilityPass(now);
if (!regularPass && !focusPass) return;
if (regularPass) state.lastVisibilityUpdate = now;
if (focusPass) state.lastFocusUpdate = now;
if (!state.records.all.length) {
// No records — flush any lingering card entries (vanished-feed case).
if (regularPass) updateClusteredLabels([]);
if (focusPass) state.activeFocusCount = 0;
// Still record the pose this pass evaluated, or every later frame that
// falls inside the cadence would schedule another trailing frame.
if (regularPass) {
if (state.viewer?.camera)
vesselState._lastCamPoseSig = cameraPoseSignature(
state.viewer.camera,
);
updateClusteredLabels([]);
}
if (focusPass) {
state.activeFocusCount = 0;
releaseContinuousRender('ais-vessels');
}
return;
}
@@ -272,6 +322,9 @@ export function createRendering({
Cesium.Cartesian3.distance(camera.positionWC, position),
});
state.activeFocusCount = result.activeCount;
if (result.activeCount > 0 || result.transitioning)
holdContinuousRender('ais-vessels');
else releaseContinuousRender('ais-vessels');
}
}
@@ -302,6 +355,16 @@ export function createRendering({
const bb = visual?.billboard;
const position = bb?.position || visual?.position;
if (!bb || !position) continue;
// Most of a worldwide feed sits behind the horizon. A hidden sprite with
// no pending focus state and full alpha would advance to exactly what it
// already is, so skip it instead of paying its distance and state work
// every 80 ms while anything is tracked.
if (
bb.show === false &&
spriteFocusAtRest(bb) &&
!focusAlphaNeedsWrite(bb.color?.alpha, 1, params)
)
continue;
const focus = advanceSpriteFocus(bb, {
// Hidden/far-side sprites still finish any pending release so the active
// count remains truthful and they cannot reappear with stale dim alpha.
@@ -510,6 +573,7 @@ export function createRendering({
shipIcon,
installRuntime,
updateVisibility,
cancelTrailingVisibilityPass,
applyVesselFocusDeemphasis,
makeOccluder,
isVisible,
+2
View File
@@ -64,6 +64,8 @@ export function createVesselState({ source, services }) {
/** Test-only key target paired with interactionHandlerFactory. */
interactionKeyTarget: null,
preRenderRemover: null,
/** Pending timer that renders one frame after a cadence-skipped pass. */
trailingVisibilityTimer: null,
lastVisibilityUpdate: 0,
lastFocusUpdate: 0,
/** Sprites whose animated emphasis remains outside the 1.0 deadband. */
+14
View File
@@ -151,6 +151,18 @@ export function createTesting({
};
}
/** Run one preRender visibility/focus pass. Test-only seam. */
function _runVisibilityPassForTest(force = false) {
components.rendering.updateVisibility(force);
}
/** True while a trailing visibility frame is scheduled. Test-only seam. */
function _hasTrailingVisibilityPassForTest() {
return state.trailingVisibilityTimer !== null;
}
/**
* Read lifecycle ownership state without exposing the mutable state object.
* Test-only seam.
@@ -176,5 +188,7 @@ export function createTesting({
_setAisRuntimeForTest,
_getVesselFeedStateForTest,
_getVesselStateForTest,
_runVisibilityPassForTest,
_hasTrailingVisibilityPassForTest,
};
}