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) => {
>