fix(vessels): world-clock motion moves only drawn vessels, once per world time

Vessels out of view move on every 250 ms so one coming into view is current;
vessels at the end of their fixes ask for no frames; nothing moves while the
layer is off; 2D views judge the half-pixel step across the view's width; a
re-floor redraws vessels where they are on the next pass.
This commit is contained in:
Sameh Khamis committed 2026-11-14 23:30:38 -08:00
1 parent 7bea88b395
commit 52695daf5c
5 files changed
+183 -45

No files matched your search

+105 -3
View File
@@ -5,9 +5,9 @@ import { VISIBILITY_UPDATE_MS } from './policy.js';
import { layerPlacement } from '../../data/placementProvider.js';
import * as Cesium from 'cesium';
// 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.
// The layer must let the scene idle: it asks for frames for forced passes,
// for vessels that move, and one trailing frame after the camera stops, and
// never schedules a frame from a pass nothing caused.
const noop = () => {};
@@ -252,4 +252,106 @@ test('a vessel moving visibly asks for its next frame as soon as it moves half a
assert.ok(waitAfterStep(0.0001) <= 16);
// About 1 cm in a frame: half a pixel takes seconds, so once a second.
assert.equal(waitAfterStep(0.0000001), 1000);
// A 2D view 40 km across in 1,000 px: 11 m is over a quarter pixel, so the
// next frame comes within two frames.
camera.frustum = { left: -20_000, right: 20_000 };
layer.testing._setVesselStateForTest({
viewer: { camera, scene: {}, canvas: { clientWidth: 1000 } },
records: [record],
});
layer.testing._prepareVesselVisualForTest(record);
assert.ok(waitAfterStep(0.0001) <= 32);
});
test('following the clock moves drawn vessels in place each world time, prepares missing ones and leaves hidden ones be', (t) => {
let mono = 0;
t.mock.method(performance, 'now', () => mono);
const originalDocument = globalThis.document;
globalThis.document = { getElementById: () => null };
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
});
const { layer, camera, timers, advance } = setup();
camera.positionWC = Cesium.Cartesian3.fromDegrees(4.05, 51.93, 20_000);
camera.frustum = { fovy: 1 };
const motion = {
fromLat: 51.93,
fromLon: 4.05,
fromMs: 0,
toLat: 51.94,
toLon: 4.05,
toMs: 60_000,
};
const vessel = (mmsi, show) => ({
mmsi,
lat: 51.94,
lon: 4.05,
motion: { ...motion },
billboard: { show, position: null },
});
const drawn = vessel('111', true);
const hidden = vessel('222', false);
const owned = vessel('333', false);
const missing = vessel('444', true);
layer.testing._setVesselStateForTest({
viewer: { camera, scene: {}, canvas: { clientHeight: 1000 } },
records: [drawn, hidden, owned, missing],
});
const visuals = [drawn, hidden, owned].map((record) =>
layer.testing._prepareVesselVisualForTest(record),
);
visuals[2].modelOwnsVisual = true;
const before = visuals.map((visual) => ({ ...visual.position }));
const positions = visuals.map((visual) => visual.position);
// The first pass also moves hidden vessels, so the next stays within
// HIDDEN_FOLLOW_MS of it.
layer.testing._runClockPassForTest(10_000);
mono += 16;
layer.testing._runClockPassForTest(20_000);
assert.equal(visuals[0].position, positions[0], 'written in place');
assert.notDeepEqual({ ...visuals[0].position }, before[0]);
assert.notDeepEqual({ ...visuals[2].position }, before[2], 'model moves');
const hiddenAt = { ...visuals[1].position };
assert.ok(missing.billboard.position, 'a missing visual is prepared');
assert.equal(timers.size, 1, 'moving vessels ask for a frame');
// The hidden vessel waits for HIDDEN_FOLLOW_MS.
mono += 16;
layer.testing._runClockPassForTest(30_000);
assert.deepEqual({ ...visuals[1].position }, hiddenAt);
mono += 250;
layer.testing._runClockPassForTest(40_000);
assert.notDeepEqual({ ...visuals[1].position }, hiddenAt);
// The same world time again moves nothing and asks for no frame.
advance(1_000);
assert.equal(timers.size, 0);
const still = { ...visuals[0].position };
mono += 16;
layer.testing._runClockPassForTest(40_000);
assert.deepEqual({ ...visuals[0].position }, still);
assert.equal(timers.size, 0);
// Scrubbed back, a vessel moves back.
mono += 16;
layer.testing._runClockPassForTest(20_000);
assert.notDeepEqual({ ...visuals[0].position }, still);
// Each vessel moves once per world time: new fixes at the same world time
// are drawn when the snapshot that brings them prepares the visual.
const backAt = { ...visuals[0].position };
drawn.motion = { ...motion, toLat: 52 };
mono += 16;
layer.testing._runClockPassForTest(20_000);
assert.deepEqual({ ...visuals[0].position }, backAt);
// Past the newest fix, vessels hold still and ask for nothing.
mono += 16;
layer.testing._runClockPassForTest(70_000);
advance(1_000);
mono += 16;
layer.testing._runClockPassForTest(80_000);
assert.equal(timers.size, 0);
});
+65 -36
View File
@@ -37,6 +37,9 @@ import { isAidToNavigation } from '../../data/vesselTypes.js';
const VESSEL_STEP_PX = 0.5;
/** A frame is asked for at least this often while any drawn vessel moves. */
const PLACEMENT_FRAME_MS = 1000;
/** How often vessels not drawn move on as the world clock plays (wall ms),
* so one that comes into view is judged where it is. */
const HIDDEN_FOLLOW_MS = 250;
/** How often the vessels near enough for a model are gathered again (wall ms). */
const MODEL_CANDIDATES_MS = 500;
@@ -87,10 +90,8 @@ export function createRendering({
visual.position,
new Cesium.Cartesian3(),
);
if (services.placement) {
visual.drawnLat = lat;
visual.drawnLon = lon;
}
visual.drawnLat = lat;
visual.drawnLon = lon;
return visual;
}
@@ -429,39 +430,51 @@ export function createRendering({
modelCandidatesAtMs = -Infinity;
}
// 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
// The layer does not hold the continuous render loop. Its passes run in
// preRender, which idle mode fires only for rendered frames: moving vessels
// ask for the frame that next moves them visibly (askMotionFrame), 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(() => {
if (followsClock(vesselState)) followClockPositions();
else if (services.placement && state.feed.enabled) followPlacements();
if (state.feed.enabled) {
if (followsClock(vesselState)) followClockPositions();
else if (services.placement) followPlacements();
}
updateVisibility();
if (state.feed.enabled) updateModels();
});
}
// As the world clock plays, vessels between two fixes move on along them,
// every frame.
let followedAtMs = null;
// As the world clock plays, vessels between two fixes move on along them:
// drawn ones every frame, each once per world time, hidden ones (which may
// come into view) every HIDDEN_FOLLOW_MS.
let hiddenFollowedAtWallMs = -Infinity;
function followClockPositions() {
const atMs = vesselState.worldNow();
if (atMs === followedAtMs) return;
followedAtMs = atMs;
const motion = startMotionPass();
const moveHidden =
motion.wallMs - hiddenFollowedAtWallMs >= HIDDEN_FOLLOW_MS;
if (moveHidden) hiddenFollowedAtWallMs = motion.wallMs;
for (const record of state.records.all) {
if (!record.motion) continue;
let visual = visualRecords.get(record);
if (visual?.position) {
if (!visual?.position) {
visual = prepareRecordVisual(record);
visual.followedAtMs = atMs;
} else {
const drawn = visual.billboard?.show || visual.modelOwnsVisual;
if (visual.followedAtMs === atMs || (!drawn && !moveHidden)) continue;
visual.followedAtMs = atMs;
const { lat, lon } = vesselPositionAt(record, atMs);
// At either end of its fixes a vessel holds still.
if (lat === visual.drawnLat && lon === visual.drawnLon) continue;
moveDrawnVisual(visual, lat, lon, motion);
} else visual = prepareRecordVisual(record);
}
if (visual.billboard) visual.billboard.position = visual.position;
}
askMotionFrame(motion);
@@ -490,18 +503,27 @@ export function createRendering({
askMotionFrame(motion);
}
// One pass of moving vessels: whether any drawn vessel is moving, the
// largest angle one moved through as seen from the camera (radians), and
// the wall time since the pass before.
// One pass of moving vessels: whether any drawn vessel is moving, how far
// the fastest moved (as an angle seen from the camera, radians, and in
// metres), the pass's wall time and the wall time since the pass before.
let motionPassWallMs = null;
const motionPass = { camera: null, sinceMs: 0, moving: false, angle: 0 };
const motionPass = {
camera: null,
wallMs: 0,
sinceMs: 0,
moving: false,
angle: 0,
metres: 0,
};
function startMotionPass() {
const wallMs = performance.now();
motionPass.camera = state.viewer?.camera ?? null;
motionPass.wallMs = wallMs;
motionPass.sinceMs =
motionPassWallMs === null ? 0 : wallMs - motionPassWallMs;
motionPass.moving = false;
motionPass.angle = 0;
motionPass.metres = 0;
motionPassWallMs = wallMs;
return motionPass;
}
@@ -512,14 +534,28 @@ export function createRendering({
moveRecordVisual(visual, lat, lon);
if (!visual.billboard?.show && !visual.modelOwnsVisual) return;
motion.moving = true;
const metres = Cesium.Cartesian3.distance(from, visual.position);
motion.metres = Math.max(motion.metres, metres);
const eye = motion.camera?.positionWC;
if (!eye) return;
const distance = Cesium.Cartesian3.distance(eye, from);
if (distance > 0)
motion.angle = Math.max(
motion.angle,
Cesium.Cartesian3.distance(from, visual.position) / distance,
if (distance > 0) motion.angle = Math.max(motion.angle, metres / distance);
}
/** How far the fastest drawn vessel moved on screen in a pass (pixels):
* through the perspective view's angle, or across the 2D view's width. */
function movedPixels(motion) {
const canvas = state.viewer?.canvas;
const frustum = motion.camera?.frustum;
if (!canvas || !frustum) return 0;
if (frustum.fovy > 0 && canvas.clientHeight > 0)
return (
(motion.angle * canvas.clientHeight) / (2 * Math.tan(frustum.fovy / 2))
);
const width = frustum.right - frustum.left;
if (!(frustum.fovy > 0) && width > 0 && canvas.clientWidth > 0)
return (motion.metres * canvas.clientWidth) / width;
return 0;
}
// The timer for the next vessel frame while one is asked for, and when it
@@ -534,17 +570,9 @@ export function createRendering({
function askMotionFrame(motion) {
if (!motion.moving) return;
let waitMs = PLACEMENT_FRAME_MS;
const canvasHeight = state.viewer?.canvas?.clientHeight;
const fovy = motion.camera?.frustum?.fovy;
if (
canvasHeight > 0 &&
fovy > 0 &&
motion.angle > 0 &&
motion.sinceMs > 0
) {
const movedPx = (motion.angle * canvasHeight) / (2 * Math.tan(fovy / 2));
const movedPx = movedPixels(motion);
if (movedPx > 0 && motion.sinceMs > 0)
waitMs = Math.min(waitMs, (VESSEL_STEP_PX * motion.sinceMs) / movedPx);
}
// A frame already asked for no later than this one stands.
const runtime = vesselState._aisRuntime;
const dueMs = runtime.now() + waitMs;
@@ -943,6 +971,7 @@ export function createRendering({
cancelTrailingVisibilityPass,
cancelPlacementFrame,
followPlacements,
followClockPositions,
applyVesselFocusDeemphasis,
makeOccluder,
isVisible,
+8
View File
@@ -174,6 +174,13 @@ export function createTesting({
components.rendering.followPlacements();
}
/** Run one world-clock pass at world time `atMs`. Test-only seam. */
function _runClockPassForTest(atMs) {
vesselState.worldNow = () => atMs;
components.rendering.followClockPositions();
}
/** True while a trailing visibility frame is scheduled. Test-only seam. */
function _hasTrailingVisibilityPassForTest() {
@@ -208,6 +215,7 @@ export function createTesting({
_runVisibilityPassForTest,
_prepareVesselVisualForTest,
_runPlacementPassForTest,
_runClockPassForTest,
_hasTrailingVisibilityPassForTest,
};
}
+4 -5
View File
@@ -73,11 +73,10 @@ export function createTracking({
heightM,
);
if (visual.billboard) visual.billboard.position = visual.position;
// A placed vessel is drawn again at its placement on the next pass.
if (services.placement) {
visual.drawnLat = NaN;
visual.drawnLon = NaN;
}
// A moving vessel is drawn again where it is on the next pass.
visual.drawnLat = NaN;
visual.drawnLon = NaN;
visual.followedAtMs = undefined;
}
}