Drag the timeline along, and play through it smoothly

The zoomed track now drags: the handle still drags the time, while the
track around it drags the view along (or the time, with all of it shown),
a tap goes to the time under it, two fingers pinch it, and a sideways swipe
or a shift-wheel drags it too, the same with a mouse, a trackpad or a
finger. A view dragged off the handle stays put until the time moves.

Play moved in steps: the world clock told of its time four times a second,
so the handle and the globe jumped, and play stopped at every weather frame
while it drew, loaded ahead or not. The clock now tells of its time each
animation frame, and play no longer waits on the frame it is at: that
frame loads as play goes on, and play holds only at the next frame while
the one before it still loads. Weather loads its next frame ahead while the
world plays too, tells its listeners of frames as they change rather than
every frame, and the sun and moon move on each minute of world time.
This commit is contained in:
Sameh Khamis committed 2026-11-02 16:13:27 -08:00
1 parent 4fc9722356
commit 8eab6a76a8
20 files changed
+534 -137

No files matched your search

+12 -1
View File
@@ -32,6 +32,9 @@ export const KEYHOLE_LABEL_FEATHER_MAX_RATIO = 0.4;
*/ */
export const KEYHOLE_OUTSIDE_OPACITY_DEFAULT = 0.01; export const KEYHOLE_OUTSIDE_OPACITY_DEFAULT = 0.01;
// How far the world time moves before the sun and moon move with it: a
// minute moves the sun a quarter of a degree.
const EPHEMERIS_WORLD_STEP_MS = 60_000;
const RING_INSET_PX = 11; const RING_INSET_PX = 11;
const MARKER_INSET_PX = 36; const MARKER_INSET_PX = 36;
const COLLIDING_MARKER_EXTRA_INSET_PX = 32; const COLLIDING_MARKER_EXTRA_INSET_PX = 32;
@@ -452,7 +455,15 @@ export class CelestialRing {
// A jump in the world time moves the sun and moon at once. // A jump in the world time moves the sun and moon at once.
this._skyDate = new Date(); this._skyDate = new Date();
this._skyTime = new Cesium.JulianDate(); this._skyTime = new Cesium.JulianDate();
this._unsubscribeWorldTime = worldClock.subscribe(() => { // Played through, the sky moves on each minute of world time it passes.
this._ephemerisWorldMs = null;
this._unsubscribeWorldTime = worldClock.subscribe(({ timeMs }) => {
if (
this._ephemerisWorldMs !== null &&
Math.abs(timeMs - this._ephemerisWorldMs) < EPHEMERIS_WORLD_STEP_MS
)
return;
this._ephemerisWorldMs = timeMs;
this._ephemerisDirty = true; this._ephemerisDirty = true;
}); });
this.setEnabled(this.enabled); this.setEnabled(this.enabled);
+1 -1
View File
@@ -3231,5 +3231,5 @@
"1 d/s": "يوم/ث", "1 d/s": "يوم/ث",
"1 wk/s": "أسبوع/ث", "1 wk/s": "أسبوع/ث",
"Play speed": "سرعة التشغيل", "Play speed": "سرعة التشغيل",
"Scroll or pinch to zoom · double-click to see it all": "مرّر أو اقرص للتكبير · انقر نقرًا مزدوجًا لرؤية الكل" "Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل"
} }
+1 -1
View File
@@ -2951,5 +2951,5 @@
"1 d/s": "1 Tag/s", "1 d/s": "1 Tag/s",
"1 wk/s": "1 Wo./s", "1 wk/s": "1 Wo./s",
"Play speed": "Wiedergabegeschwindigkeit", "Play speed": "Wiedergabegeschwindigkeit",
"Scroll or pinch to zoom · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen · Doppelklick zeigt alles" "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles"
} }
+1 -1
View File
@@ -3021,5 +3021,5 @@
"1 d/s": "1 d/s", "1 d/s": "1 d/s",
"1 wk/s": "1 sem/s", "1 wk/s": "1 sem/s",
"Play speed": "Velocidad de reproducción", "Play speed": "Velocidad de reproducción",
"Scroll or pinch to zoom · double-click to see it all": "Desplaza o pellizca para ampliar · doble clic para verlo todo" "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo"
} }
+1 -1
View File
@@ -3021,5 +3021,5 @@
"1 d/s": "1 j/s", "1 d/s": "1 j/s",
"1 wk/s": "1 sem/s", "1 wk/s": "1 sem/s",
"Play speed": "Vitesse de lecture", "Play speed": "Vitesse de lecture",
"Scroll or pinch to zoom · double-click to see it all": "Faites défiler ou pincez pour zoomer · double-clic pour tout voir" "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir"
} }
+1 -1
View File
@@ -3021,5 +3021,5 @@
"1 d/s": "1 g/s", "1 d/s": "1 g/s",
"1 wk/s": "1 sett/s", "1 wk/s": "1 sett/s",
"Play speed": "Velocità di riproduzione", "Play speed": "Velocità di riproduzione",
"Scroll or pinch to zoom · double-click to see it all": "Scorri o pizzica per lo zoom · doppio clic per vedere tutto" "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scorri o pizzica per lo zoom, trascina per spostarti · doppio clic per vedere tutto"
} }
+1 -1
View File
@@ -2881,5 +2881,5 @@
"1 d/s": "1日/秒", "1 d/s": "1日/秒",
"1 wk/s": "1週間/秒", "1 wk/s": "1週間/秒",
"Play speed": "再生速度", "Play speed": "再生速度",
"Scroll or pinch to zoom · double-click to see it all": "スクロールまたはピンチでズーム · ダブルクリックで全体表示" "Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示"
} }
+1 -1
View File
@@ -3021,5 +3021,5 @@
"1 d/s": "1 d/s", "1 d/s": "1 d/s",
"1 wk/s": "1 sem/s", "1 wk/s": "1 sem/s",
"Play speed": "Velocidade de reprodução", "Play speed": "Velocidade de reprodução",
"Scroll or pinch to zoom · double-click to see it all": "Role ou faça pinça para ampliar · clique duplo para ver tudo" "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo"
} }
+1 -1
View File
@@ -3091,5 +3091,5 @@
"1 d/s": "1 дн/с", "1 d/s": "1 дн/с",
"1 wk/s": "1 нед/с", "1 wk/s": "1 нед/с",
"Play speed": "Скорость воспроизведения", "Play speed": "Скорость воспроизведения",
"Scroll or pinch to zoom · double-click to see it all": "Прокрутите или сведите пальцы для масштаба · двойной щелчок — показать всё" "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё"
} }
+1 -1
View File
@@ -3091,5 +3091,5 @@
"1 d/s": "1 дн/с", "1 d/s": "1 дн/с",
"1 wk/s": "1 тиж/с", "1 wk/s": "1 тиж/с",
"Play speed": "Швидкість відтворення", "Play speed": "Швидкість відтворення",
"Scroll or pinch to zoom · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу · подвійне клацання — показати все" "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все"
} }
+1 -1
View File
@@ -2881,5 +2881,5 @@
"1 d/s": "1 天/秒", "1 d/s": "1 天/秒",
"1 wk/s": "1 周/秒", "1 wk/s": "1 周/秒",
"Play speed": "播放速度", "Play speed": "播放速度",
"Scroll or pinch to zoom · double-click to see it all": "滚动或双指缩放 · 双击查看全部" "Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部"
} }
+1 -1
View File
@@ -2881,5 +2881,5 @@
"1 d/s": "1 天/秒", "1 d/s": "1 天/秒",
"1 wk/s": "1 週/秒", "1 wk/s": "1 週/秒",
"Play speed": "播放速度", "Play speed": "播放速度",
"Scroll or pinch to zoom · double-click to see it all": "捲動或雙指縮放 · 按兩下查看全部" "Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部"
} }
+44 -12
View File
@@ -6,6 +6,11 @@ export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000; export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
/** The longest play waits on a frame before it goes on without it. */ /** The longest play waits on a frame before it goes on without it. */
export const PLAY_WAIT_MS = 8_000; export const PLAY_WAIT_MS = 8_000;
// Playing on its own, the world does not wait on the frame it is at: that
// frame loads as play goes on. Play holds only at the next frame while the
// one before it still loads, then goes on to it once it shows, so every frame
// shows, play stops only for a load that is slow, and a frame loaded ahead
// costs play nothing.
let clocks = 0; let clocks = 0;
@@ -39,10 +44,12 @@ export function createWeatherClock({
let pending = null; let pending = null;
// This clock's name on the world, for its holds and its gate. // This clock's name on the world, for its holds and its gate.
const holdId = `weather:${++clocks}`; const holdId = `weather:${++clocks}`;
// While the world plays, the hold on it as frames for its time load, and // While the world plays, the hold on it at the next frame as the one before
// the timer that lets play go on should a frame never come. // it loads, and the timer that lets play go on should that frame never
// come; and whether the move to the next frame waits on it.
let waiting = null; let waiting = null;
let waitTimer = null; let waitTimer = null;
let deferred = false;
const stopWaiting = () => { const stopWaiting = () => {
const release = waiting; const release = waiting;
waiting = null; waiting = null;
@@ -56,6 +63,8 @@ export function createWeatherClock({
waitTimer = setTimeout(() => { waitTimer = setTimeout(() => {
waitTimer = null; waitTimer = null;
stopWaiting(); stopWaiting();
// The frame that never came gives way to the one play is at.
if (deferred) void transition();
}, PLAY_WAIT_MS); }, PLAY_WAIT_MS);
}; };
// Playing on its own, the world waits for frames as they load. // Playing on its own, the world waits for frames as they load.
@@ -93,6 +102,9 @@ export function createWeatherClock({
mode, mode,
target, target,
playing, playing,
// Frames come on one after another: this clock replays them, or the world
// plays on through them, so the next is worth loading ahead.
advancing: playing || worldPlaysOn(),
timeline: getTimeline(), timeline: getTimeline(),
products: [...registry.values()].map(({ product }) => ({ products: [...registry.values()].map(({ product }) => ({
id: product.id, id: product.id,
@@ -158,13 +170,10 @@ export function createWeatherClock({
cancelTimer(); cancelTimer();
const owner = ++generation; const owner = ++generation;
if (!canPlay()) stopPlaying(); if (!canPlay()) stopPlaying();
// Playing, the world waits for new frames; a later move takes over the // A scrub drops what loads and moves on; play goes on as this loads.
// wait, and a scrub (not playing) drops what loads and moves on. A refresh
// that shows the same frames does not hold play.
const framesChange = selections() !== applied;
applied = selections(); applied = selections();
deferred = false;
stopWaiting(); stopWaiting();
if (framesChange && worldPlaysOn()) startWaiting();
for (const entry of registry.values()) entry.controller?.abort(); for (const entry of registry.values()) entry.controller?.abort();
// Start all products together; a rejection must not skip the settle barrier. // Start all products together; a rejection must not skip the settle barrier.
const tasks = [...registry.values()].map((entry) => { const tasks = [...registry.values()].map((entry) => {
@@ -183,6 +192,8 @@ export function createWeatherClock({
if (!canPlay()) stopPlaying(); if (!canPlay()) stopPlaying();
notify(); notify();
schedule(); schedule();
// Play held at the next frame for this one goes on to it.
if (deferred) void transition();
return true; return true;
}); });
pending = result; pending = result;
@@ -346,22 +357,43 @@ export function createWeatherClock({
function followWorld(state) { function followWorld(state) {
if (destroyed) return; if (destroyed) return;
gateNext(state); gateNext(state);
// Play pressed while a frame still loads waits for it too. // Play starting or stopping changes whether the next frame loads ahead.
const playsOn = worldPlaysOn(state); const playsOn = worldPlaysOn(state);
if (playsOn && !playedOn && pending) startWaiting(); const playChanged = playsOn !== playedOn;
playedOn = playsOn; playedOn = playsOn;
const nextMode = state.live ? 'latest' : 'history'; const nextMode = state.live ? 'latest' : 'history';
const nextTarget = state.live const nextTarget = state.live
? null ? null
: new Date(Math.floor(state.timeMs)).toISOString(); : new Date(Math.floor(state.timeMs)).toISOString();
const key = nextTarget ?? 'latest'; const key = nextTarget ?? 'latest';
if (key === seen) return; if (key === seen) {
if (playChanged) notify();
return;
}
seen = key; seen = key;
const modeChanged = nextMode !== mode; const modeChanged = nextMode !== mode;
mode = nextMode; mode = nextMode;
target = nextTarget; target = nextTarget;
if (moving || modeChanged || selections() !== applied) void transition(); if (!moving && !modeChanged && selections() === applied) {
else notify(); // Back at the frame that loads, play need not hold for it.
if (deferred) {
deferred = false;
stopWaiting();
}
// Played through, the time moves each frame with nothing new to show:
// listeners hear of the frames as they change.
if (!playsOn || playChanged) notify();
return;
}
// Playing on into the next frame while the one before it loads, the
// world holds here until that one shows.
if (!moving && !modeChanged && pending && playsOn) {
deferred = true;
startWaiting();
notify();
return;
}
void transition();
} }
// Playing on its own, the world stops at each next frame to load it rather // Playing on its own, the world stops at each next frame to load it rather
// than run past frames between its ticks. // than run past frames between its ticks.
+116 -49
View File
@@ -495,48 +495,6 @@ test('a clock destroyed while it registers a product lets go of it', (t) => {
assert.equal(world.getState().supported, false); assert.equal(world.getState().supported, false);
}); });
test('playing, the world waits for each frame; a scrub drops it and moves on', async (t) => {
const { clock, world, tick } = harness(t);
const radar = product('radar', [0, 10, 20]);
const loads = [];
radar.apply = (selected, { signal }) =>
new Promise((resolve) => {
loads.push({ selected, signal, done: () => resolve(true) });
});
clock.register(radar);
world.seek(Date.parse(time(9)));
await flush();
loads.at(-1).done();
await flush();
world.play({ rate: 60, continuous: true });
tick(2_000);
// Into the next frame (the page's ticker tells of it): the world holds
// there while it loads.
world.coverageChanged();
await flush();
assert.equal(loads.at(-1).selected, time(10));
assert.equal(world.getState().buffering, true);
const held = world.now();
tick(5_000);
assert.equal(world.now(), held, 'held while the frame loads');
loads.at(-1).done();
await flush();
assert.equal(world.getState().buffering, false);
tick(1_000);
assert.equal(world.now(), held + 60_000, 'and plays on once it shows');
// A scrub aborts what loads and loads where the hand is, with no wait.
world.pause();
world.seek(Date.parse(time(20)));
await flush();
const scrubbed = loads.at(-1);
world.seek(Date.parse(time(0)));
await flush();
assert.equal(scrubbed.signal.aborted, true);
assert.equal(loads.at(-1).selected, time(0));
assert.equal(world.getState().buffering, false);
});
// A radar whose frames load only when the test says. // A radar whose frames load only when the test says.
function slowRadar(minutes) { function slowRadar(minutes) {
const radar = product('radar', minutes); const radar = product('radar', minutes);
@@ -573,6 +531,61 @@ test('play stops at the next frame, not past it, until it shows', async (t) => {
); );
}); });
test('playing, a frame loads as play goes on, and play holds at the next until it shows', async (t) => {
const { clock, world, tick } = harness(t);
const radar = slowRadar([0, 10, 20, 30]);
clock.register(radar);
world.seek(Date.parse(time(9)));
await flush();
radar.loads.at(-1).done();
await flush();
world.play({ rate: 60, continuous: true });
// Into the next frame (the page's frames tell of it): it loads as play goes on.
tick(2_000);
world.coverageChanged();
await flush();
assert.equal(radar.loads.at(-1).selected, time(10));
assert.equal(
world.getState().buffering,
false,
'no hold for the frame play is at',
);
// At the frame after while that one still loads, play holds there.
tick(10_000);
world.coverageChanged();
await flush();
assert.equal(world.now(), Date.parse(time(20)));
assert.equal(world.getState().buffering, true);
assert.equal(
radar.loads.at(-1).selected,
time(10),
'the frame before still loads',
);
tick(5_000);
assert.equal(world.now(), Date.parse(time(20)), 'held while it loads');
radar.loads.at(-1).done();
await flush();
assert.equal(
radar.loads.at(-1).selected,
time(20),
'and on to the next once it shows',
);
assert.equal(world.getState().buffering, false);
tick(1_000);
assert.equal(world.now(), Date.parse(time(20)) + 60_000);
// A scrub aborts what loads and loads where the hand is, with no wait.
world.pause();
world.seek(Date.parse(time(30)));
await flush();
const scrubbed = radar.loads.at(-1);
world.seek(Date.parse(time(0)));
await flush();
assert.equal(scrubbed.signal.aborted, true);
assert.equal(radar.loads.at(-1).selected, time(0));
assert.equal(world.getState().buffering, false);
});
test('a frame that never comes holds play only so long', async (t) => { test('a frame that never comes holds play only so long', async (t) => {
const { clock, world, tick } = harness(t); const { clock, world, tick } = harness(t);
const radar = slowRadar([0, 10, 20]); const radar = slowRadar([0, 10, 20]);
@@ -585,26 +598,58 @@ test('a frame that never comes holds play only so long', async (t) => {
tick(2_000); tick(2_000);
world.coverageChanged(); world.coverageChanged();
await flush(); await flush();
tick(10_000);
world.coverageChanged();
await flush();
assert.equal(world.getState().buffering, true); assert.equal(world.getState().buffering, true);
const stuck = radar.loads.at(-1);
tick(PLAY_WAIT_MS); tick(PLAY_WAIT_MS);
await flush();
assert.equal(world.getState().buffering, false, 'play goes on without it'); assert.equal(world.getState().buffering, false, 'play goes on without it');
assert.equal(stuck.signal.aborted, true);
assert.equal(radar.loads.at(-1).selected, time(20), 'loading where play is');
}); });
test('play pressed while a frame loads waits for it; a refresh does not', async (t) => { test('held at the next frame, a scrub back to the one loading lets play go', async (t) => {
const { clock, world, tick } = harness(t);
const radar = slowRadar([0, 10, 20]);
clock.register(radar);
world.seek(Date.parse(time(9)));
await flush();
radar.loads.at(-1).done();
await flush();
world.play({ rate: 60, continuous: true });
tick(2_000);
world.coverageChanged();
await flush();
tick(10_000);
world.coverageChanged();
await flush();
assert.equal(world.getState().buffering, true);
world.pause();
world.seek(Date.parse(time(11)));
await flush();
world.play({ rate: 60, continuous: true });
assert.equal(
world.getState().buffering,
false,
'no hold for the frame it is at',
);
const at = world.now();
tick(1_000);
assert.equal(world.now(), at + 60_000);
});
test('a frame in flight as play starts loads as it goes on; a refresh does not hold', async (t) => {
const { clock, world } = harness(t); const { clock, world } = harness(t);
const radar = slowRadar([0, 10, 20]); const radar = slowRadar([0, 10, 20]);
clock.register(radar); clock.register(radar);
world.seek(Date.parse(time(5))); world.seek(Date.parse(time(5)));
await flush(); await flush();
world.play({ continuous: true }); world.play({ continuous: true });
assert.equal( assert.equal(world.getState().buffering, false);
world.getState().buffering,
true,
'waits for the frame in flight',
);
radar.loads.at(-1).done(); radar.loads.at(-1).done();
await flush(); await flush();
assert.equal(world.getState().buffering, false);
void clock.refresh(); void clock.refresh();
await flush(); await flush();
assert.equal(world.getState().buffering, false, 'the same frames, no hold'); assert.equal(world.getState().buffering, false, 'the same frames, no hold');
@@ -640,3 +685,25 @@ test('weather clocks sharing a world gate it each on their own', async (t) => {
'the other clock’s gate stays', 'the other clock’s gate stays',
); );
}); });
test('played through, listeners hear of frames as they change, and of play stopping', async (t) => {
const { clock, world, tick } = harness(t);
const radar = product('radar', [0, 10, 20]);
clock.register(radar);
world.seek(Date.parse(time(1)));
await flush();
world.play({ rate: 60, continuous: true });
await flush();
const heard = [];
clock.subscribe((state) => heard.push(state));
// Seconds of world time within one frame: nothing new to show.
for (let i = 0; i < 5; i++) {
tick(100);
world.coverageChanged();
}
await flush();
assert.equal(heard.length, 0);
world.pause();
await flush();
assert.equal(heard.at(-1)?.advancing, false, 'heard play stop');
});
+3 -2
View File
@@ -109,7 +109,8 @@ export function createWeatherLayer({
const notify = () => listener?.(); const notify = () => listener?.();
const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time); const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time);
const unsubscribeClock = clock?.subscribe(() => { const unsubscribeClock = clock?.subscribe(() => {
if (!clock.getState().playing || suspended()) rendering?.cancelPrefetch?.(); if (!clock.getState().advancing || suspended())
rendering?.cancelPrefetch?.();
notify(); notify();
}); });
const observationDelayed = () => const observationDelayed = () =>
@@ -211,7 +212,7 @@ export function createWeatherLayer({
if ( if (
!enabled || !enabled ||
suspended() || suspended() ||
!(clock ? clock.getState().playing : playing) !(clock ? clock.getState().advancing : playing)
) )
return; return;
const times = manifest?.times ?? []; const times = manifest?.times ?? [];
+24 -4
View File
@@ -24,15 +24,21 @@ const MAX_DATE_MS = 8.64e15;
export const isWorldTime = (ms) => export const isWorldTime = (ms) =>
Number.isFinite(ms) && Math.abs(ms) <= MAX_DATE_MS; Number.isFinite(ms) && Math.abs(ms) <= MAX_DATE_MS;
/** How often subscribers hear of a continuously playing cursor. */ /**
* How often subscribers hear of a continuously playing cursor with no
* animation frames to go by; with them, they hear of it every frame.
*/
export const WORLD_CLOCK_TICK_MS = 250; export const WORLD_CLOCK_TICK_MS = 250;
/** /**
* @param {{now?: () => number, monotonicNow?: () => number, * @param {{now?: () => number, monotonicNow?: () => number,
* setInterval?: typeof globalThis.setInterval, * setInterval?: typeof globalThis.setInterval,
* clearInterval?: typeof globalThis.clearInterval, * clearInterval?: typeof globalThis.clearInterval,
* requestFrame?: typeof globalThis.requestAnimationFrame | null,
* cancelFrame?: typeof globalThis.cancelAnimationFrame | null,
* follow?: Iterable<string>}} [options] `follow` lists the layers that * follow?: Iterable<string>}} [options] `follow` lists the layers that
* move with the cursor from the start. * move with the cursor from the start. Playing, the clock tells its
* listeners of the time each animation frame, or each tick without frames.
*/ */
export function createWorldClock({ export function createWorldClock({
// Read at each call, so a stand-in for Date.now (a test's, a harness's) // Read at each call, so a stand-in for Date.now (a test's, a harness's)
@@ -41,6 +47,8 @@ export function createWorldClock({
monotonicNow = () => globalThis.performance.now(), monotonicNow = () => globalThis.performance.now(),
setInterval = globalThis.setInterval, setInterval = globalThis.setInterval,
clearInterval = globalThis.clearInterval, clearInterval = globalThis.clearInterval,
requestFrame = globalThis.requestAnimationFrame?.bind(globalThis) ?? null,
cancelFrame = globalThis.cancelAnimationFrame?.bind(globalThis) ?? null,
follow = [], follow = [],
} = {}) { } = {}) {
const listeners = new Set(); const listeners = new Set();
@@ -128,11 +136,23 @@ export function createWorldClock({
} }
} }
}; };
// Playing, the time moves on smoothly: each frame, so the timeline and the
// globe move with it and a layer's next frame is met as it comes, or each
// tick where there are no frames.
const frames = Boolean(requestFrame && cancelFrame);
const frame = () => {
ticker = requestFrame(frame);
notify();
};
const syncTicker = () => { const syncTicker = () => {
if (continuous() && listeners.size) { if (continuous() && listeners.size) {
ticker ??= setInterval(notify, WORLD_CLOCK_TICK_MS); if (ticker === null)
ticker = frames
? requestFrame(frame)
: setInterval(notify, WORLD_CLOCK_TICK_MS);
} else if (ticker !== null) { } else if (ticker !== null) {
clearInterval(ticker); if (frames) cancelFrame(ticker);
else clearInterval(ticker);
ticker = null; ticker = null;
} }
}; };
+34
View File
@@ -353,3 +353,37 @@ test('a seek still moves a waiting clock', () => {
assert.equal(clock.now(), t.wall - 60_000); assert.equal(clock.now(), t.wall - 60_000);
release(); release();
}); });
test('playing, listeners hear of the time every animation frame', () => {
const t = fakeTime();
const frames = new Map();
let nextFrame = 1;
const clock = createWorldClock({
...t.options,
requestFrame: (fn) => {
frames.set(nextFrame, fn);
return nextFrame++;
},
cancelFrame: (id) => frames.delete(id),
});
const heard = [];
clock.subscribe((state) => heard.push(state.timeMs));
const start = t.wall - 60_000;
clock.seek(start);
clock.play({ rate: 60 });
assert.equal(t.timers.size, 0, 'no ticks with frames to go by');
assert.equal(frames.size, 1);
const runFrame = () => {
const [[id, fn]] = frames;
frames.delete(id);
fn();
};
heard.length = 0;
for (let i = 0; i < 3; i++) {
t.advance(16);
runFrame();
}
assert.deepEqual(heard, [start + 960, start + 1_920, start + 2_880]);
clock.pause();
assert.equal(frames.size, 0);
});
+48 -1
View File
@@ -142,6 +142,7 @@ body.cockpit-mode #world-timeline {
/* Two fingers pinch the track, not the page. */ /* Two fingers pinch the track, not the page. */
touch-action: none; touch-action: none;
flex: 1 1 auto; flex: 1 1 auto;
cursor: pointer;
min-width: 80px; min-width: 80px;
height: 24px; height: 24px;
} }
@@ -193,6 +194,17 @@ body.cockpit-mode #world-timeline {
pointer-events: none; pointer-events: none;
} }
/* Zoomed, the track drags along. */
#world-timeline .world-timeline-track.is-zoomed {
cursor: grab;
}
#world-timeline .world-timeline-track.is-zoomed:active {
cursor: grabbing;
}
/* Only the handle takes the pointer as a slider; the track around it takes
taps, drags and pinches itself. */
#world-timeline .world-timeline-scrub { #world-timeline .world-timeline-scrub {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -200,8 +212,29 @@ body.cockpit-mode #world-timeline {
height: 100%; height: 100%;
margin: 0; margin: 0;
background: transparent; background: transparent;
cursor: pointer;
appearance: none; appearance: none;
pointer-events: none;
}
#world-timeline .world-timeline-scrub::-webkit-slider-thumb {
cursor: ew-resize;
pointer-events: auto;
}
#world-timeline .world-timeline-scrub::-moz-range-thumb {
cursor: ew-resize;
pointer-events: auto;
}
/* The time dragged off the track: the handle waits at its edge, unseen. */
#world-timeline .world-timeline-scrub.is-off-view::-webkit-slider-thumb {
opacity: 0;
pointer-events: none;
}
#world-timeline .world-timeline-scrub.is-off-view::-moz-range-thumb {
opacity: 0;
pointer-events: none;
} }
#world-timeline .world-timeline-scrub::-webkit-slider-runnable-track { #world-timeline .world-timeline-scrub::-webkit-slider-runnable-track {
@@ -232,6 +265,20 @@ body.cockpit-mode #world-timeline {
background: #031018; background: #031018;
} }
/* A finger needs a bigger handle. */
@media (pointer: coarse) {
#world-timeline .world-timeline-scrub::-webkit-slider-thumb {
width: 22px;
height: 22px;
margin-top: 1px;
}
#world-timeline .world-timeline-scrub::-moz-range-thumb {
width: 20px;
height: 20px;
}
}
#world-timeline .world-timeline-scrub:focus-visible { #world-timeline .world-timeline-scrub:focus-visible {
outline: 1px solid var(--accent); outline: 1px solid var(--accent);
outline-offset: 2px; outline-offset: 2px;
+103 -5
View File
@@ -378,10 +378,6 @@ test('zoomed, a scrub near now stays off live, and the view keeps a still handle
await nextTick(); await nextTick();
} }
assert.ok(Number(input.max) - Number(input.min) < 6 * 3_600_000, 'lines zoom like pixels'); assert.ok(Number(input.max) - Number(input.min) < 6 * 3_600_000, 'lines zoom like pixels');
// A sideways swipe leaves the zoom alone.
const sideways = new WheelEvent('wheel', { deltaX: 50, deltaY: 1, cancelable: true });
track.dispatchEvent(sideways);
assert.equal(sideways.defaultPrevented, false);
// Twenty minutes back is near now on a month, not on a few hours. // Twenty minutes back is near now on a month, not on a few hours.
input.value = String(now - 20 * 60_000); input.value = String(now - 20 * 60_000);
@@ -403,7 +399,8 @@ test('zoomed, a scrub near now stays off live, and the view keeps a still handle
assert.equal(browserZoom.defaultPrevented, false); assert.equal(browserZoom.defaultPrevented, false);
// A double-click puts back the time its first press moved. // A double-click puts back the time its first press moved.
track.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); track.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1 }));
track.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1 }));
input.value = String(at - 3_600_000); input.value = String(at - 3_600_000);
input.dispatchEvent(new Event('input')); input.dispatchEvent(new Event('input'));
track.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); track.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
@@ -440,3 +437,104 @@ test('play held for a layer to load shows a spinner once the hold lasts', async
release(); release();
assert.ok(await until(false), 'gone once play goes on'); assert.ok(await until(false), 'gone once play goes on');
}); });
test('the track drags along, a tap goes to its time, and the handle waits off it', async (t) => {
const bar = mount(t);
const now = Date.now();
const start = now - 26 * 86_400_000;
const release = worldClock.hold('test-launches');
const unregister = worldClock.register({
id: 'test-launches',
coverage: () => ({ start, end: now, marks: [start] }),
});
t.after(() => {
unregister();
release();
});
await nextTick();
const track = bar().querySelector('.world-timeline-track');
const input = bar().querySelector('input[type=range]');
// A 1000 px track from x = 0.
Object.defineProperty(track, 'clientWidth', { value: 1_000 });
track.getBoundingClientRect = () => ({ left: 0, width: 1_000, top: 0, height: 24 });
const press = (type, clientX, pointerId = 1) =>
track.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId }));
const view = () => ({ min: Number(input.min), max: Number(input.max) });
// All of it shown, a tap goes to the time under it.
press('pointerdown', 500);
press('pointerup', 500);
await nextTick();
const middle = start + (now - start) / 2;
assert.ok(Math.abs(worldClock.now() - middle) < 60_000, 'went to the middle');
// Zoomed to a day around it, dragging right by half the track goes back half a day.
for (let i = 0; i < 6; i++) {
input.dispatchEvent(new KeyboardEvent('keydown', { key: '+' }));
await nextTick();
}
const before = view();
const day = before.max - before.min;
press('pointerdown', 600, 2);
press('pointermove', 700, 2);
press('pointermove', 1_100, 2);
press('pointerup', 1_100, 2);
await nextTick();
assert.ok(Math.abs(view().max - (before.max - day / 2)) < 1_000, 'dragged half a view');
assert.ok(Math.abs(worldClock.now() - middle) < 60_000, 'a drag leaves the time');
// Dragged off the handle, it waits unseen at the edge; a sideways swipe drags too.
track.dispatchEvent(new WheelEvent('wheel', { deltaX: -2_000, deltaY: 0, cancelable: true }));
await nextTick();
assert.ok(view().max < worldClock.now(), 'the handle is past the view');
assert.equal(input.classList.contains('is-off-view'), true);
// Moving the time takes the view back to it.
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp' }));
await nextTick();
assert.equal(input.classList.contains('is-off-view'), false);
});
test('a pinch measures from where the fingers are, and a lifted finger ends it', async (t) => {
const bar = mount(t);
const now = Date.now();
const start = now - 26 * 86_400_000;
const release = worldClock.hold('test-launches');
const unregister = worldClock.register({
id: 'test-launches',
coverage: () => ({ start, end: now, marks: [start] }),
});
t.after(() => {
unregister();
release();
});
await nextTick();
const track = bar().querySelector('.world-timeline-track');
const input = bar().querySelector('input[type=range]');
Object.defineProperty(track, 'clientWidth', { value: 1_000 });
track.getBoundingClientRect = () => ({ left: 0, width: 1_000, top: 0, height: 24 });
const on = (target, type, clientX, pointerId) =>
target.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId }));
const length = () => Number(input.max) - Number(input.min);
for (let i = 0; i < 4; i++) {
input.dispatchEvent(new KeyboardEvent('keydown', { key: '+' }));
await nextTick();
}
const before = length();
// A finger on the handle moves before the second comes down.
on(input, 'pointerdown', 300, 1);
on(input, 'pointermove', 500, 1);
on(track, 'pointerdown', 700, 2);
on(input, 'pointermove', 500, 1);
await nextTick();
assert.ok(Math.abs(length() - before) < 1_000, 'no zoom from a stale finger');
on(track, 'pointermove', 900, 2);
await nextTick();
assert.ok(length() < before, 'spread apart, it zooms in');
// One finger up: the other moving does nothing, and throws nothing.
const zoomedTo = length();
on(track, 'pointerup', 900, 2);
on(input, 'pointermove', 100, 1);
await nextTick();
assert.ok(Math.abs(length() - zoomedTo) < 1_000);
on(input, 'pointercancel', 100, 1);
});
+139 -52
View File
@@ -11,7 +11,8 @@ import UiSelect from '../../kit/UiSelect.js';
// across the stretch the layers on can show (each launch or frame a tick) // across the stretch the layers on can show (each launch or frame a tick)
// with a handle to scrub and the time in a bubble over it, and the way back // with a handle to scrub and the time in a bubble over it, and the way back
// to live on the right, lit while live. Scrolling or pinching the track // to live on the right, lit while live. Scrolling or pinching the track
// zooms it around the handle; a double-click shows it all again. It shows // zooms it around the handle, dragging it moves along it, and a double-click
// shows it all again. It shows
// while a layer with a time of its own (weather, satellites, launches, // while a layer with a time of its own (weather, satellites, launches,
// or one a host follows) is on with time to show; with only live feeds such // or one a host follows) is on with time to show; with only live feeds such
// as flights on, the bar stays hidden. // as flights on, the bar stays hidden.
@@ -107,46 +108,76 @@ const view = computed(() => {
}); });
const zoomed = computed(() => zoom.value !== null); const zoomed = computed(() => zoom.value !== null);
const viewLength = () => view.value.end - view.value.start; const viewLength = () => view.value.end - view.value.start;
// Narrow or widen the view to `length`, keeping the handle where it is on it. // Show `length` of the span ending at `viewEnd`, held to the span.
function zoomTo(length) { function showView(length, viewEnd) {
const { start, end } = span.value; const { start, end } = span.value;
const next = Math.max(MIN_VIEW_MS, length); if (length >= end - start) {
if (next >= end - start) { showWhole();
zoom.value = null;
return; return;
} }
const clamped = Math.min(end, Math.max(start + length, viewEnd));
zoom.value = { length, end: clamped >= end ? null : clamped };
}
// Narrow or widen the view to `length`, keeping the handle where it is on it,
// or with the handle off it, the middle.
function zoomTo(length) {
const { start: from, end: to } = view.value; const { start: from, end: to } = view.value;
const share = Math.min(1, Math.max(0, (at.value - from) / (to - from))); const pivot = offView(at.value) ? (from + to) / 2 : at.value;
const viewStart = Math.max(start, at.value - share * next); const share = (pivot - from) / (to - from);
const viewEnd = viewStart + next; const next = Math.max(MIN_VIEW_MS, length);
zoom.value = { length: next, end: viewEnd >= end ? null : viewEnd }; showView(next, pivot + (1 - share) * next);
} }
const zoomBy = (factor) => zoomTo(viewLength() * factor); const zoomBy = (factor) => zoomTo(viewLength() * factor);
// The view keeps the handle on it: playing or stepping off it, or left behind // A view dragged off the handle stays where it was put while the handle only
// as now moves the view on, the view goes to the handle. The span narrowing to // creeps on with now; playing or a move of the handle takes it back.
// the zoom or less, or going, lets go of the zoom. let panned = false;
watch([at, view], ([ms, shownView]) => { const follow = (ms) => {
if (!zoom.value) return; panned = false;
if (ms === null || zoom.value.length >= span.value.end - span.value.start) { showView(zoom.value.length, ms + zoom.value.length / 2);
zoom.value = null; };
return; const offView = (ms) => ms < view.value.start || ms > view.value.end;
} const showWhole = () => {
if (ms >= shownView.start && ms <= shownView.end) return; zoom.value = null;
const length = zoom.value.length; panned = false;
const viewEnd = ms + length / 2; };
zoom.value = { length, end: viewEnd >= span.value.end ? null : viewEnd }; // The span narrowing to the zoom or less, or going, lets go of the zoom.
watch(span, (next) => {
if (zoom.value && (!next || zoom.value.length >= next.end - next.start))
showWhole();
}); });
watch(at, (ms) => {
if (!zoom.value || ms === null || !offView(ms)) return;
if (panned && time.value.live && !time.value.playing) return;
follow(ms);
});
// Left behind as now moves the view on, the handle takes the view back.
watch(view, () => {
if (zoom.value && !panned && at.value !== null && offView(at.value))
follow(at.value);
});
const pan = (ms) => {
panned = true;
showView(zoom.value.length, view.value.end + ms);
};
// A wheel's lines and pages, in pixels. // A wheel's lines and pages, in pixels.
const WHEEL_UNITS = [1, 16, 400]; const WHEEL_UNITS = [1, 16, 400];
const onWheel = (event) => { const onWheel = (event) => {
// A sideways swipe is not a zoom. const unit = WHEEL_UNITS[event.deltaMode] ?? 1;
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; const sideways = Math.abs(event.deltaX) > Math.abs(event.deltaY);
// A sideways swipe, or a wheel with shift, drags; a wheel zooms.
if (sideways || event.shiftKey) {
if (!zoom.value) return;
event.preventDefault();
const width = event.currentTarget.clientWidth;
const pixels = (sideways ? event.deltaX : event.deltaY) * unit;
if (width > 0) pan((pixels / width) * viewLength());
return;
}
event.preventDefault(); event.preventDefault();
const pixels = event.deltaY * (WHEEL_UNITS[event.deltaMode] ?? 1); zoomBy(Math.exp(event.deltaY * unit * WHEEL_ZOOM));
zoomBy(Math.exp(pixels * WHEEL_ZOOM));
}; };
// The input takes a press before it can tell a double-click or a pinch, so // A double-click starts with presses that move the time, so it puts back the
// both put the time back where it was before their first touch. // time from before its first; so does a pinch, which may start on the handle.
let before = null; let before = null;
let pressedAt = -Infinity; let pressedAt = -Infinity;
const onPress = (event) => { const onPress = (event) => {
@@ -161,29 +192,83 @@ const restore = () => {
}; };
const showAll = () => { const showAll = () => {
restore(); restore();
zoom.value = null; showWhole();
}; };
// Two fingers on the track pinch it, and the first finger stops scrubbing.
let pinch = null; // The handle drags the time. The rest of the track takes the pointer: a tap
const spread = (touches) => // goes to the time under it, a drag drags a zoomed track along (or the time,
Math.hypot( // with all of it shown), and two fingers pinch it.
touches[0].clientX - touches[1].clientX, const DRAG_PX = 4;
touches[0].clientY - touches[1].clientY, const pointers = new Map();
); let gesture = null;
const onTouchStart = (event) => { const timeAt = (clientX, track) => {
if (event.touches.length === 1) onPress(event); const { left, width } = track.getBoundingClientRect();
if (event.touches.length !== 2) return; const share = width > 0 ? Math.min(1, Math.max(0, (clientX - left) / width)) : 1;
restore(); return view.value.start + share * viewLength();
pinch = { spread: spread(event.touches), length: viewLength() };
}; };
const onTouchMove = (event) => { // Whether the first finger has moved the time, for a pinch to put it back.
if (!pinch || event.touches.length !== 2) return; let movedTime = false;
event.preventDefault(); const onPointerDown = (event) => {
const now = spread(event.touches); // Only the main button: a right-click is for the menu.
if (now > 0) zoomTo((pinch.length * pinch.spread) / now); if (event.button > 0) return;
const onHandle = event.target instanceof HTMLInputElement;
pointers.set(event.pointerId, { x: event.clientX, onHandle });
if (pointers.size === 1) {
onPress(event);
movedTime = onHandle;
// The keys zoom and step the track as soon as it is touched.
if (!onHandle)
event.currentTarget.querySelector('input')?.focus({ preventScroll: true });
}
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
if (movedTime) restore();
gesture = {
kind: 'pinch',
spread: Math.abs(a.x - b.x),
length: viewLength(),
};
return;
}
if (onHandle || pointers.size !== 1) return;
// The drag goes on past the track's ends; without the capture, to them.
try {
event.currentTarget.setPointerCapture?.(event.pointerId);
} catch {
// A pointer the page no longer knows of drags no further than the track.
}
gesture = { kind: 'press', x: event.clientX, end: view.value.end };
}; };
const onTouchEnd = (event) => { const onPointerMove = (event) => {
if (event.touches.length === 0) pinch = null; const pointer = pointers.get(event.pointerId);
if (!pointer) return;
pointer.x = event.clientX;
if (!gesture) return;
const track = event.currentTarget;
if (gesture.kind === 'pinch') {
const [a, b] = [...pointers.values()];
const spreadNow = Math.abs(a.x - b.x);
if (spreadNow > 0) zoomTo((gesture.length * gesture.spread) / spreadNow);
return;
}
if (gesture.kind === 'press') {
if (Math.abs(event.clientX - gesture.x) < DRAG_PX) return;
gesture.kind = zoom.value ? 'pan' : 'scrub';
movedTime = gesture.kind === 'scrub';
}
if (gesture.kind === 'pan') {
if (!track.clientWidth) return;
panned = true;
const moved = ((gesture.x - event.clientX) / track.clientWidth) * viewLength();
showView(zoom.value.length, gesture.end + moved);
} else store.scrub(timeAt(event.clientX, track), viewLength());
};
const onPointerUp = (event) => {
if (!pointers.delete(event.pointerId)) return;
if (gesture?.kind === 'press' && event.type === 'pointerup')
store.scrub(timeAt(event.clientX, event.currentTarget), viewLength());
// A finger lifted from a pinch ends it; the other one moves nothing.
gesture = null;
}; };
const place = (ms) => { const place = (ms) => {
@@ -199,9 +284,10 @@ const ticks = computed(() => {
const shown = computed(() => const shown = computed(() =>
Math.min(view.value.end, Math.max(view.value.start, at.value)), Math.min(view.value.end, Math.max(view.value.start, at.value)),
); );
const handleOff = computed(() => at.value !== shown.value);
const scrub = (event) => { const scrub = (event) => {
// A pinch only zooms; the input goes back to the handle. // A pinch only zooms; the input goes back to the handle.
if (pinch) { if (gesture?.kind === 'pinch') {
event.target.value = shown.value; event.target.value = shown.value;
return; return;
} }
@@ -217,7 +303,7 @@ const onKey = (event) => {
if (event.ctrlKey || event.metaKey || event.altKey) return; if (event.ctrlKey || event.metaKey || event.altKey) return;
event.preventDefault(); event.preventDefault();
if (event.key === '-') zoomBy(ZOOM_STEP); if (event.key === '-') zoomBy(ZOOM_STEP);
else if (event.key === '0') zoom.value = null; else if (event.key === '0') showWhole();
else zoomBy(1 / ZOOM_STEP); else zoomBy(1 / ZOOM_STEP);
return; return;
} }
@@ -244,13 +330,14 @@ const onKey = (event) => {
><UiButton class="world-timeline-play" :label="time.playing ? t('Pause') : t('Play')" @click="store.togglePlay()" ><UiButton class="world-timeline-play" :label="time.playing ? t('Pause') : t('Play')" @click="store.togglePlay()"
><UiIcon :name="time.playing ? 'pause' : 'play_arrow'" :size="20" /></UiButton ><UiIcon :name="time.playing ? 'pause' : 'play_arrow'" :size="20" /></UiButton
><UiSelect class="gev-field gev-field--compact world-timeline-speed" :aria-label="t('Play speed')" :title="t('Play speed')" :options="speeds" :model-value="speed" @update:model-value="store.setSpeed($event)" ><UiSelect class="gev-field gev-field--compact world-timeline-speed" :aria-label="t('Play speed')" :title="t('Play speed')" :options="speeds" :model-value="speed" @update:model-value="store.setSpeed($event)"
/><div class="world-timeline-track" :class="{ 'is-zoomed': zoomed }" :title="t('Scroll or pinch to zoom · double-click to see it all')" @wheel="onWheel" @mousedown="onPress" @dblclick="showAll" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd" @touchcancel="onTouchEnd" /><div class="world-timeline-track" :class="{ 'is-zoomed': zoomed }" :title="t('Scroll or pinch to zoom, drag to move along · double-click to see it all')" @wheel="onWheel" @dblclick="showAll" @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="onPointerUp" @pointercancel="onPointerUp"
><time class="world-timeline-time" :class="{ 'is-waiting': waiting }" :style="{ '--at': place(shown) / 100 }" :datetime="new Date(time.timeMs).toISOString()" aria-live="off" ><time class="world-timeline-time" :class="{ 'is-waiting': waiting }" :style="{ '--at': place(shown) / 100 }" :datetime="new Date(time.timeMs).toISOString()" aria-live="off"
><span v-if="waiting" class="world-timeline-spinner" aria-hidden="true"></span>{{ bubble }}</time ><span v-if="waiting" class="world-timeline-spinner" aria-hidden="true"></span>{{ bubble }}</time
><span v-for="(left, index) in ticks" :key="index" class="world-timeline-tick" :style="{ left }" aria-hidden="true"></span ><span v-for="(left, index) in ticks" :key="index" class="world-timeline-tick" :style="{ left }" aria-hidden="true"></span
><span class="world-timeline-fill" :style="{ width: `${place(shown)}%` }" aria-hidden="true"></span ><span class="world-timeline-fill" :style="{ width: `${place(shown)}%` }" aria-hidden="true"></span
><input ><input
class="world-timeline-scrub" class="world-timeline-scrub"
:class="{ 'is-off-view': handleOff }"
type="range" type="range"
:aria-label="t('World time')" :aria-label="t('World time')"
:aria-valuetext="readout" :aria-valuetext="readout"