mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Wait for each frame while the world timeline plays, and quiet the bar
Play no longer runs ahead of the data. The world clock gains a wait, which holds continuous play where it is while a layer loads what the time needs, and a gate, which keeps play from passing a time a layer has yet to load. Weather gates play at its next frame and waits there until the frame shows, so every frame is seen however fast play runs; it waits no more than eight seconds on a frame that never comes, and takes a wait only when its frames change. Play pressed while a frame loads waits for it too. A scrub still drops what loads and moves on. The bar loses its long date and the dates at its ends: the time rides in a bubble over the handle, which pulses while play waits, and the bar runs left to right in every language. Play starts at real time.
This commit is contained in:
1 parent
d04e304502
commit
056b3b58c6
9 files changed
+357
-40
No files matched your search
@@ -241,7 +241,7 @@ const VETTED_VARS = new Set([
|
|||||||
// Custom properties CSS sets to a fixed length besides their 0 default. The
|
// Custom properties CSS sets to a fixed length besides their 0 default. The
|
||||||
// clearance checks run at each length CSS gives them, not only at 0.
|
// clearance checks run at each length CSS gives them, not only at 0.
|
||||||
const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
|
const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
|
||||||
const LIFTS_PX = [0, 50, 80];
|
const LIFTS_PX = [0, 66, 80];
|
||||||
let liftPx = 0;
|
let liftPx = 0;
|
||||||
function eachLift(check) {
|
function eachLift(check) {
|
||||||
for (const lift of LIFTS_PX) {
|
for (const lift of LIFTS_PX) {
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ export const RADAR_MAX_GAP_MS = 30 * 60_000;
|
|||||||
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
|
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
|
||||||
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
|
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
|
||||||
export const LIGHTNING_MAX_GAP_MS = 30 * 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;
|
||||||
|
|
||||||
|
let clocks = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transient observed time. Products must honor the signal before installing a
|
* Transient observed time. Products must honor the signal before installing a
|
||||||
@@ -33,6 +37,30 @@ export function createWeatherClock({
|
|||||||
let generation = 0;
|
let generation = 0;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
let pending = null;
|
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.
|
||||||
|
let waiting = null;
|
||||||
|
let waitTimer = null;
|
||||||
|
const stopWaiting = () => {
|
||||||
|
const release = waiting;
|
||||||
|
waiting = null;
|
||||||
|
if (waitTimer !== null) clearTimeout(waitTimer);
|
||||||
|
waitTimer = null;
|
||||||
|
release?.();
|
||||||
|
};
|
||||||
|
const startWaiting = () => {
|
||||||
|
if (waiting || destroyed || !registry.size) return;
|
||||||
|
waiting = world.wait(holdId);
|
||||||
|
waitTimer = setTimeout(() => {
|
||||||
|
waitTimer = null;
|
||||||
|
stopWaiting();
|
||||||
|
}, PLAY_WAIT_MS);
|
||||||
|
};
|
||||||
|
// Playing on its own, the world waits for frames as they load.
|
||||||
|
const worldPlaysOn = (state = world.getState()) =>
|
||||||
|
state.playing && !state.live && !state.stepping;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
const timesFor = (product) =>
|
const timesFor = (product) =>
|
||||||
[...new Set(product.getTimes())]
|
[...new Set(product.getTimes())]
|
||||||
@@ -130,7 +158,13 @@ export function createWeatherClock({
|
|||||||
cancelTimer();
|
cancelTimer();
|
||||||
const owner = ++generation;
|
const owner = ++generation;
|
||||||
if (!canPlay()) stopPlaying();
|
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;
|
||||||
applied = selections();
|
applied = selections();
|
||||||
|
stopWaiting();
|
||||||
|
if (framesChange && worldPlaysOn()) startWaiting();
|
||||||
for (const entry of registry.values()) entry.controller?.abort();
|
for (const entry of registry.values()) entry.controller?.abort();
|
||||||
// Start all products together; a rejection must not skip the settle barrier.
|
// Start all products together; a rejection must not skip the settle barrier.
|
||||||
const tasks = [...registry.values()].map((entry) => {
|
const tasks = [...registry.values()].map((entry) => {
|
||||||
@@ -144,6 +178,7 @@ export function createWeatherClock({
|
|||||||
});
|
});
|
||||||
const result = Promise.allSettled(tasks).then(() => {
|
const result = Promise.allSettled(tasks).then(() => {
|
||||||
if (destroyed || owner !== generation) return false;
|
if (destroyed || owner !== generation) return false;
|
||||||
|
stopWaiting();
|
||||||
pending = null;
|
pending = null;
|
||||||
if (!canPlay()) stopPlaying();
|
if (!canPlay()) stopPlaying();
|
||||||
notify();
|
notify();
|
||||||
@@ -269,8 +304,10 @@ export function createWeatherClock({
|
|||||||
},
|
},
|
||||||
refresh() {
|
refresh() {
|
||||||
if (destroyed) return Promise.resolve(false);
|
if (destroyed) return Promise.resolve(false);
|
||||||
// New frames, or products added or dropped.
|
// New frames, or products added or dropped, and with them the next.
|
||||||
world.coverageChanged();
|
world.coverageChanged();
|
||||||
|
gated = undefined;
|
||||||
|
gateNext(world.getState());
|
||||||
if (!canPlay()) {
|
if (!canPlay()) {
|
||||||
stopPlaying();
|
stopPlaying();
|
||||||
cancelTimer();
|
cancelTimer();
|
||||||
@@ -289,6 +326,8 @@ export function createWeatherClock({
|
|||||||
stopPlaying();
|
stopPlaying();
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
++generation;
|
++generation;
|
||||||
|
stopWaiting();
|
||||||
|
world.gate(holdId, null);
|
||||||
cancelTimer();
|
cancelTimer();
|
||||||
unfollowWorld();
|
unfollowWorld();
|
||||||
// The last weather clock leaves the world live, as a new one expects.
|
// The last weather clock leaves the world live, as a new one expects.
|
||||||
@@ -306,6 +345,11 @@ export function createWeatherClock({
|
|||||||
// changes the mode or a product's frame.
|
// changes the mode or a product's frame.
|
||||||
function followWorld(state) {
|
function followWorld(state) {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
|
gateNext(state);
|
||||||
|
// Play pressed while a frame still loads waits for it too.
|
||||||
|
const playsOn = worldPlaysOn(state);
|
||||||
|
if (playsOn && !playedOn && pending) startWaiting();
|
||||||
|
playedOn = playsOn;
|
||||||
const nextMode = state.live ? 'latest' : 'history';
|
const nextMode = state.live ? 'latest' : 'history';
|
||||||
const nextTarget = state.live
|
const nextTarget = state.live
|
||||||
? null
|
? null
|
||||||
@@ -319,6 +363,20 @@ export function createWeatherClock({
|
|||||||
if (moving || modeChanged || selections() !== applied) void transition();
|
if (moving || modeChanged || selections() !== applied) void transition();
|
||||||
else notify();
|
else notify();
|
||||||
}
|
}
|
||||||
|
// Playing on its own, the world stops at each next frame to load it rather
|
||||||
|
// than run past frames between its ticks.
|
||||||
|
let gated = null;
|
||||||
|
let playedOn = false;
|
||||||
|
function gateNext(state) {
|
||||||
|
const next = worldPlaysOn(state)
|
||||||
|
? (getTimeline()
|
||||||
|
.map((time) => Date.parse(time))
|
||||||
|
.find((time) => time > Math.floor(state.timeMs)) ?? null)
|
||||||
|
: null;
|
||||||
|
if (next === gated) return;
|
||||||
|
gated = next;
|
||||||
|
world.gate(holdId, next);
|
||||||
|
}
|
||||||
const unfollowWorld = world.subscribe(followWorld);
|
const unfollowWorld = world.subscribe(followWorld);
|
||||||
driver = world.attachDriver({
|
driver = world.attachDriver({
|
||||||
play: () => (canPlay() ? void api.play() : false),
|
play: () => (canPlay() ? void api.play() : false),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
REGIONAL_INFRARED_MAX_GAP_MS,
|
REGIONAL_INFRARED_MAX_GAP_MS,
|
||||||
GLOBAL_INFRARED_MAX_GAP_MS,
|
GLOBAL_INFRARED_MAX_GAP_MS,
|
||||||
LIGHTNING_MAX_GAP_MS,
|
LIGHTNING_MAX_GAP_MS,
|
||||||
|
PLAY_WAIT_MS,
|
||||||
} from './clock.js';
|
} from './clock.js';
|
||||||
import { createWorldClock } from '../../time/worldClock.js';
|
import { createWorldClock } from '../../time/worldClock.js';
|
||||||
|
|
||||||
@@ -493,3 +494,149 @@ test('a clock destroyed while it registers a product lets go of it', (t) => {
|
|||||||
assert.equal(world.coverage(), null);
|
assert.equal(world.coverage(), null);
|
||||||
assert.equal(world.getState().supported, false);
|
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);
|
||||||
|
radar.loads = [];
|
||||||
|
radar.apply = (selected, { signal }) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
radar.loads.push({
|
||||||
|
selected,
|
||||||
|
signal,
|
||||||
|
done() {
|
||||||
|
radar.shown = selected;
|
||||||
|
resolve(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return radar;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('play stops at the next frame, not past it, until it shows', async (t) => {
|
||||||
|
const { clock, world, tick } = harness(t);
|
||||||
|
const radar = slowRadar([0, 10, 20]);
|
||||||
|
clock.register(radar);
|
||||||
|
world.seek(Date.parse(time(1)));
|
||||||
|
await flush();
|
||||||
|
radar.loads.at(-1).done();
|
||||||
|
await flush();
|
||||||
|
world.play({ rate: 3_600, continuous: true });
|
||||||
|
world.coverageChanged();
|
||||||
|
tick(1_000);
|
||||||
|
assert.equal(
|
||||||
|
world.now(),
|
||||||
|
Date.parse(time(10)),
|
||||||
|
'an hour on, but held at the frame',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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]);
|
||||||
|
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();
|
||||||
|
assert.equal(world.getState().buffering, true);
|
||||||
|
tick(PLAY_WAIT_MS);
|
||||||
|
assert.equal(world.getState().buffering, false, 'play goes on without it');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('play pressed while a frame loads waits for it; a refresh does not', 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',
|
||||||
|
);
|
||||||
|
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');
|
||||||
|
radar.loads.at(-1).done();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('weather clocks sharing a world gate it each on their own', async (t) => {
|
||||||
|
let mono = 0;
|
||||||
|
const world = createWorldClock({
|
||||||
|
now: () => Date.parse(time(30)),
|
||||||
|
monotonicNow: () => mono,
|
||||||
|
setInterval: () => 0,
|
||||||
|
clearInterval() {},
|
||||||
|
});
|
||||||
|
const a = createWeatherClock({ world, now: () => Date.parse(time(30)) });
|
||||||
|
const b = createWeatherClock({ world, now: () => Date.parse(time(30)) });
|
||||||
|
t.after(() => {
|
||||||
|
a.destroy();
|
||||||
|
b.destroy();
|
||||||
|
});
|
||||||
|
a.register(product('radar', [0, 10]));
|
||||||
|
b.register(product('clouds', [0, 15]));
|
||||||
|
world.seek(Date.parse(time(1)));
|
||||||
|
await flush();
|
||||||
|
world.play({ rate: 3_600, continuous: true });
|
||||||
|
world.coverageChanged();
|
||||||
|
b.destroy();
|
||||||
|
world.coverageChanged();
|
||||||
|
mono += 1_000;
|
||||||
|
assert.equal(
|
||||||
|
world.now(),
|
||||||
|
Date.parse(time(10)),
|
||||||
|
'the other clock’s gate stays',
|
||||||
|
);
|
||||||
|
});
|
||||||
+46
-2
@@ -60,8 +60,13 @@ export function createWorldClock({
|
|||||||
let active = null;
|
let active = null;
|
||||||
let ticker = null;
|
let ticker = null;
|
||||||
let notifying = 0;
|
let notifying = 0;
|
||||||
|
// Layers loading what the cursor needs; continuous play holds while any is.
|
||||||
|
const waits = new Map();
|
||||||
|
// Times continuous play may not pass yet, by layer: each one's next frame.
|
||||||
|
const gates = new Map();
|
||||||
|
|
||||||
const continuous = () => playing && !active;
|
const continuous = () => playing && !active;
|
||||||
|
const buffering = () => waits.size > 0;
|
||||||
const isFollowed = (id) => following.has(id) || (holds.get(id) ?? 0) > 0;
|
const isFollowed = (id) => following.has(id) || (holds.get(id) ?? 0) > 0;
|
||||||
const followedIds = () => [
|
const followedIds = () => [
|
||||||
...new Set([...following, ...[...holds.keys()].filter(isFollowed)]),
|
...new Set([...following, ...[...holds.keys()].filter(isFollowed)]),
|
||||||
@@ -87,8 +92,11 @@ export function createWorldClock({
|
|||||||
};
|
};
|
||||||
const time = () => {
|
const time = () => {
|
||||||
if (live) return now();
|
if (live) return now();
|
||||||
if (continuous()) return cursorMs + (monotonicNow() - anchorMono) * rate;
|
if (!continuous() || buffering()) return cursorMs;
|
||||||
return cursorMs;
|
let at = cursorMs + (monotonicNow() - anchorMono) * rate;
|
||||||
|
for (const gate of gates.values())
|
||||||
|
if (gate >= cursorMs) at = Math.min(at, gate);
|
||||||
|
return at;
|
||||||
};
|
};
|
||||||
const getState = () =>
|
const getState = () =>
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
@@ -98,6 +106,8 @@ export function createWorldClock({
|
|||||||
rate,
|
rate,
|
||||||
// A driver steps the clock, at its own pace rather than the rate.
|
// A driver steps the clock, at its own pace rather than the rate.
|
||||||
stepping: Boolean(active),
|
stepping: Boolean(active),
|
||||||
|
// Playing, but held while a layer loads what the cursor needs.
|
||||||
|
buffering: continuous() && buffering(),
|
||||||
following: followedIds(),
|
following: followedIds(),
|
||||||
supported: isSupported(),
|
supported: isSupported(),
|
||||||
});
|
});
|
||||||
@@ -198,6 +208,40 @@ export function createWorldClock({
|
|||||||
togglePlay() {
|
togglePlay() {
|
||||||
return playing ? api.pause() : api.play();
|
return playing ? api.pause() : api.play();
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Hold continuous play where it is while layer `id` loads what the cursor
|
||||||
|
* needs, as a video waits on its buffer; play goes on once every hold is
|
||||||
|
* released. A seek still moves the cursor. Returns the release. Neither
|
||||||
|
* tells listeners (play's own ticks do), so a layer can take and release a
|
||||||
|
* hold mid-change without anyone moving the clock under it.
|
||||||
|
*/
|
||||||
|
wait(id) {
|
||||||
|
settle();
|
||||||
|
waits.set(id, (waits.get(id) ?? 0) + 1);
|
||||||
|
let held = true;
|
||||||
|
return () => {
|
||||||
|
if (!held) return;
|
||||||
|
held = false;
|
||||||
|
const count = waits.get(id) - 1;
|
||||||
|
if (count > 0) {
|
||||||
|
waits.set(id, count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
waits.delete(id);
|
||||||
|
// Play goes on from where it held, not from where it would have been.
|
||||||
|
if (!buffering()) anchorMono = monotonicNow();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Keep continuous play from passing `ms` (null lifts it): the time layer
|
||||||
|
* `id` shows next, which it has yet to load. Play stops there, to wait on
|
||||||
|
* the load as it starts, rather than rush past frames between ticks.
|
||||||
|
*/
|
||||||
|
gate(id, ms) {
|
||||||
|
settle();
|
||||||
|
if (ms === null || ms === undefined) gates.delete(id);
|
||||||
|
else if (isWorldTime(ms)) gates.set(id, ms);
|
||||||
|
},
|
||||||
/** Simulated ms per real ms for continuous playback. */
|
/** Simulated ms per real ms for continuous playback. */
|
||||||
setRate(nextRate) {
|
setRate(nextRate) {
|
||||||
if (!Number.isFinite(nextRate) || nextRate <= 0)
|
if (!Number.isFinite(nextRate) || nextRate <= 0)
|
||||||
|
|||||||
@@ -319,3 +319,37 @@ test('continuous play runs past every driver, and live resets the rate', () => {
|
|||||||
clock.goLive();
|
clock.goLive();
|
||||||
assert.equal(clock.getRate(), 1);
|
assert.equal(clock.getRate(), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('continuous play holds while a layer waits, and goes on from there', () => {
|
||||||
|
const t = fakeTime();
|
||||||
|
const clock = createWorldClock(t.options);
|
||||||
|
const start = t.wall - 600_000;
|
||||||
|
clock.seek(start);
|
||||||
|
clock.play({ rate: 60, continuous: true });
|
||||||
|
t.advance(1_000);
|
||||||
|
assert.equal(clock.now(), start + 60_000);
|
||||||
|
const release = clock.wait('radar');
|
||||||
|
const other = clock.wait('clouds');
|
||||||
|
assert.equal(clock.getState().buffering, true);
|
||||||
|
t.advance(5_000);
|
||||||
|
assert.equal(clock.now(), start + 60_000, 'held while loading');
|
||||||
|
release();
|
||||||
|
release();
|
||||||
|
t.advance(1_000);
|
||||||
|
assert.equal(clock.now(), start + 60_000, 'held until every layer is ready');
|
||||||
|
other();
|
||||||
|
assert.equal(clock.getState().buffering, false);
|
||||||
|
t.advance(1_000);
|
||||||
|
assert.equal(clock.now(), start + 120_000, 'on from where it held');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a seek still moves a waiting clock', () => {
|
||||||
|
const t = fakeTime();
|
||||||
|
const clock = createWorldClock(t.options);
|
||||||
|
clock.seek(t.wall - 600_000);
|
||||||
|
clock.play({ continuous: true });
|
||||||
|
const release = clock.wait('radar');
|
||||||
|
clock.seek(t.wall - 60_000);
|
||||||
|
assert.equal(clock.now(), t.wall - 60_000);
|
||||||
|
release();
|
||||||
|
});
|
||||||
@@ -18,7 +18,7 @@ export const WORLD_TIME_SPEEDS = Object.freeze({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The world clock as the timeline shows it: `{live, playing, stepping,
|
* The world clock as the timeline shows it: `{live, playing, stepping,
|
||||||
* timeMs, supported, span}`, where `supported` says a layer that moves with the clock
|
* buffering, timeMs, supported, span}`, where `supported` says a layer that moves with the clock
|
||||||
* has time to show and `span` is the stretch the timeline runs over: the
|
* has time to show and `span` is the stretch the timeline runs over: the
|
||||||
* time those layers cover, reaching to now and to the world time, with their
|
* time those layers cover, reaching to now and to the world time, with their
|
||||||
* marks. It follows the page's world clock, which tells it of every move, and
|
* marks. It follows the page's world clock, which tells it of every move, and
|
||||||
@@ -81,12 +81,13 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
|
|||||||
live: state.live,
|
live: state.live,
|
||||||
playing: state.playing,
|
playing: state.playing,
|
||||||
stepping: state.stepping,
|
stepping: state.stepping,
|
||||||
|
buffering: state.buffering,
|
||||||
timeMs: state.timeMs,
|
timeMs: state.timeMs,
|
||||||
supported: state.supported,
|
supported: state.supported,
|
||||||
span: spanOf(state),
|
span: spanOf(state),
|
||||||
});
|
});
|
||||||
const time = shallowRef(read());
|
const time = shallowRef(read());
|
||||||
const speed = shallowRef('day');
|
const speed = shallowRef('real');
|
||||||
const rate = () => WORLD_TIME_SPEEDS[speed.value];
|
const rate = () => WORLD_TIME_SPEEDS[speed.value];
|
||||||
let ticker = null;
|
let ticker = null;
|
||||||
const syncTicker = () => {
|
const syncTicker = () => {
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
--world-timeline-lift: 0px;
|
--world-timeline-lift: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The bar and the time's bubble over its handle. */
|
||||||
body:has(#world-timeline) {
|
body:has(#world-timeline) {
|
||||||
--world-timeline-lift: 50px;
|
--world-timeline-lift: 66px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Clean view, recording and the cockpit put the dock away; the bar goes too. */
|
/* Clean view, recording and the cockpit put the dock away; the bar goes too. */
|
||||||
@@ -22,7 +23,10 @@ body.cockpit-mode #world-timeline {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Time runs left to right in every language, so the handle, the fill, the
|
||||||
|
ticks and the bubble agree under a right-to-left page too. */
|
||||||
#world-timeline {
|
#world-timeline {
|
||||||
|
direction: ltr;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 2vh;
|
bottom: 2vh;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -81,21 +85,43 @@ body.cockpit-mode #world-timeline {
|
|||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The time rides over the handle. It slides across itself as the handle
|
||||||
|
crosses the track (its start at the handle at the track's start, its end at
|
||||||
|
the end), so however wide its date it stays within the track. */
|
||||||
#world-timeline .world-timeline-time {
|
#world-timeline .world-timeline-time {
|
||||||
overflow: hidden;
|
--inset: 0px;
|
||||||
font-family: var(--font-mono);
|
position: absolute;
|
||||||
font-size: 13px;
|
bottom: calc(100% + 6px);
|
||||||
font-variant-numeric: tabular-nums;
|
left: calc(var(--inset) + (100% - var(--inset)) * var(--at));
|
||||||
text-overflow: ellipsis;
|
padding: 2px 8px;
|
||||||
white-space: nowrap;
|
border: 1px solid rgba(0, 212, 255, 0.4);
|
||||||
}
|
border-radius: 6px;
|
||||||
|
background: var(--glass-bg);
|
||||||
#world-timeline .world-timeline-edge {
|
color: var(--text-primary);
|
||||||
flex: none;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
transform: translateX(calc(var(--at) * -100%));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Playing, held while a layer loads the time's data. */
|
||||||
|
#world-timeline .world-timeline-time.is-buffering {
|
||||||
|
animation: world-timeline-buffering 1s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes world-timeline-buffering {
|
||||||
|
to {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#world-timeline .world-timeline-time.is-buffering {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The track: ticks for each launch or frame, the elapsed fill, and a native
|
/* The track: ticks for each launch or frame, the elapsed fill, and a native
|
||||||
@@ -194,7 +220,7 @@ body.cockpit-mode #world-timeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* On phones the bar sits just above the tab bar, under the voice dock, with
|
/* On phones the bar sits just above the tab bar, under the voice dock, with
|
||||||
the track on a row of its own beneath play, the time and live. */
|
the track and the speed on a row of their own beneath play and live. */
|
||||||
body.mobile-shell:has(#world-timeline) {
|
body.mobile-shell:has(#world-timeline) {
|
||||||
--world-timeline-lift: 80px;
|
--world-timeline-lift: 80px;
|
||||||
}
|
}
|
||||||
@@ -206,8 +232,9 @@ body.mobile-shell #world-timeline {
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The bubble rises into the first row, clear of play on its left. */
|
||||||
body.mobile-shell #world-timeline .world-timeline-time {
|
body.mobile-shell #world-timeline .world-timeline-time {
|
||||||
flex: 1 1 0;
|
--inset: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The speed sits on the track's row, a full-size target beside it. */
|
/* The speed sits on the track's row, a full-size target beside it. */
|
||||||
@@ -216,17 +243,12 @@ body.mobile-shell #world-timeline .world-timeline-track {
|
|||||||
flex: 1 1 calc(100% - 96px);
|
flex: 1 1 calc(100% - 96px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.mobile-shell #world-timeline .world-timeline-live {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
|
||||||
body.mobile-shell #world-timeline .world-timeline-speed {
|
body.mobile-shell #world-timeline .world-timeline-speed {
|
||||||
order: 2;
|
order: 2;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
#world-timeline .world-timeline-edge {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#world-timeline .world-timeline-time {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -149,7 +149,7 @@ test('the track runs over the time the layers show, to now, and scrubs', async (
|
|||||||
assert.equal(worldClock.now(), ahead);
|
assert.equal(worldClock.now(), ahead);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('play runs a day a second, from the start of the track when at live', async (t) => {
|
test('play runs in real time, from the start of the track when at live', async (t) => {
|
||||||
const bar = mount(t);
|
const bar = mount(t);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const start = now - 10 * 86_400_000;
|
const start = now - 10 * 86_400_000;
|
||||||
@@ -176,7 +176,7 @@ test('play runs a day a second, from the start of the track when at live', async
|
|||||||
assert.deepEqual(stepped, []);
|
assert.deepEqual(stepped, []);
|
||||||
const rate = worldClock.getRate();
|
const rate = worldClock.getRate();
|
||||||
assert.ok(worldClock.now() - start < rate * 1_000, 'from the start of the track');
|
assert.ok(worldClock.now() - start < rate * 1_000, 'from the start of the track');
|
||||||
assert.equal(rate, 86_400, 'a day a second');
|
assert.equal(rate, 1, 'real time to start with');
|
||||||
worldClock.play({ continuous: true });
|
worldClock.play({ continuous: true });
|
||||||
|
|
||||||
// Playing on to now goes live.
|
// Playing on to now goes live.
|
||||||
@@ -231,7 +231,7 @@ test('the speed chosen sets how fast play runs, at once while playing', async (t
|
|||||||
});
|
});
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const select = bar().querySelector('select.world-timeline-speed');
|
const select = bar().querySelector('select.world-timeline-speed');
|
||||||
assert.equal(select.value, 'day');
|
assert.equal(select.value, 'real');
|
||||||
select.value = 'week';
|
select.value = 'week';
|
||||||
select.dispatchEvent(new Event('change'));
|
select.dispatchEvent(new Event('change'));
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import UiButton from '../../kit/UiButton.vue';
|
|||||||
import UiIcon from '../../kit/UiIcon.vue';
|
import UiIcon from '../../kit/UiIcon.vue';
|
||||||
import UiSelect from '../../kit/UiSelect.js';
|
import UiSelect from '../../kit/UiSelect.js';
|
||||||
|
|
||||||
// The world timeline: play and pause on the left, the time, a track across
|
// The world timeline: play and pause and the speed on the left, a track
|
||||||
// the stretch the layers on can show (each launch or frame a tick) with a
|
// across the stretch the layers on can show (each launch or frame a tick)
|
||||||
// handle to scrub, and the way back to live on the right, lit while live. It
|
// 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. It
|
||||||
// shows while a layer with a time of its own (weather, satellites, launches,
|
// 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
|
// or one a host follows) is on with time to show; with only live feeds such
|
||||||
// as flights on, the bar stays hidden.
|
// as flights on, the bar stays hidden.
|
||||||
@@ -27,11 +28,18 @@ const DATE_TIME = Object.freeze({
|
|||||||
timeStyle: 'medium',
|
timeStyle: 'medium',
|
||||||
timeZone: 'UTC',
|
timeZone: 'UTC',
|
||||||
});
|
});
|
||||||
const EDGE_DATE = Object.freeze({
|
// The bubble over the handle: the date and the minute, kept short.
|
||||||
|
const BUBBLE_DATE = Object.freeze({
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
timeZone: 'UTC',
|
timeZone: 'UTC',
|
||||||
});
|
});
|
||||||
|
const BUBBLE_TIME = Object.freeze({
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
// The keyboard's fallback step, as a share of the track, with no mark to go to.
|
// The keyboard's fallback step, as a share of the track, with no mark to go to.
|
||||||
const KEY_STEP = 0.01;
|
const KEY_STEP = 0.01;
|
||||||
const PAGE_STEP = 0.1;
|
const PAGE_STEP = 0.1;
|
||||||
@@ -41,6 +49,11 @@ const readout = computed(() =>
|
|||||||
? `${formatDate(time.value.timeMs, DATE_TIME)} UTC`
|
? `${formatDate(time.value.timeMs, DATE_TIME)} UTC`
|
||||||
: '',
|
: '',
|
||||||
);
|
);
|
||||||
|
const bubble = computed(() =>
|
||||||
|
Number.isFinite(time.value.timeMs)
|
||||||
|
? `${formatDate(time.value.timeMs, BUBBLE_DATE)} · ${formatDate(time.value.timeMs, BUBBLE_TIME)} UTC`
|
||||||
|
: '',
|
||||||
|
);
|
||||||
const span = computed(() => time.value.span);
|
const span = computed(() => time.value.span);
|
||||||
const at = computed(() => {
|
const at = computed(() => {
|
||||||
const { start, end } = span.value;
|
const { start, end } = span.value;
|
||||||
@@ -80,9 +93,8 @@ const onKey = (event) => {
|
|||||||
><UiButton class="world-timeline-play" :label="time.playing ? t('Pause') : t('Play')" @click="store.togglePlay()"
|
><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
|
><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)"
|
><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)"
|
||||||
/><time class="world-timeline-time" :datetime="new Date(time.timeMs).toISOString()" aria-live="off">{{ readout }}</time
|
/><div class="world-timeline-track"
|
||||||
><span class="world-timeline-edge">{{ formatDate(span.start, EDGE_DATE) }}</span
|
><time class="world-timeline-time" :class="{ 'is-buffering': time.buffering }" :style="{ '--at': place(at) / 100 }" :datetime="new Date(time.timeMs).toISOString()" aria-live="off">{{ bubble }}</time
|
||||||
><div class="world-timeline-track"
|
|
||||||
><span v-for="(left, index) in ticks" :key="index" class="world-timeline-tick" :style="{ left }" aria-hidden="true"></span
|
><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(at)}%` }" aria-hidden="true"></span
|
><span class="world-timeline-fill" :style="{ width: `${place(at)}%` }" aria-hidden="true"></span
|
||||||
><input
|
><input
|
||||||
@@ -97,7 +109,6 @@ const onKey = (event) => {
|
|||||||
@input="scrub"
|
@input="scrub"
|
||||||
@keydown="onKey"
|
@keydown="onKey"
|
||||||
/></div
|
/></div
|
||||||
><span class="world-timeline-edge">{{ formatDate(span.end, EDGE_DATE) }}</span
|
|
||||||
><UiButton class="world-timeline-live" :label="time.live ? t('Live') : t('Back to live')" :pressed="time.live" pressed-class="is-live" :disabled="time.live" @click="store.goLive()">{{ t('Live') }}</UiButton
|
><UiButton class="world-timeline-live" :label="time.live ? t('Live') : t('Back to live')" :pressed="time.live" pressed-class="is-live" :disabled="time.live" @click="store.goLive()">{{ t('Live') }}</UiButton
|
||||||
></div>
|
></div>
|
||||||
</template>
|
</template>
|
||||||
Reference in new issue
Block a user