The main timeline is the only time control; the Weather panel shows its frame time

The Weather panel kept a second time transport of its own: a slider, step
buttons, Latest and Play, backed by a replay in the weather clock that
stepped the observed frames every two seconds and went round from the last
to the first. The weather clock also attached itself to the world clock as
a driver, so the timeline's Play became that replay whenever weather was on.

The panel now shows the frame time only: the newest frame and its age while
live, the timeline time alone at a past time. The weather clock no longer
has setTarget, step, latest, play, pause or togglePlay, its state no longer
has playing, and it no longer drives the world clock. It still follows the
world time, registers each product's frames as marks the timeline's arrow
keys step to, holds its products, and gates the world's continuous play at
each next frame until it loads. weatherClock.world names the world clock it
follows.

A weather row's play, latest and step params move the world clock as the
timeline does: play toggles continuous play (from the start of the covered
time when live), latest goes live, and step goes to the previous or next
mark. The rail timeline component, which only the panel used, is removed,
along with its unused messages in every locale.
This commit is contained in:
Sameh Khamis committed 2026-11-14 20:25:31 -08:00
1 parent bce737bf32
commit 3cfd414e70
35 files changed
+328 -1033

No files matched your search

+22 -146
View File
@@ -59,12 +59,15 @@ export const PLAY_WAIT_MS = 8_000;
// costs play nothing.
let clocks = 0;
// How many weather clocks follow each world clock.
const followers = new WeakMap();
/**
* Transient observed time. Products must honor the signal before installing a
* frame. Its time is the world clock's: latest while the world is live,
* history at the world's time otherwise. Replay steps the observed frames and
* moves the world clock as it goes, as the world clock's driver.
* history at the world's time otherwise. It never moves the world clock
* itself; the world's own controls (its timeline) move time, and while the
* world plays it stops at each next frame until that frame loads.
*/
export function createWeatherClock({
now = Date.now,
@@ -76,17 +79,10 @@ export function createWeatherClock({
const listeners = new Set();
let mode = 'latest';
let target = null;
let playing = false;
// The world time last applied; while this clock moves the world, the
// transition its move started.
// The world time last applied.
let seen = 'latest';
let moving = false;
let moved = null;
const releases = new Map();
// Attached at the end, once the API exists.
let driver = null;
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}`;
@@ -147,10 +143,9 @@ export function createWeatherClock({
const getState = () => ({
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(),
// Frames come on one after another as the world plays on through them,
// so the next is worth loading ahead.
advancing: worldPlaysOn(),
timeline: getTimeline(),
products: [...registry.values()].map(({ product }) => ({
id: product.id,
@@ -162,60 +157,12 @@ export function createWeatherClock({
const notify = () => {
if (!destroyed) for (const listener of listeners) listener(getState());
};
const cancelTimer = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
};
const canPlay = () => getTimeline().length >= 2;
const stopPlaying = () => {
if (!playing) return;
playing = false;
driver?.driven(false);
};
// Move the world clock; the change comes back through followWorld. Returns
// the transition the move started, if it started one.
const move = (fn) => {
const outer = [moving, moved];
moving = true;
moved = null;
try {
fn();
return moved;
} finally {
[moving, moved] = outer;
}
};
const selections = () =>
[...registry.values()].map(({ product }) => selectedFor(product)).join();
let applied = null;
function schedule() {
cancelTimer();
if (!playing || destroyed || pending) return;
if (!canPlay()) {
stopPlaying();
notify();
return;
}
const owner = generation;
timer = setTimeout(() => {
timer = null;
if (destroyed || !playing || owner !== generation) return;
const timeline = getTimeline();
if (timeline.length < 2) {
api.pause();
return;
}
const next = timeline.find(
(time) => Date.parse(time) > Date.parse(target),
);
void api.setTarget(next ?? timeline[0]);
}, 2000);
}
function transition() {
if (destroyed) return Promise.resolve(false);
cancelTimer();
const owner = ++generation;
if (!canPlay()) stopPlaying();
// A scrub drops what loads and moves on; play goes on as this loads.
applied = selections();
deferred = false;
@@ -235,15 +182,12 @@ export function createWeatherClock({
if (destroyed || owner !== generation) return false;
stopWaiting();
pending = null;
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;
if (moving) moved ??= result;
notify();
return result;
}
@@ -294,81 +238,17 @@ export function createWeatherClock({
},
getTimeline,
selectFor,
setTarget(time) {
if (destroyed) return Promise.resolve(false);
if (!Number.isFinite(Date.parse(time)))
throw new TypeError('Weather target must be a UTC time');
const normalized = new Date(time).toISOString();
if (mode === 'history' && target === normalized)
return pending ?? Promise.resolve(true);
return (
move(() => world.seek(Date.parse(normalized))) ??
pending ??
Promise.resolve(true)
);
},
step(direction) {
if (destroyed || ![-1, 1].includes(direction))
return Promise.resolve(false);
api.pause();
const timeline = getTimeline();
if (!timeline.length) return Promise.resolve(false);
const at = Date.parse(target ?? timeline.at(-1));
const next =
direction < 0
? (timeline.findLast((time) => Date.parse(time) < at) ?? timeline[0])
: (timeline.find((time) => Date.parse(time) > at) ?? timeline.at(-1));
return api.setTarget(next);
},
latest() {
if (destroyed) return Promise.resolve(false);
stopPlaying();
if (mode === 'latest') return transition();
return move(() => world.goLive()) ?? pending ?? Promise.resolve(true);
},
play() {
if (destroyed || playing || !canPlay())
return pending ?? Promise.resolve(false);
playing = true;
if (mode === 'latest') {
const timeline = getTimeline();
const start =
timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0];
// Playing and at the first frame in one move of the world.
return (
move(() => driver.driven(true, Date.parse(start))) ??
pending ??
Promise.resolve(true)
);
}
driver.driven(true);
notify();
schedule();
return pending ?? Promise.resolve(true);
},
pause() {
cancelTimer();
if (!playing) {
// The world may be playing on its own; a pause or step here holds it.
if (world.isPlaying()) world.pause();
return;
}
stopPlaying();
notify();
},
togglePlay() {
return playing ? api.pause() : api.play();
},
/**
* The world clock this clock follows. Time moves there, through the same
* seek, play and live the world's timeline uses, never through this clock.
*/
world,
refresh() {
if (destroyed) return Promise.resolve(false);
// New frames, or products added or dropped, and with them the next.
world.coverageChanged();
gated = undefined;
gateNext(world.getState());
if (!canPlay()) {
stopPlaying();
cancelTimer();
}
if (mode === 'history') return transition();
notify();
return Promise.resolve(true);
@@ -380,15 +260,15 @@ export function createWeatherClock({
},
destroy() {
if (destroyed) return;
stopPlaying();
destroyed = true;
++generation;
stopWaiting();
world.gate(holdId, null);
cancelTimer();
unfollowWorld();
// The last weather clock leaves the world live, as a new one expects.
if (driver.detach() === 0) world.goLive();
const left = followers.get(world) - 1;
followers.set(world, left);
if (left === 0) world.goLive();
for (const release of releases.values()) release();
releases.clear();
for (const entry of registry.values()) entry.controller?.abort();
@@ -397,9 +277,8 @@ export function createWeatherClock({
pending = null;
},
};
// Follow the world clock. A move of this clock's own always applies; one
// from elsewhere (a host's seek, continuous play) applies only when it
// changes the mode or a product's frame.
// Follow the world clock. A move (a seek, play going on) applies only when
// it changes the mode or a product's frame.
function followWorld(state) {
if (destroyed) return;
gateNext(state);
@@ -420,7 +299,7 @@ export function createWeatherClock({
const modeChanged = nextMode !== mode;
mode = nextMode;
target = nextTarget;
if (!moving && !modeChanged && selections() === applied) {
if (!modeChanged && selections() === applied) {
// Back at the frame that loads, play need not hold for it.
if (deferred) {
deferred = false;
@@ -433,7 +312,7 @@ export function createWeatherClock({
}
// 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) {
if (!modeChanged && pending && playsOn) {
deferred = true;
startWaiting();
notify();
@@ -456,10 +335,7 @@ export function createWeatherClock({
world.gate(holdId, next);
}
const unfollowWorld = world.subscribe(followWorld);
driver = world.attachDriver({
play: () => (canPlay() ? void api.play() : false),
pause: () => api.pause(),
});
followers.set(world, (followers.get(world) ?? 0) + 1);
followWorld(world.getState());
return api;
}