mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Drag the timeline along, and play through it smoothly
The zoomed track now drags: the handle still drags the time, while the track around it drags the view along (or the time, with all of it shown), a tap goes to the time under it, two fingers pinch it, and a sideways swipe or a shift-wheel drags it too, the same with a mouse, a trackpad or a finger. A view dragged off the handle stays put until the time moves. Play moved in steps: the world clock told of its time four times a second, so the handle and the globe jumped, and play stopped at every weather frame while it drew, loaded ahead or not. The clock now tells of its time each animation frame, and play no longer waits on the frame it is at: that frame loads as play goes on, and play holds only at the next frame while the one before it still loads. Weather loads its next frame ahead while the world plays too, tells its listeners of frames as they change rather than every frame, and the sun and moon move on each minute of world time.
This commit is contained in:
1 parent
4fc9722356
commit
8eab6a76a8
20 files changed
+534
-137
No files matched your search
+12
-1
@@ -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);
|
||||
|
||||
@@ -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": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё"
|
||||
}
|
||||
@@ -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": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все"
|
||||
}
|
||||
@@ -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": "滚动或双指缩放,拖动以移动 · 双击查看全部"
|
||||
}
|
||||
@@ -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": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部"
|
||||
}
|
||||
+44
-12
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 ?? [];
|
||||
|
||||
+24
-4
@@ -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<string>}} [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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) => {
|
||||
><UiButton class="world-timeline-play" :label="time.playing ? t('Pause') : t('Play')" @click="store.togglePlay()"
|
||||
><UiIcon :name="time.playing ? 'pause' : 'play_arrow'" :size="20" /></UiButton
|
||||
><UiSelect class="gev-field gev-field--compact world-timeline-speed" :aria-label="t('Play speed')" :title="t('Play speed')" :options="speeds" :model-value="speed" @update:model-value="store.setSpeed($event)"
|
||||
/><div class="world-timeline-track" :class="{ 'is-zoomed': zoomed }" :title="t('Scroll or pinch to zoom · double-click to see it all')" @wheel="onWheel" @mousedown="onPress" @dblclick="showAll" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd" @touchcancel="onTouchEnd"
|
||||
/><div class="world-timeline-track" :class="{ 'is-zoomed': zoomed }" :title="t('Scroll or pinch to zoom, drag to move along · double-click to see it all')" @wheel="onWheel" @dblclick="showAll" @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="onPointerUp" @pointercancel="onPointerUp"
|
||||
><time class="world-timeline-time" :class="{ 'is-waiting': waiting }" :style="{ '--at': place(shown) / 100 }" :datetime="new Date(time.timeMs).toISOString()" aria-live="off"
|
||||
><span v-if="waiting" class="world-timeline-spinner" aria-hidden="true"></span>{{ bubble }}</time
|
||||
><span v-for="(left, index) in ticks" :key="index" class="world-timeline-tick" :style="{ left }" aria-hidden="true"></span
|
||||
><span class="world-timeline-fill" :style="{ width: `${place(shown)}%` }" aria-hidden="true"></span
|
||||
><input
|
||||
class="world-timeline-scrub"
|
||||
:class="{ 'is-off-view': handleOff }"
|
||||
type="range"
|
||||
:aria-label="t('World time')"
|
||||
:aria-valuetext="readout"
|
||||
|
||||
Reference in new issue
Block a user