Keep one world clock that every timed layer reads

Core told time eight ways: Date.now() in most layers, JulianDate.now() for
flights and military, Cesium's clock frozen at page load for the sky, the
weather clock for observed imagery. They now read one clock,
src/time/worldClock.js, exported as ./time.

Live is a mode, not a timestamp: while live the clock is the wall clock,
so every layer draws as before. Away from live only the layers that follow
the clock move with it. The weather products follow it, as they followed
the weather transport; a host opts others in with follow(id).

- The weather clock takes its time from the world clock and drives its
  replay, stepping its frames as before.
- Satellites, launches, flights and military (still 30 s and 15 s behind),
  transit, GNSS, Recent Imagery, street level, the HUD and the celestial
  ring read worldTimeFor(id). Cadences stay on the wall clock.
- Cesium's sky (stars, sun, moon, atmosphere, lighting) draws at the world
  time each frame instead of the moment the page loaded.
- Weather frames and satellites declare coverage() for a host's timeline.
This commit is contained in:
Sameh Khamis committed 2026-11-01 21:47:32 -08:00
1 parent 92bb1d6a89
commit 0c4da3cdb2
43 files changed
+1091 -75

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
import * as Cesium from 'cesium';
const scratch = new Date();
/**
* Epoch ms as a Cesium JulianDate, like `JulianDate.now(result)` for any time.
* @param {number} ms
* @param {Cesium.JulianDate} [result]
*/
export function julianAt(ms, result) {
scratch.setTime(ms);
return Cesium.JulianDate.fromDate(scratch, result);
}
+46
View File
@@ -0,0 +1,46 @@
import * as Cesium from 'cesium';
/**
* Draw Cesium's sky at the world time: the sun, moon, stars, atmosphere and
* the lighting on the tiles all read `frameState.time`, which Cesium takes
* from its own clock. That clock never animates in GEV, so before this the
* sky stayed at the moment the page loaded.
*
* Each frame's time is set in `preUpdate`, before the scene stamps the frame
* with it, so the sky is drawn at the same moment the layers read that frame.
* The clock's `currentTime` follows too, for anything that samples it.
* @param {Cesium.Viewer} viewer
* @param {() => number} readTime Epoch ms the sky is drawn at.
* @param {{subscribe?: Function, requestRender?: () => void}} [options]
* A world clock to hear moves from, so a resting scene repaints when the
* world time jumps.
* @returns {() => void} Stop following.
*/
export function followWorldTime(
viewer,
readTime,
{ subscribe, requestRender = () => viewer.scene.requestRender() } = {},
) {
const date = new Date();
const removePreUpdate = viewer.scene.preUpdate.addEventListener(
(_scene, time) => {
date.setTime(readTime());
const clock = viewer.clock;
Cesium.JulianDate.fromDate(date, clock.currentTime);
// The frame's own time: Cesium stamps the frame with this object next.
if (time) Cesium.JulianDate.clone(clock.currentTime, time);
},
);
let last = readTime();
const unsubscribe =
subscribe?.(() => {
const next = readTime();
if (next === last) return;
last = next;
requestRender();
}) ?? (() => {});
return () => {
removePreUpdate();
unsubscribe();
};
}
+308
View File
@@ -0,0 +1,308 @@
/**
* The world's one time, in epoch ms. Every layer that draws something at a
* moment reads it here instead of calling `Date.now()` itself, so layers can
* never disagree about when the world is.
*
* Live is a mode, not a timestamp: while live the clock is the wall clock and
* every layer behaves as it would reading `Date.now()`. Away from live the
* clock holds a cursor, fixed or playing. Only the layers that follow the clock
* (`follow(id)`) move with the cursor; the rest keep reading the live edge
* through `timeFor(id)`, so a host opts layers in one at a time.
*
* Playback is continuous (`cursor + elapsed × rate`) unless a driver takes
* it: weather replay steps its observed frames itself and moves the clock as
* it goes, so play and pause hand over to it. Several drivers may be attached;
* play goes to the newest that can play, pause to the one playing.
*/
/** The span a JS Date can hold, either side of 1970. */
const MAX_DATE_MS = 8.64e15;
/** Whether `ms` is a time the world clock can hold. */
export const isWorldTime = (ms) =>
Number.isFinite(ms) && Math.abs(ms) <= MAX_DATE_MS;
/** How often subscribers hear of a continuously playing cursor. */
export const WORLD_CLOCK_TICK_MS = 250;
/**
* @param {{now?: () => number, monotonicNow?: () => number,
* setInterval?: typeof globalThis.setInterval,
* clearInterval?: typeof globalThis.clearInterval}} [options]
*/
export function createWorldClock({
// Read at each call, so a stand-in for Date.now (a test's, a harness's)
// reaches every layer as it did when they called Date.now themselves.
now = () => Date.now(),
monotonicNow = () => globalThis.performance.now(),
setInterval = globalThis.setInterval,
clearInterval = globalThis.clearInterval,
} = {}) {
const listeners = new Set();
// Layers a host follows, and layers a part of the app holds following.
const following = new Set();
const holds = new Map();
const readers = new Map();
const sources = new Set();
const drivers = [];
let live = true;
// The cursor while fixed; while playing continuously, the cursor at `anchorMono`.
let cursorMs = null;
let anchorMono = 0;
let rate = 1;
let playing = false;
// The driver handle stepping the clock, or null.
let active = null;
let ticker = null;
let notifying = 0;
const continuous = () => playing && !active;
const isFollowed = (id) => following.has(id) || (holds.get(id) ?? 0) > 0;
const followedIds = () => [
...new Set([...following, ...[...holds.keys()].filter(isFollowed)]),
];
const time = () => {
if (live) return now();
if (continuous()) return cursorMs + (monotonicNow() - anchorMono) * rate;
return cursorMs;
};
const getState = () =>
Object.freeze({
live,
timeMs: time(),
playing,
rate,
following: followedIds(),
});
// Each listener hears the state as it is when called. A change made from
// inside a listener notifies everyone afresh, and the outer round stops, so
// nobody is left holding a state that has since changed.
const notify = () => {
const round = ++notifying;
for (const listener of [...listeners]) {
if (round !== notifying) return;
if (!listeners.has(listener)) continue;
try {
listener(getState());
} catch (error) {
console.error('World clock listener failed', error);
}
}
};
const syncTicker = () => {
if (continuous() && listeners.size) {
ticker ??= setInterval(notify, WORLD_CLOCK_TICK_MS);
} else if (ticker !== null) {
clearInterval(ticker);
ticker = null;
}
};
const changed = () => {
syncTicker();
notify();
};
// Freeze a continuously playing cursor where it is.
const settle = () => {
if (continuous()) cursorMs = time();
anchorMono = monotonicNow();
};
const api = {
/** The live edge: the wall clock, whatever the cursor does. */
wallNow: () => now(),
/** The world time: the wall clock while live, else the cursor. */
now: time,
isLive: () => live,
isPlaying: () => playing,
getRate: () => rate,
/** Hold the world at `ms`; a playing clock plays on from there. */
seek(ms) {
if (!isWorldTime(ms)) throw new TypeError('World time must be epoch ms');
if (!live && cursorMs === ms && !continuous()) return;
live = false;
cursorMs = ms;
anchorMono = monotonicNow();
changed();
},
/** Back to the wall clock; playback stops. */
goLive() {
if (live && !playing) return;
const stepping = active;
live = true;
cursorMs = null;
playing = false;
active = null;
// Its driven(false) finds nothing left to stop.
stepping?.driver.pause();
changed();
},
/** Play from the cursor (from now, when live). */
play({ rate: nextRate } = {}) {
if (nextRate !== undefined) api.setRate(nextRate);
if (playing) return;
for (let index = drivers.length - 1; index >= 0; index -= 1) {
if (drivers[index].driver.play() !== false) return;
}
if (live) {
live = false;
cursorMs = now();
}
playing = true;
anchorMono = monotonicNow();
changed();
},
pause() {
if (!playing) return;
if (active) {
active.driver.pause();
return;
}
settle();
playing = false;
changed();
},
togglePlay() {
return playing ? api.pause() : api.play();
},
/** Simulated ms per real ms for continuous playback. */
setRate(nextRate) {
if (!Number.isFinite(nextRate) || nextRate <= 0)
throw new RangeError('World clock rate must be positive');
if (nextRate === rate) return;
settle();
rate = nextRate;
changed();
},
/**
* Offer a stepping player. The driver moves the clock with `seek()` and
* reports through its handle's `driven(playing, atMs?)`; its `play()`
* returns false when it cannot play, and the clock tries an older driver
* or plays continuously instead.
* @param {{play: () => (boolean|void), pause: () => void}} driver
* @returns {{driven: (playing: boolean, atMs?: number) => void,
* detach: () => number}} `detach()` returns how many drivers remain.
*/
attachDriver(driver) {
const handle = {
driver,
/** This driver started stepping (at `atMs`, if given) or stopped. */
driven(isPlaying, atMs) {
if (!drivers.includes(handle)) return;
if (!isPlaying) {
if (active !== handle) return;
active = null;
playing = false;
changed();
return;
}
// A continuous run hands over where it is; another driver stops.
settle();
const previous = active;
active = handle;
playing = true;
live = false;
if (Number.isFinite(atMs)) cursorMs = atMs;
else cursorMs ??= now();
anchorMono = monotonicNow();
if (previous && previous !== handle) previous.driver.pause();
changed();
},
detach() {
const index = drivers.indexOf(handle);
if (index < 0) return drivers.length;
handle.driven(false);
drivers.splice(index, 1);
return drivers.length;
},
};
drivers.push(handle);
return handle;
},
/** Let layer `id` move with the cursor instead of reading the live edge. */
follow(id, on = true) {
if (on === following.has(id)) return;
if (on) following.add(id);
else following.delete(id);
changed();
},
/**
* Keep layer `id` following until the returned release is called, however
* many parts of the app hold it.
*/
hold(id) {
const was = isFollowed(id);
holds.set(id, (holds.get(id) ?? 0) + 1);
if (!was) changed();
let held = true;
return () => {
if (!held) return;
held = false;
const count = holds.get(id) - 1;
if (count > 0) holds.set(id, count);
else holds.delete(id);
if (!isFollowed(id)) changed();
};
},
follows: isFollowed,
/**
* The time layer `id` draws at: the cursor when it follows the clock,
* otherwise the live edge.
* @returns {() => number}
*/
timeFor(id) {
let reader = readers.get(id);
if (!reader) {
reader = () => (isFollowed(id) ? time() : now());
readers.set(id, reader);
}
return reader;
},
/**
* A layer's span of time it can show, for a host's timeline; null while
* it has none (off, or nothing loaded). Returns the unregister.
* @param {{id: string, coverage: () => ({start: number, end: number, marks?: number[]} | null)}} source
*/
register(source) {
// Each registration is its own, even under a shared id.
const entry = { source };
sources.add(entry);
return () => sources.delete(entry);
},
/** The union of the registered sources' coverage, or null with none. */
coverage() {
let start = Infinity;
let end = -Infinity;
const marks = new Set();
for (const { source } of sources) {
const span = source.coverage();
if (!span || !(span.end >= span.start)) continue;
start = Math.min(start, span.start);
end = Math.max(end, span.end);
for (const mark of span.marks ?? []) marks.add(mark);
}
if (start > end) return null;
return { start, end, marks: [...marks].sort((a, b) => a - b) };
},
getState,
subscribe(listener) {
listeners.add(listener);
syncTicker();
return () => {
listeners.delete(listener);
syncTicker();
};
},
};
return api;
}
/** The page's world clock. Layers, the sky and a host all read this one. */
export const worldClock = createWorldClock();
/** The time layer `id` draws at on the page's clock. */
export const worldTimeFor = (id) => worldClock.timeFor(id);
+254
View File
@@ -0,0 +1,254 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWorldClock, WORLD_CLOCK_TICK_MS } from './worldClock.js';
function fakeTime() {
const t = {
wall: Date.UTC(2026, 10, 1, 12),
mono: 1_000,
timers: new Map(),
nextId: 1,
advance(ms) {
t.wall += ms;
t.mono += ms;
},
options: {
now: () => t.wall,
monotonicNow: () => t.mono,
setInterval: (fn, ms) => {
const id = t.nextId++;
t.timers.set(id, { fn, ms });
return id;
},
clearInterval: (id) => t.timers.delete(id),
},
};
return t;
}
test('live is the wall clock for every layer', () => {
const t = fakeTime();
const clock = createWorldClock(t.options);
assert.equal(clock.isLive(), true);
assert.equal(clock.now(), t.wall);
t.advance(5_000);
assert.equal(clock.now(), t.wall);
assert.equal(clock.timeFor('flights')(), t.wall);
clock.follow('flights');
assert.equal(clock.timeFor('flights')(), t.wall);
});
test('away from live only following layers move with the cursor', () => {
const t = fakeTime();
const clock = createWorldClock(t.options);
const satellites = clock.timeFor('satellites');
const flights = clock.timeFor('flights');
clock.follow('satellites');
const past = t.wall - 3_600_000;
clock.seek(past);
assert.equal(clock.isLive(), false);
assert.equal(clock.now(), past);
assert.equal(satellites(), past);
assert.equal(flights(), t.wall);
assert.equal(clock.timeFor('satellites'), satellites);
clock.goLive();
assert.equal(satellites(), t.wall);
});
test('continuous play advances the cursor at the rate and pause holds it', () => {
const t = fakeTime();
const clock = createWorldClock(t.options);
const start = t.wall - 60_000;
clock.seek(start);
clock.play({ rate: 60 });
t.advance(1_000);
assert.equal(clock.now(), start + 60_000);
clock.setRate(120);
t.advance(1_000);
assert.equal(clock.now(), start + 180_000);
clock.pause();
t.advance(1_000);
assert.equal(clock.now(), start + 180_000);
assert.equal(clock.isPlaying(), false);
});
test('play from live starts at now, and goLive returns to the wall clock', () => {
const t = fakeTime();
const clock = createWorldClock(t.options);
const from = t.wall;
clock.play({ rate: 2 });
assert.equal(clock.isLive(), false);
t.advance(1_000);
assert.equal(clock.now(), from + 2_000);
clock.goLive();
assert.equal(clock.isPlaying(), false);
assert.equal(clock.now(), t.wall);
});
test('subscribers hear changes, and ticks only while playing continuously', () => {
const t = fakeTime();
const clock = createWorldClock(t.options);
const states = [];
const off = clock.subscribe((state) => states.push(state));
clock.seek(t.wall - 1_000);
assert.equal(states.length, 1);
assert.equal(states[0].live, false);
assert.equal(t.timers.size, 0);
clock.play();
assert.equal(t.timers.size, 1);
assert.equal([...t.timers.values()][0].ms, WORLD_CLOCK_TICK_MS);
clock.pause();
assert.equal(t.timers.size, 0);
clock.play();
off();
assert.equal(t.timers.size, 0);
});
test('a driver takes play and pause, and moves the clock with seek', () => {
const t = fakeTime();
const clock = createWorldClock(t.options);
const calls = [];
const handle = clock.attachDriver({
play() {
calls.push('play');
handle.driven(true, t.wall - 10_000);
},
pause() {
calls.push('pause');
handle.driven(false);
},
});
clock.play();
assert.deepEqual(calls, ['play']);
assert.equal(clock.isPlaying(), true);
assert.equal(clock.now(), t.wall - 10_000);
const at = clock.now();
t.advance(5_000);
// Stepped, not continuous: the cursor waits for the driver.
assert.equal(clock.now(), at);
clock.pause();
assert.deepEqual(calls, ['play', 'pause']);
assert.equal(clock.isPlaying(), false);
clock.play();
clock.goLive();
assert.deepEqual(calls, ['play', 'pause', 'play', 'pause']);
assert.equal(clock.isLive(), true);
assert.equal(clock.isPlaying(), false);
assert.equal(handle.detach(), 0);
clock.play();
assert.equal(calls.length, 4);
t.advance(1_000);
assert.equal(clock.now(), t.wall);
});
test('play goes to the newest driver that can play, pause to the one playing', () => {
const t = fakeTime();
const clock = createWorldClock(t.options);
const calls = [];
const driver = (name, canPlay) => {
const handle = clock.attachDriver({
play() {
if (!canPlay()) return false;
calls.push(`${name} play`);
handle.driven(true);
},
pause() {
calls.push(`${name} pause`);
handle.driven(false);
},
});
return handle;
};
let bCanPlay = false;
const a = driver('a', () => true);
const b = driver('b', () => bCanPlay);
clock.seek(t.wall - 1_000);
clock.play();
assert.deepEqual(calls, ['a play']);
// b starting takes over from a.
bCanPlay = true;
b.driven(true);
assert.deepEqual(calls, ['a play', 'a pause']);
clock.pause();
assert.deepEqual(calls, ['a play', 'a pause', 'b pause']);
assert.equal(clock.isPlaying(), false);
// A detached driver stops, and the other one plays next.
clock.play();
assert.equal(b.detach(), 1);
assert.equal(clock.isPlaying(), false);
clock.play();
assert.equal(calls.at(-1), 'a play');
assert.equal(a.detach(), 0);
assert.equal(clock.isPlaying(), false);
});
test('a listener that changes the clock leaves nobody with a stale state', () => {
const t = fakeTime();
const clock = createWorldClock(t.options);
const seen = [];
clock.subscribe((state) => {
if (state.timeMs === 0) clock.seek(1_000);
});
clock.subscribe((state) => seen.push(state.timeMs));
clock.seek(0);
assert.deepEqual(seen, [1_000]);
});
test('holds keep a layer following until every holder releases it', () => {
const clock = createWorldClock(fakeTime().options);
const first = clock.hold('radar');
const second = clock.hold('radar');
first();
first();
assert.equal(clock.follows('radar'), true);
second();
assert.equal(clock.follows('radar'), false);
clock.follow('radar');
const third = clock.hold('radar');
third();
assert.equal(clock.follows('radar'), true);
assert.deepEqual(clock.getState().following, ['radar']);
});
test('a driver that cannot play leaves continuous playback', () => {
const t = fakeTime();
const clock = createWorldClock(t.options);
clock.attachDriver({ play: () => false, pause() {} });
clock.seek(t.wall - 10_000);
clock.play();
assert.equal(clock.isPlaying(), true);
t.advance(1_000);
// Wall and cursor moved the same second at rate 1.
assert.equal(clock.now(), t.wall - 10_000);
});
test('coverage is the union of the registered sources', () => {
const clock = createWorldClock(fakeTime().options);
assert.equal(clock.coverage(), null);
const off = clock.register({
id: 'radar',
coverage: () => ({ start: 100, end: 200, marks: [100, 150, 200] }),
});
clock.register({ id: 'satellites', coverage: () => ({ start: 50, end: 180 }) });
clock.register({ id: 'empty', coverage: () => null });
assert.deepEqual(clock.coverage(), {
start: 50,
end: 200,
marks: [100, 150, 200],
});
off();
assert.deepEqual(clock.coverage(), { start: 50, end: 180, marks: [] });
});
test('seek refuses anything but epoch ms and rate must be positive', () => {
const clock = createWorldClock(fakeTime().options);
assert.throws(() => clock.seek(Number.NaN), TypeError);
assert.throws(() => clock.setRate(0), RangeError);
});
test('seek refuses a time no Date can hold', () => {
const clock = createWorldClock(fakeTime().options);
assert.throws(() => clock.seek(8.8e15), TypeError);
clock.seek(-8.64e15);
assert.equal(clock.now(), -8.64e15);
});