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

+1 -1
View File
@@ -2001,7 +2001,7 @@ snapshot still loading for more than 8 seconds. Those snapshots apply as polls
do, so contacts move on smoothly: aircraft are drawn at the clock's time do, so contacts move on smoothly: aircraft are drawn at the clock's time
rather than behind it, and vessels between two fixes move along the great rather than behind it, and vessels between two fixes move along the great
circle, never past the newer one, redrawn every frame with frames asked for circle, never past the newer one, redrawn every frame with frames asked for
as for placed live vessels. Records, absences and the Layers panel's age as for placed live vessels (vessels out of view move on every 250 ms). Records, absences and the Layers panel's age
are judged at the clock's time. Outside the coverage the layer shows no are judged at the clock's time. Outside the coverage the layer shows no
contacts and loads nothing; while the coverage is unknown it loads nothing and contacts and loads nothing; while the coverage is unknown it loads nothing and
clears nothing. A snapshot that fails clears the time and is asked again after clears nothing. A snapshot that fails clears the time and is asked again after
+105 -3
View File
@@ -5,9 +5,9 @@ import { VISIBILITY_UPDATE_MS } from './policy.js';
import { layerPlacement } from '../../data/placementProvider.js'; import { layerPlacement } from '../../data/placementProvider.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
// Vessels move only when a poll lands, so the layer must let the scene idle: // The layer must let the scene idle: it asks for frames for forced passes,
// it asks for frames for forced passes and one trailing frame after the camera // for vessels that move, and one trailing frame after the camera stops, and
// stops, and never schedules a frame from a pass the camera did not cause. // never schedules a frame from a pass nothing caused.
const noop = () => {}; 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); assert.ok(waitAfterStep(0.0001) <= 16);
// About 1 cm in a frame: half a pixel takes seconds, so once a second. // About 1 cm in a frame: half a pixel takes seconds, so once a second.
assert.equal(waitAfterStep(0.0000001), 1000); 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; const VESSEL_STEP_PX = 0.5;
/** A frame is asked for at least this often while any drawn vessel moves. */ /** A frame is asked for at least this often while any drawn vessel moves. */
const PLACEMENT_FRAME_MS = 1000; 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). */ /** How often the vessels near enough for a model are gathered again (wall ms). */
const MODEL_CANDIDATES_MS = 500; const MODEL_CANDIDATES_MS = 500;
@@ -87,10 +90,8 @@ export function createRendering({
visual.position, visual.position,
new Cesium.Cartesian3(), new Cesium.Cartesian3(),
); );
if (services.placement) { visual.drawnLat = lat;
visual.drawnLat = lat; visual.drawnLon = lon;
visual.drawnLon = lon;
}
return visual; return visual;
} }
@@ -429,39 +430,51 @@ export function createRendering({
modelCandidatesAtMs = -Infinity; modelCandidatesAtMs = -Infinity;
} }
// Vessels move only when a poll lands, so the layer does not hold the // The layer does not hold the continuous render loop. Its passes run in
// continuous render loop. Its passes run in preRender, which idle mode fires // preRender, which idle mode fires only for rendered frames: moving vessels
// only for rendered frames: a forced pass (new data, selection) requests its // ask for the frame that next moves them visibly (askMotionFrame), a forced
// frame, a frame that moved the camera but fell inside the 800 ms cadence // pass (new data, selection) requests its frame, a frame that moved the
// leaves a trailing frame so the view the camera stopped on is always // 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 // 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 // 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. // own label publication requests a frame, and scheduling on that would loop.
function installRuntime(viewer) { function installRuntime(viewer) {
if (state.preRenderRemover || !viewer) return; if (state.preRenderRemover || !viewer) return;
state.preRenderRemover = viewer.scene.preRender.addEventListener(() => { state.preRenderRemover = viewer.scene.preRender.addEventListener(() => {
if (followsClock(vesselState)) followClockPositions(); if (state.feed.enabled) {
else if (services.placement && state.feed.enabled) followPlacements(); if (followsClock(vesselState)) followClockPositions();
else if (services.placement) followPlacements();
}
updateVisibility(); updateVisibility();
if (state.feed.enabled) updateModels(); if (state.feed.enabled) updateModels();
}); });
} }
// As the world clock plays, vessels between two fixes move on along them, // As the world clock plays, vessels between two fixes move on along them:
// every frame. // drawn ones every frame, each once per world time, hidden ones (which may
let followedAtMs = null; // come into view) every HIDDEN_FOLLOW_MS.
let hiddenFollowedAtWallMs = -Infinity;
function followClockPositions() { function followClockPositions() {
const atMs = vesselState.worldNow(); const atMs = vesselState.worldNow();
if (atMs === followedAtMs) return;
followedAtMs = atMs;
const motion = startMotionPass(); const motion = startMotionPass();
const moveHidden =
motion.wallMs - hiddenFollowedAtWallMs >= HIDDEN_FOLLOW_MS;
if (moveHidden) hiddenFollowedAtWallMs = motion.wallMs;
for (const record of state.records.all) { for (const record of state.records.all) {
if (!record.motion) continue; if (!record.motion) continue;
let visual = visualRecords.get(record); 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); 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); moveDrawnVisual(visual, lat, lon, motion);
} else visual = prepareRecordVisual(record); }
if (visual.billboard) visual.billboard.position = visual.position; if (visual.billboard) visual.billboard.position = visual.position;
} }
askMotionFrame(motion); askMotionFrame(motion);
@@ -490,18 +503,27 @@ export function createRendering({
askMotionFrame(motion); askMotionFrame(motion);
} }
// One pass of moving vessels: whether any drawn vessel is moving, the // One pass of moving vessels: whether any drawn vessel is moving, how far
// largest angle one moved through as seen from the camera (radians), and // the fastest moved (as an angle seen from the camera, radians, and in
// the wall time since the pass before. // metres), the pass's wall time and the wall time since the pass before.
let motionPassWallMs = null; 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() { function startMotionPass() {
const wallMs = performance.now(); const wallMs = performance.now();
motionPass.camera = state.viewer?.camera ?? null; motionPass.camera = state.viewer?.camera ?? null;
motionPass.wallMs = wallMs;
motionPass.sinceMs = motionPass.sinceMs =
motionPassWallMs === null ? 0 : wallMs - motionPassWallMs; motionPassWallMs === null ? 0 : wallMs - motionPassWallMs;
motionPass.moving = false; motionPass.moving = false;
motionPass.angle = 0; motionPass.angle = 0;
motionPass.metres = 0;
motionPassWallMs = wallMs; motionPassWallMs = wallMs;
return motionPass; return motionPass;
} }
@@ -512,14 +534,28 @@ export function createRendering({
moveRecordVisual(visual, lat, lon); moveRecordVisual(visual, lat, lon);
if (!visual.billboard?.show && !visual.modelOwnsVisual) return; if (!visual.billboard?.show && !visual.modelOwnsVisual) return;
motion.moving = true; motion.moving = true;
const metres = Cesium.Cartesian3.distance(from, visual.position);
motion.metres = Math.max(motion.metres, metres);
const eye = motion.camera?.positionWC; const eye = motion.camera?.positionWC;
if (!eye) return; if (!eye) return;
const distance = Cesium.Cartesian3.distance(eye, from); const distance = Cesium.Cartesian3.distance(eye, from);
if (distance > 0) if (distance > 0) motion.angle = Math.max(motion.angle, metres / distance);
motion.angle = Math.max( }
motion.angle,
Cesium.Cartesian3.distance(from, visual.position) / 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 // 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) { function askMotionFrame(motion) {
if (!motion.moving) return; if (!motion.moving) return;
let waitMs = PLACEMENT_FRAME_MS; let waitMs = PLACEMENT_FRAME_MS;
const canvasHeight = state.viewer?.canvas?.clientHeight; const movedPx = movedPixels(motion);
const fovy = motion.camera?.frustum?.fovy; if (movedPx > 0 && motion.sinceMs > 0)
if (
canvasHeight > 0 &&
fovy > 0 &&
motion.angle > 0 &&
motion.sinceMs > 0
) {
const movedPx = (motion.angle * canvasHeight) / (2 * Math.tan(fovy / 2));
waitMs = Math.min(waitMs, (VESSEL_STEP_PX * motion.sinceMs) / movedPx); waitMs = Math.min(waitMs, (VESSEL_STEP_PX * motion.sinceMs) / movedPx);
}
// A frame already asked for no later than this one stands. // A frame already asked for no later than this one stands.
const runtime = vesselState._aisRuntime; const runtime = vesselState._aisRuntime;
const dueMs = runtime.now() + waitMs; const dueMs = runtime.now() + waitMs;
@@ -943,6 +971,7 @@ export function createRendering({
cancelTrailingVisibilityPass, cancelTrailingVisibilityPass,
cancelPlacementFrame, cancelPlacementFrame,
followPlacements, followPlacements,
followClockPositions,
applyVesselFocusDeemphasis, applyVesselFocusDeemphasis,
makeOccluder, makeOccluder,
isVisible, isVisible,
+8
View File
@@ -174,6 +174,13 @@ export function createTesting({
components.rendering.followPlacements(); 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. */ /** True while a trailing visibility frame is scheduled. Test-only seam. */
function _hasTrailingVisibilityPassForTest() { function _hasTrailingVisibilityPassForTest() {
@@ -208,6 +215,7 @@ export function createTesting({
_runVisibilityPassForTest, _runVisibilityPassForTest,
_prepareVesselVisualForTest, _prepareVesselVisualForTest,
_runPlacementPassForTest, _runPlacementPassForTest,
_runClockPassForTest,
_hasTrailingVisibilityPassForTest, _hasTrailingVisibilityPassForTest,
}; };
} }
+4 -5
View File
@@ -73,11 +73,10 @@ export function createTracking({
heightM, heightM,
); );
if (visual.billboard) visual.billboard.position = visual.position; if (visual.billboard) visual.billboard.position = visual.position;
// A placed vessel is drawn again at its placement on the next pass. // A moving vessel is drawn again where it is on the next pass.
if (services.placement) { visual.drawnLat = NaN;
visual.drawnLat = NaN; visual.drawnLon = NaN;
visual.drawnLon = NaN; visual.followedAtMs = undefined;
}
} }
} }