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

+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');
await rocketLaunchesLayer.update();
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');
const entities = dataSources[0].entities.values;
+5 -3
View File
@@ -29,7 +29,8 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
let _lastUpdate = null;
let _lastError = null;
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 _uncover = null;
// 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;
const coverage = () => (_enabled ? earthquakeCoverage(_events) : null);
const coverageChanged = () => {
const key = coverage()?.marks.join() ?? null;
const covered = coverage();
const key = covered ? JSON.stringify(covered.marks) : null;
if (key === _marksKey) return;
_marksKey = key;
worldClock.coverageChanged();
@@ -113,7 +115,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
time,
} of rows) {
count++;
nextEvents.push({ mag, time });
nextEvents.push({ mag, time, place, usgsId });
const baseRadius = Math.pow(2, mag) * 1000;
const color = depthColor(depthKm || 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;
/** 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}`,
* time in ms): the span of the quakes of EARTHQUAKE_MARK_MAGNITUDE and up,
* each one a mark, or null with none, so the layer never stretches the
* A strong quake's timeline label: its magnitude and, where the feed names
* one, its place, cut short (`M6.4 · 113 km SSE of Lata, Solomon Islands`).
* @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.
* @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) {
const marks = [
...new Set(
events
.filter(
({ mag, time }) =>
mag >= EARTHQUAKE_MARK_MAGNITUDE && Number.isFinite(time),
)
.map(({ time }) => time),
),
].sort((a, b) => a - b);
return marks.length ? { start: marks[0], end: marks.at(-1), marks } : null;
const marks = events
.filter(
({ mag, time }) =>
mag >= EARTHQUAKE_MARK_MAGNITUDE && Number.isFinite(time),
)
.map((event) => ({
time: event.time,
label: earthquakeMarkLabel(event),
...(event.usgsId ? { id: String(event.usgsId) } : {}),
kind: 'earthquake',
}))
.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),
position,
variant: 'label',
title: `M${mag.toFixed(1)}`,
title: earthquakeMagnitudeTitle(mag),
accent,
priority: Math.round(mag * 1000),
collisionGroup: 'ambient-label',
+34 -3
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { createEarthquakesLayer } from './index.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';
function harness(source) {
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);
const rows = [
{ ...row, stableId: 'small', mag: 4.5, time: 1_000 },
{ ...row, stableId: 'strong', mag: 6, time: 5_000 },
{ ...row, stableId: 'great', mag: 7.2, time: 3_000 },
{ ...row, stableId: 'strong', usgsId: 'us-strong', mag: 6, time: 5_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 },
];
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);
// Every quake is drawn; only M6 and up are marks, spanning their own time.
assert.equal(h.sources[0].entities.values.length, 4);
// Each labelled with its magnitude and, cut short, its place.
assert.deepEqual(worldClock.coverage(), {
start: 3_000,
end: 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;
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);
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);
}
/** The longest name a timeline mark carries, as in the mission index. */
const MARK_LABEL_LENGTH = 27;
export function createLifecycle({
state: layerState,
services,
@@ -158,18 +161,30 @@ export function createLifecycle({
const { holdContinuousRender, releaseContinuousRender } = services.render;
let hover = null;
// 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 step tells the clock once it has settled.
// each one a mark labelled with its mission's name (the launch's where it
// 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({
id: 'rocket-launches',
coverage: () => {
if (!layerState._enabled) return null;
const marks = layerState._launches
.map((launch) => Date.parse(launch.launchTime))
.filter(Number.isFinite)
.sort((a, b) => a - b);
.map((launch) => ({
time: Date.parse(launch.launchTime),
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;
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)),
end: Date.parse(time(10)),
marks: [],
markDetails: [],
});
assert.equal(world.getState().supported, true);
unregister();
@@ -385,6 +386,7 @@ test('each product’s frames span world clock coverage, with no marks, and supp
start: Date.parse(time(5)),
end: Date.parse(time(5)),
marks: [],
markDetails: [],
});
clock.destroy();
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)),
end: Date.parse(time(10)),
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]),
end: Date.parse(times.at(-1)),
marks: [],
markDetails: [],
});
h.layer.setParams({ play: 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,
end: WALL,
marks: [],
markDetails: [],
});
assert.equal(h.follower.active(), false);
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.
*/
/**
* 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. */
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
* 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) {
// Each registration is its own, even under a shared id.
@@ -387,20 +438,45 @@ export function createWorldClock({
coverageChanged: () => changed(),
/** Whether a followed layer has time to show; see getState().supported. */
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() {
let start = Infinity;
let end = -Infinity;
const marks = new Set();
const details = new Map();
for (const { source } of sources) {
const span = spanOf(source);
if (!span) continue;
start = Math.min(start, span.start);
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;
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,
+52 -1
View File
@@ -253,9 +253,60 @@ test('coverage is the union of the registered sources', () => {
start: 50,
end: 200,
marks: [100, 150, 200],
markDetails: [{ time: 100 }, { time: 150 }, { time: 200 }],
});
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', () => {
+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
* has time to show and `span` is the stretch the timeline runs over: the
* time those layers cover, reaching to now and to the world time, with their
* marks. It follows the page's world clock, which tells it of every move, and
* 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
* timeline shows, it reads the clock each second itself, so live and the
* track's end keep up with now.
@@ -42,6 +43,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
start: Math.min(coverage.start, now),
end: Math.max(coverage.end, now),
marks: coverage.marks,
markDetails: coverage.markDetails,
};
};
const spanOf = (state) => {
@@ -52,6 +54,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
start: Math.min(covered.start, state.timeMs),
end: Math.max(covered.end, state.timeMs),
marks: covered.marks,
markDetails: covered.markDetails,
});
};
const nearNow = (ms, length) =>
+20
View File
@@ -194,6 +194,26 @@ body.cockpit-mode #world-timeline {
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. */
#world-timeline .world-timeline-track.is-zoomed {
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);
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';
// The world timeline: play and pause and the speed on the left, a track
// across the stretch the layers on can show (each launch or frame a tick)
// with a handle to scrub and the time in a bubble over it, and the way back
// to live on the right, lit while live. Scrolling or pinching the track
// zooms it around the handle, dragging it moves along it, and a double-click
// shows it all again. It shows
// across the stretch the layers on can show (each mark, a launch or a strong
// earthquake, a tick, its label shown over it on hover) with a handle to
// scrub and the time in a bubble over it, and the way back to live on the
// right, lit while live. Scrolling or pinching the track zooms it around the
// 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,
// or one a host follows) is on with time to show; with only live feeds such
// as flights on, the bar stays hidden.
@@ -53,6 +54,8 @@ const ZOOM_STEP = 1.5;
const WHEEL_ZOOM = 0.0025;
// Two presses this close are a double-click.
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(() =>
Number.isFinite(time.value.timeMs)
@@ -212,6 +215,7 @@ let movedTime = false;
const onPointerDown = (event) => {
// Only the main button: a right-click is for the menu.
if (event.button > 0) return;
hovered.value = null;
const onHandle = event.target instanceof HTMLInputElement;
pointers.set(event.pointerId, { x: event.clientX, onHandle });
if (pointers.size === 1) {
@@ -241,6 +245,10 @@ const onPointerDown = (event) => {
gesture = { kind: 'press', x: event.clientX, end: view.value.end };
};
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);
if (!pointer) return;
pointer.x = event.clientX;
@@ -266,8 +274,14 @@ const onPointerMove = (event) => {
};
const onPointerUp = (event) => {
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')
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.
gesture = null;
};
@@ -276,11 +290,66 @@ const place = (ms) => {
const { start, end } = view.value;
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 { start, end } = view.value;
return span.value.marks
.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(() =>
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());
};
// Arrows go to the previous or next mark (a launch, a strong earthquake), or
// a step along the track as shown where there is none; Page keys take a tenth
// Arrows go to the previous or next mark (a launch, a strong earthquake),
// 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
// zero shows it all; with a modifier they are the browser's own zoom.
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()"
><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)"
/><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"
><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
><input
class="world-timeline-scrub"
:class="{ 'is-off-view': handleOff }"
type="range"
:aria-label="t('World time')"
:aria-valuetext="readout"
:aria-valuetext="valueText"
:min="view.start"
:max="view.end"
step="any"
@@ -349,6 +420,8 @@ const onKey = (event) => {
@input="scrub"
aria-keyshortcuts="+ - 0"
@keydown="onKey"
@focus="focused = true"
@blur="focused = false"
/></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
></div>