From 558e4a919ac04af4c7fe7ed72960467251e18edd Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 20:30:01 -0800 Subject: [PATCH] Timeline marks carry labels; a tap by a tick goes to its mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../0000-timeline-one-control-and-marks.md | 10 ++ docs/APPLICATION.md | 18 ++++ docs/CURRENT-STATE.md | 14 ++- src/data/rocketLaunches.test.mjs | 10 +- src/layers/earthquakes/index.js | 8 +- src/layers/earthquakes/model.js | 62 +++++++++--- src/layers/earthquakes/ownership.test.mjs | 37 ++++++- src/layers/launches/lifecycle.js | 27 ++++-- src/layers/weather/clock.test.mjs | 3 + src/layers/weather/weather.test.mjs | 1 + src/time/snapshotTimeline.test.mjs | 1 + src/time/worldClock.js | 86 +++++++++++++++- src/time/worldClock.test.mjs | 53 +++++++++- src/ui/stores/worldTime.js | 5 +- src/ui/styles/world-timeline.css | 20 ++++ src/ui/views/panels/WorldTimeline.test.mjs | 88 +++++++++++++++++ src/ui/views/panels/WorldTimeline.vue | 97 ++++++++++++++++--- 17 files changed, 490 insertions(+), 50 deletions(-) diff --git a/changelog.d/0000-timeline-one-control-and-marks.md b/changelog.d/0000-timeline-one-control-and-marks.md index 6fa6ca5be..6b619c3f1 100644 --- a/changelog.d/0000-timeline-one-control-and-marks.md +++ b/changelog.d/0000-timeline-one-control-and-marks.md @@ -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 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 diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index c268a5206..42660a10e 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -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`) 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). + +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 `getFrameImage({ product, time, size, bbox, signal })` returns a whole-extent or detail image and an optional `getFrameTile({ product, time, z, x, y, size, diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 37b8b6615..8885629aa 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 (`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+ -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 @@ -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 that timeline but are not marks on it: the timeline marks only events (launches, and earthquakes of magnitude 6 and up while that layer is on), so its arrow keys -step to those, or along the track where there are none. A 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 nearest frame at or before the world time, never a future frame or an interpolation. diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index f1c66f032..9a40c0e8d 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -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; diff --git a/src/layers/earthquakes/index.js b/src/layers/earthquakes/index.js index 66d8f3d70..0999681b7 100644 --- a/src/layers/earthquakes/index.js +++ b/src/layers/earthquakes/index.js @@ -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; diff --git a/src/layers/earthquakes/model.js b/src/layers/earthquakes/model.js index 1c2a68836..66471b59e 100644 --- a/src/layers/earthquakes/model.js +++ b/src/layers/earthquakes/model.js @@ -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', diff --git a/src/layers/earthquakes/ownership.test.mjs b/src/layers/earthquakes/ownership.test.mjs index a86a797a1..c17514184 100644 --- a/src/layers/earthquakes/ownership.test.mjs +++ b/src/layers/earthquakes/ownership.test.mjs @@ -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', + ); +}); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 2bff72090..9d75e3e8e 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -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 }; }, }); diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs index 1041f69d4..ba2544073 100644 --- a/src/layers/weather/clock.test.mjs +++ b/src/layers/weather/clock.test.mjs @@ -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: [], }); }); diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 296acbb30..5081afa67 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -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); diff --git a/src/time/snapshotTimeline.test.mjs b/src/time/snapshotTimeline.test.mjs index a9cf52eac..6e053b611 100644 --- a/src/time/snapshotTimeline.test.mjs +++ b/src/time/snapshotTimeline.test.mjs @@ -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, []); diff --git a/src/time/worldClock.js b/src/time/worldClock.js index c600c7b3f..772252338 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -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} | 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, diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs index 323fa7202..0533db957 100644 --- a/src/time/worldClock.test.mjs +++ b/src/time/worldClock.test.mjs @@ -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', () => { diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index 72b4738dc..19184b482 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -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) => diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css index f644f8543..6f6005859 100644 --- a/src/ui/styles/world-timeline.css +++ b/src/ui/styles/world-timeline.css @@ -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; diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 7785e86c1..def5f43e9 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -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); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 2c8e2981b..6cbede0b5 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -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) => { >
{ @input="scrub" aria-keyshortcuts="+ - 0" @keydown="onKey" + @focus="focused = true" + @blur="focused = false" />
{{ t('Live') }}