diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index 39fd5b8f2..dafc51df1 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -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) { diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js index 67973bf94..21a081b48 100644 --- a/src/layers/weather/clock.js +++ b/src/layers/weather/clock.js @@ -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), diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs index c803e5d72..55b79a87b 100644 --- a/src/layers/weather/clock.test.mjs +++ b/src/layers/weather/clock.test.mjs @@ -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', + ); +}); diff --git a/src/time/worldClock.js b/src/time/worldClock.js index f9c16c5d8..7d493bbc7 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -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) diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs index 0e7a544a3..8b5154854 100644 --- a/src/time/worldClock.test.mjs +++ b/src/time/worldClock.test.mjs @@ -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(); +}); diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index 46c620ab6..ea73e262f 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -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 = () => { diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css index 0ae69baf4..0fd12fe28 100644 --- a/src/ui/styles/world-timeline.css +++ b/src/ui/styles/world-timeline.css @@ -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; - } -} diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 251ede717..746158ddb 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -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(); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 0cedda02d..e56737fda 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -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) => { >{{ formatDate(span.start, EDGE_DATE) }}
{ @input="scrub" @keydown="onKey" />
{{ formatDate(span.end, EDGE_DATE) }}{{ t('Live') }}