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') }}