From d04e30450272aa9545ab8031408ccc25bbf7b0c5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 07:41:50 -0800 Subject: [PATCH] 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. --- src/i18n/locales/ar.json | 6 +- src/i18n/locales/de.json | 6 +- src/i18n/locales/es.json | 6 +- src/i18n/locales/fr.json | 6 +- src/i18n/locales/it.json | 6 +- src/i18n/locales/ja.json | 6 +- src/i18n/locales/pt.json | 6 +- src/i18n/locales/ru.json | 6 +- src/i18n/locales/uk.json | 6 +- src/i18n/locales/zh-CN.json | 6 +- src/i18n/locales/zh-Hant.json | 6 +- src/time/worldClock.js | 2 + src/ui/stores/worldTime.js | 45 +++++++---- src/ui/styles/world-timeline.css | 12 ++- src/ui/views/panels/WorldTimeline.test.mjs | 89 +++++++++++++++++++++- src/ui/views/panels/WorldTimeline.vue | 12 ++- 16 files changed, 194 insertions(+), 32 deletions(-) diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 69ae84544..b25dc7319 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -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": "سرعة التشغيل" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index da1bebb4a..d4d0e6949 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -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" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f592a72c5..d27be0acd 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 4d7289ff7..8b225e347 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 065137689..29f832bcc 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -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" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3ff743076..efbf7d3a2 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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": "再生速度" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 455515ab2..7606a3069 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -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" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 90099d66c..b79bcc88c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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": "Скорость воспроизведения" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 6ca398f07..fda4b5a9b 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -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": "Швидкість відтворення" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 019e97701..fc5101fa8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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": "播放速度" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 3e41803e6..3411c3838 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -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": "播放速度" } diff --git a/src/time/worldClock.js b/src/time/worldClock.js index 68fa976fa..f9c16c5d8 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -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(), }); diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index bec2a6a82..46c620ab6 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -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. */ diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css index e7560a467..0ae69baf4 100644 --- a/src/ui/styles/world-timeline.css +++ b/src/ui/styles/world-timeline.css @@ -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) { diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 1d495d9a2..251ede717 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 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'); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 1c4a8bdc0..0cedda02d 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -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) => {
{{ formatDate(span.start, EDGE_DATE) }}