diff --git a/src/celestialRing.js b/src/celestialRing.js index f470e7d18..525ded44e 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -32,6 +32,9 @@ export const KEYHOLE_LABEL_FEATHER_MAX_RATIO = 0.4; */ export const KEYHOLE_OUTSIDE_OPACITY_DEFAULT = 0.01; +// How far the world time moves before the sun and moon move with it: a +// minute moves the sun a quarter of a degree. +const EPHEMERIS_WORLD_STEP_MS = 60_000; const RING_INSET_PX = 11; const MARKER_INSET_PX = 36; const COLLIDING_MARKER_EXTRA_INSET_PX = 32; @@ -452,7 +455,15 @@ export class CelestialRing { // A jump in the world time moves the sun and moon at once. this._skyDate = new Date(); this._skyTime = new Cesium.JulianDate(); - this._unsubscribeWorldTime = worldClock.subscribe(() => { + // Played through, the sky moves on each minute of world time it passes. + this._ephemerisWorldMs = null; + this._unsubscribeWorldTime = worldClock.subscribe(({ timeMs }) => { + if ( + this._ephemerisWorldMs !== null && + Math.abs(timeMs - this._ephemerisWorldMs) < EPHEMERIS_WORLD_STEP_MS + ) + return; + this._ephemerisWorldMs = timeMs; this._ephemerisDirty = true; }); this.setEnabled(this.enabled); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 3715c31b0..045eb805a 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3231,5 +3231,5 @@ "1 d/s": "يوم/ث", "1 wk/s": "أسبوع/ث", "Play speed": "سرعة التشغيل", - "Scroll or pinch to zoom · double-click to see it all": "مرّر أو اقرص للتكبير · انقر نقرًا مزدوجًا لرؤية الكل" + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index ec76e1298..70aa6ab52 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2951,5 +2951,5 @@ "1 d/s": "1 Tag/s", "1 wk/s": "1 Wo./s", "Play speed": "Wiedergabegeschwindigkeit", - "Scroll or pinch to zoom · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen · Doppelklick zeigt alles" + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 81f0e6403..668727f02 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3021,5 +3021,5 @@ "1 d/s": "1 d/s", "1 wk/s": "1 sem/s", "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" + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c95e70e93..3c4d7b29b 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3021,5 +3021,5 @@ "1 d/s": "1 j/s", "1 wk/s": "1 sem/s", "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" + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 47145cc25..e351e1ab5 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3021,5 +3021,5 @@ "1 d/s": "1 g/s", "1 wk/s": "1 sett/s", "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" + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scorri o pizzica per lo zoom, trascina per spostarti · doppio clic per vedere tutto" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index fc4ad110b..a7df054cd 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2881,5 +2881,5 @@ "1 d/s": "1日/秒", "1 wk/s": "1週間/秒", "Play speed": "再生速度", - "Scroll or pinch to zoom · double-click to see it all": "スクロールまたはピンチでズーム · ダブルクリックで全体表示" + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index c01472e42..ba3a5f551 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3021,5 +3021,5 @@ "1 d/s": "1 d/s", "1 wk/s": "1 sem/s", "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" + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 83d18b2d2..8648bc37d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3091,5 +3091,5 @@ "1 d/s": "1 дн/с", "1 wk/s": "1 нед/с", "Play speed": "Скорость воспроизведения", - "Scroll or pinch to zoom · double-click to see it all": "Прокрутите или сведите пальцы для масштаба · двойной щелчок — показать всё" + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index ab598a937..0a3872414 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3091,5 +3091,5 @@ "1 d/s": "1 дн/с", "1 wk/s": "1 тиж/с", "Play speed": "Швидкість відтворення", - "Scroll or pinch to zoom · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу · подвійне клацання — показати все" + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index cb34c135d..394df0057 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2881,5 +2881,5 @@ "1 d/s": "1 天/秒", "1 wk/s": "1 周/秒", "Play speed": "播放速度", - "Scroll or pinch to zoom · double-click to see it all": "滚动或双指缩放 · 双击查看全部" + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 7d82fbcd9..21018551c 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2881,5 +2881,5 @@ "1 d/s": "1 天/秒", "1 wk/s": "1 週/秒", "Play speed": "播放速度", - "Scroll or pinch to zoom · double-click to see it all": "捲動或雙指縮放 · 按兩下查看全部" + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部" } diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js index 21a081b48..7384935b0 100644 --- a/src/layers/weather/clock.js +++ b/src/layers/weather/clock.js @@ -6,6 +6,11 @@ export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000; export const LIGHTNING_MAX_GAP_MS = 30 * 60_000; /** The longest play waits on a frame before it goes on without it. */ export const PLAY_WAIT_MS = 8_000; +// Playing on its own, the world does not wait on the frame it is at: that +// frame loads as play goes on. Play holds only at the next frame while the +// one before it still loads, then goes on to it once it shows, so every frame +// shows, play stops only for a load that is slow, and a frame loaded ahead +// costs play nothing. let clocks = 0; @@ -39,10 +44,12 @@ export function createWeatherClock({ let pending = null; // This clock's name on the world, for its holds and its gate. const holdId = `weather:${++clocks}`; - // While the world plays, the hold on it as frames for its time load, and - // the timer that lets play go on should a frame never come. + // While the world plays, the hold on it at the next frame as the one before + // it loads, and the timer that lets play go on should that frame never + // come; and whether the move to the next frame waits on it. let waiting = null; let waitTimer = null; + let deferred = false; const stopWaiting = () => { const release = waiting; waiting = null; @@ -56,6 +63,8 @@ export function createWeatherClock({ waitTimer = setTimeout(() => { waitTimer = null; stopWaiting(); + // The frame that never came gives way to the one play is at. + if (deferred) void transition(); }, PLAY_WAIT_MS); }; // Playing on its own, the world waits for frames as they load. @@ -93,6 +102,9 @@ export function createWeatherClock({ mode, target, playing, + // Frames come on one after another: this clock replays them, or the world + // plays on through them, so the next is worth loading ahead. + advancing: playing || worldPlaysOn(), timeline: getTimeline(), products: [...registry.values()].map(({ product }) => ({ id: product.id, @@ -158,13 +170,10 @@ export function createWeatherClock({ cancelTimer(); const owner = ++generation; if (!canPlay()) stopPlaying(); - // Playing, the world waits for new frames; a later move takes over the - // wait, and a scrub (not playing) drops what loads and moves on. A refresh - // that shows the same frames does not hold play. - const framesChange = selections() !== applied; + // A scrub drops what loads and moves on; play goes on as this loads. applied = selections(); + deferred = false; stopWaiting(); - if (framesChange && worldPlaysOn()) startWaiting(); for (const entry of registry.values()) entry.controller?.abort(); // Start all products together; a rejection must not skip the settle barrier. const tasks = [...registry.values()].map((entry) => { @@ -183,6 +192,8 @@ export function createWeatherClock({ if (!canPlay()) stopPlaying(); notify(); schedule(); + // Play held at the next frame for this one goes on to it. + if (deferred) void transition(); return true; }); pending = result; @@ -346,22 +357,43 @@ export function createWeatherClock({ function followWorld(state) { if (destroyed) return; gateNext(state); - // Play pressed while a frame still loads waits for it too. + // Play starting or stopping changes whether the next frame loads ahead. const playsOn = worldPlaysOn(state); - if (playsOn && !playedOn && pending) startWaiting(); + const playChanged = playsOn !== playedOn; playedOn = playsOn; const nextMode = state.live ? 'latest' : 'history'; const nextTarget = state.live ? null : new Date(Math.floor(state.timeMs)).toISOString(); const key = nextTarget ?? 'latest'; - if (key === seen) return; + if (key === seen) { + if (playChanged) notify(); + return; + } seen = key; const modeChanged = nextMode !== mode; mode = nextMode; target = nextTarget; - if (moving || modeChanged || selections() !== applied) void transition(); - else notify(); + if (!moving && !modeChanged && selections() === applied) { + // Back at the frame that loads, play need not hold for it. + if (deferred) { + deferred = false; + stopWaiting(); + } + // Played through, the time moves each frame with nothing new to show: + // listeners hear of the frames as they change. + if (!playsOn || playChanged) notify(); + return; + } + // Playing on into the next frame while the one before it loads, the + // world holds here until that one shows. + if (!moving && !modeChanged && pending && playsOn) { + deferred = true; + startWaiting(); + notify(); + return; + } + void transition(); } // Playing on its own, the world stops at each next frame to load it rather // than run past frames between its ticks. diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs index 55b79a87b..dd2535ffc 100644 --- a/src/layers/weather/clock.test.mjs +++ b/src/layers/weather/clock.test.mjs @@ -495,48 +495,6 @@ test('a clock destroyed while it registers a product lets go of it', (t) => { assert.equal(world.getState().supported, false); }); -test('playing, the world waits for each frame; a scrub drops it and moves on', async (t) => { - const { clock, world, tick } = harness(t); - const radar = product('radar', [0, 10, 20]); - const loads = []; - radar.apply = (selected, { signal }) => - new Promise((resolve) => { - loads.push({ selected, signal, done: () => resolve(true) }); - }); - clock.register(radar); - world.seek(Date.parse(time(9))); - await flush(); - loads.at(-1).done(); - await flush(); - world.play({ rate: 60, continuous: true }); - tick(2_000); - // Into the next frame (the page's ticker tells of it): the world holds - // there while it loads. - world.coverageChanged(); - await flush(); - assert.equal(loads.at(-1).selected, time(10)); - assert.equal(world.getState().buffering, true); - const held = world.now(); - tick(5_000); - assert.equal(world.now(), held, 'held while the frame loads'); - loads.at(-1).done(); - await flush(); - assert.equal(world.getState().buffering, false); - tick(1_000); - assert.equal(world.now(), held + 60_000, 'and plays on once it shows'); - - // A scrub aborts what loads and loads where the hand is, with no wait. - world.pause(); - world.seek(Date.parse(time(20))); - await flush(); - const scrubbed = loads.at(-1); - world.seek(Date.parse(time(0))); - await flush(); - assert.equal(scrubbed.signal.aborted, true); - assert.equal(loads.at(-1).selected, time(0)); - assert.equal(world.getState().buffering, false); -}); - // A radar whose frames load only when the test says. function slowRadar(minutes) { const radar = product('radar', minutes); @@ -573,6 +531,61 @@ test('play stops at the next frame, not past it, until it shows', async (t) => { ); }); +test('playing, a frame loads as play goes on, and play holds at the next until it shows', async (t) => { + const { clock, world, tick } = harness(t); + const radar = slowRadar([0, 10, 20, 30]); + clock.register(radar); + world.seek(Date.parse(time(9))); + await flush(); + radar.loads.at(-1).done(); + await flush(); + world.play({ rate: 60, continuous: true }); + // Into the next frame (the page's frames tell of it): it loads as play goes on. + tick(2_000); + world.coverageChanged(); + await flush(); + assert.equal(radar.loads.at(-1).selected, time(10)); + assert.equal( + world.getState().buffering, + false, + 'no hold for the frame play is at', + ); + // At the frame after while that one still loads, play holds there. + tick(10_000); + world.coverageChanged(); + await flush(); + assert.equal(world.now(), Date.parse(time(20))); + assert.equal(world.getState().buffering, true); + assert.equal( + radar.loads.at(-1).selected, + time(10), + 'the frame before still loads', + ); + tick(5_000); + assert.equal(world.now(), Date.parse(time(20)), 'held while it loads'); + radar.loads.at(-1).done(); + await flush(); + assert.equal( + radar.loads.at(-1).selected, + time(20), + 'and on to the next once it shows', + ); + assert.equal(world.getState().buffering, false); + tick(1_000); + assert.equal(world.now(), Date.parse(time(20)) + 60_000); + + // A scrub aborts what loads and loads where the hand is, with no wait. + world.pause(); + world.seek(Date.parse(time(30))); + await flush(); + const scrubbed = radar.loads.at(-1); + world.seek(Date.parse(time(0))); + await flush(); + assert.equal(scrubbed.signal.aborted, true); + assert.equal(radar.loads.at(-1).selected, time(0)); + assert.equal(world.getState().buffering, false); +}); + test('a frame that never comes holds play only so long', async (t) => { const { clock, world, tick } = harness(t); const radar = slowRadar([0, 10, 20]); @@ -585,26 +598,58 @@ test('a frame that never comes holds play only so long', async (t) => { tick(2_000); world.coverageChanged(); await flush(); + tick(10_000); + world.coverageChanged(); + await flush(); assert.equal(world.getState().buffering, true); + const stuck = radar.loads.at(-1); tick(PLAY_WAIT_MS); + await flush(); assert.equal(world.getState().buffering, false, 'play goes on without it'); + assert.equal(stuck.signal.aborted, true); + assert.equal(radar.loads.at(-1).selected, time(20), 'loading where play is'); }); -test('play pressed while a frame loads waits for it; a refresh does not', async (t) => { +test('held at the next frame, a scrub back to the one loading lets play go', async (t) => { + const { clock, world, tick } = harness(t); + const radar = slowRadar([0, 10, 20]); + clock.register(radar); + world.seek(Date.parse(time(9))); + await flush(); + radar.loads.at(-1).done(); + await flush(); + world.play({ rate: 60, continuous: true }); + tick(2_000); + world.coverageChanged(); + await flush(); + tick(10_000); + world.coverageChanged(); + await flush(); + assert.equal(world.getState().buffering, true); + world.pause(); + world.seek(Date.parse(time(11))); + await flush(); + world.play({ rate: 60, continuous: true }); + assert.equal( + world.getState().buffering, + false, + 'no hold for the frame it is at', + ); + const at = world.now(); + tick(1_000); + assert.equal(world.now(), at + 60_000); +}); + +test('a frame in flight as play starts loads as it goes on; a refresh does not hold', async (t) => { const { clock, world } = harness(t); const radar = slowRadar([0, 10, 20]); clock.register(radar); world.seek(Date.parse(time(5))); await flush(); world.play({ continuous: true }); - assert.equal( - world.getState().buffering, - true, - 'waits for the frame in flight', - ); + assert.equal(world.getState().buffering, false); radar.loads.at(-1).done(); await flush(); - assert.equal(world.getState().buffering, false); void clock.refresh(); await flush(); assert.equal(world.getState().buffering, false, 'the same frames, no hold'); @@ -640,3 +685,25 @@ test('weather clocks sharing a world gate it each on their own', async (t) => { 'the other clock’s gate stays', ); }); + +test('played through, listeners hear of frames as they change, and of play stopping', async (t) => { + const { clock, world, tick } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + world.seek(Date.parse(time(1))); + await flush(); + world.play({ rate: 60, continuous: true }); + await flush(); + const heard = []; + clock.subscribe((state) => heard.push(state)); + // Seconds of world time within one frame: nothing new to show. + for (let i = 0; i < 5; i++) { + tick(100); + world.coverageChanged(); + } + await flush(); + assert.equal(heard.length, 0); + world.pause(); + await flush(); + assert.equal(heard.at(-1)?.advancing, false, 'heard play stop'); +}); diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 4ccfdef6e..fb1447827 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -109,7 +109,8 @@ export function createWeatherLayer({ const notify = () => listener?.(); const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time); const unsubscribeClock = clock?.subscribe(() => { - if (!clock.getState().playing || suspended()) rendering?.cancelPrefetch?.(); + if (!clock.getState().advancing || suspended()) + rendering?.cancelPrefetch?.(); notify(); }); const observationDelayed = () => @@ -211,7 +212,7 @@ export function createWeatherLayer({ if ( !enabled || suspended() || - !(clock ? clock.getState().playing : playing) + !(clock ? clock.getState().advancing : playing) ) return; const times = manifest?.times ?? []; diff --git a/src/time/worldClock.js b/src/time/worldClock.js index 7d493bbc7..edfe4c283 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -24,15 +24,21 @@ const MAX_DATE_MS = 8.64e15; export const isWorldTime = (ms) => Number.isFinite(ms) && Math.abs(ms) <= MAX_DATE_MS; -/** How often subscribers hear of a continuously playing cursor. */ +/** + * How often subscribers hear of a continuously playing cursor with no + * animation frames to go by; with them, they hear of it every frame. + */ export const WORLD_CLOCK_TICK_MS = 250; /** * @param {{now?: () => number, monotonicNow?: () => number, * setInterval?: typeof globalThis.setInterval, * clearInterval?: typeof globalThis.clearInterval, + * requestFrame?: typeof globalThis.requestAnimationFrame | null, + * cancelFrame?: typeof globalThis.cancelAnimationFrame | null, * follow?: Iterable}} [options] `follow` lists the layers that - * move with the cursor from the start. + * move with the cursor from the start. Playing, the clock tells its + * listeners of the time each animation frame, or each tick without frames. */ export function createWorldClock({ // Read at each call, so a stand-in for Date.now (a test's, a harness's) @@ -41,6 +47,8 @@ export function createWorldClock({ monotonicNow = () => globalThis.performance.now(), setInterval = globalThis.setInterval, clearInterval = globalThis.clearInterval, + requestFrame = globalThis.requestAnimationFrame?.bind(globalThis) ?? null, + cancelFrame = globalThis.cancelAnimationFrame?.bind(globalThis) ?? null, follow = [], } = {}) { const listeners = new Set(); @@ -128,11 +136,23 @@ export function createWorldClock({ } } }; + // Playing, the time moves on smoothly: each frame, so the timeline and the + // globe move with it and a layer's next frame is met as it comes, or each + // tick where there are no frames. + const frames = Boolean(requestFrame && cancelFrame); + const frame = () => { + ticker = requestFrame(frame); + notify(); + }; const syncTicker = () => { if (continuous() && listeners.size) { - ticker ??= setInterval(notify, WORLD_CLOCK_TICK_MS); + if (ticker === null) + ticker = frames + ? requestFrame(frame) + : setInterval(notify, WORLD_CLOCK_TICK_MS); } else if (ticker !== null) { - clearInterval(ticker); + if (frames) cancelFrame(ticker); + else clearInterval(ticker); ticker = null; } }; diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs index 8b5154854..f81fa85a9 100644 --- a/src/time/worldClock.test.mjs +++ b/src/time/worldClock.test.mjs @@ -353,3 +353,37 @@ test('a seek still moves a waiting clock', () => { assert.equal(clock.now(), t.wall - 60_000); release(); }); + +test('playing, listeners hear of the time every animation frame', () => { + const t = fakeTime(); + const frames = new Map(); + let nextFrame = 1; + const clock = createWorldClock({ + ...t.options, + requestFrame: (fn) => { + frames.set(nextFrame, fn); + return nextFrame++; + }, + cancelFrame: (id) => frames.delete(id), + }); + const heard = []; + clock.subscribe((state) => heard.push(state.timeMs)); + const start = t.wall - 60_000; + clock.seek(start); + clock.play({ rate: 60 }); + assert.equal(t.timers.size, 0, 'no ticks with frames to go by'); + assert.equal(frames.size, 1); + const runFrame = () => { + const [[id, fn]] = frames; + frames.delete(id); + fn(); + }; + heard.length = 0; + for (let i = 0; i < 3; i++) { + t.advance(16); + runFrame(); + } + assert.deepEqual(heard, [start + 960, start + 1_920, start + 2_880]); + clock.pause(); + assert.equal(frames.size, 0); +}); diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css index 2eeea8504..f644f8543 100644 --- a/src/ui/styles/world-timeline.css +++ b/src/ui/styles/world-timeline.css @@ -142,6 +142,7 @@ body.cockpit-mode #world-timeline { /* Two fingers pinch the track, not the page. */ touch-action: none; flex: 1 1 auto; + cursor: pointer; min-width: 80px; height: 24px; } @@ -193,6 +194,17 @@ body.cockpit-mode #world-timeline { pointer-events: none; } +/* Zoomed, the track drags along. */ +#world-timeline .world-timeline-track.is-zoomed { + cursor: grab; +} + +#world-timeline .world-timeline-track.is-zoomed:active { + cursor: grabbing; +} + +/* Only the handle takes the pointer as a slider; the track around it takes + taps, drags and pinches itself. */ #world-timeline .world-timeline-scrub { position: absolute; inset: 0; @@ -200,8 +212,29 @@ body.cockpit-mode #world-timeline { height: 100%; margin: 0; background: transparent; - cursor: pointer; appearance: none; + pointer-events: none; +} + +#world-timeline .world-timeline-scrub::-webkit-slider-thumb { + cursor: ew-resize; + pointer-events: auto; +} + +#world-timeline .world-timeline-scrub::-moz-range-thumb { + cursor: ew-resize; + pointer-events: auto; +} + +/* The time dragged off the track: the handle waits at its edge, unseen. */ +#world-timeline .world-timeline-scrub.is-off-view::-webkit-slider-thumb { + opacity: 0; + pointer-events: none; +} + +#world-timeline .world-timeline-scrub.is-off-view::-moz-range-thumb { + opacity: 0; + pointer-events: none; } #world-timeline .world-timeline-scrub::-webkit-slider-runnable-track { @@ -232,6 +265,20 @@ body.cockpit-mode #world-timeline { background: #031018; } +/* A finger needs a bigger handle. */ +@media (pointer: coarse) { + #world-timeline .world-timeline-scrub::-webkit-slider-thumb { + width: 22px; + height: 22px; + margin-top: 1px; + } + + #world-timeline .world-timeline-scrub::-moz-range-thumb { + width: 20px; + height: 20px; + } +} + #world-timeline .world-timeline-scrub:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 40e7d3ad1..3725d9abc 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -378,10 +378,6 @@ test('zoomed, a scrub near now stays off live, and the view keeps a still handle 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); @@ -403,7 +399,8 @@ test('zoomed, a scrub near now stays off live, and the view keeps a still handle assert.equal(browserZoom.defaultPrevented, false); // A double-click puts back the time its first press moved. - track.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + track.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1 })); + track.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1 })); input.value = String(at - 3_600_000); input.dispatchEvent(new Event('input')); track.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); @@ -440,3 +437,104 @@ test('play held for a layer to load shows a spinner once the hold lasts', async release(); assert.ok(await until(false), 'gone once play goes on'); }); + +test('the track drags along, a tap goes to its time, and the handle waits off it', 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', + coverage: () => ({ start, end: now, marks: [start] }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const track = bar().querySelector('.world-timeline-track'); + const input = bar().querySelector('input[type=range]'); + // A 1000 px track from x = 0. + Object.defineProperty(track, 'clientWidth', { value: 1_000 }); + track.getBoundingClientRect = () => ({ left: 0, width: 1_000, top: 0, height: 24 }); + const press = (type, clientX, pointerId = 1) => + track.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId })); + const view = () => ({ min: Number(input.min), max: Number(input.max) }); + + // All of it shown, a tap goes to the time under it. + press('pointerdown', 500); + press('pointerup', 500); + await nextTick(); + const middle = start + (now - start) / 2; + assert.ok(Math.abs(worldClock.now() - middle) < 60_000, 'went to the middle'); + + // Zoomed to a day around it, dragging right by half the track goes back half a day. + for (let i = 0; i < 6; i++) { + input.dispatchEvent(new KeyboardEvent('keydown', { key: '+' })); + await nextTick(); + } + const before = view(); + const day = before.max - before.min; + press('pointerdown', 600, 2); + press('pointermove', 700, 2); + press('pointermove', 1_100, 2); + press('pointerup', 1_100, 2); + await nextTick(); + assert.ok(Math.abs(view().max - (before.max - day / 2)) < 1_000, 'dragged half a view'); + assert.ok(Math.abs(worldClock.now() - middle) < 60_000, 'a drag leaves the time'); + + // Dragged off the handle, it waits unseen at the edge; a sideways swipe drags too. + track.dispatchEvent(new WheelEvent('wheel', { deltaX: -2_000, deltaY: 0, cancelable: true })); + await nextTick(); + assert.ok(view().max < worldClock.now(), 'the handle is past the view'); + assert.equal(input.classList.contains('is-off-view'), true); + // Moving the time takes the view back to it. + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp' })); + await nextTick(); + assert.equal(input.classList.contains('is-off-view'), false); +}); + +test('a pinch measures from where the fingers are, and a lifted finger ends it', 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', + coverage: () => ({ start, end: now, marks: [start] }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const track = bar().querySelector('.world-timeline-track'); + const input = bar().querySelector('input[type=range]'); + Object.defineProperty(track, 'clientWidth', { value: 1_000 }); + track.getBoundingClientRect = () => ({ left: 0, width: 1_000, top: 0, height: 24 }); + const on = (target, type, clientX, pointerId) => + target.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId })); + const length = () => Number(input.max) - Number(input.min); + for (let i = 0; i < 4; i++) { + input.dispatchEvent(new KeyboardEvent('keydown', { key: '+' })); + await nextTick(); + } + const before = length(); + // A finger on the handle moves before the second comes down. + on(input, 'pointerdown', 300, 1); + on(input, 'pointermove', 500, 1); + on(track, 'pointerdown', 700, 2); + on(input, 'pointermove', 500, 1); + await nextTick(); + assert.ok(Math.abs(length() - before) < 1_000, 'no zoom from a stale finger'); + on(track, 'pointermove', 900, 2); + await nextTick(); + assert.ok(length() < before, 'spread apart, it zooms in'); + // One finger up: the other moving does nothing, and throws nothing. + const zoomedTo = length(); + on(track, 'pointerup', 900, 2); + on(input, 'pointermove', 100, 1); + await nextTick(); + assert.ok(Math.abs(length() - zoomedTo) < 1_000); + on(input, 'pointercancel', 100, 1); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 40baecaae..a58e22139 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -11,7 +11,8 @@ import UiSelect from '../../kit/UiSelect.js'; // across the stretch the layers on can show (each launch or frame a tick) // with a handle to scrub and the time in a bubble over it, and the way back // to live on the right, lit while live. Scrolling or pinching the track -// zooms it around the handle; a double-click shows it all again. It shows +// zooms it around the handle, dragging it moves along it, and a double-click +// shows it all again. It shows // while a layer with a time of its own (weather, satellites, launches, // or one a host follows) is on with time to show; with only live feeds such // as flights on, the bar stays hidden. @@ -107,46 +108,76 @@ const view = computed(() => { }); const zoomed = computed(() => zoom.value !== null); const viewLength = () => view.value.end - view.value.start; -// Narrow or widen the view to `length`, keeping the handle where it is on it. -function zoomTo(length) { +// Show `length` of the span ending at `viewEnd`, held to the span. +function showView(length, viewEnd) { const { start, end } = span.value; - const next = Math.max(MIN_VIEW_MS, length); - if (next >= end - start) { - zoom.value = null; + if (length >= end - start) { + showWhole(); return; } + const clamped = Math.min(end, Math.max(start + length, viewEnd)); + zoom.value = { length, end: clamped >= end ? null : clamped }; +} +// Narrow or widen the view to `length`, keeping the handle where it is on it, +// or with the handle off it, the middle. +function zoomTo(length) { const { start: from, end: to } = view.value; - const share = Math.min(1, Math.max(0, (at.value - from) / (to - from))); - const viewStart = Math.max(start, at.value - share * next); - const viewEnd = viewStart + next; - zoom.value = { length: next, end: viewEnd >= end ? null : viewEnd }; + const pivot = offView(at.value) ? (from + to) / 2 : at.value; + const share = (pivot - from) / (to - from); + const next = Math.max(MIN_VIEW_MS, length); + showView(next, pivot + (1 - share) * next); } const zoomBy = (factor) => zoomTo(viewLength() * factor); -// The view keeps the handle on it: playing or stepping off it, or left behind -// as now moves the view on, the view goes to the handle. The span narrowing to -// the zoom or less, or going, lets go of the zoom. -watch([at, view], ([ms, shownView]) => { - if (!zoom.value) return; - if (ms === null || zoom.value.length >= span.value.end - span.value.start) { - zoom.value = null; - return; - } - if (ms >= shownView.start && ms <= shownView.end) return; - const length = zoom.value.length; - const viewEnd = ms + length / 2; - zoom.value = { length, end: viewEnd >= span.value.end ? null : viewEnd }; +// A view dragged off the handle stays where it was put while the handle only +// creeps on with now; playing or a move of the handle takes it back. +let panned = false; +const follow = (ms) => { + panned = false; + showView(zoom.value.length, ms + zoom.value.length / 2); +}; +const offView = (ms) => ms < view.value.start || ms > view.value.end; +const showWhole = () => { + zoom.value = null; + panned = false; +}; +// The span narrowing to the zoom or less, or going, lets go of the zoom. +watch(span, (next) => { + if (zoom.value && (!next || zoom.value.length >= next.end - next.start)) + showWhole(); }); +watch(at, (ms) => { + if (!zoom.value || ms === null || !offView(ms)) return; + if (panned && time.value.live && !time.value.playing) return; + follow(ms); +}); +// Left behind as now moves the view on, the handle takes the view back. +watch(view, () => { + if (zoom.value && !panned && at.value !== null && offView(at.value)) + follow(at.value); +}); +const pan = (ms) => { + panned = true; + showView(zoom.value.length, view.value.end + ms); +}; // A wheel's lines and pages, in pixels. const WHEEL_UNITS = [1, 16, 400]; const onWheel = (event) => { - // A sideways swipe is not a zoom. - if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; + const unit = WHEEL_UNITS[event.deltaMode] ?? 1; + const sideways = Math.abs(event.deltaX) > Math.abs(event.deltaY); + // A sideways swipe, or a wheel with shift, drags; a wheel zooms. + if (sideways || event.shiftKey) { + if (!zoom.value) return; + event.preventDefault(); + const width = event.currentTarget.clientWidth; + const pixels = (sideways ? event.deltaX : event.deltaY) * unit; + if (width > 0) pan((pixels / width) * viewLength()); + return; + } event.preventDefault(); - const pixels = event.deltaY * (WHEEL_UNITS[event.deltaMode] ?? 1); - zoomBy(Math.exp(pixels * WHEEL_ZOOM)); + zoomBy(Math.exp(event.deltaY * unit * WHEEL_ZOOM)); }; -// The input takes a press before it can tell a double-click or a pinch, so -// both put the time back where it was before their first touch. +// A double-click starts with presses that move the time, so it puts back the +// time from before its first; so does a pinch, which may start on the handle. let before = null; let pressedAt = -Infinity; const onPress = (event) => { @@ -161,29 +192,83 @@ const restore = () => { }; const showAll = () => { restore(); - zoom.value = null; + showWhole(); }; -// Two fingers on the track pinch it, and the first finger stops scrubbing. -let pinch = null; -const spread = (touches) => - Math.hypot( - touches[0].clientX - touches[1].clientX, - touches[0].clientY - touches[1].clientY, - ); -const onTouchStart = (event) => { - if (event.touches.length === 1) onPress(event); - if (event.touches.length !== 2) return; - restore(); - pinch = { spread: spread(event.touches), length: viewLength() }; + +// The handle drags the time. The rest of the track takes the pointer: a tap +// goes to the time under it, a drag drags a zoomed track along (or the time, +// with all of it shown), and two fingers pinch it. +const DRAG_PX = 4; +const pointers = new Map(); +let gesture = null; +const timeAt = (clientX, track) => { + const { left, width } = track.getBoundingClientRect(); + const share = width > 0 ? Math.min(1, Math.max(0, (clientX - left) / width)) : 1; + return view.value.start + share * viewLength(); }; -const onTouchMove = (event) => { - if (!pinch || event.touches.length !== 2) return; - event.preventDefault(); - const now = spread(event.touches); - if (now > 0) zoomTo((pinch.length * pinch.spread) / now); +// Whether the first finger has moved the time, for a pinch to put it back. +let movedTime = false; +const onPointerDown = (event) => { + // Only the main button: a right-click is for the menu. + if (event.button > 0) return; + const onHandle = event.target instanceof HTMLInputElement; + pointers.set(event.pointerId, { x: event.clientX, onHandle }); + if (pointers.size === 1) { + onPress(event); + movedTime = onHandle; + // The keys zoom and step the track as soon as it is touched. + if (!onHandle) + event.currentTarget.querySelector('input')?.focus({ preventScroll: true }); + } + if (pointers.size === 2) { + const [a, b] = [...pointers.values()]; + if (movedTime) restore(); + gesture = { + kind: 'pinch', + spread: Math.abs(a.x - b.x), + length: viewLength(), + }; + return; + } + if (onHandle || pointers.size !== 1) return; + // The drag goes on past the track's ends; without the capture, to them. + try { + event.currentTarget.setPointerCapture?.(event.pointerId); + } catch { + // A pointer the page no longer knows of drags no further than the track. + } + gesture = { kind: 'press', x: event.clientX, end: view.value.end }; }; -const onTouchEnd = (event) => { - if (event.touches.length === 0) pinch = null; +const onPointerMove = (event) => { + const pointer = pointers.get(event.pointerId); + if (!pointer) return; + pointer.x = event.clientX; + if (!gesture) return; + const track = event.currentTarget; + if (gesture.kind === 'pinch') { + const [a, b] = [...pointers.values()]; + const spreadNow = Math.abs(a.x - b.x); + if (spreadNow > 0) zoomTo((gesture.length * gesture.spread) / spreadNow); + return; + } + if (gesture.kind === 'press') { + if (Math.abs(event.clientX - gesture.x) < DRAG_PX) return; + gesture.kind = zoom.value ? 'pan' : 'scrub'; + movedTime = gesture.kind === 'scrub'; + } + if (gesture.kind === 'pan') { + if (!track.clientWidth) return; + panned = true; + const moved = ((gesture.x - event.clientX) / track.clientWidth) * viewLength(); + showView(zoom.value.length, gesture.end + moved); + } else store.scrub(timeAt(event.clientX, track), viewLength()); +}; +const onPointerUp = (event) => { + if (!pointers.delete(event.pointerId)) return; + if (gesture?.kind === 'press' && event.type === 'pointerup') + store.scrub(timeAt(event.clientX, event.currentTarget), viewLength()); + // A finger lifted from a pinch ends it; the other one moves nothing. + gesture = null; }; const place = (ms) => { @@ -199,9 +284,10 @@ const ticks = computed(() => { const shown = computed(() => Math.min(view.value.end, Math.max(view.value.start, at.value)), ); +const handleOff = computed(() => at.value !== shown.value); const scrub = (event) => { // A pinch only zooms; the input goes back to the handle. - if (pinch) { + if (gesture?.kind === 'pinch') { event.target.value = shown.value; return; } @@ -217,7 +303,7 @@ const onKey = (event) => { if (event.ctrlKey || event.metaKey || event.altKey) return; event.preventDefault(); if (event.key === '-') zoomBy(ZOOM_STEP); - else if (event.key === '0') zoom.value = null; + else if (event.key === '0') showWhole(); else zoomBy(1 / ZOOM_STEP); return; } @@ -244,13 +330,14 @@ const onKey = (event) => { >