Choose how fast the world timeline plays

A speed menu beside play offers real time, an hour, a day or a week a
second, a day a second to start with. A speed chosen while playing applies
at once; chosen while weather's own replay steps its frames, it takes
playback over from where it is. Play starts again from the top only at live
or at the very end, so a pause just before the end plays on. On phones the
menu sits beside the track, a full-size target.
This commit is contained in:
Sameh Khamis committed 2026-11-02 07:41:50 -08:00
1 parent e7973e81cf
commit d04e304502
16 files changed
+194 -32

No files matched your search

+5 -1
View File
@@ -3221,5 +3221,9 @@
"Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد",
"World time": "وقت العالم",
"Back to live": "العودة إلى البث المباشر",
"Live": "مباشر"
"Live": "مباشر",
"1 h/s": "ساعة/ث",
"1 d/s": "يوم/ث",
"1 wk/s": "أسبوع/ث",
"Play speed": "سرعة التشغيل"
}
+5 -1
View File
@@ -2941,5 +2941,9 @@
"PAD / RTLS": "STARTPLATZ / RTLS",
"World time": "Weltzeit",
"Back to live": "Zurück zu live",
"Live": "Live"
"Live": "Live",
"1 h/s": "1 Std./s",
"1 d/s": "1 Tag/s",
"1 wk/s": "1 Wo./s",
"Play speed": "Wiedergabegeschwindigkeit"
}
+5 -1
View File
@@ -3011,5 +3011,9 @@
"Right panels": "Paneles derechos",
"World time": "Hora mundial",
"Back to live": "Volver al directo",
"Live": "En directo"
"Live": "En directo",
"1 h/s": "1 h/s",
"1 d/s": "1 d/s",
"1 wk/s": "1 sem/s",
"Play speed": "Velocidad de reproducción"
}
+5 -1
View File
@@ -3011,5 +3011,9 @@
"PAD / RTLS": "PAS DE TIR / RTLS",
"World time": "Heure du monde",
"Back to live": "Revenir au direct",
"Live": "Direct"
"Live": "Direct",
"1 h/s": "1 h/s",
"1 d/s": "1 j/s",
"1 wk/s": "1 sem/s",
"Play speed": "Vitesse de lecture"
}
+5 -1
View File
@@ -3011,5 +3011,9 @@
"PAD / RTLS": "SITO DI LANCIO / RTLS",
"World time": "Ora del mondo",
"Back to live": "Torna alla diretta",
"Live": "Diretta"
"Live": "Diretta",
"1 h/s": "1 h/s",
"1 d/s": "1 g/s",
"1 wk/s": "1 sett/s",
"Play speed": "Velocità di riproduzione"
}
+5 -1
View File
@@ -2871,5 +2871,9 @@
"PAD / RTLS": "射場 / RTLS",
"World time": "世界時刻",
"Back to live": "ライブに戻る",
"Live": "ライブ"
"Live": "ライブ",
"1 h/s": "1時間/秒",
"1 d/s": "1日/秒",
"1 wk/s": "1週間/秒",
"Play speed": "再生速度"
}
+5 -1
View File
@@ -3011,5 +3011,9 @@
"PAD / RTLS": "PLATAFORMA / RTLS",
"World time": "Hora do mundo",
"Back to live": "Voltar ao vivo",
"Live": "Ao vivo"
"Live": "Ao vivo",
"1 h/s": "1 h/s",
"1 d/s": "1 d/s",
"1 wk/s": "1 sem/s",
"Play speed": "Velocidade de reprodução"
}
+5 -1
View File
@@ -3081,5 +3081,9 @@
"Right panels": "Правые панели",
"World time": "Мировое время",
"Back to live": "Вернуться к прямому эфиру",
"Live": "Сейчас"
"Live": "Сейчас",
"1 h/s": "1 ч/с",
"1 d/s": "1 дн/с",
"1 wk/s": "1 нед/с",
"Play speed": "Скорость воспроизведения"
}
+5 -1
View File
@@ -3081,5 +3081,9 @@
"PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS",
"World time": "Світовий час",
"Back to live": "Повернутися до прямого ефіру",
"Live": "Зараз"
"Live": "Зараз",
"1 h/s": "1 год/с",
"1 d/s": "1 дн/с",
"1 wk/s": "1 тиж/с",
"Play speed": "Швидкість відтворення"
}
+5 -1
View File
@@ -2871,5 +2871,9 @@
"Right panels": "右侧面板",
"World time": "世界时间",
"Back to live": "返回实时",
"Live": "实时"
"Live": "实时",
"1 h/s": "1 小时/秒",
"1 d/s": "1 天/秒",
"1 wk/s": "1 周/秒",
"Play speed": "播放速度"
}
+5 -1
View File
@@ -2871,5 +2871,9 @@
"Right panels": "右側面板",
"World time": "世界時間",
"Back to live": "返回即時",
"Live": "即時"
"Live": "即時",
"1 h/s": "1 小時/秒",
"1 d/s": "1 天/秒",
"1 wk/s": "1 週/秒",
"Play speed": "播放速度"
}
+2
View File
@@ -96,6 +96,8 @@ export function createWorldClock({
timeMs: time(),
playing,
rate,
// A driver steps the clock, at its own pace rather than the rate.
stepping: Boolean(active),
following: followedIds(),
supported: isSupported(),
});
+31 -14
View File
@@ -8,12 +8,17 @@ const LIVE_TICK_MS = 1000;
const LIVE_EDGE = 0.005;
/** The least that counts as at now, past the readout's own second. */
const LIVE_EDGE_MS = 5000;
/** How long play takes to run the whole track, in real ms. */
const PLAY_THROUGH_MS = 60_000;
/** The speeds play offers, in world ms per real ms. */
export const WORLD_TIME_SPEEDS = Object.freeze({
real: 1,
hour: 3_600,
day: 86_400,
week: 604_800,
});
/**
* The world clock as the timeline shows it: `{live, playing, timeMs,
* supported, span}`, where `supported` says a layer that moves with the clock
* The world clock as the timeline shows it: `{live, playing, stepping,
* 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
@@ -75,11 +80,14 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
Object.freeze({
live: state.live,
playing: state.playing,
stepping: state.stepping,
timeMs: state.timeMs,
supported: state.supported,
span: spanOf(state),
});
const time = shallowRef(read());
const speed = shallowRef('day');
const rate = () => WORLD_TIME_SPEEDS[speed.value];
let ticker = null;
const syncTicker = () => {
// Playing, the clock tells of the time itself.
@@ -108,8 +116,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
return {
time,
/**
* Play runs the whole track in about a minute, from the start when it is
* at its end (at live, say), as a video plays again from the top. It runs
* Play runs the track at the speed chosen, from the start when it is at
* its end (at live, say), as a video plays again from the top. It runs
* the clock itself, past weather's own replay: weather follows the time
* frame by frame like every other layer the track covers.
*/
@@ -117,15 +125,24 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
const { playing, live, timeMs, span } = time.value;
if (playing) return worldClock.pause();
const covered = reach();
if (!span || !covered) return worldClock.play({ rate: 1 });
const length = span.end - span.start;
if (live || timeMs >= covered.end - LIVE_EDGE_MS)
if (span && covered && (live || timeMs >= covered.end))
worldClock.seek(span.start);
// An end at now moves on with it, so play outruns it by the track.
const rate = endsAtNow(covered)
? 1 + length / PLAY_THROUGH_MS
: Math.max(1, length / PLAY_THROUGH_MS);
worldClock.play({ rate, continuous: true });
worldClock.play({ rate: rate(), continuous: true });
},
speed,
/**
* Choose how fast play runs; playing, it changes at once. Weather's own
* replay steps at its own pace, so a speed chosen while it plays takes
* playback over from where it is.
*/
setSpeed(next) {
if (!Object.hasOwn(WORLD_TIME_SPEEDS, next)) return;
speed.value = next;
const { playing, stepping } = worldClock.getState();
if (!playing) return;
if (!stepping) return worldClock.setRate(rate());
worldClock.pause();
worldClock.play({ rate: rate(), continuous: true });
},
goLive: () => worldClock.goLive(),
/** Scrub to `ms`: playback stops where the hand puts it; at now, live. */
+11 -1
View File
@@ -77,6 +77,10 @@ body.cockpit-mode #world-timeline {
text-transform: uppercase;
}
#world-timeline .world-timeline-speed {
flex: none;
}
#world-timeline .world-timeline-time {
overflow: hidden;
font-family: var(--font-mono);
@@ -206,9 +210,15 @@ body.mobile-shell #world-timeline .world-timeline-time {
flex: 1 1 0;
}
/* The speed sits on the track's row, a full-size target beside it. */
body.mobile-shell #world-timeline .world-timeline-track {
order: 1;
flex-basis: 100%;
flex: 1 1 calc(100% - 96px);
}
body.mobile-shell #world-timeline .world-timeline-speed {
order: 2;
height: 32px;
}
@media (max-width: 640px) {
+85 -4
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 the whole track in a minute, from the start when at live', async (t) => {
test('play runs a day a second, 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,9 +176,7 @@ test('play runs the whole track in a minute, from the start 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');
// The end is now and moves on with it: play outruns it by the track.
const length = Date.now() - start;
assert.ok(Math.abs(rate - (1 + length / 60_000)) < rate * 0.01, 'a minute end to end');
assert.equal(rate, 86_400, 'a day a second');
worldClock.play({ continuous: true });
// Playing on to now goes live.
@@ -218,3 +216,86 @@ test('play holds at the end of a track that reaches past now', async (t) => {
assert.equal(worldClock.isLive(), false);
assert.equal(worldClock.now(), end);
});
test('the speed chosen sets how fast play runs, at once while playing', async (t) => {
const bar = mount(t);
const now = Date.now();
const release = worldClock.hold('test-launches');
const unregister = worldClock.register({
id: 'test-launches',
coverage: () => ({ start: now - 10 * 86_400_000, end: now - 86_400_000 }),
});
t.after(() => {
unregister();
release();
});
await nextTick();
const select = bar().querySelector('select.world-timeline-speed');
assert.equal(select.value, 'day');
select.value = 'week';
select.dispatchEvent(new Event('change'));
await nextTick();
bar().querySelector('.world-timeline-play').click();
assert.equal(worldClock.getRate(), 604_800, 'a week a second');
select.value = 'hour';
select.dispatchEvent(new Event('change'));
await nextTick();
assert.equal(worldClock.isPlaying(), true);
assert.equal(worldClock.getRate(), 3_600, 'changed while playing');
select.value = 'real';
select.dispatchEvent(new Event('change'));
await nextTick();
assert.equal(worldClock.getRate(), 1, 'the world as it goes');
});
test('a speed chosen while weather replay steps takes playback over', async (t) => {
const bar = mount(t);
const at = Date.now() - 3_600_000;
supportingLayer(t, at);
const calls = [];
const driver = worldClock.attachDriver({
play() {
calls.push('play');
driver.driven(true, at - 600_000);
},
pause() {
calls.push('pause');
driver.driven(false);
},
});
t.after(() => driver.detach());
worldClock.play();
await nextTick();
assert.equal(worldClock.getState().stepping, true);
const select = bar().querySelector('select.world-timeline-speed');
select.value = 'hour';
select.dispatchEvent(new Event('change'));
await nextTick();
assert.deepEqual(calls, ['play', 'pause']);
assert.equal(worldClock.getState().stepping, false);
assert.equal(worldClock.isPlaying(), true);
assert.equal(worldClock.getRate(), 3_600);
assert.ok(worldClock.now() - (at - 600_000) < 3_600 * 1_000, 'on from where it was');
worldClock.pause();
});
test('paused just before the end, play carries on rather than restarting', async (t) => {
const bar = mount(t);
const now = Date.now();
const end = now + 86_400_000;
const release = worldClock.hold('test-ahead');
const unregister = worldClock.register({
id: 'test-ahead',
coverage: () => ({ start: now - 86_400_000, end }),
});
t.after(() => {
unregister();
release();
});
await nextTick();
worldClock.seek(end - 4_000);
await nextTick();
bar().querySelector('.world-timeline-play').click();
worldClock.pause();
assert.ok(worldClock.now() >= end - 4_000, 'not back to the start');
});
+10 -2
View File
@@ -5,6 +5,7 @@ import { formatDate, t } from '../../../i18n/index.js';
import { useWorldTimeStore } from '../../stores/worldTime.js';
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
@@ -13,7 +14,13 @@ import UiIcon from '../../kit/UiIcon.vue';
// or one a host follows) is on with time to show; with only live feeds such
// as flights on, the bar stays hidden.
const store = useWorldTimeStore();
const { time } = storeToRefs(store);
const { time, speed } = storeToRefs(store);
const speeds = computed(() => [
{ value: 'real', label: '1×' },
{ value: 'hour', label: t('1 h/s') },
{ value: 'day', label: t('1 d/s') },
{ value: 'week', label: t('1 wk/s') },
]);
const DATE_TIME = Object.freeze({
dateStyle: 'medium',
@@ -72,7 +79,8 @@ const onKey = (event) => {
<div v-if="time.supported && time.span" id="world-timeline" role="group" :aria-label="t('World time')"
><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
><time class="world-timeline-time" :datetime="new Date(time.timeMs).toISOString()" aria-live="off">{{ readout }}</time
><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"
><span v-for="(left, index) in ticks" :key="index" class="world-timeline-tick" :style="{ left }" aria-hidden="true"></span