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
@@ -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