feat(time): the app clock on the engine

src/time/clock.js is the app's own time in ms since the epoch: by default
Date.now exactly (read at each call), with freeze/offset/rate/reset for QA
and tests, and one frame time latched per engine frame (beginFrame). The
contract gains engine.clock { now, frameTime, ticked }, in the fake engine
(a controllable wall clock, fake.advanceTime, fake.appClock), the contract
suite (one frame time for every listener of a frame, latched before
preUpdate, never decreasing), Cesium and Navara.

Cesium latches on the viewer clock's onTick, which CesiumWidget runs right
before scene.render, so scene listeners the app added before the engine
existed read the same frame time; the subscription appears on first use
and destroy removes it. The viewer clock itself is untouched: in the app
it never animates (shouldAnimate false, multiplier 1, its time frozen at
boot), and its time still drives Cesium's lighting as before. Navara
latches in the engine's handler of the view's preUpdate, and the look
reads the engine clock's frame time for the sun, so the app clock is the
one time source on Navara too.

src/time is a portable root in check-import-directions: no engine
packages, no browser globals.
This commit is contained in:
German Khokhlov committed 2026-10-16 15:01:36 +04:00
1 parent 27e28373d0
commit f1a2df5cfe
20 files changed
+771 -13

No files matched your search

+7 -1
View File
@@ -21,6 +21,8 @@ const source = (file) =>
/^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file);
// Engine-neutral geometry ported from Cesium; every runtime file is a portable root.
const geo = (file) => file.startsWith('src/geo/');
// The app clock (src/time): plain numbers, no engine, no DOM.
const time = (file) => file.startsWith('src/time/');
// The engine-neutral globe contract and its test support; engines live below it.
const globeContract = (file) =>
/^src\/globe\/[^/]+$/.test(file) || file.startsWith('src/globe/testing/');
@@ -210,6 +212,7 @@ export function checkImportDirections(root) {
browser(file);
const roots = new Map(files.filter(source).map((file) => [file, 'source']));
for (const file of files.filter(geo)) roots.set(file, 'geo');
for (const file of files.filter(time)) roots.set(file, 'time');
for (const [key, value] of Object.entries(pkg.exports || {})) {
if (portableExport(key) && typeof value === 'string')
roots.set(value.replace(/^\.\//, ''), key);
@@ -237,7 +240,10 @@ export function checkImportDirections(root) {
specifier.startsWith('node:') ||
/^(?:cesium|vite)(?:\/|$)/.test(specifier) ||
// Geo must not reach Cesium through its workspace packages either.
(label === 'geo' && /^@cesium\//.test(specifier))
(label === 'geo' && /^@cesium\//.test(specifier)) ||
// Neither engine reaches the app clock.
(label === 'time' &&
(cesiumPackage.test(specifier) || navaraPackage.test(specifier)))
)
report(
file,
+54
View File
@@ -0,0 +1,54 @@
// The engine clock on a Cesium viewer: the app clock (src/time/clock.js),
// with one frame time latched per turn of CesiumWidget's loop. The latch
// listens to the viewer clock's tick, which the widget runs right before
// scene.render, so every preUpdate/preRender/postRender listener of the frame
// (including those the app added to the scene before the engine existed)
// reads the same frame time. The viewer clock itself is never changed: the
// app does not animate it (shouldAnimate stays false), and its time still
// drives Cesium's own lighting as before. The tick subscription appears on
// first use (a ticked listener or a frameTime read) and destroy removes it.
import { createEngineEvent } from '../contract.js';
import { appClock as pageClock } from '../../time/clock.js';
/**
* @param {Cesium.Viewer} viewer
* @param {{ appClock?: import('../../time/clock.js').AppClock }} [options]
* `appClock` defaults to the page's app clock.
* @returns {{ clock: import('../contract.js').GlobeClock, destroy: () => void }}
*/
export function createCesiumClock(viewer, { appClock = pageClock } = {}) {
// A viewer-shaped object without a clock (tests, a bare scene): the
// scene's preUpdate opens the frame instead.
const tickSource = viewer.clock?.onTick ?? viewer.scene.preUpdate;
const ticked = createEngineEvent();
let removeTick = null;
let destroyed = false;
const onTick = () => ticked.raiseValue(appClock.beginFrame());
const attach = () => {
if (!removeTick && !destroyed)
removeTick = tickSource.addEventListener(onTick);
};
const add = ticked.addEventListener;
ticked.addEventListener = (listener) => {
const remove = add(listener);
attach();
return remove;
};
return {
clock: {
now: () => appClock.now(),
frameTime() {
attach();
return appClock.frameTime();
},
ticked,
},
destroy() {
destroyed = true;
const remove = removeTick;
removeTick = null;
remove?.();
},
};
}
+116
View File
@@ -0,0 +1,116 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { isEngineEvent } from '../contract.js';
import { createAppClock } from '../../time/clock.js';
import { createCesiumClock } from './clock.js';
import { createFakeViewer } from './testing/fakeViewer.mjs';
function manualAppClock(start = Date.UTC(2026, 9, 16)) {
let wall = start;
const appClock = createAppClock({ now: () => wall });
return {
appClock,
advance(ms) {
wall += ms;
},
};
}
// One turn of CesiumWidget's loop: the clock ticks, then scene.render
// raises preUpdate (and preRender/postRender when it draws).
function cesiumFrame(viewer, { draw = true } = {}) {
const time = Cesium.JulianDate.now();
viewer.clock.onTick.raiseEvent(viewer.clock);
viewer.scene.preUpdate.raiseEvent(viewer.scene, time);
if (!draw) return;
viewer.scene.preRender.raiseEvent(viewer.scene, time);
viewer.scene.postRender.raiseEvent(viewer.scene, time);
}
test('the clock reads the app clock and subscribes to nothing at creation', () => {
const viewer = createFakeViewer();
const wall = manualAppClock();
const { clock } = createCesiumClock(viewer, { appClock: wall.appClock });
assert.ok(isEngineEvent(clock.ticked));
assert.equal(viewer.clock.onTick.numberOfListeners, 0);
assert.equal(clock.now(), wall.appClock.now());
wall.advance(5);
assert.equal(clock.now(), wall.appClock.now());
});
test('frameTime is latched on the viewer clock tick, before preUpdate', () => {
const viewer = createFakeViewer();
const wall = manualAppClock();
const { clock } = createCesiumClock(viewer, { appClock: wall.appClock });
const seen = [];
clock.ticked.addEventListener((time) => seen.push(['ticked', time]));
// A preUpdate listener the app added before the engine existed still
// reads this frame's time: the latch is on the tick, not on preUpdate.
viewer.scene.preUpdate.addEventListener(() =>
seen.push(['preUpdate', clock.frameTime()]),
);
viewer.scene.preRender.addEventListener(() => {
wall.advance(3);
seen.push(['preRender', clock.frameTime()]);
});
const t0 = wall.appClock.now();
cesiumFrame(viewer);
wall.advance(16);
cesiumFrame(viewer, { draw: false });
assert.deepEqual(seen, [
['ticked', t0],
['preUpdate', t0],
['preRender', t0],
['ticked', t0 + 19],
['preUpdate', t0 + 19],
]);
});
test('frameTime alone starts the latch; destroy removes it', () => {
const viewer = createFakeViewer();
const wall = manualAppClock();
const { clock, destroy } = createCesiumClock(viewer, {
appClock: wall.appClock,
});
const before = clock.frameTime();
assert.equal(before, wall.appClock.now());
assert.equal(viewer.clock.onTick.numberOfListeners, 1);
wall.advance(40);
cesiumFrame(viewer);
assert.equal(clock.frameTime(), before + 40);
wall.advance(10);
assert.equal(clock.frameTime(), before + 40);
destroy();
assert.equal(viewer.clock.onTick.numberOfListeners, 0);
});
test('a viewer without a clock latches on preUpdate', () => {
const viewer = createFakeViewer();
delete viewer.clock;
const wall = manualAppClock();
const { clock, destroy } = createCesiumClock(viewer, {
appClock: wall.appClock,
});
const seen = [];
clock.ticked.addEventListener((time) => seen.push(time));
wall.advance(7);
viewer.scene.preUpdate.raiseEvent(viewer.scene);
assert.deepEqual(seen, [wall.appClock.now()]);
destroy();
assert.equal(viewer.scene.preUpdate.numberOfListeners, 0);
});
test('the viewer clock itself is left alone', () => {
const viewer = createFakeViewer();
viewer.clock.shouldAnimate = false;
viewer.clock.multiplier = 1;
const { clock } = createCesiumClock(viewer, {
appClock: manualAppClock().appClock,
});
clock.ticked.addEventListener(() => {});
cesiumFrame(viewer);
assert.equal(viewer.clock.shouldAnimate, false);
assert.equal(viewer.clock.multiplier, 1);
assert.equal(viewer.clock.currentTime, undefined);
});
+5 -2
View File
@@ -4,6 +4,7 @@
// (the viewer itself stays owned by the app).
import { createCesiumCamera } from './camera.js';
import { createCesiumClock } from './clock.js';
import { createCesiumCredits } from './credits.js';
import { createCesiumFrames } from './frames.js';
import { createCesiumInput } from './input.js';
@@ -21,8 +22,9 @@ import { createCesiumSurface } from './surface.js';
* createEventHandler?: Function,
* createImageryLayer?: Function,
* loadPhotorealTileset?: Function,
* }} [options] `requestRender` defaults to the render governor; the
* `create*`/`load*` options are test seams.
* appClock?: import('../../time/clock.js').AppClock,
* }} [options] `requestRender` defaults to the render governor; `appClock`
* to the page's app clock; the `create*`/`load*` options are test seams.
*
* `render.setContinuous` takes a hold on the app's singleton renderGovernor
* (src/renderGovernor.js), which governs the one viewer it was installed on;
@@ -40,6 +42,7 @@ export function createCesiumEngine(viewer, options = {}) {
picker: createCesiumPicker(viewer),
projector: createCesiumProjector(viewer),
frames: createCesiumFrames(viewer),
clock: createCesiumClock(viewer, options),
render,
surface: createCesiumSurface(viewer),
input: createCesiumInput(viewer, options),
@@ -138,3 +138,21 @@ test('the engine reads the Viewer aliases and builds on a minimal viewer', () =>
assert.equal(renders, 1);
engine.destroy();
});
test('the engine clock is the app clock given, latched on the viewer clock tick', async () => {
const { createAppClock } = await import('../../time/clock.js');
let wall = 1_000_000;
const appClock = createAppClock({ now: () => wall });
const viewer = createFakeViewer();
const engine = createCesiumEngine(viewer, { appClock });
assert.equal(engine.clock.now(), 1_000_000);
const seen = [];
engine.clock.ticked.addEventListener((time) => seen.push(time));
assert.equal(viewer.clock.onTick.numberOfListeners, 1);
wall += 16;
viewer.clock.onTick.raiseEvent(viewer.clock);
assert.deepEqual(seen, [1_000_016]);
assert.equal(engine.clock.frameTime(), 1_000_016);
engine.destroy();
assert.equal(viewer.clock.onTick.numberOfListeners, 0);
});
+3
View File
@@ -165,6 +165,9 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) {
staticCredits,
primitiveList,
trackedEntityChanged: new Cesium.Event(),
// The viewer clock: only its tick event (CesiumWidget ticks it right
// before scene.render on every animation frame).
clock: { onTick: new Cesium.Event() },
imageryList,
terrainProvider: undefined,
imageryLayers: {
+21
View File
@@ -209,6 +209,25 @@
* The source objects on screen, as passed: the base imagery layer on the
* globe and the photoreal tiles shown (null for none).
*
* @typedef {object} GlobeClock
* The app's own time (src/time/clock.js), in milliseconds since the Unix
* epoch: the one time source for layers and the engine's own time-driven
* drawing (Navara's sun). By default it is the wall clock (Date.now); QA
* and tests may freeze, offset or speed it up on the app clock. Cesium's
* viewer.clock is not driven by it (the app never animates that clock).
* @property {() => number} now The current app time.
* @property {() => number} frameTime The app time latched at the start of the
* engine's current frame: every reader in one frame gets the same value; it
* does not decrease from frame to frame while the app clock runs forward.
* Before the first frame, the current time.
* @property {EngineEvent} ticked Raised once per engine frame, with the
* frame time, before that frame's preUpdate (and so before preRender). A
* frame is one turn of the engine's update loop, also when it draws
* nothing: Cesium raises it on the viewer clock's tick, right before
* scene.render (which raises preUpdate on every animation frame, drawn or
* not); Navara on the view's preUpdate, which it emits on every turn of
* its loop.
*
* @typedef {object} GlobeEngine
* @property {'cesium'|'navara'} kind
* @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host
@@ -228,6 +247,7 @@
* held and one without fires only while no modifier is held.
* @property {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits
* @property {GlobeMaps} maps
* @property {GlobeClock} clock
* @property {() => void} destroy
* @property {() => boolean} isDestroyed True once destroy() has run.
*/
@@ -406,6 +426,7 @@ const SHAPE = {
},
input: { on: isFunction, setCameraInputs: isFunction },
credits: { addStatic: isFunction },
clock: { now: isFunction, frameTime: isFunction, ticked: isEngineEvent },
maps: {
setBaseImagery: isFunction,
setTerrain: isFunction,
+18
View File
@@ -314,3 +314,21 @@ test('the contract carries the sharpening and checks its values', () => {
])
assert.throws(() => mergeSharpen(on, bad), TypeError);
});
test('the contract carries the app clock', () => {
for (const member of ['clock.now', 'clock.frameTime', 'clock.ticked'])
assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member);
const engine = createFakeEngine();
assert.equal(assertGlobeEngine(engine), engine);
const broken = {
...engine,
clock: { ...engine.clock, frameTime: 1, ticked: {} },
};
assert.throws(
() => assertGlobeEngine(broken),
/clock\.frameTime.*clock\.ticked/,
);
const missing = { ...engine };
delete missing.clock;
assert.throws(() => assertGlobeEngine(missing), /\bclock\b/);
});
+28
View File
@@ -0,0 +1,28 @@
// The engine clock on a Navara view: the app clock (src/time/clock.js), with
// one frame time latched per turn of the view's loop. The engine calls
// beginFrame from its handler of the view's preUpdate, which Navara emits
// first on every turn of its loop (drawn or not), before raising the
// contract's preUpdate; every listener of the frame then reads that time.
// The look (look.js) reads the same clock for the sun.
import { createEngineEvent } from '../contract.js';
import { appClock as pageClock } from '../../time/clock.js';
/**
* @param {{ appClock?: import('../../time/clock.js').AppClock }} [options]
* `appClock` defaults to the page's app clock.
* @returns {{ clock: import('../contract.js').GlobeClock, beginFrame: () => void }}
*/
export function createNavaraClock({ appClock = pageClock } = {}) {
const ticked = createEngineEvent();
return {
clock: {
now: () => appClock.now(),
frameTime: () => appClock.frameTime(),
ticked,
},
beginFrame() {
ticked.raiseValue(appClock.beginFrame());
},
};
}
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { isEngineEvent } from '../contract.js';
import { createAppClock } from '../../time/clock.js';
import { createNavaraClock } from './clock.js';
test('beginFrame latches the app clock and raises ticked with the frame time', () => {
let wall = 10_000;
const appClock = createAppClock({ now: () => wall });
const { clock, beginFrame } = createNavaraClock({ appClock });
assert.ok(isEngineEvent(clock.ticked));
const seen = [];
clock.ticked.addEventListener((time) => seen.push(time));
assert.equal(clock.frameTime(), 10_000);
wall = 10_016;
beginFrame();
wall = 10_020;
assert.deepEqual(seen, [10_016]);
assert.equal(clock.frameTime(), 10_016);
assert.equal(clock.now(), 10_020);
});
+19 -4
View File
@@ -14,6 +14,7 @@ import {
} from '../contract.js';
import { cesiumFovy } from './cameraMath.js';
import { createCameraModel } from './cameraModel.js';
import { createNavaraClock } from './clock.js';
import { createNavaraCredits } from './credits.js';
import { createNavaraInput } from './input.js';
import { createNavaraMaps } from './maps.js';
@@ -68,16 +69,25 @@ function rayRaisedEllipsoid({ origin, direction }, height) {
* container?: HTMLElement,
* fetch?: typeof fetch,
* clock?: object,
* appClock?: import('../../time/clock.js').AppClock,
* sharpen?: { set: (state: { enabled: boolean, amount: number }) => void, destroy: () => void },
* }} options `hooks` are internals.js; `container` defaults to the
* canvas's parent; `sharpen` draws render.setSharpen (sharpen.js; without
* it the engine only keeps the state) and the engine destroys it; `fetch`
* and `clock` are test seams.
* it the engine only keeps the state) and the engine destroys it;
* `appClock` (the engine clock's time) defaults to the page's app clock;
* `fetch` and `clock` (the camera model's timers) are test seams.
* @returns {import('../contract.js').GlobeEngine}
*/
export function createNavaraEngine(
view,
{ hooks, container, fetch, clock, sharpen: sharpenPort = null } = {},
{
hooks,
container,
fetch,
clock,
appClock,
sharpen: sharpenPort = null,
} = {},
) {
if (!view?.camera?.raw)
throw new TypeError('createNavaraEngine needs a view');
@@ -112,10 +122,14 @@ export function createNavaraEngine(
preRender: createEngineEvent(),
postRender: createEngineEvent(),
};
const engineClock = createNavaraClock(appClock ? { appClock } : {});
// Defined below, with the surface and input it needs.
let groundFloor = null;
const handlers = {
preUpdate: (time) => frames.preUpdate.raiseEvent(time),
preUpdate: (time) => {
engineClock.beginFrame();
frames.preUpdate.raiseEvent(time);
},
preRender: (time) => {
model.sync();
groundFloor?.update();
@@ -219,6 +233,7 @@ export function createNavaraEngine(
input: input.input,
credits: credits.credits,
maps: maps.maps,
clock: engineClock.clock,
destroy() {
if (destroyed) return;
destroyed = true;
@@ -130,6 +130,37 @@ test("Navara's frame events drive the contract frames in order with timestamps",
]);
});
test('the engine clock is the app clock, latched on the view preUpdate before frames', async () => {
const { createAppClock } = await import('../../time/clock.js');
let wall = 2_000_000;
const appClock = createAppClock({ now: () => wall });
const view = fakeView();
const engine = createNavaraEngine(view, { hooks, appClock });
assert.equal(engine.clock.now(), 2_000_000);
const seen = [];
engine.clock.ticked.addEventListener((time) => seen.push(['ticked', time]));
for (const name of ['preUpdate', 'preRender', 'postRender'])
engine.frames[name].addEventListener(() =>
seen.push([name, engine.clock.frameTime()]),
);
wall += 16;
view.emit('preUpdate', 1);
wall += 4;
view.emit('preRender', 2);
view.emit('postRender', 3);
assert.deepEqual(seen, [
['ticked', 2_000_016],
['preUpdate', 2_000_016],
['preRender', 2_000_016],
['postRender', 2_000_016],
]);
assert.equal(engine.clock.now(), 2_000_020);
engine.destroy();
wall += 16;
view.emit('preUpdate', 4);
assert.equal(seen.length, 4, 'no tick after destroy');
});
test('render requests are forceUpdate and continuous mode is Navara animation', () => {
const { engine, view } = setup();
engine.render.request();
+10 -1
View File
@@ -79,9 +79,12 @@ function groundInView(engine) {
* effects?: 'minimal' | 'photoreal' | 'cinematic',
* search?: string,
* pixelRatio?: number,
* appClock?: import('../../time/clock.js').AppClock,
* }} options `effects` names the look (cinematic by default); `search`, the
* page's query string, picks it from `?look=` instead (the app's way).
* `pixelRatio` only for measurements; the product uses the device's.
* `appClock` (the page's by default) is the engine clock's time and so the
* sun's: the look reads the engine clock's frame time.
* @returns {Promise<{ view: object, engine: import('../contract.js').GlobeEngine, destroy: () => void }>}
*/
export async function createNavaraGlobe({
@@ -89,6 +92,7 @@ export async function createNavaraGlobe({
effects,
search,
pixelRatio,
appClock,
}) {
if (!container) throw new TypeError('createNavaraGlobe needs a container');
const canvas = preservedCanvas(container);
@@ -103,10 +107,15 @@ export async function createNavaraGlobe({
const plan = navaraScenePlan(effects ?? resolveNavaraLook(search), {
mobile: Boolean(view.isMobileOptimized?.()),
});
const look = buildNavaraLook(view, plugin, plan);
// The sun follows the engine clock (one time source for layers and the
// look); the look only runs in preRender, after the engine exists.
const look = buildNavaraLook(view, plugin, plan, () =>
engine.clock.frameTime(),
);
engine = createNavaraEngine(view, {
hooks: navaraHooks(view),
container,
...(appClock ? { appClock } : {}),
sharpen: createNavaraSharpen(view),
});
const camera = engine.camera;
+4 -2
View File
@@ -1,6 +1,8 @@
// Builds a Navara look (viewOptions.js navaraScenePlan) on an initialised
// view and keeps it current while the view renders: the sun follows the wall
// clock as the HUD's SUN elevation does; the cinematic look lights the globe
// view and keeps it current while the view renders: the sun follows `now`
// (the engine clock's frame time in the app: the app clock, which is the wall
// clock the HUD's SUN elevation reads unless QA moves it); the cinematic look
// lights the globe
// from the atmosphere when seen from space, shows its clouds only for low
// views over low ground, brightens the atmosphere (atmosphereLuminance.js)
// and shows its stars only at night or from space. All of it runs only on frames the view draws
+71
View File
@@ -359,6 +359,77 @@ export function createContractSuite(
);
},
],
[
'clock: one frame time per frame, latched before preUpdate, never decreasing',
async () => {
const records = [];
const read = (name, value) =>
records.push({
name,
value,
frame: engine.clock.frameTime(),
now: engine.clock.now(),
});
const removers = [
engine.clock.ticked.addEventListener((time) => read('ticked', time)),
...['preUpdate', 'preRender', 'postRender'].map((name) =>
engine.frames[name].addEventListener(() => read(name)),
),
];
try {
await until(
() =>
records.filter(({ name }) => name === 'postRender').length >= 3,
'three drawn frames',
);
} finally {
for (const remove of removers) remove();
}
ensure(
Number.isFinite(engine.clock.now()) &&
Number.isFinite(engine.clock.frameTime()),
'clock.now or clock.frameTime is not a number',
);
// From the first tick on: every frame event belongs to the frame the
// last tick opened, and reads that tick's frame time.
const start = records.findIndex(({ name }) => name === 'ticked');
ensure(start >= 0, 'clock.ticked never fired');
let tick = null;
let ticksSincePreUpdate = 0;
let previousFrame = -Infinity;
for (const record of records.slice(start)) {
ensure(
record.frame <= record.now,
`${record.name}: frameTime ${record.frame} is after now ${record.now}`,
);
if (record.name === 'ticked') {
ensure(
record.value === record.frame,
`ticked passed ${record.value}, frameTime read ${record.frame}`,
);
ensure(
record.frame >= previousFrame,
`frame time went back from ${previousFrame} to ${record.frame}`,
);
previousFrame = record.frame;
tick = record;
ticksSincePreUpdate++;
continue;
}
ensure(
record.frame === tick.frame,
`${record.name} read frame time ${record.frame}, its frame's is ${tick.frame}`,
);
if (record.name === 'preUpdate') {
ensure(
ticksSincePreUpdate >= 1,
'preUpdate ran without a tick before it',
);
ticksSincePreUpdate = 0;
}
}
},
],
[
'render: continuous mode toggles',
async () => {
+27 -3
View File
@@ -25,9 +25,12 @@ import {
createEngineEvent,
mergeSharpen,
} from '../contract.js';
import { createAppClock } from '../../time/clock.js';
const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
const WGS84 = ellipsoid.WGS84;
// Where the fake wall clock starts: 2026-01-01T00:00:00Z.
const FAKE_EPOCH_MS = Date.UTC(2026, 0, 1);
const RADII = WGS84.radii;
const MAXIMUM_RADIUS = Math.max(RADII.x, RADII.y, RADII.z);
// The terrain kinds the engines build themselves (src/maps/registry.js).
@@ -220,10 +223,14 @@ function rectangleFromCartographics(points) {
/**
* @param {{ kind?: 'cesium'|'navara', width?: number, height?: number, fovy?: number,
* longitude?: number, latitude?: number, altitude?: number,
* container?: object, canvas?: object }} [options]
* container?: object, canvas?: object, nowMs?: number,
* appClock?: import('../../time/clock.js').AppClock }} [options]
* Canvas size in CSS px, angles in radians; `container`/`canvas` stand in
* for the host elements (DOM mocks for shell tests). The initial pose looks straight
* down from `altitude` metres (10 000 km).
* down from `altitude` metres (10 000 km). The engine clock reads
* `appClock`, by default an app clock on a fake wall clock that starts at
* `nowMs` (2026-01-01T00:00Z) and moves only with `fake.frame(dt)` and
* `fake.advanceTime(ms)` (a given appClock keeps its own wall clock).
* @returns {import('../contract.js').GlobeEngine & { fake: object }}
*/
export function createFakeEngine(options = {}) {
@@ -248,7 +255,11 @@ export function createFakeEngine(options = {}) {
'frames.preUpdate': createEngineEvent(),
'frames.preRender': createEngineEvent(),
'frames.postRender': createEngineEvent(),
'clock.ticked': createEngineEvent(),
};
let wallMs = options.nowMs ?? FAKE_EPOCH_MS;
const appClock = options.appClock ?? createAppClock({ now: () => wallMs });
const ownsWall = !options.appClock;
const inputs = new Map(INPUT_TYPES.map((type) => [type, new Set()]));
const livePosition = cartesian3.create();
let lastBaseImagery = null;
@@ -782,6 +793,11 @@ export function createFakeEngine(options = {}) {
viewProjection,
horizonVisible,
},
clock: {
now: () => appClock.now(),
frameTime: () => appClock.frameTime(),
ticked: events['clock.ticked'],
},
frames: {
preUpdate: events['frames.preUpdate'],
preRender: events['frames.preRender'],
@@ -951,9 +967,17 @@ export function createFakeEngine(options = {}) {
scripts.delete(name);
},
lookAtTarget: () => (lookAtTarget ? copy3(lookAtTarget) : undefined),
/** Runs one frame: preUpdate, flight progress, preRender, camera events, postRender. */
/** The app clock the engine clock reads (freeze/offset/rate for tests). */
appClock,
/** Moves the fake wall clock without a frame (own app clock only). */
advanceTime(ms) {
if (ownsWall) wallMs += ms;
},
/** Runs one frame: clock.ticked, preUpdate, flight progress, preRender, camera events, postRender. */
frame(dt = 16) {
clock += dt;
if (ownsWall) wallMs += dt;
events['clock.ticked'].raiseValue(appClock.beginFrame());
events['frames.preUpdate'].raiseEvent(clock);
advanceFlight(dt);
events['frames.preRender'].raiseEvent(clock);
+43
View File
@@ -397,3 +397,46 @@ test('fake isDestroyed, commanded and pickGlobe are inspectable', () => {
engine.destroy();
assert.equal(engine.isDestroyed(), true);
});
test('the fake clock is the app clock the test controls, latched per frame', () => {
const start = Date.UTC(2026, 9, 16, 12);
const engine = createFakeEngine({ nowMs: start });
const seen = [];
engine.clock.ticked.addEventListener((time) =>
seen.push(['ticked', time, engine.clock.frameTime()]),
);
for (const name of ['preUpdate', 'preRender', 'postRender'])
engine.frames[name].addEventListener(() =>
seen.push([name, engine.clock.frameTime()]),
);
assert.equal(engine.clock.now(), start);
engine.fake.frame(16);
assert.deepEqual(seen, [
['ticked', start + 16, start + 16],
['preUpdate', start + 16],
['preRender', start + 16],
['postRender', start + 16],
]);
// Time moves between frames; the frame time only at the next frame.
engine.fake.advanceTime(1000);
assert.equal(engine.clock.now(), start + 1016);
assert.equal(engine.clock.frameTime(), start + 16);
engine.fake.frame(16);
assert.equal(engine.clock.frameTime(), start + 1032);
// The app clock's controls work through engine.fake.appClock.
engine.fake.appClock.freeze(start);
engine.fake.frame(16);
assert.equal(engine.clock.frameTime(), start);
});
test('a fake engine can share an app clock', async () => {
const { createAppClock } = await import('../../time/clock.js');
let wall = 5000;
const appClock = createAppClock({ now: () => wall });
const engine = createFakeEngine({ appClock });
assert.equal(engine.fake.appClock, appClock);
wall = 6000;
assert.equal(engine.clock.now(), 6000);
engine.fake.frame();
assert.equal(appClock.frameTime(), 6000);
});
+113
View File
@@ -0,0 +1,113 @@
// The app's own time: milliseconds since the Unix epoch, read by layers and
// both globe engines instead of Cesium's JulianDate or viewer.clock. By
// default it is the wall clock (Date.now) exactly; QA and tests can freeze,
// offset or speed it up. The globe engine latches one frame time per engine
// frame (beginFrame), so every reader in a frame sees the same value.
// No engine, DOM or Node imports: this runs in the browser and in Node tests.
const isFiniteNumber = (value) =>
typeof value === 'number' && Number.isFinite(value);
function finite(value, label) {
if (!isFiniteNumber(value))
throw new TypeError(`${label} must be a finite number: ${value}`);
return value;
}
/**
* @typedef {object} AppClock
* @property {() => number} now The current app time (ms since the epoch).
* @property {() => number} frameTime The time latched for the current engine
* frame; the current time before the first frame.
* @property {() => number} beginFrame Latches and returns the frame time; the
* globe engine calls it once at the start of each of its frames.
* @property {(ms?: number) => void} freeze Holds the time at `ms` (default:
* the current time). QA and tests only.
* @property {() => void} resume Runs on from the frozen time.
* @property {(ms: number) => void} offset Sets the time to the wall clock
* plus `ms`, running on from it at the current rate (it ends a freeze).
* QA and tests only.
* @property {(rate: number) => void} rate App milliseconds per wall
* millisecond (>= 0), without a jump. QA and tests only.
* @property {() => void} reset Back to the wall clock.
* @property {() => { frozen: boolean, offsetMs: number, rate: number }} state
* The controls in force (offsetMs: the current time minus the wall clock).
*/
/**
* @param {{ now?: () => number }} [options] `now`: the wall clock (Date.now).
* @returns {AppClock}
*/
export function createAppClock({ now: source = () => Date.now() } = {}) {
if (typeof source !== 'function')
throw new TypeError('createAppClock needs a now() function');
// Rate 1: wall + offsetMs, exact. Any other rate: anchorValue plus the wall
// time since anchorSource, scaled.
let offsetMs = 0;
let rateValue = 1;
let anchorValue = 0;
let anchorSource = 0;
let frozen = null;
let latched = null;
const running = () =>
rateValue === 1
? source() + offsetMs
: anchorValue + (source() - anchorSource) * rateValue;
const now = () => (frozen === null ? running() : frozen);
// Make the running clock read `value` from this moment on.
const runFrom = (value) => {
if (rateValue === 1) offsetMs = value - source();
else {
anchorValue = value;
anchorSource = source();
}
};
return {
now,
frameTime: () => (latched === null ? now() : latched),
beginFrame() {
latched = now();
return latched;
},
freeze(ms) {
frozen = ms === undefined ? now() : finite(ms, 'Frozen time');
},
resume() {
if (frozen === null) return;
const value = frozen;
frozen = null;
runFrom(value);
},
offset(ms) {
const value = source() + finite(ms, 'Clock offset');
frozen = null;
runFrom(value);
},
rate(rate) {
if (!(isFiniteNumber(rate) && rate >= 0))
throw new TypeError(`Clock rate must be a finite number >= 0: ${rate}`);
if (frozen !== null) {
rateValue = rate;
return;
}
const value = running();
rateValue = rate;
runFrom(value);
},
reset() {
offsetMs = 0;
rateValue = 1;
frozen = null;
},
state: () => ({
frozen: frozen !== null,
offsetMs: now() - source(),
rate: rateValue,
}),
};
}
/** The page's app clock: the one both globe engines and the layers read. */
export const appClock = createAppClock();
+145
View File
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { appClock, createAppClock } from './clock.js';
// A wall clock the test moves by hand.
function manualSource(start = Date.UTC(2026, 9, 16, 12)) {
let value = start;
return {
now: () => value,
advance(ms) {
value += ms;
},
};
}
test('by default the app clock is the wall clock, exactly', () => {
const wall = manualSource();
const clock = createAppClock({ now: wall.now });
assert.equal(clock.now(), wall.now());
wall.advance(1234.5);
assert.equal(clock.now(), wall.now());
});
test('the page clock reads Date.now', () => {
const before = Date.now();
const value = appClock.now();
assert.ok(value >= before && value <= Date.now());
});
test('frameTime is the time latched by beginFrame, one value per frame', () => {
const wall = manualSource();
const clock = createAppClock({ now: wall.now });
// Before the first frame it is the current time.
assert.equal(clock.frameTime(), wall.now());
const latched = clock.beginFrame();
assert.equal(latched, wall.now());
wall.advance(7);
assert.equal(clock.frameTime(), latched);
assert.equal(clock.now(), latched + 7);
assert.equal(clock.beginFrame(), latched + 7);
assert.equal(clock.frameTime(), latched + 7);
});
test('freeze holds the time, resume continues from it', () => {
const wall = manualSource();
const clock = createAppClock({ now: wall.now });
const frozenAt = Date.UTC(2020, 0, 1);
clock.freeze(frozenAt);
wall.advance(5000);
assert.equal(clock.now(), frozenAt);
assert.equal(clock.beginFrame(), frozenAt);
clock.resume();
assert.equal(clock.now(), frozenAt);
wall.advance(250);
assert.equal(clock.now(), frozenAt + 250);
});
test('freeze without a time holds the current time', () => {
const wall = manualSource();
const clock = createAppClock({ now: wall.now });
const at = wall.now();
clock.freeze();
wall.advance(100);
assert.equal(clock.now(), at);
});
test('offset shifts the clock from the wall clock', () => {
const wall = manualSource();
const clock = createAppClock({ now: wall.now });
clock.offset(-30_000);
assert.equal(clock.now(), wall.now() - 30_000);
wall.advance(10);
assert.equal(clock.now(), wall.now() - 30_000);
clock.offset(0);
assert.equal(clock.now(), wall.now());
});
test('rate runs the clock faster or slower without a jump', () => {
const wall = manualSource();
const clock = createAppClock({ now: wall.now });
const start = clock.now();
clock.rate(4);
assert.equal(clock.now(), start);
wall.advance(1000);
assert.equal(clock.now(), start + 4000);
clock.rate(0);
wall.advance(1000);
assert.equal(clock.now(), start + 4000);
clock.rate(1);
wall.advance(500);
assert.equal(clock.now(), start + 4500);
});
test('a rate set while frozen applies from resume', () => {
const wall = manualSource();
const clock = createAppClock({ now: wall.now });
clock.freeze(1000);
clock.rate(2);
wall.advance(400);
assert.equal(clock.now(), 1000);
clock.resume();
wall.advance(400);
assert.equal(clock.now(), 1800);
});
test('reset returns to the wall clock and reports the state', () => {
const wall = manualSource();
const clock = createAppClock({ now: wall.now });
assert.deepEqual(clock.state(), { frozen: false, offsetMs: 0, rate: 1 });
clock.rate(3);
clock.freeze();
assert.equal(clock.state().frozen, true);
assert.equal(clock.state().rate, 3);
clock.reset();
assert.deepEqual(clock.state(), { frozen: false, offsetMs: 0, rate: 1 });
assert.equal(clock.now(), wall.now());
});
test('non-finite controls are refused', () => {
const clock = createAppClock({ now: () => 0 });
for (const call of [
() => clock.freeze(Number.NaN),
() => clock.offset(Number.POSITIVE_INFINITY),
() => clock.rate('2'),
() => clock.rate(-1),
])
assert.throws(call, TypeError);
assert.throws(() => createAppClock({ now: 5 }), TypeError);
});
test('offset ends a freeze and runs on from the wall clock plus the offset', () => {
const wall = manualSource();
const clock = createAppClock({ now: wall.now });
clock.freeze(5);
clock.offset(1000);
assert.equal(clock.now(), wall.now() + 1000);
wall.advance(20);
assert.equal(clock.now(), wall.now() + 1000);
});
test('the default wall clock is read through Date.now at each call', (t) => {
const clock = createAppClock();
t.mock.method(Date, 'now', () => 1_800_000_000_000);
assert.equal(clock.now(), 1_800_000_000_000);
});
+17
View File
@@ -100,6 +100,23 @@ for (const [name, files, pattern] of [
},
/src\/data\/helper\.js: geo reaches platform\/rendering dependency: cesium/,
],
[
'app clock imports cesium',
{ 'src/time/clock.js': "import 'cesium';" },
/src\/time\/clock\.js: time reaches platform\/rendering dependency: cesium/,
],
[
'app clock imports three',
{ 'src/time/clock.js': "import * as THREE from 'three';" },
/src\/time\/clock\.js: time reaches platform\/rendering dependency: three/,
],
[
'app clock reaches the DOM',
{
'src/time/clock.js': 'export const now = () => window.performance.now();',
},
/src\/time\/clock\.js: time reaches browser globals/,
],
[
'globe contract imports cesium',
{ 'src/globe/contract.js': "import 'cesium';" },