mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +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
|
||||
// clearance checks run at each length CSS gives them, not only at 0.
|
||||
const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
|
||||
const LIFTS_PX = [0, 50, 80];
|
||||
const LIFTS_PX = [0, 66, 80];
|
||||
let liftPx = 0;
|
||||
function eachLift(check) {
|
||||
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 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;
|
||||
|
||||
let clocks = 0;
|
||||
|
||||
/**
|
||||
* Transient observed time. Products must honor the signal before installing a
|
||||
@@ -33,6 +37,30 @@ export function createWeatherClock({
|
||||
let generation = 0;
|
||||
let timer = 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;
|
||||
const timesFor = (product) =>
|
||||
[...new Set(product.getTimes())]
|
||||
@@ -130,7 +158,13 @@ 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;
|
||||
applied = selections();
|
||||
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) => {
|
||||
@@ -144,6 +178,7 @@ export function createWeatherClock({
|
||||
});
|
||||
const result = Promise.allSettled(tasks).then(() => {
|
||||
if (destroyed || owner !== generation) return false;
|
||||
stopWaiting();
|
||||
pending = null;
|
||||
if (!canPlay()) stopPlaying();
|
||||
notify();
|
||||
@@ -269,8 +304,10 @@ export function createWeatherClock({
|
||||
},
|
||||
refresh() {
|
||||
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();
|
||||
gated = undefined;
|
||||
gateNext(world.getState());
|
||||
if (!canPlay()) {
|
||||
stopPlaying();
|
||||
cancelTimer();
|
||||
@@ -289,6 +326,8 @@ export function createWeatherClock({
|
||||
stopPlaying();
|
||||
destroyed = true;
|
||||
++generation;
|
||||
stopWaiting();
|
||||
world.gate(holdId, null);
|
||||
cancelTimer();
|
||||
unfollowWorld();
|
||||
// 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.
|
||||
function followWorld(state) {
|
||||
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 nextTarget = state.live
|
||||
? null
|
||||
@@ -319,6 +363,20 @@ export function createWeatherClock({
|
||||
if (moving || modeChanged || selections() !== applied) void transition();
|
||||
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);
|
||||
driver = world.attachDriver({
|
||||
play: () => (canPlay() ? void api.play() : false),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
REGIONAL_INFRARED_MAX_GAP_MS,
|
||||
GLOBAL_INFRARED_MAX_GAP_MS,
|
||||
LIGHTNING_MAX_GAP_MS,
|
||||
PLAY_WAIT_MS,
|
||||
} from './clock.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.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 ticker = null;
|
||||
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 buffering = () => waits.size > 0;
|
||||
const isFollowed = (id) => following.has(id) || (holds.get(id) ?? 0) > 0;
|
||||
const followedIds = () => [
|
||||
...new Set([...following, ...[...holds.keys()].filter(isFollowed)]),
|
||||
@@ -87,8 +92,11 @@ export function createWorldClock({
|
||||
};
|
||||
const time = () => {
|
||||
if (live) return now();
|
||||
if (continuous()) return cursorMs + (monotonicNow() - anchorMono) * rate;
|
||||
return cursorMs;
|
||||
if (!continuous() || buffering()) 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 = () =>
|
||||
Object.freeze({
|
||||
@@ -98,6 +106,8 @@ export function createWorldClock({
|
||||
rate,
|
||||
// A driver steps the clock, at its own pace rather than the rate.
|
||||
stepping: Boolean(active),
|
||||
// Playing, but held while a layer loads what the cursor needs.
|
||||
buffering: continuous() && buffering(),
|
||||
following: followedIds(),
|
||||
supported: isSupported(),
|
||||
});
|
||||
@@ -198,6 +208,40 @@ export function createWorldClock({
|
||||
togglePlay() {
|
||||
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. */
|
||||
setRate(nextRate) {
|
||||
if (!Number.isFinite(nextRate) || nextRate <= 0)
|
||||
|
||||
@@ -319,3 +319,37 @@ test('continuous play runs past every driver, and live resets the rate', () => {
|
||||
clock.goLive();
|
||||
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,
|
||||
* 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
|
||||
* 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
|
||||
@@ -81,12 +81,13 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
|
||||
live: state.live,
|
||||
playing: state.playing,
|
||||
stepping: state.stepping,
|
||||
buffering: state.buffering,
|
||||
timeMs: state.timeMs,
|
||||
supported: state.supported,
|
||||
span: spanOf(state),
|
||||
});
|
||||
const time = shallowRef(read());
|
||||
const speed = shallowRef('day');
|
||||
const speed = shallowRef('real');
|
||||
const rate = () => WORLD_TIME_SPEEDS[speed.value];
|
||||
let ticker = null;
|
||||
const syncTicker = () => {
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
--world-timeline-lift: 0px;
|
||||
}
|
||||
|
||||
/* The bar and the time's bubble over its handle. */
|
||||
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. */
|
||||
@@ -22,7 +23,10 @@ body.cockpit-mode #world-timeline {
|
||||
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 {
|
||||
direction: ltr;
|
||||
position: fixed;
|
||||
bottom: 2vh;
|
||||
left: 50%;
|
||||
@@ -81,21 +85,43 @@ body.cockpit-mode #world-timeline {
|
||||
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 {
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#world-timeline .world-timeline-edge {
|
||||
flex: none;
|
||||
color: var(--text-secondary);
|
||||
--inset: 0px;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: calc(var(--inset) + (100% - var(--inset)) * var(--at));
|
||||
padding: 2px 8px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.4);
|
||||
border-radius: 6px;
|
||||
background: var(--glass-bg);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
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
|
||||
@@ -194,7 +220,7 @@ body.cockpit-mode #world-timeline {
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
--world-timeline-lift: 80px;
|
||||
}
|
||||
@@ -206,8 +232,9 @@ body.mobile-shell #world-timeline {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* The bubble rises into the first row, clear of play on its left. */
|
||||
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. */
|
||||
@@ -216,17 +243,12 @@ body.mobile-shell #world-timeline .world-timeline-track {
|
||||
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 {
|
||||
order: 2;
|
||||
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);
|
||||
});
|
||||
|
||||
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 now = Date.now();
|
||||
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, []);
|
||||
const rate = worldClock.getRate();
|
||||
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 });
|
||||
|
||||
// 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();
|
||||
const select = bar().querySelector('select.world-timeline-speed');
|
||||
assert.equal(select.value, 'day');
|
||||
assert.equal(select.value, 'real');
|
||||
select.value = 'week';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
await nextTick();
|
||||
|
||||
@@ -7,9 +7,10 @@ import UiButton from '../../kit/UiButton.vue';
|
||||
import UiIcon from '../../kit/UiIcon.vue';
|
||||
import UiSelect from '../../kit/UiSelect.js';
|
||||
|
||||
// The world timeline: play and pause on the left, the time, a track across
|
||||
// the stretch the layers on can show (each launch or frame a tick) with a
|
||||
// handle to scrub, and the way back to live on the right, lit while live. It
|
||||
// The world timeline: play and pause and the speed on the left, a track
|
||||
// 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. 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.
|
||||
@@ -27,11 +28,18 @@ const DATE_TIME = Object.freeze({
|
||||
timeStyle: 'medium',
|
||||
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',
|
||||
day: 'numeric',
|
||||
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.
|
||||
const KEY_STEP = 0.01;
|
||||
const PAGE_STEP = 0.1;
|
||||
@@ -41,6 +49,11 @@ const readout = computed(() =>
|
||||
? `${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 at = computed(() => {
|
||||
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()"
|
||||
><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)"
|
||||
/><time class="world-timeline-time" :datetime="new Date(time.timeMs).toISOString()" aria-live="off">{{ readout }}</time
|
||||
><span class="world-timeline-edge">{{ formatDate(span.start, EDGE_DATE) }}</span
|
||||
><div class="world-timeline-track"
|
||||
/><div class="world-timeline-track"
|
||||
><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
|
||||
><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
|
||||
><input
|
||||
@@ -97,7 +109,6 @@ const onKey = (event) => {
|
||||
@input="scrub"
|
||||
@keydown="onKey"
|
||||
/></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
|
||||
></div>
|
||||
</template>
|
||||
Reference in new issue
Block a user