mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Weather rows play at the timeline's speed; the frame time shows under reduced motion; tidy the timeline's mark label
A weather row's play ran the world clock at whatever rate it had, and
going live set that back to 1, so the speed chosen on the timeline was
lost. The world clock now keeps its rate while paused and live, and the
timeline's speed sets it whenever it is chosen, so any play runs at it.
A row's play starts from the start of the covered time when live or at
its end, as the timeline's does, and does nothing while every product the
row shows is suspended, as its reduced-motion note says.
Live, the Weather panel's frame time is the newest of each product's
frames, suspended or not, so it no longer goes away under reduced motion,
on a hidden page or without an imagery host.
A weather layer made without a weather clock builds its own when first
enabled rather than when made, so a layer never turned on leaves the
world clock alone, and its own clock (createWeatherClock({ shared: false }))
leaves the world's time where it is when destroyed; only the last shared
weather clock sends the world live.
On the timeline, a zoom or a pan clears the hovered tick's label, a label
is never wider than the track and ends in an ellipsis, and a mark's label
is cut to 60 characters (WORLD_CLOCK_MARK_LABEL_LENGTH).
The changelog entry says what a row's step does, names the earthquake
layer's new exports and says it registers with the world clock.
This commit is contained in:
1 parent
558e4a919a
commit
620a337ce8
14 files changed
+351
-71
No files matched your search
@@ -6,8 +6,11 @@
|
||||
mark, and the handle then tells the mark's label with the time to screen
|
||||
readers and shows it over the track while focused. A layer's world clock
|
||||
coverage may give each mark as `{ time, label, id, kind }` as well as a
|
||||
bare time; `worldClock.coverage()` keeps `marks` as the sorted times and
|
||||
adds `markDetails`, the merged marks themselves (samehkhamis, #0000).
|
||||
bare time (a label over 60 characters, `WORLD_CLOCK_MARK_LABEL_LENGTH`, is
|
||||
cut, ending in `…`); `worldClock.coverage()` keeps `marks` as the sorted
|
||||
times and adds `markDetails`, the merged marks themselves. A zoom or a pan clears the
|
||||
hovered label, and a label is never wider than the track (samehkhamis,
|
||||
#0000).
|
||||
- The timeline marks only events: launches, and earthquakes of magnitude 6
|
||||
and up while the Earthquakes layer is on (about one every two days
|
||||
worldwide). It no longer draws a tick for every weather frame, which came
|
||||
@@ -15,18 +18,29 @@
|
||||
still set the time the timeline covers, and Play still waits at each next
|
||||
frame until it loads. With no mark ahead, the arrow keys step along the
|
||||
track. The map still draws every earthquake, and an earthquake layer never
|
||||
widens the timeline beyond its strong quakes' own times. A weather row's
|
||||
`step` param goes to the previous or next weather frame (samehkhamis,
|
||||
#0000).
|
||||
widens the timeline beyond its strong quakes' own times. The Earthquakes
|
||||
layer registers with the world clock (`gods-eye-view/time`) once it is
|
||||
initialised, and has time to show only while on;
|
||||
`gods-eye-view/layers/earthquakes` adds `EARTHQUAKE_MARK_MAGNITUDE`,
|
||||
`earthquakeMagnitudeTitle`, `earthquakeMarkLabel` and `earthquakeCoverage`.
|
||||
A weather row's `step` param goes to the previous or next weather frame
|
||||
(samehkhamis, #0000).
|
||||
- The main timeline is the only time control. The Weather panel no longer
|
||||
has a slider, step buttons, Latest or Play of its own; it shows the frame
|
||||
time instead: the newest frame and its age while live, the timeline time
|
||||
alone at a past time. The timeline's Play runs the world clock at the
|
||||
chosen speed for weather too, stopping at each next frame until it loads,
|
||||
rather than stepping the observed frames every two seconds and going round
|
||||
from the last to the first. A weather row's `play`, `latest` and `step`
|
||||
params move the world clock as the timeline does (`step` goes to the
|
||||
previous or next mark, as its arrow keys do). **Breaking:** the catalog's
|
||||
alone at a past time; while live it shows the newest frame even when the
|
||||
products are suspended (reduced motion, a hidden page, no imagery host).
|
||||
The timeline's Play runs the world clock at the chosen speed for weather
|
||||
too, stopping at each next frame until it loads, rather than stepping the
|
||||
observed frames every two seconds and going round from the last to the
|
||||
first. A weather row's `play` and `latest` params move the world clock as
|
||||
the timeline does: `play` runs at the timeline's chosen speed, from the
|
||||
start when live or at the end, and does nothing while every product the
|
||||
row shows is suspended. The world clock keeps a chosen rate while paused
|
||||
and live (`goLive()` no longer sets it back to 1), so any play runs at it.
|
||||
A weather layer made without a weather clock builds its own when first
|
||||
enabled, and destroying it leaves the world clock's time where it is
|
||||
(`createWeatherClock({ shared: false })`). **Breaking:** the catalog's
|
||||
`weatherClock` (`gods-eye-view/application/layers`) no longer has
|
||||
`setTarget`, `step`, `latest`, `play`, `pause` or `togglePlay`, its state
|
||||
no longer has `playing`, and it no longer attaches itself to the world
|
||||
|
||||
+2
-2
@@ -199,8 +199,8 @@ the unregister; call `worldClock.coverageChanged()` when that time changes.
|
||||
`{ start, end, marks? }` in epoch ms. `marks` are the moments worth going to,
|
||||
drawn as ticks the timeline's arrow keys step to and a tap on or within 6 px of
|
||||
snaps to. Each mark is a number, or `{ time, label?, id?, kind? }`: `label` is
|
||||
short text shown when the pointer is on the tick and told with the time while
|
||||
the handle is on the mark, `id` the layer's own key for the event and `kind`
|
||||
short text (cut to 60 characters, ending in `…`) shown when the pointer is on
|
||||
the tick and told with the time while the handle is on the mark, `id` the layer's own key for the event and `kind`
|
||||
what sort of event it is (`'launch'`, `'earthquake'`). The two forms may be
|
||||
mixed; a mark without a finite time, and a `label`, `id` or `kind` that is not
|
||||
a non-empty string, are ignored. `worldClock.coverage()` returns the union of
|
||||
|
||||
+15
-6
@@ -8545,7 +8545,9 @@ that timeline but are not marks on it: the timeline marks only events (launches,
|
||||
and earthquakes of magnitude 6 and up while that layer is on), so its arrow keys
|
||||
step to those, or along the track where there are none. A mark may carry a
|
||||
label (a launch's mission name, cut to 27 characters in capitals as the mission
|
||||
index shows names; an earthquake's magnitude and place). The pointer within
|
||||
index shows names; an earthquake's magnitude and place) of up to 60 characters;
|
||||
a longer one ends in `…`, and a label is never wider than the track. A zoom or
|
||||
a pan clears a hovered label. The pointer within
|
||||
6 px of a tick shows its label over the track, and a tap there goes to the
|
||||
mark's time exactly; a tap elsewhere goes to the time under it and a drag
|
||||
scrubs. With the handle on a mark, its label follows the time in the handle's
|
||||
@@ -8570,14 +8572,21 @@ every frame shows. Hidden documents, reduced motion and unavailable imagery
|
||||
hosts suspend products. Re-enabling or
|
||||
restoring a host joins the retained history target. The wind row explicitly says
|
||||
“Forecast · does not follow history” while the observed clock is in history mode.
|
||||
The Weather panel's frame time shows the newest frame and its age while live,
|
||||
and the timeline time alone at a past time.
|
||||
The Weather panel's frame time shows the newest frame and its age while live
|
||||
(each product's newest, suspended products included), and the timeline time
|
||||
alone at a past time.
|
||||
The catalog exposes `weatherClock` and destroys it with its lifetime signal. It
|
||||
has no time controls of its own: `weatherClock.world` is the world clock it
|
||||
follows, where time moves. A weather row's `play`, `latest` and `step` params
|
||||
move that world clock as the timeline does. A weather layer built without a shared
|
||||
clock keeps one of its own on the page's world clock for its lifetime; it has
|
||||
no frame replay of its own either way.
|
||||
move that world clock as the timeline does: `play` runs at the speed chosen on
|
||||
the timeline (the world clock keeps its rate while paused and live), from the
|
||||
start of the covered time when live or at its end, and does nothing while
|
||||
every product the row shows is suspended. A weather layer built without a
|
||||
shared clock builds one of its own on the page's world clock when first
|
||||
enabled (`createWeatherClock({ shared: false })`) and keeps it for its
|
||||
lifetime; destroying it leaves the world's time where it is, and only the last
|
||||
shared weather clock destroyed sends the world live. Neither has a frame replay
|
||||
of its own.
|
||||
|
||||
Source, opacity and coverage navigation live in the WEATHER panel's cards, not
|
||||
in the rows; the panel shows the frame time and has no time controls. The
|
||||
|
||||
@@ -68,12 +68,18 @@ const followers = new WeakMap();
|
||||
* 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.
|
||||
*
|
||||
* `shared` (the default) is a clock several layers share, such as the
|
||||
* catalog's: the last shared clock destroyed leaves the world live. A clock
|
||||
* one layer keeps for itself (`shared: false`) leaves the world's time where
|
||||
* it is.
|
||||
*/
|
||||
export function createWeatherClock({
|
||||
now = Date.now,
|
||||
setTimeout = globalThis.setTimeout,
|
||||
clearTimeout = globalThis.clearTimeout,
|
||||
world = createWorldClock({ now }),
|
||||
shared = true,
|
||||
} = {}) {
|
||||
const registry = new Map();
|
||||
const listeners = new Set();
|
||||
@@ -270,10 +276,13 @@ export function createWeatherClock({
|
||||
stopWaiting();
|
||||
world.gate(holdId, null);
|
||||
unfollowWorld();
|
||||
// The last weather clock leaves the world live, as a new one expects.
|
||||
const left = followers.get(world) - 1;
|
||||
followers.set(world, left);
|
||||
if (left === 0) world.goLive();
|
||||
// The last shared weather clock leaves the world live, as a new one
|
||||
// expects.
|
||||
if (shared) {
|
||||
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();
|
||||
@@ -340,7 +349,7 @@ export function createWeatherClock({
|
||||
world.gate(holdId, next);
|
||||
}
|
||||
const unfollowWorld = world.subscribe(followWorld);
|
||||
followers.set(world, (followers.get(world) ?? 0) + 1);
|
||||
if (shared) followers.set(world, (followers.get(world) ?? 0) + 1);
|
||||
followWorld(world.getState());
|
||||
return api;
|
||||
}
|
||||
+52
-24
@@ -56,20 +56,24 @@ const utc = (value) =>
|
||||
: t('Unavailable');
|
||||
|
||||
/**
|
||||
* Move `world`, the world clock, as its timeline does: `play` toggles play
|
||||
* (from the start of the covered time when live) and `latest` goes live.
|
||||
* `step` (-1 or 1) goes to the previous or next of `frames`, the weather
|
||||
* frames; the timeline does not mark them, so this is the row's own way to
|
||||
* step through them.
|
||||
* Move `world`, the world clock, as its timeline does: `play` toggles play at
|
||||
* the world clock's rate (the speed the timeline chose), from the start of the
|
||||
* covered time when live or at its end, unless `canPlay` is false; `latest`
|
||||
* goes live. `step` (-1 or 1) goes to the previous or next of `frames`, the
|
||||
* weather frames; the timeline does not mark them, so this is the row's own
|
||||
* way to step through them.
|
||||
*/
|
||||
function moveWorldTime(world, frames, { play, latest, step }) {
|
||||
function moveWorldTime(world, frames, { play, latest, step }, canPlay) {
|
||||
if (!world) return;
|
||||
if (play === true) {
|
||||
const state = world.getState();
|
||||
if (state.playing) world.pause();
|
||||
else {
|
||||
else if (canPlay) {
|
||||
const covered = world.coverage();
|
||||
if (state.live && covered) world.seek(covered.start);
|
||||
// The covered time reaches to now, as the timeline's track does.
|
||||
const end = covered && Math.max(covered.end, world.wallNow());
|
||||
if (covered && (state.live || state.timeMs >= end))
|
||||
world.seek(Math.min(covered.start, state.timeMs));
|
||||
world.play({ continuous: true });
|
||||
}
|
||||
}
|
||||
@@ -90,11 +94,21 @@ function moveWorldTime(world, frames, { play, latest, step }) {
|
||||
}
|
||||
}
|
||||
|
||||
// What a layer that has not built its own clock yet reads: live, no frames.
|
||||
const IDLE_CLOCK = Object.freeze({
|
||||
mode: 'latest',
|
||||
target: null,
|
||||
advancing: false,
|
||||
timeline: [],
|
||||
products: [],
|
||||
});
|
||||
|
||||
/** A per-application observation layer, using the existing layer lifecycle and
|
||||
* row controls. Its time is the world clock's, so a link made away from live
|
||||
* opens its imagery at that time; one made live opens the latest. Its frames
|
||||
* follow `clock`, a weather clock several layers share; without one the layer
|
||||
* keeps its own on the page's world clock, for its lifetime. The feed may also
|
||||
* builds its own on the page's world clock when first enabled and keeps it
|
||||
* for its lifetime; that clock never sends the world live. The feed may also
|
||||
* name its source with `sourceName(kind)`, `kind` being 'radar', 'lightning'
|
||||
* or 'satellite'; NOAA nowCOAST is named otherwise. */
|
||||
export function createWeatherLayer({
|
||||
@@ -109,10 +123,10 @@ export function createWeatherLayer({
|
||||
} = {}) {
|
||||
if (typeof feed?.getSnapshot !== 'function')
|
||||
throw new TypeError('Weather requires a snapshot source');
|
||||
const ownClock = sharedClock
|
||||
? null
|
||||
: createWeatherClock({ world: worldClock });
|
||||
const clock = sharedClock ?? ownClock;
|
||||
// Without a shared clock the layer builds its own when first enabled, so a
|
||||
// layer never turned on leaves the world clock alone.
|
||||
let ownClock = null;
|
||||
let clock = sharedClock ?? null;
|
||||
// A feed may also supply decoded frames; see weatherFrameSource.
|
||||
const radar = id === 'weather-radar';
|
||||
const lightning = id === 'weather-lightning';
|
||||
@@ -181,23 +195,31 @@ export function createWeatherLayer({
|
||||
return spaced;
|
||||
};
|
||||
const maxGap = () => spacedTimes().gap;
|
||||
const isLatest = () => clock.getState().mode === 'latest';
|
||||
const clockState = () => clock?.getState() ?? IDLE_CLOCK;
|
||||
const isLatest = () => clockState().mode === 'latest';
|
||||
const getHost = () =>
|
||||
imageryHost?.() ?? { collection: viewer?.imageryLayers, kind: 'globe' };
|
||||
const notify = () => listener?.();
|
||||
const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time);
|
||||
const unsubscribeClock = clock.subscribe(() => {
|
||||
const onClock = () => {
|
||||
if (!clock.getState().advancing || suspended())
|
||||
rendering?.cancelPrefetch?.();
|
||||
notify();
|
||||
});
|
||||
};
|
||||
let unsubscribeClock = clock?.subscribe(onClock) ?? null;
|
||||
const ensureClock = () => {
|
||||
if (clock) return;
|
||||
ownClock = createWeatherClock({ world: worldClock, shared: false });
|
||||
clock = ownClock;
|
||||
unsubscribeClock = clock.subscribe(onClock);
|
||||
};
|
||||
const observationDelayed = () =>
|
||||
manifest?.latest &&
|
||||
Date.now() - Date.parse(manifest.latest) > spacedTimes().delay;
|
||||
const suspended = () => hostHidden || documentRef?.hidden || motion?.matches;
|
||||
const onVisibility = () => {
|
||||
if (suspended()) rendering?.cancelPrefetch?.();
|
||||
void clock.refresh();
|
||||
void clock?.refresh();
|
||||
notify();
|
||||
};
|
||||
|
||||
@@ -234,7 +256,7 @@ export function createWeatherLayer({
|
||||
return true;
|
||||
}
|
||||
function registerClock() {
|
||||
if (unregisterClock || !rendering || !enabled) return;
|
||||
if (unregisterClock || !rendering || !enabled || !clock) return;
|
||||
unregisterClock = clock.register({
|
||||
id,
|
||||
get maxGapMs() {
|
||||
@@ -247,7 +269,7 @@ export function createWeatherLayer({
|
||||
});
|
||||
}
|
||||
async function warmNext(time) {
|
||||
if (!enabled || suspended() || !clock.getState().advancing) return;
|
||||
if (!enabled || suspended() || !clockState().advancing) return;
|
||||
// The world plays on past the newest frame, never round to the first.
|
||||
const times = manifest?.times ?? [];
|
||||
const next = times[times.indexOf(time) + 1];
|
||||
@@ -297,7 +319,7 @@ export function createWeatherLayer({
|
||||
if (owner === generation) {
|
||||
loading = Boolean(request && !request.signal.aborted);
|
||||
notify();
|
||||
if (isLatest()) void clock.refresh();
|
||||
if (isLatest()) void clock?.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,6 +367,7 @@ export function createWeatherLayer({
|
||||
},
|
||||
enable() {
|
||||
enabled = true;
|
||||
ensureClock();
|
||||
if (!removeCamera) {
|
||||
removeCamera = viewer.camera.moveEnd?.addEventListener(() => {
|
||||
checkHost();
|
||||
@@ -458,7 +481,7 @@ export function createWeatherLayer({
|
||||
error = null;
|
||||
loading = false;
|
||||
rendering?.clear();
|
||||
void clock.refresh();
|
||||
void clock?.refresh();
|
||||
if (enabled) void layer.update(viewer);
|
||||
}
|
||||
if (infraredChanged && enabled && manifest) {
|
||||
@@ -492,11 +515,14 @@ export function createWeatherLayer({
|
||||
// does, and every layer that follows it moves along. A step goes
|
||||
// through every shown observation's frames, and this row's own even
|
||||
// while it is suspended.
|
||||
// Play does nothing while every observation it would show is suspended
|
||||
// (reduced motion, a hidden page, no imagery host), as the row says.
|
||||
if (enabled)
|
||||
moveWorldTime(
|
||||
clock.world,
|
||||
[...clock.getTimeline(), ...(manifest?.times ?? [])],
|
||||
params,
|
||||
clock.getState().products.some(({ suspended }) => !suspended),
|
||||
);
|
||||
notify();
|
||||
},
|
||||
@@ -508,7 +534,7 @@ export function createWeatherLayer({
|
||||
: { product, opacity };
|
||||
},
|
||||
getRowControls() {
|
||||
const shared = clock.getState();
|
||||
const shared = clockState();
|
||||
const followLatest = isLatest();
|
||||
// Source-described text is data: shown as is, never translated.
|
||||
const display = weatherDisplay(manifest);
|
||||
@@ -830,7 +856,7 @@ export function createWeatherLayer({
|
||||
};
|
||||
},
|
||||
getDiagnostics() {
|
||||
const shared = clock.getState();
|
||||
const shared = clockState();
|
||||
return {
|
||||
...rendering?.getDiagnostics(),
|
||||
playing: shared.advancing,
|
||||
@@ -845,8 +871,10 @@ export function createWeatherLayer({
|
||||
},
|
||||
destroy() {
|
||||
layer.disable();
|
||||
unsubscribeClock();
|
||||
unsubscribeClock?.();
|
||||
unsubscribeClock = null;
|
||||
ownClock?.destroy();
|
||||
if (ownClock) clock = ownClock = null;
|
||||
removeCamera?.();
|
||||
removeCamera = null;
|
||||
motion?.removeEventListener?.('change', onVisibility);
|
||||
|
||||
@@ -490,6 +490,7 @@ afterEach(() => {
|
||||
for (const layer of harnessed) layer.destroy();
|
||||
harnessed.clear();
|
||||
worldClock.goLive();
|
||||
worldClock.setRate(1);
|
||||
});
|
||||
function layerHarness({
|
||||
clock,
|
||||
@@ -497,6 +498,7 @@ function layerHarness({
|
||||
feed,
|
||||
id,
|
||||
eventTarget = target(),
|
||||
enable = true,
|
||||
} = {}) {
|
||||
const stages = [];
|
||||
const motion = target({ matches: reducedMotion });
|
||||
@@ -570,7 +572,7 @@ function layerHarness({
|
||||
createRendering: () => rendering,
|
||||
});
|
||||
layer.init(viewer);
|
||||
layer.enable();
|
||||
if (enable) layer.enable();
|
||||
harnessed.add(layer);
|
||||
return {
|
||||
layer,
|
||||
@@ -621,6 +623,70 @@ test('without a clock the layer keeps one of its own on the world clock and lets
|
||||
assert.ok(h.clears() >= 1);
|
||||
});
|
||||
|
||||
test('a layer without a clock leaves the world clock alone until enabled, and never sends it live', async (t) => {
|
||||
const past = Date.parse(times[0]) - 3_600_000;
|
||||
worldClock.seek(past);
|
||||
const subscribe = t.mock.method(worldClock, 'subscribe');
|
||||
const gate = t.mock.method(worldClock, 'gate');
|
||||
const h = layerHarness({ enable: false });
|
||||
h.layer.getRowControls();
|
||||
h.layer.getDiagnostics();
|
||||
h.layer.setParams({ play: true, step: -1 });
|
||||
assert.equal(subscribe.mock.callCount(), 0, 'off, no clock is built');
|
||||
assert.equal(gate.mock.callCount(), 0);
|
||||
assert.equal(worldClock.isPlaying(), false);
|
||||
h.layer.destroy();
|
||||
harnessed.delete(h.layer);
|
||||
assert.equal(worldClock.now(), past, 'never on, the world is where it was');
|
||||
assert.equal(worldClock.isLive(), false);
|
||||
|
||||
const on = layerHarness({ enable: false });
|
||||
on.layer.enable();
|
||||
assert.equal(subscribe.mock.callCount(), 1, 'built on first enable');
|
||||
const update = on.layer.update();
|
||||
await flush();
|
||||
on.stages.at(-1)?.finish();
|
||||
await update;
|
||||
on.layer.disable();
|
||||
on.layer.enable();
|
||||
assert.equal(subscribe.mock.callCount(), 1, 'built once');
|
||||
on.layer.destroy();
|
||||
harnessed.delete(on.layer);
|
||||
assert.equal(
|
||||
worldClock.isLive(),
|
||||
false,
|
||||
'its own clock does not send it live',
|
||||
);
|
||||
assert.equal(worldClock.now(), past);
|
||||
});
|
||||
|
||||
test('a row plays at the speed the world clock keeps, from the start at the end of the covered time', async () => {
|
||||
const h = layerHarness();
|
||||
const update = h.layer.update();
|
||||
await flush();
|
||||
h.stages[0].finish();
|
||||
await update;
|
||||
// A speed chosen on the timeline, while live.
|
||||
worldClock.setRate(3_600);
|
||||
h.layer.setParams({ play: true });
|
||||
assert.equal(worldClock.isPlaying(), true);
|
||||
assert.equal(worldClock.getRate(), 3_600, 'played at the speed chosen');
|
||||
assert.ok(
|
||||
worldClock.now() - Date.parse(times[0]) < 3_600_000,
|
||||
'live, play starts at the start of the covered time',
|
||||
);
|
||||
h.layer.setParams({ play: true });
|
||||
assert.equal(worldClock.isPlaying(), false);
|
||||
// Paused at the end of the covered time (it reaches to now), play starts
|
||||
// again from the start, as the timeline's does.
|
||||
worldClock.seek(worldClock.wallNow() + 1_000);
|
||||
h.layer.setParams({ play: true });
|
||||
assert.equal(worldClock.isPlaying(), true);
|
||||
assert.ok(worldClock.now() - Date.parse(times[0]) < 3_600_000);
|
||||
assert.equal(worldClock.getRate(), 3_600);
|
||||
worldClock.pause();
|
||||
});
|
||||
|
||||
test('reduced motion offers no play; steps still move the world clock from frame to frame', async () => {
|
||||
const h = layerHarness({ reducedMotion: true });
|
||||
const update = h.layer.update();
|
||||
@@ -631,6 +697,10 @@ test('reduced motion offers no play; steps still move the world clock from frame
|
||||
h.layer.getRowControls().chips.some((chip) => chip.id === 'play'),
|
||||
false,
|
||||
);
|
||||
// Play, which the row does not offer, does nothing while it is suspended.
|
||||
h.layer.setParams({ play: true });
|
||||
assert.equal(worldClock.isPlaying(), false);
|
||||
assert.equal(worldClock.isLive(), true);
|
||||
// A step back from live goes to the newest frame, and the next one back
|
||||
// to the frame before it.
|
||||
h.layer.setParams({ step: -1 });
|
||||
|
||||
+17
-5
@@ -33,6 +33,8 @@
|
||||
*/
|
||||
|
||||
const MARK_TEXT = ['label', 'id', 'kind'];
|
||||
/** The longest label a mark carries, ellipsis and all. */
|
||||
export const WORLD_CLOCK_MARK_LABEL_LENGTH = 60;
|
||||
|
||||
/**
|
||||
* A mark (a number, or `{time, label?, id?, kind?}`) in its one shape, or
|
||||
@@ -51,6 +53,10 @@ function readMark(input) {
|
||||
const value = input[key];
|
||||
if (typeof value === 'string' && value.trim()) mark[key] = value.trim();
|
||||
}
|
||||
if (mark.label?.length > WORLD_CLOCK_MARK_LABEL_LENGTH)
|
||||
mark.label = `${mark.label
|
||||
.slice(0, WORLD_CLOCK_MARK_LABEL_LENGTH - 1)
|
||||
.trimEnd()}…`;
|
||||
return Object.freeze(mark);
|
||||
}
|
||||
|
||||
@@ -235,15 +241,17 @@ export function createWorldClock({
|
||||
jumps += 1;
|
||||
changed();
|
||||
},
|
||||
/** Back to the wall clock; playback stops and its rate goes back to 1. */
|
||||
/**
|
||||
* Back to the wall clock; playback stops. The rate stays as chosen, so
|
||||
* the next play, from wherever it starts, runs at it.
|
||||
*/
|
||||
goLive() {
|
||||
if (live && !playing && rate === 1) return;
|
||||
if (live && !playing) return;
|
||||
const stepping = active;
|
||||
live = true;
|
||||
cursorMs = null;
|
||||
playing = false;
|
||||
active = null;
|
||||
rate = 1;
|
||||
jumps += 1;
|
||||
// Its driven(false) finds nothing left to stop.
|
||||
stepping?.driver.pause();
|
||||
@@ -314,7 +322,10 @@ export function createWorldClock({
|
||||
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. It holds while paused
|
||||
* and live, so any play (`play()` with no rate) runs at the one chosen.
|
||||
*/
|
||||
setRate(nextRate) {
|
||||
if (!Number.isFinite(nextRate) || nextRate <= 0)
|
||||
throw new RangeError('World clock rate must be positive');
|
||||
@@ -419,7 +430,8 @@ export function createWorldClock({
|
||||
* `marks` are the moments in that span worth going to (a launch, a strong
|
||||
* earthquake), drawn as ticks the timeline's arrow keys step to. Each is
|
||||
* an epoch ms number, or `{time, label?, id?, kind?}`: `label` is short
|
||||
* text the timeline shows for it (a name, `M6.4 · a place`), `id` the
|
||||
* text the timeline shows for it (a name, `M6.4 · a place`; one longer
|
||||
* than WORLD_CLOCK_MARK_LABEL_LENGTH is cut to it, ending in `…`), `id` the
|
||||
* layer's own key for the event and `kind` what sort of event it is.
|
||||
* Numbers and objects may be mixed; a mark with no finite time is
|
||||
* ignored, as are a label, id or kind that is not a non-empty string.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWorldClock, WORLD_CLOCK_TICK_MS } from './worldClock.js';
|
||||
import {
|
||||
createWorldClock,
|
||||
WORLD_CLOCK_MARK_LABEL_LENGTH,
|
||||
WORLD_CLOCK_TICK_MS,
|
||||
} from './worldClock.js';
|
||||
|
||||
function fakeTime() {
|
||||
const t = {
|
||||
@@ -357,7 +361,7 @@ test('a coverage that throws counts as none', (t) => {
|
||||
assert.equal(clock.coverage(), null);
|
||||
});
|
||||
|
||||
test('continuous play runs past every driver, and live resets the rate', () => {
|
||||
test('continuous play runs past every driver, and live keeps the rate chosen', () => {
|
||||
const t = fakeTime();
|
||||
const clock = createWorldClock(t.options);
|
||||
const calls = [];
|
||||
@@ -368,7 +372,13 @@ test('continuous play runs past every driver, and live resets the rate', () => {
|
||||
t.advance(1_000);
|
||||
assert.equal(clock.now(), t.wall - 61_000 + 30_000);
|
||||
clock.goLive();
|
||||
assert.equal(clock.getRate(), 1);
|
||||
assert.equal(clock.getRate(), 30);
|
||||
assert.equal(clock.now(), t.wall, 'live is the wall clock, whatever the rate');
|
||||
// Play with no rate given runs at the one chosen.
|
||||
clock.seek(t.wall - 60_000);
|
||||
clock.play({ continuous: true });
|
||||
t.advance(1_000);
|
||||
assert.equal(clock.now(), t.wall - 61_000 + 30_000);
|
||||
});
|
||||
|
||||
test('continuous play holds while a layer waits, and goes on from there', () => {
|
||||
@@ -459,3 +469,23 @@ test('a seek or a return to live counts as a jump; play does not', () => {
|
||||
clock.goLive();
|
||||
assert.equal(clock.getState().jumps, 2, 'already live is no jump');
|
||||
});
|
||||
|
||||
test('a mark label longer than the timeline shows is cut, ending in an ellipsis', () => {
|
||||
const clock = createWorldClock(fakeTime().options);
|
||||
const long = `${'A'.repeat(58)} BEYOND THE CUT`;
|
||||
clock.register({
|
||||
id: 'long',
|
||||
coverage: () => ({
|
||||
start: 1,
|
||||
end: 2,
|
||||
marks: [
|
||||
{ time: 1, label: long },
|
||||
{ time: 2, label: 'B'.repeat(WORLD_CLOCK_MARK_LABEL_LENGTH) },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const [cut, whole] = clock.coverage().markDetails;
|
||||
assert.equal(cut.label, `${'A'.repeat(58)}…`);
|
||||
assert.ok(cut.label.length <= WORLD_CLOCK_MARK_LABEL_LENGTH);
|
||||
assert.equal(whole.label, 'B'.repeat(WORLD_CLOCK_MARK_LABEL_LENGTH));
|
||||
});
|
||||
@@ -96,7 +96,12 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
|
||||
span: spanOf(state),
|
||||
});
|
||||
const time = shallowRef(read());
|
||||
const speed = shallowRef('real');
|
||||
// The speed the world clock keeps, where it is one of those offered.
|
||||
const speed = shallowRef(
|
||||
Object.keys(WORLD_TIME_SPEEDS).find(
|
||||
(key) => WORLD_TIME_SPEEDS[key] === worldClock.getRate(),
|
||||
) ?? 'real',
|
||||
);
|
||||
const rate = () => WORLD_TIME_SPEEDS[speed.value];
|
||||
let ticker = null;
|
||||
const syncTicker = () => {
|
||||
@@ -141,16 +146,16 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
|
||||
},
|
||||
speed,
|
||||
/**
|
||||
* Choose how fast play runs; playing, it changes at once. A driver steps
|
||||
* at its own pace, so a speed chosen while one plays takes playback over
|
||||
* from where it is.
|
||||
* Choose how fast play runs: the world clock keeps it, so any play runs
|
||||
* at it (a layer's own Play too), and playing, it changes at once. A
|
||||
* driver steps at its own pace, so a speed chosen while one plays takes
|
||||
* playback over from where it is.
|
||||
*/
|
||||
setSpeed(next) {
|
||||
if (!Object.hasOwn(WORLD_TIME_SPEEDS, next)) return;
|
||||
speed.value = next;
|
||||
const { playing, stepping } = worldClock.getState();
|
||||
if (!playing) return;
|
||||
if (!stepping) return worldClock.setRate(rate());
|
||||
if (!playing || !stepping) return worldClock.setRate(rate());
|
||||
worldClock.pause();
|
||||
worldClock.play({ rate: rate(), continuous: true });
|
||||
},
|
||||
|
||||
@@ -135,7 +135,7 @@ body.cockpit-mode #world-timeline {
|
||||
}
|
||||
}
|
||||
|
||||
/* The track: ticks for each launch or frame, the elapsed fill, and a native
|
||||
/* The track: ticks for each launch or strong earthquake, the elapsed fill, and a native
|
||||
range input on top for dragging and the keyboard. */
|
||||
#world-timeline .world-timeline-track {
|
||||
position: relative;
|
||||
@@ -196,9 +196,14 @@ body.cockpit-mode #world-timeline {
|
||||
|
||||
/* A tick's label, shown over the time's bubble while the pointer is on the
|
||||
tick (or the handle rests on its mark). It slides across itself as the
|
||||
bubble does, so it stays within the track. */
|
||||
bubble does, so it stays within the track, and is never wider than the
|
||||
track: a long label ends in an ellipsis. */
|
||||
#world-timeline .world-timeline-mark-label {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
bottom: calc(100% + 30px);
|
||||
left: calc(100% * var(--at));
|
||||
z-index: 1;
|
||||
|
||||
@@ -669,3 +669,71 @@ test('a tick shows its label on hover, and a tap by it goes to its mark', async
|
||||
await nextTick();
|
||||
assert.equal(tip(), null);
|
||||
});
|
||||
|
||||
test('the pointer shows the nearer of two ticks within 6 px, and a zoom or a pan clears it', async (t) => {
|
||||
const bar = mount(t);
|
||||
const now = Date.now();
|
||||
const start = now - 10 * 86_400_000;
|
||||
// Two ticks 8 px apart on a 1000 px track, at 200 and 208 px.
|
||||
const first = start + (now - start) * 0.2;
|
||||
const second = start + (now - start) * 0.208;
|
||||
const release = worldClock.hold('test-launches');
|
||||
const unregister = worldClock.register({
|
||||
id: 'test-launches',
|
||||
coverage: () => ({
|
||||
start,
|
||||
end: now,
|
||||
marks: [
|
||||
{ time: first, label: 'FIRST', kind: 'launch' },
|
||||
{ time: second, label: 'SECOND', kind: 'launch' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
t.after(() => {
|
||||
unregister();
|
||||
release();
|
||||
});
|
||||
await nextTick();
|
||||
const track = bar().querySelector('.world-timeline-track');
|
||||
Object.defineProperty(track, 'clientWidth', { value: 1_000 });
|
||||
track.getBoundingClientRect = () => ({ left: 0, width: 1_000, top: 0, height: 24 });
|
||||
const pointer = (type, clientX) =>
|
||||
track.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId: 1 }));
|
||||
const tip = () => bar().querySelector('.world-timeline-mark-label');
|
||||
// Where the first tick sits now, in px along the track.
|
||||
const firstAt = () =>
|
||||
Number.parseFloat(bar().querySelector('.world-timeline-tick').style.left) * 10;
|
||||
// The handle between them, so a zoom keeps both in view.
|
||||
worldClock.seek((first + second) / 2);
|
||||
await nextTick();
|
||||
pointer('pointermove', 203);
|
||||
await nextTick();
|
||||
assert.equal(tip()?.textContent, 'FIRST');
|
||||
pointer('pointermove', 205);
|
||||
await nextTick();
|
||||
assert.equal(tip()?.textContent, 'SECOND', 'the nearer of the two');
|
||||
|
||||
// A wheel zoom moves the ticks from under the pointer: the label goes.
|
||||
track.dispatchEvent(new WheelEvent('wheel', { deltaY: -200, cancelable: true }));
|
||||
await nextTick();
|
||||
assert.equal(track.classList.contains('is-zoomed'), true);
|
||||
assert.equal(tip(), null, 'a zoom clears the label');
|
||||
// So does a pan, by wheel or by drag.
|
||||
pointer('pointermove', firstAt());
|
||||
await nextTick();
|
||||
assert.equal(tip()?.textContent, 'FIRST');
|
||||
track.dispatchEvent(
|
||||
new WheelEvent('wheel', { deltaY: 40, shiftKey: true, cancelable: true }),
|
||||
);
|
||||
await nextTick();
|
||||
assert.equal(tip(), null, 'a pan clears the label');
|
||||
|
||||
// A tap between them goes to the nearer mark.
|
||||
track.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
||||
await nextTick();
|
||||
assert.equal(track.classList.contains('is-zoomed'), false);
|
||||
pointer('pointerdown', 205);
|
||||
pointer('pointerup', 205);
|
||||
await nextTick();
|
||||
assert.equal(worldClock.now(), second, 'a tap goes to the nearer mark');
|
||||
});
|
||||
@@ -112,8 +112,10 @@ const view = computed(() => {
|
||||
});
|
||||
const zoomed = computed(() => zoom.value !== null);
|
||||
const viewLength = () => view.value.end - view.value.start;
|
||||
// Show `length` of the span ending at `viewEnd`, held to the span.
|
||||
// Show `length` of the span ending at `viewEnd`, held to the span. A zoom
|
||||
// or a pan moves the ticks from under the pointer, so its label goes.
|
||||
function showView(length, viewEnd) {
|
||||
hovered.value = null;
|
||||
const { start, end } = span.value;
|
||||
if (length >= end - start) {
|
||||
showWhole();
|
||||
@@ -141,6 +143,7 @@ const follow = (ms) => {
|
||||
};
|
||||
const offView = (ms) => ms < view.value.start || ms > view.value.end;
|
||||
const showWhole = () => {
|
||||
hovered.value = null;
|
||||
zoom.value = null;
|
||||
panned = false;
|
||||
};
|
||||
|
||||
+11
-3
@@ -118,9 +118,17 @@ export function createWeatherPanel({
|
||||
'weather-lightning': t('Lightning density'),
|
||||
};
|
||||
// While live, the newest frame and its age; at a past time, the timeline
|
||||
// time alone. The world timeline moves the time.
|
||||
const shownTime =
|
||||
state.mode === 'latest' ? state.timeline.at(-1) : state.target;
|
||||
// time alone. The world timeline moves the time. Live, each product's
|
||||
// newest frame counts, suspended or not (reduced motion, a hidden page),
|
||||
// as the timeline leaves those out.
|
||||
const newest = () => {
|
||||
const times = (state.products ?? [])
|
||||
.map(({ selected }) => selected)
|
||||
.filter((time) => Number.isFinite(Date.parse(time)))
|
||||
.sort((a, b) => Date.parse(a) - Date.parse(b));
|
||||
return times.at(-1) ?? state.timeline.at(-1);
|
||||
};
|
||||
const shownTime = state.mode === 'latest' ? newest() : state.target;
|
||||
const frameTime =
|
||||
showObserved && shownTime
|
||||
? state.mode === 'history'
|
||||
|
||||
@@ -86,10 +86,29 @@ test('the frame time shows with any observed product; the panel has no time cont
|
||||
view.update([wind, radar]);
|
||||
assert.equal(frameTime(f).hidden, false);
|
||||
assert.equal(frameTime(f).textContent, '01:05 UTC · 12 min ago');
|
||||
f.state({ timeline: [ticks[0]] });
|
||||
f.state({
|
||||
timeline: [ticks[0]],
|
||||
products: [{ id: radar.id, shown: ticks[0], selected: ticks[0] }],
|
||||
});
|
||||
assert.equal(frameTime(f).textContent, '01:00 UTC · 17 min ago');
|
||||
f.state({ timeline: [] });
|
||||
f.state({ timeline: [], products: [] });
|
||||
assert.equal(frameTime(f).hidden, true);
|
||||
// Suspended products (reduced motion, a hidden page) leave the timeline;
|
||||
// live, their newest frame still shows.
|
||||
f.state({
|
||||
timeline: [],
|
||||
products: [
|
||||
{ id: radar.id, shown: ticks[1], selected: ticks[1], suspended: true },
|
||||
{
|
||||
id: 'weather-lightning',
|
||||
shown: null,
|
||||
selected: ticks[0],
|
||||
suspended: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(frameTime(f).hidden, false);
|
||||
assert.equal(frameTime(f).textContent, '01:05 UTC · 12 min ago');
|
||||
f.state({ timeline: ticks, mode: 'history', target: ticks[0] });
|
||||
assert.equal(frameTime(f).textContent, '01:00 UTC');
|
||||
assert.equal(
|
||||
|
||||
Reference in new issue
Block a user