diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs
index 39fd5b8f2..dafc51df1 100644
--- a/src/creditAttribution.test.mjs
+++ b/src/creditAttribution.test.mjs
@@ -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) {
diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js
index 67973bf94..21a081b48 100644
--- a/src/layers/weather/clock.js
+++ b/src/layers/weather/clock.js
@@ -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),
diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs
index c803e5d72..55b79a87b 100644
--- a/src/layers/weather/clock.test.mjs
+++ b/src/layers/weather/clock.test.mjs
@@ -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',
+ );
+});
diff --git a/src/time/worldClock.js b/src/time/worldClock.js
index f9c16c5d8..7d493bbc7 100644
--- a/src/time/worldClock.js
+++ b/src/time/worldClock.js
@@ -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)
diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs
index 0e7a544a3..8b5154854 100644
--- a/src/time/worldClock.test.mjs
+++ b/src/time/worldClock.test.mjs
@@ -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();
+});
diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js
index 46c620ab6..ea73e262f 100644
--- a/src/ui/stores/worldTime.js
+++ b/src/ui/stores/worldTime.js
@@ -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 = () => {
diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css
index 0ae69baf4..0fd12fe28 100644
--- a/src/ui/styles/world-timeline.css
+++ b/src/ui/styles/world-timeline.css
@@ -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;
- }
-}
diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs
index 251ede717..746158ddb 100644
--- a/src/ui/views/panels/WorldTimeline.test.mjs
+++ b/src/ui/views/panels/WorldTimeline.test.mjs
@@ -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();
diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue
index 0cedda02d..e56737fda 100644
--- a/src/ui/views/panels/WorldTimeline.vue
+++ b/src/ui/views/panels/WorldTimeline.vue
@@ -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) => {
>