Wait for each frame while the world timeline plays, and quiet the bar

Play no longer runs ahead of the data. The world clock gains a wait, which
holds continuous play where it is while a layer loads what the time needs,
and a gate, which keeps play from passing a time a layer has yet to load.
Weather gates play at its next frame and waits there until the frame shows,
so every frame is seen however fast play runs; it waits no more than eight
seconds on a frame that never comes, and takes a wait only when its frames
change. Play pressed while a frame loads waits for it too. A scrub still
drops what loads and moves on.

The bar loses its long date and the dates at its ends: the time rides in a
bubble over the handle, which pulses while play waits, and the bar runs
left to right in every language. Play starts at real time.
This commit is contained in:
Sameh Khamis committed 2026-11-02 08:03:46 -08:00
1 parent d04e304502
commit 056b3b58c6
9 files changed
+357 -40

No files matched your search

+1 -1
View File
@@ -241,7 +241,7 @@ const VETTED_VARS = new Set([
// Custom properties CSS sets to a fixed length besides their 0 default. The
// clearance checks run at each length CSS gives them, not only at 0.
const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
const LIFTS_PX = [0, 50, 80];
const LIFTS_PX = [0, 66, 80];
let liftPx = 0;
function eachLift(check) {
for (const lift of LIFTS_PX) {
+59 -1
View File
@@ -4,6 +4,10 @@ export const RADAR_MAX_GAP_MS = 30 * 60_000;
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
/** The longest play waits on a frame before it goes on without it. */
export const PLAY_WAIT_MS = 8_000;
let clocks = 0;
/**
* Transient observed time. Products must honor the signal before installing a
@@ -33,6 +37,30 @@ export function createWeatherClock({
let generation = 0;
let timer = null;
let pending = null;
// This clock's name on the world, for its holds and its gate.
const holdId = `weather:${++clocks}`;
// While the world plays, the hold on it as frames for its time load, and
// the timer that lets play go on should a frame never come.
let waiting = null;
let waitTimer = null;
const stopWaiting = () => {
const release = waiting;
waiting = null;
if (waitTimer !== null) clearTimeout(waitTimer);
waitTimer = null;
release?.();
};
const startWaiting = () => {
if (waiting || destroyed || !registry.size) return;
waiting = world.wait(holdId);
waitTimer = setTimeout(() => {
waitTimer = null;
stopWaiting();
}, PLAY_WAIT_MS);
};
// Playing on its own, the world waits for frames as they load.
const worldPlaysOn = (state = world.getState()) =>
state.playing && !state.live && !state.stepping;
let destroyed = false;
const timesFor = (product) =>
[...new Set(product.getTimes())]
@@ -130,7 +158,13 @@ export function createWeatherClock({
cancelTimer();
const owner = ++generation;
if (!canPlay()) stopPlaying();
// Playing, the world waits for new frames; a later move takes over the
// 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();
stopWaiting();
if (framesChange && worldPlaysOn()) startWaiting();
for (const entry of registry.values()) entry.controller?.abort();
// Start all products together; a rejection must not skip the settle barrier.
const tasks = [...registry.values()].map((entry) => {
@@ -144,6 +178,7 @@ export function createWeatherClock({
});
const result = Promise.allSettled(tasks).then(() => {
if (destroyed || owner !== generation) return false;
stopWaiting();
pending = null;
if (!canPlay()) stopPlaying();
notify();
@@ -269,8 +304,10 @@ export function createWeatherClock({
},
refresh() {
if (destroyed) return Promise.resolve(false);
// New frames, or products added or dropped.
// New frames, or products added or dropped, and with them the next.
world.coverageChanged();
gated = undefined;
gateNext(world.getState());
if (!canPlay()) {
stopPlaying();
cancelTimer();
@@ -289,6 +326,8 @@ export function createWeatherClock({
stopPlaying();
destroyed = true;
++generation;
stopWaiting();
world.gate(holdId, null);
cancelTimer();
unfollowWorld();
// The last weather clock leaves the world live, as a new one expects.
@@ -306,6 +345,11 @@ export function createWeatherClock({
// changes the mode or a product's frame.
function followWorld(state) {
if (destroyed) return;
gateNext(state);
// Play pressed while a frame still loads waits for it too.
const playsOn = worldPlaysOn(state);
if (playsOn && !playedOn && pending) startWaiting();
playedOn = playsOn;
const nextMode = state.live ? 'latest' : 'history';
const nextTarget = state.live
? null
@@ -319,6 +363,20 @@ export function createWeatherClock({
if (moving || modeChanged || selections() !== applied) void transition();
else notify();
}
// Playing on its own, the world stops at each next frame to load it rather
// than run past frames between its ticks.
let gated = null;
let playedOn = false;
function gateNext(state) {
const next = worldPlaysOn(state)
? (getTimeline()
.map((time) => Date.parse(time))
.find((time) => time > Math.floor(state.timeMs)) ?? null)
: null;
if (next === gated) return;
gated = next;
world.gate(holdId, next);
}
const unfollowWorld = world.subscribe(followWorld);
driver = world.attachDriver({
play: () => (canPlay() ? void api.play() : false),
+147
View File
@@ -6,6 +6,7 @@ import {
REGIONAL_INFRARED_MAX_GAP_MS,
GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS,
PLAY_WAIT_MS,
} from './clock.js';
import { createWorldClock } from '../../time/worldClock.js';
@@ -493,3 +494,149 @@ test('a clock destroyed while it registers a product lets go of it', (t) => {
assert.equal(world.coverage(), null);
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.
function slowRadar(minutes) {
const radar = product('radar', minutes);
radar.loads = [];
radar.apply = (selected, { signal }) =>
new Promise((resolve) => {
radar.loads.push({
selected,
signal,
done() {
radar.shown = selected;
resolve(true);
},
});
});
return radar;
}
test('play stops at the next frame, not past it, until it shows', async (t) => {
const { clock, world, tick } = harness(t);
const radar = slowRadar([0, 10, 20]);
clock.register(radar);
world.seek(Date.parse(time(1)));
await flush();
radar.loads.at(-1).done();
await flush();
world.play({ rate: 3_600, continuous: true });
world.coverageChanged();
tick(1_000);
assert.equal(
world.now(),
Date.parse(time(10)),
'an hour on, but held at the frame',
);
});
test('a frame that never comes holds play only so long', 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();
assert.equal(world.getState().buffering, true);
tick(PLAY_WAIT_MS);
assert.equal(world.getState().buffering, false, 'play goes on without it');
});
test('play pressed while a frame loads waits for it; a refresh does not', async (t) => {
const { clock, world } = harness(t);
const radar = slowRadar([0, 10, 20]);
clock.register(radar);
world.seek(Date.parse(time(5)));
await flush();
world.play({ continuous: true });
assert.equal(
world.getState().buffering,
true,
'waits for the frame in flight',
);
radar.loads.at(-1).done();
await flush();
assert.equal(world.getState().buffering, false);
void clock.refresh();
await flush();
assert.equal(world.getState().buffering, false, 'the same frames, no hold');
radar.loads.at(-1).done();
});
test('weather clocks sharing a world gate it each on their own', async (t) => {
let mono = 0;
const world = createWorldClock({
now: () => Date.parse(time(30)),
monotonicNow: () => mono,
setInterval: () => 0,
clearInterval() {},
});
const a = createWeatherClock({ world, now: () => Date.parse(time(30)) });
const b = createWeatherClock({ world, now: () => Date.parse(time(30)) });
t.after(() => {
a.destroy();
b.destroy();
});
a.register(product('radar', [0, 10]));
b.register(product('clouds', [0, 15]));
world.seek(Date.parse(time(1)));
await flush();
world.play({ rate: 3_600, continuous: true });
world.coverageChanged();
b.destroy();
world.coverageChanged();
mono += 1_000;
assert.equal(
world.now(),
Date.parse(time(10)),
'the other clock’s gate stays',
);
});
+46 -2
View File
@@ -60,8 +60,13 @@ export function createWorldClock({
let active = null;
let ticker = null;
let notifying = 0;
// Layers loading what the cursor needs; continuous play holds while any is.
const waits = new Map();
// Times continuous play may not pass yet, by layer: each one's next frame.
const gates = new Map();
const continuous = () => playing && !active;
const buffering = () => waits.size > 0;
const isFollowed = (id) => following.has(id) || (holds.get(id) ?? 0) > 0;
const followedIds = () => [
...new Set([...following, ...[...holds.keys()].filter(isFollowed)]),
@@ -87,8 +92,11 @@ export function createWorldClock({
};
const time = () => {
if (live) return now();
if (continuous()) return cursorMs + (monotonicNow() - anchorMono) * rate;
return cursorMs;
if (!continuous() || buffering()) return cursorMs;
let at = cursorMs + (monotonicNow() - anchorMono) * rate;
for (const gate of gates.values())
if (gate >= cursorMs) at = Math.min(at, gate);
return at;
};
const getState = () =>
Object.freeze({
@@ -98,6 +106,8 @@ export function createWorldClock({
rate,
// A driver steps the clock, at its own pace rather than the rate.
stepping: Boolean(active),
// Playing, but held while a layer loads what the cursor needs.
buffering: continuous() && buffering(),
following: followedIds(),
supported: isSupported(),
});
@@ -198,6 +208,40 @@ export function createWorldClock({
togglePlay() {
return playing ? api.pause() : api.play();
},
/**
* Hold continuous play where it is while layer `id` loads what the cursor
* needs, as a video waits on its buffer; play goes on once every hold is
* released. A seek still moves the cursor. Returns the release. Neither
* tells listeners (play's own ticks do), so a layer can take and release a
* hold mid-change without anyone moving the clock under it.
*/
wait(id) {
settle();
waits.set(id, (waits.get(id) ?? 0) + 1);
let held = true;
return () => {
if (!held) return;
held = false;
const count = waits.get(id) - 1;
if (count > 0) {
waits.set(id, count);
return;
}
waits.delete(id);
// Play goes on from where it held, not from where it would have been.
if (!buffering()) anchorMono = monotonicNow();
};
},
/**
* Keep continuous play from passing `ms` (null lifts it): the time layer
* `id` shows next, which it has yet to load. Play stops there, to wait on
* the load as it starts, rather than rush past frames between ticks.
*/
gate(id, ms) {
settle();
if (ms === null || ms === undefined) gates.delete(id);
else if (isWorldTime(ms)) gates.set(id, ms);
},
/** Simulated ms per real ms for continuous playback. */
setRate(nextRate) {
if (!Number.isFinite(nextRate) || nextRate <= 0)
+34
View File
@@ -319,3 +319,37 @@ test('continuous play runs past every driver, and live resets the rate', () => {
clock.goLive();
assert.equal(clock.getRate(), 1);
});
test('continuous play holds while a layer waits, and goes on from there', () => {
const t = fakeTime();
const clock = createWorldClock(t.options);
const start = t.wall - 600_000;
clock.seek(start);
clock.play({ rate: 60, continuous: true });
t.advance(1_000);
assert.equal(clock.now(), start + 60_000);
const release = clock.wait('radar');
const other = clock.wait('clouds');
assert.equal(clock.getState().buffering, true);
t.advance(5_000);
assert.equal(clock.now(), start + 60_000, 'held while loading');
release();
release();
t.advance(1_000);
assert.equal(clock.now(), start + 60_000, 'held until every layer is ready');
other();
assert.equal(clock.getState().buffering, false);
t.advance(1_000);
assert.equal(clock.now(), start + 120_000, 'on from where it held');
});
test('a seek still moves a waiting clock', () => {
const t = fakeTime();
const clock = createWorldClock(t.options);
clock.seek(t.wall - 600_000);
clock.play({ continuous: true });
const release = clock.wait('radar');
clock.seek(t.wall - 60_000);
assert.equal(clock.now(), t.wall - 60_000);
release();
});
+3 -2
View File
@@ -18,7 +18,7 @@ export const WORLD_TIME_SPEEDS = Object.freeze({
/**
* The world clock as the timeline shows it: `{live, playing, stepping,
* timeMs, supported, span}`, where `supported` says a layer that moves with the clock
* buffering, timeMs, supported, span}`, where `supported` says a layer that moves with the clock
* has time to show and `span` is the stretch the timeline runs over: the
* time those layers cover, reaching to now and to the world time, with their
* marks. It follows the page's world clock, which tells it of every move, and
@@ -81,12 +81,13 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
live: state.live,
playing: state.playing,
stepping: state.stepping,
buffering: state.buffering,
timeMs: state.timeMs,
supported: state.supported,
span: spanOf(state),
});
const time = shallowRef(read());
const speed = shallowRef('day');
const speed = shallowRef('real');
const rate = () => WORLD_TIME_SPEEDS[speed.value];
let ticker = null;
const syncTicker = () => {
+45 -23
View File
@@ -5,8 +5,9 @@
--world-timeline-lift: 0px;
}
/* The bar and the time's bubble over its handle. */
body:has(#world-timeline) {
--world-timeline-lift: 50px;
--world-timeline-lift: 66px;
}
/* Clean view, recording and the cockpit put the dock away; the bar goes too. */
@@ -22,7 +23,10 @@ body.cockpit-mode #world-timeline {
display: none;
}
/* Time runs left to right in every language, so the handle, the fill, the
ticks and the bubble agree under a right-to-left page too. */
#world-timeline {
direction: ltr;
position: fixed;
bottom: 2vh;
left: 50%;
@@ -81,21 +85,43 @@ body.cockpit-mode #world-timeline {
flex: none;
}
/* The time rides over the handle. It slides across itself as the handle
crosses the track (its start at the handle at the track's start, its end at
the end), so however wide its date it stays within the track. */
#world-timeline .world-timeline-time {
overflow: hidden;
font-family: var(--font-mono);
font-size: 13px;
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
#world-timeline .world-timeline-edge {
flex: none;
color: var(--text-secondary);
--inset: 0px;
position: absolute;
bottom: calc(100% + 6px);
left: calc(var(--inset) + (100% - var(--inset)) * var(--at));
padding: 2px 8px;
border: 1px solid rgba(0, 212, 255, 0.4);
border-radius: 6px;
background: var(--glass-bg);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
transform: translateX(calc(var(--at) * -100%));
pointer-events: none;
}
/* Playing, held while a layer loads the time's data. */
#world-timeline .world-timeline-time.is-buffering {
animation: world-timeline-buffering 1s ease-in-out infinite alternate;
}
@keyframes world-timeline-buffering {
to {
opacity: 0.45;
}
}
@media (prefers-reduced-motion: reduce) {
#world-timeline .world-timeline-time.is-buffering {
animation: none;
opacity: 0.6;
}
}
/* The track: ticks for each launch or frame, the elapsed fill, and a native
@@ -194,7 +220,7 @@ body.cockpit-mode #world-timeline {
}
/* On phones the bar sits just above the tab bar, under the voice dock, with
the track on a row of its own beneath play, the time and live. */
the track and the speed on a row of their own beneath play and live. */
body.mobile-shell:has(#world-timeline) {
--world-timeline-lift: 80px;
}
@@ -206,8 +232,9 @@ body.mobile-shell #world-timeline {
height: auto;
}
/* The bubble rises into the first row, clear of play on its left. */
body.mobile-shell #world-timeline .world-timeline-time {
flex: 1 1 0;
--inset: 48px;
}
/* The speed sits on the track's row, a full-size target beside it. */
@@ -216,17 +243,12 @@ body.mobile-shell #world-timeline .world-timeline-track {
flex: 1 1 calc(100% - 96px);
}
body.mobile-shell #world-timeline .world-timeline-live {
margin-inline-start: auto;
}
body.mobile-shell #world-timeline .world-timeline-speed {
order: 2;
height: 32px;
}
@media (max-width: 640px) {
#world-timeline .world-timeline-edge {
display: none;
}
#world-timeline .world-timeline-time {
font-size: 11px;
}
}
+3 -3
View File
@@ -149,7 +149,7 @@ test('the track runs over the time the layers show, to now, and scrubs', async (
assert.equal(worldClock.now(), ahead);
});
test('play runs a day a second, from the start of the track when at live', async (t) => {
test('play runs in real time, from the start of the track when at live', async (t) => {
const bar = mount(t);
const now = Date.now();
const start = now - 10 * 86_400_000;
@@ -176,7 +176,7 @@ test('play runs a day a second, from the start of the track when at live', async
assert.deepEqual(stepped, []);
const rate = worldClock.getRate();
assert.ok(worldClock.now() - start < rate * 1_000, 'from the start of the track');
assert.equal(rate, 86_400, 'a day a second');
assert.equal(rate, 1, 'real time to start with');
worldClock.play({ continuous: true });
// Playing on to now goes live.
@@ -231,7 +231,7 @@ test('the speed chosen sets how fast play runs, at once while playing', async (t
});
await nextTick();
const select = bar().querySelector('select.world-timeline-speed');
assert.equal(select.value, 'day');
assert.equal(select.value, 'real');
select.value = 'week';
select.dispatchEvent(new Event('change'));
await nextTick();
+19 -8
View File
@@ -7,9 +7,10 @@ import UiButton from '../../kit/UiButton.vue';
import UiIcon from '../../kit/UiIcon.vue';
import UiSelect from '../../kit/UiSelect.js';
// The world timeline: play and pause on the left, the time, a track across
// the stretch the layers on can show (each launch or frame a tick) with a
// handle to scrub, and the way back to live on the right, lit while live. It
// The world timeline: play and pause and the speed on the left, a track
// 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
// to live on the right, lit while live. It
// shows 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
// as flights on, the bar stays hidden.
@@ -27,11 +28,18 @@ const DATE_TIME = Object.freeze({
timeStyle: 'medium',
timeZone: 'UTC',
});
const EDGE_DATE = Object.freeze({
// The bubble over the handle: the date and the minute, kept short.
const BUBBLE_DATE = Object.freeze({
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
const BUBBLE_TIME = Object.freeze({
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
timeZone: 'UTC',
});
// The keyboard's fallback step, as a share of the track, with no mark to go to.
const KEY_STEP = 0.01;
const PAGE_STEP = 0.1;
@@ -41,6 +49,11 @@ const readout = computed(() =>
? `${formatDate(time.value.timeMs, DATE_TIME)} UTC`
: '',
);
const bubble = computed(() =>
Number.isFinite(time.value.timeMs)
? `${formatDate(time.value.timeMs, BUBBLE_DATE)} · ${formatDate(time.value.timeMs, BUBBLE_TIME)} UTC`
: '',
);
const span = computed(() => time.value.span);
const at = computed(() => {
const { start, end } = span.value;
@@ -80,9 +93,8 @@ const onKey = (event) => {
><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
><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)"
/><time class="world-timeline-time" :datetime="new Date(time.timeMs).toISOString()" aria-live="off">{{ readout }}</time
><span class="world-timeline-edge">{{ formatDate(span.start, EDGE_DATE) }}</span
><div class="world-timeline-track"
/><div class="world-timeline-track"
><time class="world-timeline-time" :class="{ 'is-buffering': time.buffering }" :style="{ '--at': place(at) / 100 }" :datetime="new Date(time.timeMs).toISOString()" aria-live="off">{{ bubble }}</time
><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(at)}%` }" aria-hidden="true"></span
><input
@@ -97,7 +109,6 @@ const onKey = (event) => {
@input="scrub"
@keydown="onKey"
/></div
><span class="world-timeline-edge">{{ formatDate(span.end, EDGE_DATE) }}</span
><UiButton class="world-timeline-live" :label="time.live ? t('Live') : t('Back to live')" :pressed="time.live" pressed-class="is-live" :disabled="time.live" @click="store.goLive()">{{ t('Live') }}</UiButton
></div>
</template>