mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
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:
1 parent
7bea88b395
commit
52695daf5c
5 files changed
+183
-45
No files matched your search
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user