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