mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
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:
1 parent
7cb854c923
commit
558e4a919a
17 files changed
+490
-50
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user