Timeline marks carry labels; a tap by a tick goes to its mark

A layer's world clock coverage may now give each mark as an object,
{ time, label?, id?, kind? }, as well as a bare epoch ms number, and the
two may be mixed. worldClock.coverage() keeps marks as the sorted list of
distinct times, so existing readers are unchanged, and adds markDetails:
the merged marks in time order (then kind, label and id), each frozen and
holding only the fields given. Marks given alike by several sources appear
once, a bare time is dropped where a labelled mark shares it, and a mark
with no finite time is ignored. The contract is documented in the
worldClock JSDoc and docs/APPLICATION.md.

Launches label their marks with the mission's name (the launch's where it
has none), cut to 27 characters in capitals as the mission index shows
names. Earthquakes of magnitude 6 and up label theirs with the magnitude
as the map shows it and the feed's place cut to 40 characters, such as
"M6.4 · 113 km SSE of Lata, Solomon Islands"; the layer now tells the clock
when the labels change as well as the times.

On the timeline, a pointer within 6 px of a tick shows its label over the
track (nothing for an unlabelled mark), and a tap there goes exactly to
the mark's time instead of the point tapped. A tap elsewhere and a drag
work as before, and ticks stay ticks. The arrow keys still step from mark
to mark; with the handle on a mark, its labels follow the time in the
handle's aria-valuetext, and show over the track while it has focus.
This commit is contained in:
Sameh Khamis committed 2026-11-14 20:30:01 -08:00
1 parent 7cb854c923
commit 558e4a919a
17 files changed
+490 -50

No files matched your search

@@ -1,3 +1,13 @@
- Timeline marks have labels. Hovering a tick shows its label (a launch's
mission name, or an earthquake's magnitude and place, such as
`M6.4 · 113 km SSE of Lata, Solomon Islands`), and a tap on or within 6 px
of a tick goes exactly to its mark rather than to the point tapped; a tap
elsewhere and a drag work as before. The arrow keys still step from mark to
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).
- The timeline marks only events: launches, and earthquakes of magnitude 6 - The timeline marks only events: launches, and earthquakes of magnitude 6
and up while the Earthquakes layer is on (about one every two days 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 worldwide). It no longer draws a tick for every weather frame, which came
+18
View File
@@ -191,6 +191,24 @@ observed-history clock those layers share for the catalog's lifetime. That
clock, the catalog's `weatherClock`, follows the world clock (`gods-eye-view/time`) clock, the catalog's `weatherClock`, follows the world clock (`gods-eye-view/time`)
and has no time controls of its own: move time with the world clock's `seek`, and has no time controls of its own: move time with the world clock's `seek`,
`play`, `pause` and `goLive` (`weatherClock.world` is the one it follows). `play`, `pause` and `goLive` (`weatherClock.world` is the one it follows).
A layer gives the world clock's timeline the time it can show with
`worldClock.register({ id, coverage })` (`gods-eye-view/time`), which returns
the unregister; call `worldClock.coverageChanged()` when that time changes.
`coverage()` returns null while the layer has none, or
`{ 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`
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
the registered layers, `{ start, end, marks, markDetails }`, or null: `marks`
is every mark's time once, in order, as numbers, and `markDetails` the marks in
time order, each a frozen `{ time, label?, id?, kind? }` holding only the
fields given, with marks given alike by several layers once and a bare time
left out where a mark with more to say shares it.
A `weather` source may also supply decoded frames: an optional A `weather` source may also supply decoded frames: an optional
`getFrameImage({ product, time, size, bbox, signal })` returns a whole-extent or `getFrameImage({ product, time, size, bbox, signal })` returns a whole-extent or
detail image and an optional `getFrameTile({ product, time, z, x, y, size, detail image and an optional `getFrameTile({ product, time, z, x, y, size,
+12 -2
View File
@@ -1785,7 +1785,10 @@ destroying the layer cancels pending work and ignores late results. While on,
the layer registers its quakes of magnitude 6 and up the layer registers its quakes of magnitude 6 and up
(`EARTHQUAKE_MARK_MAGNITUDE`) with the world clock as timeline marks, spanning (`EARTHQUAKE_MARK_MAGNITUDE`) with the world clock as timeline marks, spanning
only their own times, and none while off; the map still draws every M2.5+ only their own times, and none while off; the map still draws every M2.5+
quake. quake. Each mark is labelled with the magnitude as the map shows it and the
feed's place, cut to 40 characters (`earthquakeMarkLabel` in
`src/layers/earthquakes/model.js`: `M6.4 · 113 km SSE of Lata, Solomon
Islands`, or `M6.4` with no place).
## Rendered-mesh floor readings — November 9, 2026 ## Rendered-mesh floor readings — November 9, 2026
@@ -8540,7 +8543,14 @@ clock moves all enabled observation rows with the world clock; the world
timeline is the only time control. Each product's advertised times span time on timeline is the only time control. Each product's advertised times span time on
that timeline but are not marks on it: the timeline marks only events (launches, 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 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 weather row's `step` 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
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
`aria-valuetext` and shows over the track while the handle has focus. The
coverage contract is in [APPLICATION.md](./APPLICATION.md). A weather row's `step`
param goes to the previous or next weather frame. Each product selects its param goes to the previous or next weather frame. Each product selects its
nearest frame at or before the world time, never a future frame or an nearest frame at or before the world time, never a future frame or an
interpolation. interpolation.
+9 -1
View File
@@ -1244,7 +1244,15 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show'); assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show');
await rocketLaunchesLayer.update(); await rocketLaunchesLayer.update();
const at = Date.parse(launchTime); const at = Date.parse(launchTime);
assert.deepEqual(worldClock.coverage(), { start: at, end: at, marks: [at] }); // The launch is a mark labelled with its mission's name.
assert.deepEqual(worldClock.coverage(), {
start: at,
end: at,
marks: [at],
markDetails: [
{ time: at, label: 'GAUNTLET PAYLOAD', id: 'mission-runtime', kind: 'launch' },
],
});
assert.equal(worldClock.isSupported(), true, 'loaded launches bring up the timeline'); assert.equal(worldClock.isSupported(), true, 'loaded launches bring up the timeline');
const entities = dataSources[0].entities.values; const entities = dataSources[0].entities.values;
+5 -3
View File
@@ -29,7 +29,8 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
let _lastUpdate = null; let _lastUpdate = null;
let _lastError = null; let _lastError = null;
let _enabled = false; let _enabled = false;
// The quakes last drawn, as the timeline reads them ({ mag, time }). // The quakes last drawn, as the timeline reads them
// ({ mag, time, place, usgsId }).
let _events = []; let _events = [];
let _uncover = null; let _uncover = null;
// The marks last told to the clock, so a refresh with the same strong // The marks last told to the clock, so a refresh with the same strong
@@ -37,7 +38,8 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
let _marksKey = null; let _marksKey = null;
const coverage = () => (_enabled ? earthquakeCoverage(_events) : null); const coverage = () => (_enabled ? earthquakeCoverage(_events) : null);
const coverageChanged = () => { const coverageChanged = () => {
const key = coverage()?.marks.join() ?? null; const covered = coverage();
const key = covered ? JSON.stringify(covered.marks) : null;
if (key === _marksKey) return; if (key === _marksKey) return;
_marksKey = key; _marksKey = key;
worldClock.coverageChanged(); worldClock.coverageChanged();
@@ -113,7 +115,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
time, time,
} of rows) { } of rows) {
count++; count++;
nextEvents.push({ mag, time }); nextEvents.push({ mag, time, place, usgsId });
const baseRadius = Math.pow(2, mag) * 1000; const baseRadius = Math.pow(2, mag) * 1000;
const color = depthColor(depthKm || 0); const color = depthColor(depthKm || 0);
const isSignificant = mag >= 5.0; const isSignificant = mag >= 5.0;
+46 -16
View File
@@ -10,25 +10,55 @@ export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48;
*/ */
export const EARTHQUAKE_MARK_MAGNITUDE = 6; export const EARTHQUAKE_MARK_MAGNITUDE = 6;
/** The longest place a timeline mark's label carries, ellipsis and all. */
const MARK_PLACE_LENGTH = 40;
/** A magnitude as the map's labels show it: `M6.4`. */
export const earthquakeMagnitudeTitle = (magnitude) =>
`M${Number(magnitude).toFixed(1)}`;
/** /**
* The time a world clock's timeline can show for `events` (`{mag, time}`, * A strong quake's timeline label: its magnitude and, where the feed names
* time in ms): the span of the quakes of EARTHQUAKE_MARK_MAGNITUDE and up, * one, its place, cut short (`M6.4 · 113 km SSE of Lata, Solomon Islands`).
* each one a mark, or null with none, so the layer never stretches the * @param {{mag: number, place?: string | null}} event
* @returns {string}
*/
export function earthquakeMarkLabel({ mag, place }) {
const title = earthquakeMagnitudeTitle(mag);
const where =
typeof place === 'string' ? place.replace(/\s+/g, ' ').trim() : '';
if (!where) return title;
const short =
where.length > MARK_PLACE_LENGTH
? `${where.slice(0, MARK_PLACE_LENGTH - 1).trimEnd()}…`
: where;
return `${title} · ${short}`;
}
/**
* The time a world clock's timeline can show for `events` (`{mag, time,
* place?, usgsId?}`, time in ms): the span of the quakes of
* EARTHQUAKE_MARK_MAGNITUDE and up, each one a mark labelled with its
* magnitude and place, or null with none, so the layer never stretches the
* timeline past what its feed covers. * timeline past what its feed covers.
* @returns {{start: number, end: number, marks: number[]} | null} * @returns {{start: number, end: number, marks: Array<{time: number, label: string, id?: string, kind: string}>} | null}
*/ */
export function earthquakeCoverage(events) { export function earthquakeCoverage(events) {
const marks = [ const marks = events
...new Set( .filter(
events ({ mag, time }) =>
.filter( mag >= EARTHQUAKE_MARK_MAGNITUDE && Number.isFinite(time),
({ mag, time }) => )
mag >= EARTHQUAKE_MARK_MAGNITUDE && Number.isFinite(time), .map((event) => ({
) time: event.time,
.map(({ time }) => time), label: earthquakeMarkLabel(event),
), ...(event.usgsId ? { id: String(event.usgsId) } : {}),
].sort((a, b) => a - b); kind: 'earthquake',
return marks.length ? { start: marks[0], end: marks.at(-1), marks } : null; }))
.sort((a, b) => a.time - b.time);
return marks.length
? { start: marks[0].time, end: marks.at(-1).time, marks }
: null;
} }
/** /**
@@ -65,7 +95,7 @@ export function createEarthquakeOverlayEntry({
id: String(id), id: String(id),
position, position,
variant: 'label', variant: 'label',
title: `M${mag.toFixed(1)}`, title: earthquakeMagnitudeTitle(mag),
accent, accent,
priority: Math.round(mag * 1000), priority: Math.round(mag * 1000),
collisionGroup: 'ambient-label', collisionGroup: 'ambient-label',
+34 -3
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createEarthquakesLayer } from './index.js'; import { createEarthquakesLayer } from './index.js';
import { createUsgsEarthquakeSource } from './source.js'; import { createUsgsEarthquakeSource } from './source.js';
import { EARTHQUAKE_MARK_MAGNITUDE } from './model.js'; import { EARTHQUAKE_MARK_MAGNITUDE, earthquakeMarkLabel } from './model.js';
import { worldClock } from '../../time/worldClock.js'; import { worldClock } from '../../time/worldClock.js';
function harness(source) { function harness(source) {
const sources = []; const sources = [];
@@ -94,8 +94,15 @@ test('the timeline marks only strong quakes, while the layer is on', async () =>
assert.equal(EARTHQUAKE_MARK_MAGNITUDE, 6); assert.equal(EARTHQUAKE_MARK_MAGNITUDE, 6);
const rows = [ const rows = [
{ ...row, stableId: 'small', mag: 4.5, time: 1_000 }, { ...row, stableId: 'small', mag: 4.5, time: 1_000 },
{ ...row, stableId: 'strong', mag: 6, time: 5_000 }, { ...row, stableId: 'strong', usgsId: 'us-strong', mag: 6, time: 5_000 },
{ ...row, stableId: 'great', mag: 7.2, time: 3_000 }, {
...row,
stableId: 'great',
usgsId: 'us-great',
mag: 7.2,
place: ' 113 km SSE of Lata, Solomon Islands region of the Pacific ',
time: 3_000,
},
{ ...row, stableId: 'untimed', mag: 6.5, time: null }, { ...row, stableId: 'untimed', mag: 6.5, time: null },
]; ];
let snapshot = rows; let snapshot = rows;
@@ -106,10 +113,25 @@ test('the timeline marks only strong quakes, while the layer is on', async () =>
await h.layer.update(h.viewer); await h.layer.update(h.viewer);
// Every quake is drawn; only M6 and up are marks, spanning their own time. // Every quake is drawn; only M6 and up are marks, spanning their own time.
assert.equal(h.sources[0].entities.values.length, 4); assert.equal(h.sources[0].entities.values.length, 4);
// Each labelled with its magnitude and, cut short, its place.
assert.deepEqual(worldClock.coverage(), { assert.deepEqual(worldClock.coverage(), {
start: 3_000, start: 3_000,
end: 5_000, end: 5_000,
marks: [3_000, 5_000], marks: [3_000, 5_000],
markDetails: [
{
time: 3_000,
label: 'M7.2 · 113 km SSE of Lata, Solomon Islands reg…',
id: 'us-great',
kind: 'earthquake',
},
{
time: 5_000,
label: 'M6.0 · Fixture',
id: 'us-strong',
kind: 'earthquake',
},
],
}); });
const told = changes; const told = changes;
await h.layer.update(h.viewer); await h.layer.update(h.viewer);
@@ -127,3 +149,12 @@ test('the timeline marks only strong quakes, while the layer is on', async () =>
assert.equal(worldClock.coverage(), null); assert.equal(worldClock.coverage(), null);
unsubscribe(); unsubscribe();
}); });
test('a strong quake with no place is labelled with its magnitude alone', () => {
assert.equal(earthquakeMarkLabel({ mag: 6.44, place: null }), 'M6.4');
assert.equal(earthquakeMarkLabel({ mag: 6, place: ' ' }), 'M6.0');
assert.equal(
earthquakeMarkLabel({ mag: 8.1, place: 'Off the coast' }),
'M8.1 · Off the coast',
);
});
+21 -6
View File
@@ -149,6 +149,9 @@ function restoreCameraView(viewer, view, landedView) {
camera.setView(view); camera.setView(view);
} }
/** The longest name a timeline mark carries, as in the mission index. */
const MARK_LABEL_LENGTH = 27;
export function createLifecycle({ export function createLifecycle({
state: layerState, state: layerState,
services, services,
@@ -158,18 +161,30 @@ export function createLifecycle({
const { holdContinuousRender, releaseContinuousRender } = services.render; const { holdContinuousRender, releaseContinuousRender } = services.render;
let hover = null; let hover = null;
// While on, the launches loaded span the time from the first to the last, // While on, the launches loaded span the time from the first to the last,
// each one a mark. Registered once; off, the layer has no time to show, and // each one a mark labelled with its mission's name (the launch's where it
// each step tells the clock once it has settled. // has none), cut short and in capitals as the mission index shows names.
// Registered once; off, the layer has no time to show, and each step tells
// the clock once it has settled.
worldClock.register({ worldClock.register({
id: 'rocket-launches', id: 'rocket-launches',
coverage: () => { coverage: () => {
if (!layerState._enabled) return null; if (!layerState._enabled) return null;
const marks = layerState._launches const marks = layerState._launches
.map((launch) => Date.parse(launch.launchTime)) .map((launch) => ({
.filter(Number.isFinite) time: Date.parse(launch.launchTime),
.sort((a, b) => a - b); label: parts.overlays
.shortMissionLabel(
launch.missionName || launch.name,
MARK_LABEL_LENGTH,
)
.toUpperCase(),
...(launch.id != null ? { id: String(launch.id) } : {}),
kind: 'launch',
}))
.filter(({ time }) => Number.isFinite(time))
.sort((a, b) => a.time - b.time);
if (!marks.length) return null; if (!marks.length) return null;
return { start: marks[0], end: marks.at(-1), marks }; return { start: marks[0].time, end: marks.at(-1).time, marks };
}, },
}); });
+3
View File
@@ -378,6 +378,7 @@ test('each product’s frames span world clock coverage, with no marks, and supp
start: Date.parse(time(0)), start: Date.parse(time(0)),
end: Date.parse(time(10)), end: Date.parse(time(10)),
marks: [], marks: [],
markDetails: [],
}); });
assert.equal(world.getState().supported, true); assert.equal(world.getState().supported, true);
unregister(); unregister();
@@ -385,6 +386,7 @@ test('each product’s frames span world clock coverage, with no marks, and supp
start: Date.parse(time(5)), start: Date.parse(time(5)),
end: Date.parse(time(5)), end: Date.parse(time(5)),
marks: [], marks: [],
markDetails: [],
}); });
clock.destroy(); clock.destroy();
assert.equal(world.coverage(), null); assert.equal(world.coverage(), null);
@@ -402,6 +404,7 @@ test('a suspended product keeps its coverage, so the way back to live stays', (t
start: Date.parse(time(0)), start: Date.parse(time(0)),
end: Date.parse(time(10)), end: Date.parse(time(10)),
marks: [], marks: [],
markDetails: [],
}); });
}); });
+1
View File
@@ -597,6 +597,7 @@ test('without a clock the layer keeps one of its own on the world clock and lets
start: Date.parse(times[0]), start: Date.parse(times[0]),
end: Date.parse(times.at(-1)), end: Date.parse(times.at(-1)),
marks: [], marks: [],
markDetails: [],
}); });
h.layer.setParams({ play: true }); h.layer.setParams({ play: true });
assert.equal(worldClock.isPlaying(), true); assert.equal(worldClock.isPlaying(), true);
+1
View File
@@ -119,6 +119,7 @@ test('at the live edge a layer with a timeline only follows and registers', asyn
start: WALL - 3_600_000, start: WALL - 3_600_000,
end: WALL, end: WALL,
marks: [], marks: [],
markDetails: [],
}); });
assert.equal(h.follower.active(), false); assert.equal(h.follower.active(), false);
assert.deepEqual(h.calls, []); assert.deepEqual(h.calls, []);
+81 -5
View File
@@ -20,6 +20,49 @@
* (weather) do not drive it: they gate continuous play at their next frame. * (weather) do not drive it: they gate continuous play at their next frame.
*/ */
/**
* A timeline mark as a layer gives it: its time in epoch ms, and optionally
* a short label to show for it, the layer's own id for it and its kind.
* @typedef {{time: number, label?: string, id?: string, kind?: string}} WorldClockMarkInput
*/
/**
* A timeline mark as `coverage().markDetails` returns it: a finite time, and
* only those of `label`, `id` and `kind` that are non-empty strings.
* @typedef {Readonly<{time: number, label?: string, id?: string, kind?: string}>} WorldClockMark
*/
const MARK_TEXT = ['label', 'id', 'kind'];
/**
* A mark (a number, or `{time, label?, id?, kind?}`) in its one shape, or
* null for one with no finite time.
* @param {unknown} input
* @returns {WorldClockMark | null}
*/
function readMark(input) {
if (typeof input === 'number')
return Number.isFinite(input) ? Object.freeze({ time: input }) : null;
if (!input || typeof input !== 'object') return null;
const { time } = input;
if (typeof time !== 'number' || !Number.isFinite(time)) return null;
const mark = { time };
for (const key of MARK_TEXT) {
const value = input[key];
if (typeof value === 'string' && value.trim()) mark[key] = value.trim();
}
return Object.freeze(mark);
}
// Marks in time order; at one time, by kind, label and id, so the order
// never depends on which source registered first.
const byText = (a = '', b = '') => (a < b ? -1 : a > b ? 1 : 0);
const byMark = (a, b) =>
a.time - b.time ||
byText(a.kind, b.kind) ||
byText(a.label, b.label) ||
byText(a.id, b.id);
/** The span a JS Date can hold, either side of 1970. */ /** The span a JS Date can hold, either side of 1970. */
const MAX_DATE_MS = 8.64e15; const MAX_DATE_MS = 8.64e15;
@@ -372,7 +415,15 @@ export function createWorldClock({
/** /**
* A layer's span of time it can show, for a host's timeline; null while * A layer's span of time it can show, for a host's timeline; null while
* it has none (off, or nothing loaded). Returns the unregister. * it has none (off, or nothing loaded). Returns the unregister.
* @param {{id: string, coverage: () => ({start: number, end: number, marks?: number[]} | null)}} source *
* `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
* 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.
* @param {{id: string, coverage: () => ({start: number, end: number, marks?: Array<number | WorldClockMarkInput>} | null)}} source
*/ */
register(source) { register(source) {
// Each registration is its own, even under a shared id. // Each registration is its own, even under a shared id.
@@ -387,20 +438,45 @@ export function createWorldClock({
coverageChanged: () => changed(), coverageChanged: () => changed(),
/** Whether a followed layer has time to show; see getState().supported. */ /** Whether a followed layer has time to show; see getState().supported. */
isSupported, isSupported,
/** The union of the registered sources' coverage, or null with none. */ /**
* The union of the registered sources' coverage, or null with none:
* `{start, end, marks, markDetails}`. `marks` is every mark's time, once
* each, in order, as numbers. `markDetails` is the marks themselves, in
* time order, each `{time, label?, id?, kind?}` (frozen): a mark several
* sources give alike is there once, and a bare time is dropped where a
* mark with more to say shares it.
* @returns {{start: number, end: number, marks: number[], markDetails: WorldClockMark[]} | null}
*/
coverage() { coverage() {
let start = Infinity; let start = Infinity;
let end = -Infinity; let end = -Infinity;
const marks = new Set(); const details = new Map();
for (const { source } of sources) { for (const { source } of sources) {
const span = spanOf(source); const span = spanOf(source);
if (!span) continue; if (!span) continue;
start = Math.min(start, span.start); start = Math.min(start, span.start);
end = Math.max(end, span.end); end = Math.max(end, span.end);
for (const mark of span.marks ?? []) marks.add(mark); for (const input of span.marks ?? []) {
const mark = readMark(input);
if (!mark) continue;
const key = JSON.stringify([
mark.time,
mark.label,
mark.id,
mark.kind,
]);
if (!details.has(key)) details.set(key, mark);
}
} }
if (start > end) return null; if (start > end) return null;
return { start, end, marks: [...marks].sort((a, b) => a - b) }; const told = new Set();
for (const mark of details.values())
if (Object.keys(mark).length > 1) told.add(mark.time);
const markDetails = [...details.values()]
.filter((mark) => Object.keys(mark).length > 1 || !told.has(mark.time))
.sort(byMark);
const marks = [...new Set(markDetails.map((mark) => mark.time))];
return { start, end, marks, markDetails };
}, },
getState, getState,
+52 -1
View File
@@ -253,9 +253,60 @@ test('coverage is the union of the registered sources', () => {
start: 50, start: 50,
end: 200, end: 200,
marks: [100, 150, 200], marks: [100, 150, 200],
markDetails: [{ time: 100 }, { time: 150 }, { time: 200 }],
}); });
off(); off();
assert.deepEqual(clock.coverage(), { start: 50, end: 180, marks: [] }); assert.deepEqual(clock.coverage(), {
start: 50,
end: 180,
marks: [],
markDetails: [],
});
});
test('marks may carry a label, an id and a kind, and merge in one order', () => {
const clock = createWorldClock(fakeTime().options);
clock.register({
id: 'launches',
coverage: () => ({
start: 100,
end: 300,
marks: [
{ time: 300, label: ' ARTEMIS II ', id: 'a2', kind: 'launch' },
100,
{ time: 200, label: 'M6.4 · Fiji', kind: 'earthquake', extra: 1 },
],
}),
});
clock.register({
id: 'others',
coverage: () => ({
start: 100,
end: 300,
marks: [
200,
{ time: 200, label: 'M6.4 · Fiji', kind: 'earthquake' },
{ time: 200, label: 'ALPHA', kind: 'launch' },
{ time: 250, label: '', id: 7, kind: null },
{ time: Number.NaN, label: 'never' },
Number.POSITIVE_INFINITY,
null,
'soon',
],
}),
});
const covered = clock.coverage();
assert.deepEqual(covered.marks, [100, 200, 250, 300], 'the times, once each');
assert.deepEqual(covered.markDetails, [
{ time: 100 },
// A bare 200 says nothing a labelled mark there does not; alike marks
// from two sources are one.
{ time: 200, label: 'M6.4 · Fiji', kind: 'earthquake' },
{ time: 200, label: 'ALPHA', kind: 'launch' },
{ time: 250 },
{ time: 300, label: 'ARTEMIS II', id: 'a2', kind: 'launch' },
]);
assert.ok(covered.markDetails.every(Object.isFrozen));
}); });
test('seek refuses anything but epoch ms and rate must be positive', () => { test('seek refuses anything but epoch ms and rate must be positive', () => {
+4 -1
View File
@@ -27,7 +27,8 @@ export const WORLD_TIME_SPEEDS = Object.freeze({
* buffering, 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 * 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 * 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 * marks (`marks`, their times; `markDetails`, each `{time, label?, id?,
* kind?}`, as the world clock's coverage gives them). It follows the page's world clock, which tells it of every move, and
* of the time a few times a second while it plays on its own. While a * of the time a few times a second while it plays on its own. While a
* timeline shows, it reads the clock each second itself, so live and the * timeline shows, it reads the clock each second itself, so live and the
* track's end keep up with now. * track's end keep up with now.
@@ -42,6 +43,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
start: Math.min(coverage.start, now), start: Math.min(coverage.start, now),
end: Math.max(coverage.end, now), end: Math.max(coverage.end, now),
marks: coverage.marks, marks: coverage.marks,
markDetails: coverage.markDetails,
}; };
}; };
const spanOf = (state) => { const spanOf = (state) => {
@@ -52,6 +54,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
start: Math.min(covered.start, state.timeMs), start: Math.min(covered.start, state.timeMs),
end: Math.max(covered.end, state.timeMs), end: Math.max(covered.end, state.timeMs),
marks: covered.marks, marks: covered.marks,
markDetails: covered.markDetails,
}); });
}; };
const nearNow = (ms, length) => const nearNow = (ms, length) =>
+20
View File
@@ -194,6 +194,26 @@ body.cockpit-mode #world-timeline {
pointer-events: none; pointer-events: none;
} }
/* 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. */
#world-timeline .world-timeline-mark-label {
position: absolute;
bottom: calc(100% + 30px);
left: calc(100% * var(--at));
z-index: 1;
padding: 2px 8px;
border: 1px solid rgba(0, 212, 255, 0.4);
border-radius: 6px;
background: var(--glass-bg);
color: var(--text-primary);
font-size: 11px;
line-height: 1.4;
white-space: pre;
transform: translateX(calc(var(--at) * -100%));
pointer-events: none;
}
/* Zoomed, the track drags along. */ /* Zoomed, the track drags along. */
#world-timeline .world-timeline-track.is-zoomed { #world-timeline .world-timeline-track.is-zoomed {
cursor: grab; cursor: grab;
@@ -581,3 +581,91 @@ test('a pinch measures from where the fingers are, and a lifted finger ends it',
assert.ok(Math.abs(length() - zoomedTo) < 1_000); assert.ok(Math.abs(length() - zoomedTo) < 1_000);
on(input, 'pointercancel', 100, 1); on(input, 'pointercancel', 100, 1);
}); });
test('a tick shows its label on hover, and a tap by it goes to its mark', async (t) => {
const bar = mount(t);
const now = Date.now();
const start = now - 10 * 86_400_000;
// Ticks a fifth and seven tenths along a 1000 px track.
const launch = start + (now - start) * 0.2;
const quake = start + (now - start) * 0.7;
const release = worldClock.hold('test-launches');
const unregister = worldClock.register({
id: 'test-launches',
coverage: () => ({
start,
end: now,
marks: [
start,
{ time: launch, label: 'GAUNTLET PAYLOAD', id: 'l1', kind: 'launch' },
{ time: quake, label: 'M6.4 · Fiji', kind: 'earthquake' },
],
}),
});
t.after(() => {
unregister();
release();
});
await nextTick();
const track = bar().querySelector('.world-timeline-track');
const input = bar().querySelector('input[type=range]');
Object.defineProperty(track, 'clientWidth', { value: 1_000 });
track.getBoundingClientRect = () => ({ left: 0, width: 1_000, top: 0, height: 24 });
const press = (type, clientX, pointerId = 1) =>
track.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId }));
const tip = () => bar().querySelector('.world-timeline-mark-label');
assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 3, 'ticks stay ticks');
assert.equal(tip(), null);
// Hovering within 6 px of a tick shows its label, in place of the track's hint.
press('pointermove', 205);
await nextTick();
assert.equal(tip()?.textContent, 'GAUNTLET PAYLOAD');
assert.equal(track.hasAttribute('title'), false);
press('pointermove', 698);
await nextTick();
assert.equal(tip()?.textContent, 'M6.4 · Fiji');
// Further off, or on a tick with no label, nothing shows.
press('pointermove', 690);
await nextTick();
assert.equal(tip(), null);
assert.equal(track.hasAttribute('title'), true);
press('pointermove', 2);
await nextTick();
assert.equal(tip(), null, 'an unlabelled mark has no label');
press('pointermove', 205);
track.dispatchEvent(new PointerEvent('pointerleave', { clientX: 1_100, pointerId: 1 }));
await nextTick();
assert.equal(tip(), null, 'leaving the track hides it');
// A tap by a tick goes to its mark exactly; elsewhere, to the time there.
press('pointerdown', 704);
press('pointerup', 704);
await nextTick();
assert.equal(worldClock.now(), quake);
assert.match(input.getAttribute('aria-valuetext'), / · M6\.4 · Fiji$/);
press('pointerdown', 500);
press('pointerup', 500);
await nextTick();
const middle = start + (now - start) / 2;
assert.ok(Math.abs(worldClock.now() - middle) < 60_000, 'went to the middle');
assert.notEqual(worldClock.now(), quake);
assert.match(input.getAttribute('aria-valuetext'), /UTC$/, 'off a mark, the time alone');
// A drag from a tick still scrubs.
press('pointerdown', 200);
press('pointermove', 300);
press('pointerup', 300);
await nextTick();
assert.ok(Math.abs(worldClock.now() - (start + (now - start) * 0.3)) < 60_000);
// The arrows go to the marks and tell their labels; focused, the label shows.
input.dispatchEvent(new Event('focus'));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
await nextTick();
assert.equal(worldClock.now(), launch);
assert.match(input.getAttribute('aria-valuetext'), / · GAUNTLET PAYLOAD$/);
assert.equal(tip()?.textContent, 'GAUNTLET PAYLOAD');
input.dispatchEvent(new Event('blur'));
await nextTick();
assert.equal(tip(), null);
});
+85 -12
View File
@@ -8,11 +8,12 @@ import UiIcon from '../../kit/UiIcon.vue';
import UiSelect from '../../kit/UiSelect.js'; import UiSelect from '../../kit/UiSelect.js';
// The world timeline: play and pause and the speed on the left, a track // 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) // across the stretch the layers on can show (each mark, a launch or a strong
// with a handle to scrub and the time in a bubble over it, and the way back // earthquake, a tick, its label shown over it on hover) with a handle to
// to live on the right, lit while live. Scrolling or pinching the track // scrub and the time in a bubble over it, and the way back to live on the
// zooms it around the handle, dragging it moves along it, and a double-click // right, lit while live. Scrolling or pinching the track zooms it around the
// shows it all again. It shows // handle, dragging it moves along it, a tap on or by a tick goes to its
// mark's time exactly, and a double-click shows it all again. It shows
// while a layer with a time of its own (weather, satellites, launches, // 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 // or one a host follows) is on with time to show; with only live feeds such
// as flights on, the bar stays hidden. // as flights on, the bar stays hidden.
@@ -53,6 +54,8 @@ const ZOOM_STEP = 1.5;
const WHEEL_ZOOM = 0.0025; const WHEEL_ZOOM = 0.0025;
// Two presses this close are a double-click. // Two presses this close are a double-click.
const DOUBLE_PRESS_MS = 500; const DOUBLE_PRESS_MS = 500;
// A tap or a hover this near a tick, in pixels, is on its mark.
const MARK_HIT_PX = 6;
const readout = computed(() => const readout = computed(() =>
Number.isFinite(time.value.timeMs) Number.isFinite(time.value.timeMs)
@@ -212,6 +215,7 @@ let movedTime = false;
const onPointerDown = (event) => { const onPointerDown = (event) => {
// Only the main button: a right-click is for the menu. // Only the main button: a right-click is for the menu.
if (event.button > 0) return; if (event.button > 0) return;
hovered.value = null;
const onHandle = event.target instanceof HTMLInputElement; const onHandle = event.target instanceof HTMLInputElement;
pointers.set(event.pointerId, { x: event.clientX, onHandle }); pointers.set(event.pointerId, { x: event.clientX, onHandle });
if (pointers.size === 1) { if (pointers.size === 1) {
@@ -241,6 +245,10 @@ const onPointerDown = (event) => {
gesture = { kind: 'press', x: event.clientX, end: view.value.end }; gesture = { kind: 'press', x: event.clientX, end: view.value.end };
}; };
const onPointerMove = (event) => { const onPointerMove = (event) => {
// A pointer hovering, none pressed, shows the label of the tick under it.
hovered.value = pointers.size
? null
: (markNear(event.clientX, event.currentTarget)?.time ?? null);
const pointer = pointers.get(event.pointerId); const pointer = pointers.get(event.pointerId);
if (!pointer) return; if (!pointer) return;
pointer.x = event.clientX; pointer.x = event.clientX;
@@ -266,8 +274,14 @@ const onPointerMove = (event) => {
}; };
const onPointerUp = (event) => { const onPointerUp = (event) => {
if (!pointers.delete(event.pointerId)) return; if (!pointers.delete(event.pointerId)) return;
// A tap on or by a tick goes to its mark exactly; elsewhere, to the time
// under it.
if (gesture?.kind === 'press' && event.type === 'pointerup') if (gesture?.kind === 'press' && event.type === 'pointerup')
store.scrub(timeAt(event.clientX, event.currentTarget), viewLength()); store.scrub(
markNear(event.clientX, event.currentTarget)?.time ??
timeAt(event.clientX, event.currentTarget),
viewLength(),
);
// A finger lifted from a pinch ends it; the other one moves nothing. // A finger lifted from a pinch ends it; the other one moves nothing.
gesture = null; gesture = null;
}; };
@@ -276,11 +290,66 @@ const place = (ms) => {
const { start, end } = view.value; const { start, end } = view.value;
return end > start ? ((ms - start) / (end - start)) * 100 : 100; return end > start ? ((ms - start) / (end - start)) * 100 : 100;
}; };
// The labels of the marks at each time marked; a time may have none.
const markLabels = computed(() => {
const labels = new Map();
for (const mark of span.value?.markDetails ?? []) {
if (!labels.has(mark.time)) labels.set(mark.time, []);
if (mark.label) labels.get(mark.time).push(mark.label);
}
return labels;
});
// A tick for each time marked in view: where it sits, as a share of the
// track, and its marks' labels.
const ticks = computed(() => { const ticks = computed(() => {
const { start, end } = view.value; const { start, end } = view.value;
return span.value.marks return span.value.marks
.filter((mark) => mark >= start && mark <= end) .filter((mark) => mark >= start && mark <= end)
.map((mark) => `${place(mark)}%`); .map((mark) => ({
time: mark,
share: place(mark) / 100,
labels: markLabels.value.get(mark) ?? [],
}));
});
// The tick nearest `clientX` within MARK_HIT_PX of it, or null.
function markNear(clientX, track) {
if (!view.value) return null;
const { left, width } = track.getBoundingClientRect();
if (!(width > 0)) return null;
let near = null;
let nearest = MARK_HIT_PX;
for (const tick of ticks.value) {
const off = Math.abs(left + tick.share * width - clientX);
if (off <= nearest) {
near = tick;
nearest = off;
}
}
return near;
}
// The time of the tick the pointer hovers, and whether the handle has focus.
const hovered = shallowRef(null);
const focused = shallowRef(false);
// The labels shown over the track: the hovered tick's, or with the handle
// focused and paused on a mark (an arrow key or a tap took it there), that
// mark's. Nothing for a mark with no label.
const markTip = computed(() => {
if (!view.value) return null;
const onMark = !time.value.live && focused.value ? time.value.timeMs : null;
const over = ticks.value.find((each) => each.time === hovered.value);
const tick = over ?? ticks.value.find((each) => each.time === onMark);
return tick?.labels.length
? { share: tick.share, text: tick.labels.join('\n'), hover: Boolean(over) }
: null;
});
// The time as the handle tells it, with the labels of the mark it is on.
const valueText = computed(() => {
const labels = time.value.live
? null
: markLabels.value.get(time.value.timeMs);
return labels?.length
? `${readout.value} · ${labels.join('; ')}`
: readout.value;
}); });
const shown = computed(() => const shown = computed(() =>
Math.min(view.value.end, Math.max(view.value.start, at.value)), Math.min(view.value.end, Math.max(view.value.start, at.value)),
@@ -294,8 +363,9 @@ const scrub = (event) => {
} }
store.scrub(Number(event.target.value), viewLength()); store.scrub(Number(event.target.value), viewLength());
}; };
// Arrows go to the previous or next mark (a launch, a strong earthquake), or // Arrows go to the previous or next mark (a launch, a strong earthquake),
// a step along the track as shown where there is none; Page keys take a tenth // whose label the handle then tells with the time, or a step along the track
// as shown where there is none; Page keys take a tenth
// of it; Home and End go to the ends of all of it. Plus and minus zoom, and // of it; Home and End go to the ends of all of it. Plus and minus zoom, and
// zero shows it all; with a modifier they are the browser's own zoom. // zero shows it all; with a modifier they are the browser's own zoom.
const ZOOM_KEYS = ['+', '=', '-', '0']; const ZOOM_KEYS = ['+', '=', '-', '0'];
@@ -331,17 +401,18 @@ const onKey = (event) => {
><UiButton class="world-timeline-play" :label="time.playing ? t('Pause') : t('Play')" @click="store.togglePlay()" ><UiButton class="world-timeline-play" :label="time.playing ? t('Pause') : t('Play')" @click="store.togglePlay()"
><UiIcon :name="time.playing ? 'pause' : 'play_arrow'" :size="20" /></UiButton ><UiIcon :name="time.playing ? 'pause' : 'play_arrow'" :size="20" /></UiButton
><UiSelect class="gev-field gev-field--compact world-timeline-speed" :aria-label="t('Play speed')" :title="t('Play speed')" :options="speeds" :model-value="speed" @update:model-value="store.setSpeed($event)" ><UiSelect class="gev-field gev-field--compact world-timeline-speed" :aria-label="t('Play speed')" :title="t('Play speed')" :options="speeds" :model-value="speed" @update:model-value="store.setSpeed($event)"
/><div class="world-timeline-track" :class="{ 'is-zoomed': zoomed }" :title="t('Scroll or pinch to zoom, drag to move along · double-click to see it all')" @wheel="onWheel" @dblclick="showAll" @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="onPointerUp" @pointercancel="onPointerUp" /><div class="world-timeline-track" :class="{ 'is-zoomed': zoomed }" :title="markTip?.hover ? undefined : t('Scroll or pinch to zoom, drag to move along · double-click to see it all')" @wheel="onWheel" @dblclick="showAll" @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="onPointerUp" @pointercancel="onPointerUp" @pointerleave="hovered = null"
><time class="world-timeline-time" :class="{ 'is-waiting': waiting }" :style="{ '--at': place(shown) / 100 }" :datetime="new Date(time.timeMs).toISOString()" aria-live="off" ><time class="world-timeline-time" :class="{ 'is-waiting': waiting }" :style="{ '--at': place(shown) / 100 }" :datetime="new Date(time.timeMs).toISOString()" aria-live="off"
><span v-if="waiting" class="world-timeline-spinner" aria-hidden="true"></span>{{ bubble }}</time ><span v-if="waiting" class="world-timeline-spinner" aria-hidden="true"></span>{{ bubble }}</time
><span v-for="(left, index) in ticks" :key="index" class="world-timeline-tick" :style="{ left }" aria-hidden="true"></span ><span v-if="markTip" class="world-timeline-mark-label" :style="{ '--at': markTip.share }" aria-hidden="true">{{ markTip.text }}</span
><span v-for="tick in ticks" :key="tick.time" class="world-timeline-tick" :style="{ left: `${tick.share * 100}%` }" aria-hidden="true"></span
><span class="world-timeline-fill" :style="{ width: `${place(shown)}%` }" aria-hidden="true"></span ><span class="world-timeline-fill" :style="{ width: `${place(shown)}%` }" aria-hidden="true"></span
><input ><input
class="world-timeline-scrub" class="world-timeline-scrub"
:class="{ 'is-off-view': handleOff }" :class="{ 'is-off-view': handleOff }"
type="range" type="range"
:aria-label="t('World time')" :aria-label="t('World time')"
:aria-valuetext="readout" :aria-valuetext="valueText"
:min="view.start" :min="view.start"
:max="view.end" :max="view.end"
step="any" step="any"
@@ -349,6 +420,8 @@ const onKey = (event) => {
@input="scrub" @input="scrub"
aria-keyshortcuts="+ - 0" aria-keyshortcuts="+ - 0"
@keydown="onKey" @keydown="onKey"
@focus="focused = true"
@blur="focused = false"
/></div /></div
><UiButton class="world-timeline-live" :label="time.live ? t('Live') : t('Back to live')" :pressed="time.live" pressed-class="is-live" :disabled="time.live" @click="store.goLive()">{{ t('Live') }}</UiButton ><UiButton class="world-timeline-live" :label="time.live ? t('Live') : t('Back to live')" :pressed="time.live" pressed-class="is-live" :disabled="time.live" @click="store.goLive()">{{ t('Live') }}</UiButton
></div> ></div>