From 287c881c17b88ae84916c007970e06ddd317e123 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 13:53:08 -0800 Subject: [PATCH] Zoom the timeline track around the handle With layers of very different reach on together, a month of launches and a few hours of lightning, the short-range ticks were crushed at the end of the one shared track. Scroll or pinch on the track now zooms it around the handle, plus and minus zoom from the keyboard, and zero or a double-click shows it all again. Everything still plays together; zooming only changes what the track shows, and the view follows the handle as it plays, steps or is left behind by now. A scrub's live edge goes by the stretch shown, so a zoomed track can land minutes from now, and a double-click or pinch puts back the time its first press moved. --- src/i18n/locales/ar.json | 3 +- src/i18n/locales/de.json | 3 +- src/i18n/locales/es.json | 3 +- src/i18n/locales/fr.json | 3 +- src/i18n/locales/it.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/pt.json | 3 +- src/i18n/locales/ru.json | 3 +- src/i18n/locales/uk.json | 3 +- src/i18n/locales/zh-CN.json | 3 +- src/i18n/locales/zh-Hant.json | 3 +- src/ui/stores/worldTime.js | 16 +- src/ui/styles/world-timeline.css | 13 ++ src/ui/views/panels/WorldTimeline.test.mjs | 112 ++++++++++++++ src/ui/views/panels/WorldTimeline.vue | 161 +++++++++++++++++++-- 15 files changed, 303 insertions(+), 32 deletions(-) diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index ce7902009..3715c31b0 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3230,5 +3230,6 @@ "1 h/s": "ساعة/ث", "1 d/s": "يوم/ث", "1 wk/s": "أسبوع/ث", - "Play speed": "سرعة التشغيل" + "Play speed": "سرعة التشغيل", + "Scroll or pinch to zoom · double-click to see it all": "مرّر أو اقرص للتكبير · انقر نقرًا مزدوجًا لرؤية الكل" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index ae988568d..ec76e1298 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2950,5 +2950,6 @@ "1 h/s": "1 Std./s", "1 d/s": "1 Tag/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" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ad207bdb0..81f0e6403 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3020,5 +3020,6 @@ "1 h/s": "1 h/s", "1 d/s": "1 d/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" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3345cf46b..c95e70e93 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3020,5 +3020,6 @@ "1 h/s": "1 h/s", "1 d/s": "1 j/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" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4c1717f5b..47145cc25 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3020,5 +3020,6 @@ "1 h/s": "1 h/s", "1 d/s": "1 g/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" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index fd70f8541..fc4ad110b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2880,5 +2880,6 @@ "1 h/s": "1時間/秒", "1 d/s": "1日/秒", "1 wk/s": "1週間/秒", - "Play speed": "再生速度" + "Play speed": "再生速度", + "Scroll or pinch to zoom · double-click to see it all": "スクロールまたはピンチでズーム · ダブルクリックで全体表示" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 3b9a03e29..c01472e42 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3020,5 +3020,6 @@ "1 h/s": "1 h/s", "1 d/s": "1 d/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" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index c4b0e2046..83d18b2d2 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3090,5 +3090,6 @@ "1 h/s": "1 ч/с", "1 d/s": "1 дн/с", "1 wk/s": "1 нед/с", - "Play speed": "Скорость воспроизведения" + "Play speed": "Скорость воспроизведения", + "Scroll or pinch to zoom · double-click to see it all": "Прокрутите или сведите пальцы для масштаба · двойной щелчок — показать всё" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 1bad7daa6..ab598a937 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3090,5 +3090,6 @@ "1 h/s": "1 год/с", "1 d/s": "1 дн/с", "1 wk/s": "1 тиж/с", - "Play speed": "Швидкість відтворення" + "Play speed": "Швидкість відтворення", + "Scroll or pinch to zoom · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу · подвійне клацання — показати все" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index ec77d015d..cb34c135d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2880,5 +2880,6 @@ "1 h/s": "1 小时/秒", "1 d/s": "1 天/秒", "1 wk/s": "1 周/秒", - "Play speed": "播放速度" + "Play speed": "播放速度", + "Scroll or pinch to zoom · double-click to see it all": "滚动或双指缩放 · 双击查看全部" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 7753a6f95..7d82fbcd9 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2880,5 +2880,6 @@ "1 h/s": "1 小時/秒", "1 d/s": "1 天/秒", "1 wk/s": "1 週/秒", - "Play speed": "播放速度" + "Play speed": "播放速度", + "Scroll or pinch to zoom · double-click to see it all": "捲動或雙指縮放 · 按兩下查看全部" } diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index ea73e262f..35cad2edc 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -48,9 +48,9 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { marks: covered.marks, }); }; - const nearNow = (ms, span) => + const nearNow = (ms, length) => Math.abs(ms - worldClock.wallNow()) <= - Math.max(LIVE_EDGE_MS, span ? (span.end - span.start) * LIVE_EDGE : 0); + Math.max(LIVE_EDGE_MS, length * LIVE_EDGE); // Whether the track ends at now (and moves on with it), not past it. const endsAtNow = (covered) => covered.end - worldClock.wallNow() <= LIVE_EDGE_MS; @@ -146,9 +146,15 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { worldClock.play({ rate: rate(), continuous: true }); }, goLive: () => worldClock.goLive(), - /** Scrub to `ms`: playback stops where the hand puts it; at now, live. */ - scrub(ms) { - if (nearNow(ms, time.value.span)) { + /** + * Scrub to `ms`: playback stops where the hand puts it; at now, live. + * How near now counts as now goes by `length`, the stretch the track + * shows, all of the span unless it is zoomed. + */ + scrub(ms, length) { + const span = time.value.span; + length ??= span ? span.end - span.start : 0; + if (nearNow(ms, length)) { worldClock.goLive(); return; } diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css index 0fd12fe28..f6c379e8e 100644 --- a/src/ui/styles/world-timeline.css +++ b/src/ui/styles/world-timeline.css @@ -128,11 +128,24 @@ body.cockpit-mode #world-timeline { range input on top for dragging and the keyboard. */ #world-timeline .world-timeline-track { position: relative; + /* Two fingers pinch the track, not the page. */ + touch-action: none; flex: 1 1 auto; min-width: 80px; height: 24px; } +/* Zoomed, the track's ends fade, as it goes on past them. */ +#world-timeline .world-timeline-track.is-zoomed::before { + background: linear-gradient( + 90deg, + transparent, + rgba(123, 189, 211, 0.22) 12px, + rgba(123, 189, 211, 0.22) calc(100% - 12px), + transparent + ); +} + #world-timeline .world-timeline-track::before { position: absolute; top: 50%; diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 746158ddb..06301f3a2 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -299,3 +299,115 @@ test('paused just before the end, play carries on rather than restarting', async worldClock.pause(); assert.ok(worldClock.now() >= end - 4_000, 'not back to the start'); }); + +test('the track zooms around the handle, follows it, and shows it all again', 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', + // A month of launches and three hours of lightning, sharing one track. + coverage: () => ({ + start, + end: now, + marks: [start, now - 3 * 3_600_000, now - 2 * 3_600_000, now - 3_600_000], + }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const track = bar().querySelector('.world-timeline-track'); + const input = bar().querySelector('input[type=range]'); + const length = () => Number(input.max) - Number(input.min); + const full = length(); + assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 4); + + // Live, the handle is at the end: zooming in keeps the view on now. + for (let i = 0; i < 10; i++) { + track.dispatchEvent(new WheelEvent('wheel', { deltaY: -200, cancelable: true })); + await nextTick(); + } + assert.ok(length() < full / 100, 'zoomed in'); + assert.ok(Number(input.max) >= now, 'still ending at now'); + assert.equal(track.classList.contains('is-zoomed'), true); + assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 3, 'only the ticks in view'); + + // A seek off the view takes the view along. + worldClock.seek(start + 86_400_000); + await nextTick(); + assert.ok(Number(input.min) <= start + 86_400_000 && Number(input.max) >= start + 86_400_000); + assert.ok(length() < full / 100, 'at the same zoom'); + + // Minus widens, zero and a double-click show it all. + input.dispatchEvent(new KeyboardEvent('keydown', { key: '-' })); + await nextTick(); + input.dispatchEvent(new KeyboardEvent('keydown', { key: '0' })); + await nextTick(); + assert.ok(Math.abs(length() - full) < 1_000, 'all of it'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: '+' })); + await nextTick(); + assert.ok(length() < full); + track.dispatchEvent(new MouseEvent('dblclick')); + await nextTick(); + assert.equal(track.classList.contains('is-zoomed'), false); +}); + +test('zoomed, a scrub near now stays off live, and the view keeps a still handle', async (t) => { + const bar = mount(t); + const now = Date.now(); + const start = now - 26 * 86_400_000; + let end = now; + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + coverage: () => ({ start, end, marks: [start, now - 3_600_000] }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const track = bar().querySelector('.world-timeline-track'); + const input = bar().querySelector('input[type=range]'); + // Ten notches of a line-mode wheel, as Firefox sends them. + for (let i = 0; i < 10; i++) { + track.dispatchEvent(new WheelEvent('wheel', { deltaY: -12, deltaMode: 1, cancelable: true })); + await nextTick(); + } + 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. + input.value = String(now - 20 * 60_000); + input.dispatchEvent(new Event('input')); + await nextTick(); + assert.equal(worldClock.isLive(), false); + assert.ok(Math.abs(worldClock.now() - (now - 20 * 60_000)) < 1_000); + + // The span running on past the paused handle takes the view back to it. + end = now + 86_400_000; + worldClock.coverageChanged(); + await nextTick(); + const at = worldClock.now(); + assert.ok(Number(input.min) <= at && Number(input.max) >= at, 'handle in view'); + + // With a modifier, plus is the browser's zoom. + const browserZoom = new KeyboardEvent('keydown', { key: '+', ctrlKey: true, cancelable: true }); + input.dispatchEvent(browserZoom); + assert.equal(browserZoom.defaultPrevented, false); + + // A double-click puts back the time its first press moved. + track.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + input.value = String(at - 3_600_000); + input.dispatchEvent(new Event('input')); + track.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + await nextTick(); + assert.equal(track.classList.contains('is-zoomed'), false); + assert.ok(Math.abs(worldClock.now() - at) < 1_000, 'time as it was'); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index e56737fda..9588b7e64 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -1,5 +1,5 @@ @@ -93,20 +221,21 @@ const onKey = (event) => { >
{{ t('Live') }}