Merge pull request #1513 from bilawalsidhu/sameh/world-timeline-launches

Move launches with the timeline, choose its speed, and wait for data while it plays
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-08 12:32:37 -08:00
commit 7ee7dc26b8
31 files changed
+1758 -98

No files matched your search

@@ -0,0 +1,7 @@
- The timeline brings launches up as the time passes them, offers play
speeds from real time to a week a second, and plays smoothly, holding only
when the next weather frame has yet to load, with a spinner by the time
while it waits. The time rides in a bubble over the handle. The track zooms
with a scroll or a pinch, drags along, and goes to the time under a tap, on
a phone as on a desktop. Zooming out on the globe stops while the globe is
still in view instead of leaving it a speck (samehkhamis, #1513).
+43
View File
@@ -7,6 +7,48 @@ import {
isFreeGlobeCamera, isFreeGlobeCamera,
} from '../globeInView.js'; } from '../globeInView.js';
/**
* At the farthest a hand can zoom out, the globe is this share of the
* screen's height across. Cesium sets no limit, so zooming on left the globe
* a speck among the stars.
*/
const MIN_GLOBE_SHARE = 0.3;
/**
* The farthest a hand can zoom out, in metres above the ellipsoid, for a
* vertical field of view. It follows the screen's shape, as the app's own
* whole-globe view does, so that view always lies within it.
* @param {number} fovy Vertical field of view, radians.
*/
export function zoomOutLimit(fovy) {
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
const angular = Math.atan(MIN_GLOBE_SHARE * Math.tan(fovy / 2));
return radius / Math.sin(angular) - radius;
}
/**
* Hold a hand's zoom out to `zoomOutLimit` on the globe, each frame, since the
* field of view follows the screen's shape. The limit never sits below the
* camera: one already past it (a flight, a screen turned) stops there rather
* than jumping in at the next zoom. Flat maps keep Cesium's own, unlimited.
* @returns {() => void} Stop limiting.
*/
export function limitZoomOut(viewer) {
const { scene, camera } = viewer;
return scene.preRender.addEventListener(() => {
const controller = scene.screenSpaceCameraController;
const fovy = camera.frustum.fovy;
if (scene.mode !== Cesium.SceneMode.SCENE3D || !Number.isFinite(fovy)) {
controller.maximumZoomDistance = Number.POSITIVE_INFINITY;
return;
}
controller.maximumZoomDistance = Math.max(
zoomOutLimit(fovy),
camera.positionCartographic?.height ?? 0,
);
});
}
const PINCH_ZOOM_MULTIPLIER = 8; const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120; const MAX_PINCH_PIXEL_DELTA = 120;
@@ -372,6 +414,7 @@ export function createApplicationViewer({ container, creditContainer }) {
// required on touch-first screens. Mouse/trackpad sensitivity stays native. // required on touch-first screens. Mouse/trackpad sensitivity stays native.
if (globalThis.matchMedia?.('(pointer: coarse)').matches) if (globalThis.matchMedia?.('(pointer: coarse)').matches)
viewer.scene.screenSpaceCameraController.zoomFactor *= 3; viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
limitZoomOut(viewer);
// Before any tile builds a draw command: Cesium's per-vertex model // Before any tile builds a draw command: Cesium's per-vertex model
// atmosphere fails to LINK on Apple's Metal backend and kills the // atmosphere fails to LINK on Apple's Metal backend and kills the
// render loop. See app/atmosphereCompat.js. // render loop. See app/atmosphereCompat.js.
+73
View File
@@ -0,0 +1,73 @@
// A hand zooming out stops while the globe is still a fair share of the
// screen, on any screen's shape, rather than leaving it a speck.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { limitZoomOut, zoomOutLimit } from './viewer.js';
// The share of the screen's height the globe spans from `altitude`.
function globeShare(altitude, fovy) {
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
const angular = Math.asin(radius / (radius + altitude));
return Math.tan(angular) / Math.tan(fovy / 2);
}
test("zooming out stops with the globe 30% of the screen's height across", () => {
for (const fovy of [Math.PI / 3, 0.4, 0.2]) {
const limit = zoomOutLimit(fovy);
assert.ok(Math.abs(globeShare(limit, fovy) - 0.3) < 1e-9, `fovy ${fovy}`);
}
});
test('a wide screen, whose view is narrower, may zoom out farther', () => {
assert.ok(zoomOutLimit(0.4) > zoomOutLimit(Math.PI / 3));
assert.ok(zoomOutLimit(Math.PI / 3) > 20_000_000, 'past the views a link opens');
});
function fakeViewer({ fovy = Math.PI / 3, height = 10_000_000 } = {}) {
const scene = {
mode: Cesium.SceneMode.SCENE3D,
preRender: new Cesium.Event(),
screenSpaceCameraController: { maximumZoomDistance: Infinity },
};
const camera = {
frustum: { fovy },
positionCartographic: { height },
};
return { scene, camera };
}
test('the limit follows the screen, and holds a camera already past it', () => {
const viewer = fakeViewer();
const controller = viewer.scene.screenSpaceCameraController;
const stop = limitZoomOut(viewer);
viewer.scene.preRender.raiseEvent();
assert.equal(controller.maximumZoomDistance, zoomOutLimit(Math.PI / 3));
// A wider screen narrows the view, and the globe may go farther.
viewer.camera.frustum.fovy = 0.4;
viewer.scene.preRender.raiseEvent();
assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4));
// Flown past it, the next zoom out stops there instead of jumping in.
viewer.camera.positionCartographic.height = zoomOutLimit(0.4) * 2;
viewer.scene.preRender.raiseEvent();
assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2);
stop();
viewer.camera.positionCartographic.height = 0;
viewer.scene.preRender.raiseEvent();
assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2);
});
test('flat maps keep zooming out unlimited', () => {
const viewer = fakeViewer();
const controller = viewer.scene.screenSpaceCameraController;
limitZoomOut(viewer);
viewer.scene.preRender.raiseEvent();
assert.ok(Number.isFinite(controller.maximumZoomDistance));
viewer.scene.mode = Cesium.SceneMode.SCENE2D;
viewer.camera.frustum = {};
viewer.scene.preRender.raiseEvent();
assert.equal(controller.maximumZoomDistance, Infinity);
});
+12 -1
View File
@@ -32,6 +32,9 @@ export const KEYHOLE_LABEL_FEATHER_MAX_RATIO = 0.4;
*/ */
export const KEYHOLE_OUTSIDE_OPACITY_DEFAULT = 0.01; export const KEYHOLE_OUTSIDE_OPACITY_DEFAULT = 0.01;
// How far the world time moves before the sun and moon move with it: a
// minute moves the sun a quarter of a degree.
const EPHEMERIS_WORLD_STEP_MS = 60_000;
const RING_INSET_PX = 11; const RING_INSET_PX = 11;
const MARKER_INSET_PX = 36; const MARKER_INSET_PX = 36;
const COLLIDING_MARKER_EXTRA_INSET_PX = 32; const COLLIDING_MARKER_EXTRA_INSET_PX = 32;
@@ -452,7 +455,15 @@ export class CelestialRing {
// A jump in the world time moves the sun and moon at once. // A jump in the world time moves the sun and moon at once.
this._skyDate = new Date(); this._skyDate = new Date();
this._skyTime = new Cesium.JulianDate(); this._skyTime = new Cesium.JulianDate();
this._unsubscribeWorldTime = worldClock.subscribe(() => { // Played through, the sky moves on each minute of world time it passes.
this._ephemerisWorldMs = null;
this._unsubscribeWorldTime = worldClock.subscribe(({ timeMs }) => {
if (
this._ephemerisWorldMs !== null &&
Math.abs(timeMs - this._ephemerisWorldMs) < EPHEMERIS_WORLD_STEP_MS
)
return;
this._ephemerisWorldMs = timeMs;
this._ephemerisDirty = true; this._ephemerisDirty = true;
}); });
this.setEnabled(this.enabled); this.setEnabled(this.enabled);
+1 -1
View File
@@ -241,7 +241,7 @@ const VETTED_VARS = new Set([
// Custom properties CSS sets to a fixed length besides their 0 default. The // Custom properties CSS sets to a fixed length besides their 0 default. The
// clearance checks run at each length CSS gives them, not only at 0. // clearance checks run at each length CSS gives them, not only at 0.
const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']); const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
const LIFTS_PX = [0, 50, 80]; const LIFTS_PX = [0, 66, 80];
let liftPx = 0; let liftPx = 0;
function eachLift(check) { function eachLift(check) {
for (const lift of LIFTS_PX) { for (const lift of LIFTS_PX) {
+7 -1
View File
@@ -3487,5 +3487,11 @@
"Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك.", "Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك.",
"World time": "وقت العالم", "World time": "وقت العالم",
"Back to live": "العودة إلى البث المباشر", "Back to live": "العودة إلى البث المباشر",
"Live": "مباشر" "Live": "مباشر",
"1 h/s": "ساعة/ث",
"1 d/s": "يوم/ث",
"1 wk/s": "أسبوع/ث",
"Play speed": "سرعة التشغيل",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل",
"1 min/s": "دقيقة/ث"
} }
+7 -1
View File
@@ -3187,5 +3187,11 @@
"Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht.", "Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht.",
"World time": "Weltzeit", "World time": "Weltzeit",
"Back to live": "Zurück zu live", "Back to live": "Zurück zu live",
"Live": "Live" "Live": "Live",
"1 h/s": "1 Std./s",
"1 d/s": "1 Tag/s",
"1 wk/s": "1 Wo./s",
"Play speed": "Wiedergabegeschwindigkeit",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles",
"1 min/s": "1 Min./s"
} }
+7 -1
View File
@@ -3262,5 +3262,11 @@
"Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP.", "Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP.",
"World time": "Hora mundial", "World time": "Hora mundial",
"Back to live": "Volver al directo", "Back to live": "Volver al directo",
"Live": "En directo" "Live": "En directo",
"1 h/s": "1 h/s",
"1 d/s": "1 d/s",
"1 wk/s": "1 sem/s",
"Play speed": "Velocidad de reproducción",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo",
"1 min/s": "1 min/s"
} }
+7 -1
View File
@@ -3262,5 +3262,11 @@
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.", "Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.",
"World time": "Heure du monde", "World time": "Heure du monde",
"Back to live": "Revenir au direct", "Back to live": "Revenir au direct",
"Live": "Direct" "Live": "Direct",
"1 h/s": "1 h/s",
"1 d/s": "1 j/s",
"1 wk/s": "1 sem/s",
"Play speed": "Vitesse de lecture",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir",
"1 min/s": "1 min/s"
} }
+7 -1
View File
@@ -3262,5 +3262,11 @@
"Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP.", "Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP.",
"World time": "Ora del mondo", "World time": "Ora del mondo",
"Back to live": "Torna alla diretta", "Back to live": "Torna alla diretta",
"Live": "Diretta" "Live": "Diretta",
"1 h/s": "1 h/s",
"1 d/s": "1 g/s",
"1 wk/s": "1 sett/s",
"Play speed": "Velocità di riproduzione",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scorri o pizzica per lo zoom, trascina per spostarti · doppio clic per vedere tutto",
"1 min/s": "1 min/s"
} }
+7 -1
View File
@@ -3112,5 +3112,11 @@
"Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。", "Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。",
"World time": "世界時刻", "World time": "世界時刻",
"Back to live": "ライブに戻る", "Back to live": "ライブに戻る",
"Live": "ライブ" "Live": "ライブ",
"1 h/s": "1時間/秒",
"1 d/s": "1日/秒",
"1 wk/s": "1週間/秒",
"Play speed": "再生速度",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示",
"1 min/s": "1分/秒"
} }
+7 -1
View File
@@ -3262,5 +3262,11 @@
"Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP.", "Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP.",
"World time": "Hora do mundo", "World time": "Hora do mundo",
"Back to live": "Voltar ao vivo", "Back to live": "Voltar ao vivo",
"Live": "Ao vivo" "Live": "Ao vivo",
"1 h/s": "1 h/s",
"1 d/s": "1 d/s",
"1 wk/s": "1 sem/s",
"Play speed": "Velocidade de reprodução",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo",
"1 min/s": "1 min/s"
} }
+7 -1
View File
@@ -3337,5 +3337,11 @@
"Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес.", "Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес.",
"World time": "Мировое время", "World time": "Мировое время",
"Back to live": "Вернуться к прямому эфиру", "Back to live": "Вернуться к прямому эфиру",
"Live": "Сейчас" "Live": "Сейчас",
"1 h/s": "1 ч/с",
"1 d/s": "1 дн/с",
"1 wk/s": "1 нед/с",
"Play speed": "Скорость воспроизведения",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё",
"1 min/s": "1 мин/с"
} }
+7 -1
View File
@@ -3337,5 +3337,11 @@
"Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу.", "Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу.",
"World time": "Світовий час", "World time": "Світовий час",
"Back to live": "Повернутися до прямого ефіру", "Back to live": "Повернутися до прямого ефіру",
"Live": "Зараз" "Live": "Зараз",
"1 h/s": "1 год/с",
"1 d/s": "1 дн/с",
"1 wk/s": "1 тиж/с",
"Play speed": "Швидкість відтворення",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все",
"1 min/s": "1 хв/с"
} }
+7 -1
View File
@@ -3112,5 +3112,11 @@
"Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。", "Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。",
"World time": "世界时间", "World time": "世界时间",
"Back to live": "返回实时", "Back to live": "返回实时",
"Live": "实时" "Live": "实时",
"1 h/s": "1 小时/秒",
"1 d/s": "1 天/秒",
"1 wk/s": "1 周/秒",
"Play speed": "播放速度",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部",
"1 min/s": "1 分钟/秒"
} }
+7 -1
View File
@@ -3112,5 +3112,11 @@
"Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。", "Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。",
"World time": "世界時間", "World time": "世界時間",
"Back to live": "返回即時", "Back to live": "返回即時",
"Live": "即時" "Live": "即時",
"1 h/s": "1 小時/秒",
"1 d/s": "1 天/秒",
"1 wk/s": "1 週/秒",
"Play speed": "播放速度",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部",
"1 min/s": "1 分鐘/秒"
} }
+4
View File
@@ -216,6 +216,7 @@
"▲ {frp} MW" "▲ {frp} MW"
], ],
"es": [ "es": [
"1 min/s",
"1090 MHz", "1090 MHz",
"978 MHz UAT", "978 MHz UAT",
"AIS: --", "AIS: --",
@@ -355,6 +356,7 @@
"iNaturalist" "iNaturalist"
], ],
"fr": [ "fr": [
"1 min/s",
"1090 MHz", "1090 MHz",
"978 MHz UAT", "978 MHz UAT",
"ALTITUDE", "ALTITUDE",
@@ -549,6 +551,7 @@
"▲ {frp} MW" "▲ {frp} MW"
], ],
"it": [ "it": [
"1 min/s",
"1090 MHz", "1090 MHz",
"978 MHz UAT", "978 MHz UAT",
"AFRICA", "AFRICA",
@@ -784,6 +787,7 @@
"▲ {frp} MW" "▲ {frp} MW"
], ],
"pt": [ "pt": [
"1 min/s",
"1090 MHz", "1090 MHz",
"978 MHz UAT", "978 MHz UAT",
"AIS: --", "AIS: --",
+3
View File
@@ -67,6 +67,9 @@ export function createOrbitRendering({
return Boolean( return Boolean(
layerState._enabled && layerState._enabled &&
layerState._dataSource?.show && layerState._dataSource?.show &&
!layerState.launchHidden(
layerState._missionOverlayRecords.get(launchId)?.launch,
) &&
(!layerState._selectedLaunchId || (!layerState._selectedLaunchId ||
layerState._selectedLaunchId === launchId), layerState._selectedLaunchId === launchId),
); );
+4 -1
View File
@@ -411,8 +411,11 @@ export function createOverlays({ state: layerState, services, parts, source }) {
false, false,
); );
layerState._selectedMissionOverlayTimeText = null; layerState._selectedMissionOverlayTimeText = null;
const records = [...layerState._missionOverlayRecords.values()].filter(
(record) => !layerState.launchHidden(record.launch),
);
const entries = selectRocketMissionMarkerOverlayCohort( const entries = selectRocketMissionMarkerOverlayCohort(
Array.from(layerState._missionOverlayRecords.values(), (record) => { records.map((record) => {
const entry = createRocketMissionMarkerOverlayEntry( const entry = createRocketMissionMarkerOverlayEntry(
record.launch, record.launch,
record.anchorPosition, record.anchorPosition,
+37 -10
View File
@@ -22,6 +22,25 @@ export function createRendering({
if (current !== next) graphic.show = next; if (current !== next) graphic.show = next;
} }
// Away from live, a launch shows from its lift-off on, so scrubbing the
// world time forward brings the launches up in the order they flew. The
// selected mission stays, whenever it flies.
function syncPendingLaunches() {
const pending = new Set();
for (const { launch } of layerState._missionOverlayRecords.values()) {
if (layerState.launchHidden(launch)) pending.add(launch.id);
}
const previous = layerState._pendingLaunchIds;
if (
pending.size === previous.size &&
[...pending].every((id) => previous.has(id))
)
return;
layerState._pendingLaunchIds = pending;
parts.overlays.syncMissionOverlayEntries();
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
}
function updateMissionFrame() { function updateMissionFrame() {
if ( if (
!layerState._enabled || !layerState._enabled ||
@@ -31,6 +50,7 @@ export function createRendering({
parts.overlays.hideReplayVehicleOverlay(); parts.overlays.hideReplayVehicleOverlay();
return; return;
} }
syncPendingLaunches();
parts.panel.updateMissionTelemetry(); parts.panel.updateMissionTelemetry();
const time = Cesium.JulianDate.now(layerState._declutterTime); const time = Cesium.JulianDate.now(layerState._declutterTime);
layerState._declutterOccluder.cameraPosition = layerState._declutterOccluder.cameraPosition =
@@ -39,21 +59,28 @@ export function createRendering({
parts.overlays.refreshSelectedMissionOverlayText(); parts.overlays.refreshSelectedMissionOverlayText();
for (const entity of layerState._dataSource.entities.values) { for (const entity of layerState._dataSource.entities.values) {
if (!entity.show) continue; if (!entity.show) continue;
if (!entity.position) continue;
const id = parts.selection.entityLaunchId(entity); const id = parts.selection.entityLaunchId(entity);
if (!id) continue; if (!id) continue;
const pending = layerState._pendingLaunchIds.has(id);
if (entity.polyline)
setGraphicVisibility(entity.polyline, !pending, time);
if (!entity.position) continue;
const position = entity.position.getValue(time); const position = entity.position.getValue(time);
let horizonVisible = Boolean( let horizonVisible =
position && layerState._declutterOccluder.isPointVisible(position), !pending &&
); Boolean(
position && layerState._declutterOccluder.isPointVisible(position),
);
const launchAnchor = entity.id.startsWith('rocket-launch:'); const launchAnchor = entity.id.startsWith('rocket-launch:');
if (launchAnchor) { if (launchAnchor) {
horizonVisible = parts.overlays.missionAnchorVisible( horizonVisible =
layerState._viewer.scene.camera.positionWC, !pending &&
position, parts.overlays.missionAnchorVisible(
id, layerState._viewer.scene.camera.positionWC,
layerState._selectedLaunchId, position,
); id,
layerState._selectedLaunchId,
);
// Keep entity.show reserved for mission-selection isolation. Horizon // Keep entity.show reserved for mission-selection isolation. Horizon
// state is applied to the individual graphics below so a rear-side // state is applied to the individual graphics below so a rear-side
// anchor remains eligible for reevaluation as the camera moves. // anchor remains eligible for reevaluation as the camera moves.
+20
View File
@@ -169,6 +169,26 @@ export function createState({ services }) {
state.coverageChanged = () => worldClock.coverageChanged(); state.coverageChanged = () => worldClock.coverageChanged();
/**
* Whether a launch has yet to lift off at the world time. While live none
* has, so the layer shows every launch in its window as it always did.
*/
state.launchPending = (launch) => {
if (worldClock.isLive()) return false;
const launchMs = Date.parse(launch?.launchTime);
return Number.isFinite(launchMs) && launchMs > state.now();
};
/** Whether a launch is hidden for lifting off after the world time. */
state.launchHidden = (launch) =>
launch?.id !== state._selectedLaunchId && state.launchPending(launch);
/** The launches hidden at the last frame, by id, to spot a change. */
state._pendingLaunchIds = new Set();
state._missionOrbitPatternRegistered = false; state._missionOrbitPatternRegistered = false;
state._pathDistanceCache = new WeakMap(); state._pathDistanceCache = new WeakMap();
+102
View File
@@ -0,0 +1,102 @@
// Away from live, a launch shows from its lift-off on, so scrubbing the world
// time brings the launches up in the order they flew.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { worldClock } from '../../time/worldClock.js';
import { createState } from './state.js';
import { createRendering } from './rendering.js';
import { createSelection } from './selection.js';
const noop = () => {};
const LAUNCH_MS = Date.UTC(2026, 9, 20, 12);
function scene(t) {
t.after(() => worldClock.goLive());
const state = createState({
services: {
overlays: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
},
});
const launch = {
id: 'ceres-1',
launchTime: new Date(LAUNCH_MS).toISOString(),
};
state._enabled = true;
state._dataSource = new Cesium.CustomDataSource('launches');
state._viewer = {
scene: { camera: { positionWC: new Cesium.Cartesian3(2e7, 0, 0) } },
};
state._missionOverlayRecords.set(launch.id, { launch });
const position = Cesium.Cartesian3.fromDegrees(0, 0);
const anchor = state._dataSource.entities.add({
id: `rocket-launch:${launch.id}`,
position,
point: { pixelSize: 5 },
});
const path = state._dataSource.entities.add({
id: `rocket-transfer:${launch.id}`,
polyline: { positions: [position, Cesium.Cartesian3.fromDegrees(1, 1)] },
});
const syncs = { labels: 0, orbits: 0 };
const parts = {
panel: { updateMissionTelemetry: noop },
overlays: {
hideReplayVehicleOverlay: noop,
updateReplayVehicleOverlay: noop,
refreshSelectedMissionOverlayText: noop,
missionAnchorVisible: () => true,
syncMissionOverlayEntries: () => syncs.labels++,
},
orbitRendering: {
syncMissionOrbitPrimitiveVisibility: () => syncs.orbits++,
},
};
const services = {
geometry: { getKeyholeGeometry: noop },
satellites: {},
};
parts.selection = createSelection({ state, services, parts });
const rendering = createRendering({ state, services, parts });
const shown = () => {
rendering.updateMissionFrame();
const time = Cesium.JulianDate.now();
return {
marker: anchor.point.show.getValue(time),
path: path.polyline.show?.getValue(time) ?? true,
};
};
return { state, launch, syncs, shown };
}
test('live shows every launch in the window, lifted off or not', (t) => {
const { state, launch, shown } = scene(t);
assert.equal(state.launchPending(launch), false);
assert.deepEqual(shown(), { marker: true, path: true });
});
test('away from live a launch shows from its lift-off on', (t) => {
const { state, launch, syncs, shown } = scene(t);
worldClock.seek(LAUNCH_MS - 60_000);
assert.equal(state.launchPending(launch), true);
assert.deepEqual(shown(), { marker: false, path: false });
assert.deepEqual(syncs, { labels: 1, orbits: 1 }, 'its label and orbit go');
shown();
assert.deepEqual(syncs, { labels: 1, orbits: 1 }, 'once, not each frame');
worldClock.seek(LAUNCH_MS);
assert.equal(state.launchPending(launch), false);
assert.deepEqual(shown(), { marker: true, path: true });
assert.deepEqual(syncs, { labels: 2, orbits: 2 });
});
test('the selected mission stays whenever it flies', (t) => {
const { state, launch, shown } = scene(t);
state._selectedLaunchId = launch.id;
worldClock.seek(LAUNCH_MS - 60_000);
assert.deepEqual(shown(), { marker: true, path: true });
});
+94 -4
View File
@@ -4,6 +4,15 @@ export const RADAR_MAX_GAP_MS = 30 * 60_000;
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000; export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000; export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000; export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
/** The longest play waits on a frame before it goes on without it. */
export const PLAY_WAIT_MS = 8_000;
// Playing on its own, the world does not wait on the frame it is at: that
// frame loads as play goes on. Play holds only at the next frame while the
// one before it still loads, then goes on to it once it shows, so every frame
// shows, play stops only for a load that is slow, and a frame loaded ahead
// costs play nothing.
let clocks = 0;
/** /**
* Transient observed time. Products must honor the signal before installing a * Transient observed time. Products must honor the signal before installing a
@@ -33,6 +42,34 @@ export function createWeatherClock({
let generation = 0; let generation = 0;
let timer = null; let timer = null;
let pending = null; let pending = null;
// This clock's name on the world, for its holds and its gate.
const holdId = `weather:${++clocks}`;
// While the world plays, the hold on it at the next frame as the one before
// it loads, and the timer that lets play go on should that frame never
// come; and whether the move to the next frame waits on it.
let waiting = null;
let waitTimer = null;
let deferred = false;
const stopWaiting = () => {
const release = waiting;
waiting = null;
if (waitTimer !== null) clearTimeout(waitTimer);
waitTimer = null;
release?.();
};
const startWaiting = () => {
if (waiting || destroyed || !registry.size) return;
waiting = world.wait(holdId);
waitTimer = setTimeout(() => {
waitTimer = null;
stopWaiting();
// The frame that never came gives way to the one play is at.
if (deferred) void transition();
}, PLAY_WAIT_MS);
};
// Playing on its own, the world waits for frames as they load.
const worldPlaysOn = (state = world.getState()) =>
state.playing && !state.live && !state.stepping;
let destroyed = false; let destroyed = false;
const timesFor = (product) => const timesFor = (product) =>
[...new Set(product.getTimes())] [...new Set(product.getTimes())]
@@ -65,6 +102,9 @@ export function createWeatherClock({
mode, mode,
target, target,
playing, playing,
// Frames come on one after another: this clock replays them, or the world
// plays on through them, so the next is worth loading ahead.
advancing: playing || worldPlaysOn(),
timeline: getTimeline(), timeline: getTimeline(),
products: [...registry.values()].map(({ product }) => ({ products: [...registry.values()].map(({ product }) => ({
id: product.id, id: product.id,
@@ -130,7 +170,10 @@ export function createWeatherClock({
cancelTimer(); cancelTimer();
const owner = ++generation; const owner = ++generation;
if (!canPlay()) stopPlaying(); if (!canPlay()) stopPlaying();
// A scrub drops what loads and moves on; play goes on as this loads.
applied = selections(); applied = selections();
deferred = false;
stopWaiting();
for (const entry of registry.values()) entry.controller?.abort(); for (const entry of registry.values()) entry.controller?.abort();
// Start all products together; a rejection must not skip the settle barrier. // Start all products together; a rejection must not skip the settle barrier.
const tasks = [...registry.values()].map((entry) => { const tasks = [...registry.values()].map((entry) => {
@@ -144,10 +187,13 @@ export function createWeatherClock({
}); });
const result = Promise.allSettled(tasks).then(() => { const result = Promise.allSettled(tasks).then(() => {
if (destroyed || owner !== generation) return false; if (destroyed || owner !== generation) return false;
stopWaiting();
pending = null; pending = null;
if (!canPlay()) stopPlaying(); if (!canPlay()) stopPlaying();
notify(); notify();
schedule(); schedule();
// Play held at the next frame for this one goes on to it.
if (deferred) void transition();
return true; return true;
}); });
pending = result; pending = result;
@@ -269,8 +315,10 @@ export function createWeatherClock({
}, },
refresh() { refresh() {
if (destroyed) return Promise.resolve(false); if (destroyed) return Promise.resolve(false);
// New frames, or products added or dropped. // New frames, or products added or dropped, and with them the next.
world.coverageChanged(); world.coverageChanged();
gated = undefined;
gateNext(world.getState());
if (!canPlay()) { if (!canPlay()) {
stopPlaying(); stopPlaying();
cancelTimer(); cancelTimer();
@@ -289,6 +337,8 @@ export function createWeatherClock({
stopPlaying(); stopPlaying();
destroyed = true; destroyed = true;
++generation; ++generation;
stopWaiting();
world.gate(holdId, null);
cancelTimer(); cancelTimer();
unfollowWorld(); unfollowWorld();
// The last weather clock leaves the world live, as a new one expects. // The last weather clock leaves the world live, as a new one expects.
@@ -306,18 +356,58 @@ export function createWeatherClock({
// changes the mode or a product's frame. // changes the mode or a product's frame.
function followWorld(state) { function followWorld(state) {
if (destroyed) return; if (destroyed) return;
gateNext(state);
// Play starting or stopping changes whether the next frame loads ahead.
const playsOn = worldPlaysOn(state);
const playChanged = playsOn !== playedOn;
playedOn = playsOn;
const nextMode = state.live ? 'latest' : 'history'; const nextMode = state.live ? 'latest' : 'history';
const nextTarget = state.live const nextTarget = state.live
? null ? null
: new Date(Math.floor(state.timeMs)).toISOString(); : new Date(Math.floor(state.timeMs)).toISOString();
const key = nextTarget ?? 'latest'; const key = nextTarget ?? 'latest';
if (key === seen) return; if (key === seen) {
if (playChanged) notify();
return;
}
seen = key; seen = key;
const modeChanged = nextMode !== mode; const modeChanged = nextMode !== mode;
mode = nextMode; mode = nextMode;
target = nextTarget; target = nextTarget;
if (moving || modeChanged || selections() !== applied) void transition(); if (!moving && !modeChanged && selections() === applied) {
else notify(); // Back at the frame that loads, play need not hold for it.
if (deferred) {
deferred = false;
stopWaiting();
}
// Played through, the time moves each frame with nothing new to show:
// listeners hear of the frames as they change.
if (!playsOn || playChanged) notify();
return;
}
// Playing on into the next frame while the one before it loads, the
// world holds here until that one shows.
if (!moving && !modeChanged && pending && playsOn) {
deferred = true;
startWaiting();
notify();
return;
}
void transition();
}
// Playing on its own, the world stops at each next frame to load it rather
// than run past frames between its ticks.
let gated = null;
let playedOn = false;
function gateNext(state) {
const next = worldPlaysOn(state)
? (getTimeline()
.map((time) => Date.parse(time))
.find((time) => time > Math.floor(state.timeMs)) ?? null)
: null;
if (next === gated) return;
gated = next;
world.gate(holdId, next);
} }
const unfollowWorld = world.subscribe(followWorld); const unfollowWorld = world.subscribe(followWorld);
driver = world.attachDriver({ driver = world.attachDriver({
+214
View File
@@ -6,6 +6,7 @@ import {
REGIONAL_INFRARED_MAX_GAP_MS, REGIONAL_INFRARED_MAX_GAP_MS,
GLOBAL_INFRARED_MAX_GAP_MS, GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS, LIGHTNING_MAX_GAP_MS,
PLAY_WAIT_MS,
} from './clock.js'; } from './clock.js';
import { createWorldClock } from '../../time/worldClock.js'; import { createWorldClock } from '../../time/worldClock.js';
@@ -493,3 +494,216 @@ test('a clock destroyed while it registers a product lets go of it', (t) => {
assert.equal(world.coverage(), null); assert.equal(world.coverage(), null);
assert.equal(world.getState().supported, false); assert.equal(world.getState().supported, false);
}); });
// A radar whose frames load only when the test says.
function slowRadar(minutes) {
const radar = product('radar', minutes);
radar.loads = [];
radar.apply = (selected, { signal }) =>
new Promise((resolve) => {
radar.loads.push({
selected,
signal,
done() {
radar.shown = selected;
resolve(true);
},
});
});
return radar;
}
test('play stops at the next frame, not past it, until it shows', async (t) => {
const { clock, world, tick } = harness(t);
const radar = slowRadar([0, 10, 20]);
clock.register(radar);
world.seek(Date.parse(time(1)));
await flush();
radar.loads.at(-1).done();
await flush();
world.play({ rate: 3_600, continuous: true });
world.coverageChanged();
tick(1_000);
assert.equal(
world.now(),
Date.parse(time(10)),
'an hour on, but held at the frame',
);
});
test('playing, a frame loads as play goes on, and play holds at the next until it shows', async (t) => {
const { clock, world, tick } = harness(t);
const radar = slowRadar([0, 10, 20, 30]);
clock.register(radar);
world.seek(Date.parse(time(9)));
await flush();
radar.loads.at(-1).done();
await flush();
world.play({ rate: 60, continuous: true });
// Into the next frame (the page's frames tell of it): it loads as play goes on.
tick(2_000);
world.coverageChanged();
await flush();
assert.equal(radar.loads.at(-1).selected, time(10));
assert.equal(
world.getState().buffering,
false,
'no hold for the frame play is at',
);
// At the frame after while that one still loads, play holds there.
tick(10_000);
world.coverageChanged();
await flush();
assert.equal(world.now(), Date.parse(time(20)));
assert.equal(world.getState().buffering, true);
assert.equal(
radar.loads.at(-1).selected,
time(10),
'the frame before still loads',
);
tick(5_000);
assert.equal(world.now(), Date.parse(time(20)), 'held while it loads');
radar.loads.at(-1).done();
await flush();
assert.equal(
radar.loads.at(-1).selected,
time(20),
'and on to the next once it shows',
);
assert.equal(world.getState().buffering, false);
tick(1_000);
assert.equal(world.now(), Date.parse(time(20)) + 60_000);
// A scrub aborts what loads and loads where the hand is, with no wait.
world.pause();
world.seek(Date.parse(time(30)));
await flush();
const scrubbed = radar.loads.at(-1);
world.seek(Date.parse(time(0)));
await flush();
assert.equal(scrubbed.signal.aborted, true);
assert.equal(radar.loads.at(-1).selected, time(0));
assert.equal(world.getState().buffering, false);
});
test('a frame that never comes holds play only so long', async (t) => {
const { clock, world, tick } = harness(t);
const radar = slowRadar([0, 10, 20]);
clock.register(radar);
world.seek(Date.parse(time(9)));
await flush();
radar.loads.at(-1).done();
await flush();
world.play({ rate: 60, continuous: true });
tick(2_000);
world.coverageChanged();
await flush();
tick(10_000);
world.coverageChanged();
await flush();
assert.equal(world.getState().buffering, true);
const stuck = radar.loads.at(-1);
tick(PLAY_WAIT_MS);
await flush();
assert.equal(world.getState().buffering, false, 'play goes on without it');
assert.equal(stuck.signal.aborted, true);
assert.equal(radar.loads.at(-1).selected, time(20), 'loading where play is');
});
test('held at the next frame, a scrub back to the one loading lets play go', async (t) => {
const { clock, world, tick } = harness(t);
const radar = slowRadar([0, 10, 20]);
clock.register(radar);
world.seek(Date.parse(time(9)));
await flush();
radar.loads.at(-1).done();
await flush();
world.play({ rate: 60, continuous: true });
tick(2_000);
world.coverageChanged();
await flush();
tick(10_000);
world.coverageChanged();
await flush();
assert.equal(world.getState().buffering, true);
world.pause();
world.seek(Date.parse(time(11)));
await flush();
world.play({ rate: 60, continuous: true });
assert.equal(
world.getState().buffering,
false,
'no hold for the frame it is at',
);
const at = world.now();
tick(1_000);
assert.equal(world.now(), at + 60_000);
});
test('a frame in flight as play starts loads as it goes on; a refresh does not hold', async (t) => {
const { clock, world } = harness(t);
const radar = slowRadar([0, 10, 20]);
clock.register(radar);
world.seek(Date.parse(time(5)));
await flush();
world.play({ continuous: true });
assert.equal(world.getState().buffering, false);
radar.loads.at(-1).done();
await flush();
void clock.refresh();
await flush();
assert.equal(world.getState().buffering, false, 'the same frames, no hold');
radar.loads.at(-1).done();
});
test('weather clocks sharing a world gate it each on their own', async (t) => {
let mono = 0;
const world = createWorldClock({
now: () => Date.parse(time(30)),
monotonicNow: () => mono,
setInterval: () => 0,
clearInterval() {},
});
const a = createWeatherClock({ world, now: () => Date.parse(time(30)) });
const b = createWeatherClock({ world, now: () => Date.parse(time(30)) });
t.after(() => {
a.destroy();
b.destroy();
});
a.register(product('radar', [0, 10]));
b.register(product('clouds', [0, 15]));
world.seek(Date.parse(time(1)));
await flush();
world.play({ rate: 3_600, continuous: true });
world.coverageChanged();
b.destroy();
world.coverageChanged();
mono += 1_000;
assert.equal(
world.now(),
Date.parse(time(10)),
'the other clock’s gate stays',
);
});
test('played through, listeners hear of frames as they change, and of play stopping', async (t) => {
const { clock, world, tick } = harness(t);
const radar = product('radar', [0, 10, 20]);
clock.register(radar);
world.seek(Date.parse(time(1)));
await flush();
world.play({ rate: 60, continuous: true });
await flush();
const heard = [];
clock.subscribe((state) => heard.push(state));
// Seconds of world time within one frame: nothing new to show.
for (let i = 0; i < 5; i++) {
tick(100);
world.coverageChanged();
}
await flush();
assert.equal(heard.length, 0);
world.pause();
await flush();
assert.equal(heard.at(-1)?.advancing, false, 'heard play stop');
});
+3 -2
View File
@@ -109,7 +109,8 @@ export function createWeatherLayer({
const notify = () => listener?.(); const notify = () => listener?.();
const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time); const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time);
const unsubscribeClock = clock?.subscribe(() => { const unsubscribeClock = clock?.subscribe(() => {
if (!clock.getState().playing || suspended()) rendering?.cancelPrefetch?.(); if (!clock.getState().advancing || suspended())
rendering?.cancelPrefetch?.();
notify(); notify();
}); });
const observationDelayed = () => const observationDelayed = () =>
@@ -211,7 +212,7 @@ export function createWeatherLayer({
if ( if (
!enabled || !enabled ||
suspended() || suspended() ||
!(clock ? clock.getState().playing : playing) !(clock ? clock.getState().advancing : playing)
) )
return; return;
const times = manifest?.times ?? []; const times = manifest?.times ?? [];
+81 -11
View File
@@ -24,15 +24,21 @@ const MAX_DATE_MS = 8.64e15;
export const isWorldTime = (ms) => export const isWorldTime = (ms) =>
Number.isFinite(ms) && Math.abs(ms) <= MAX_DATE_MS; Number.isFinite(ms) && Math.abs(ms) <= MAX_DATE_MS;
/** How often subscribers hear of a continuously playing cursor. */ /**
* How often subscribers hear of a continuously playing cursor with no
* animation frames to go by; with them, they hear of it every frame.
*/
export const WORLD_CLOCK_TICK_MS = 250; export const WORLD_CLOCK_TICK_MS = 250;
/** /**
* @param {{now?: () => number, monotonicNow?: () => number, * @param {{now?: () => number, monotonicNow?: () => number,
* setInterval?: typeof globalThis.setInterval, * setInterval?: typeof globalThis.setInterval,
* clearInterval?: typeof globalThis.clearInterval, * clearInterval?: typeof globalThis.clearInterval,
* requestFrame?: typeof globalThis.requestAnimationFrame | null,
* cancelFrame?: typeof globalThis.cancelAnimationFrame | null,
* follow?: Iterable<string>}} [options] `follow` lists the layers that * follow?: Iterable<string>}} [options] `follow` lists the layers that
* move with the cursor from the start. * move with the cursor from the start. Playing, the clock tells its
* listeners of the time each animation frame, or each tick without frames.
*/ */
export function createWorldClock({ export function createWorldClock({
// Read at each call, so a stand-in for Date.now (a test's, a harness's) // Read at each call, so a stand-in for Date.now (a test's, a harness's)
@@ -41,6 +47,8 @@ export function createWorldClock({
monotonicNow = () => globalThis.performance.now(), monotonicNow = () => globalThis.performance.now(),
setInterval = globalThis.setInterval, setInterval = globalThis.setInterval,
clearInterval = globalThis.clearInterval, clearInterval = globalThis.clearInterval,
requestFrame = globalThis.requestAnimationFrame?.bind(globalThis) ?? null,
cancelFrame = globalThis.cancelAnimationFrame?.bind(globalThis) ?? null,
follow = [], follow = [],
} = {}) { } = {}) {
const listeners = new Set(); const listeners = new Set();
@@ -60,8 +68,13 @@ export function createWorldClock({
let active = null; let active = null;
let ticker = null; let ticker = null;
let notifying = 0; let notifying = 0;
// Layers loading what the cursor needs; continuous play holds while any is.
const waits = new Map();
// Times continuous play may not pass yet, by layer: each one's next frame.
const gates = new Map();
const continuous = () => playing && !active; const continuous = () => playing && !active;
const buffering = () => waits.size > 0;
const isFollowed = (id) => following.has(id) || (holds.get(id) ?? 0) > 0; const isFollowed = (id) => following.has(id) || (holds.get(id) ?? 0) > 0;
const followedIds = () => [ const followedIds = () => [
...new Set([...following, ...[...holds.keys()].filter(isFollowed)]), ...new Set([...following, ...[...holds.keys()].filter(isFollowed)]),
@@ -87,8 +100,11 @@ export function createWorldClock({
}; };
const time = () => { const time = () => {
if (live) return now(); if (live) return now();
if (continuous()) return cursorMs + (monotonicNow() - anchorMono) * rate; if (!continuous() || buffering()) return cursorMs;
return cursorMs; let at = cursorMs + (monotonicNow() - anchorMono) * rate;
for (const gate of gates.values())
if (gate >= cursorMs) at = Math.min(at, gate);
return at;
}; };
const getState = () => const getState = () =>
Object.freeze({ Object.freeze({
@@ -96,6 +112,10 @@ export function createWorldClock({
timeMs: time(), timeMs: time(),
playing, playing,
rate, rate,
// A driver steps the clock, at its own pace rather than the rate.
stepping: Boolean(active),
// Playing, but held while a layer loads what the cursor needs.
buffering: continuous() && buffering(),
following: followedIds(), following: followedIds(),
supported: isSupported(), supported: isSupported(),
}); });
@@ -116,11 +136,23 @@ export function createWorldClock({
} }
} }
}; };
// Playing, the time moves on smoothly: each frame, so the timeline and the
// globe move with it and a layer's next frame is met as it comes, or each
// tick where there are no frames.
const frames = Boolean(requestFrame && cancelFrame);
const frame = () => {
ticker = requestFrame(frame);
notify();
};
const syncTicker = () => { const syncTicker = () => {
if (continuous() && listeners.size) { if (continuous() && listeners.size) {
ticker ??= setInterval(notify, WORLD_CLOCK_TICK_MS); if (ticker === null)
ticker = frames
? requestFrame(frame)
: setInterval(notify, WORLD_CLOCK_TICK_MS);
} else if (ticker !== null) { } else if (ticker !== null) {
clearInterval(ticker); if (frames) cancelFrame(ticker);
else clearInterval(ticker);
ticker = null; ticker = null;
} }
}; };
@@ -152,23 +184,27 @@ export function createWorldClock({
anchorMono = monotonicNow(); anchorMono = monotonicNow();
changed(); changed();
}, },
/** Back to the wall clock; playback stops. */ /** Back to the wall clock; playback stops and its rate goes back to 1. */
goLive() { goLive() {
if (live && !playing) return; if (live && !playing && rate === 1) return;
const stepping = active; const stepping = active;
live = true; live = true;
cursorMs = null; cursorMs = null;
playing = false; playing = false;
active = null; active = null;
rate = 1;
// Its driven(false) finds nothing left to stop. // Its driven(false) finds nothing left to stop.
stepping?.driver.pause(); stepping?.driver.pause();
changed(); changed();
}, },
/** Play from the cursor (from now, when live). */ /**
play({ rate: nextRate } = {}) { * Play from the cursor (from now, when live). A driver steps it unless
* `continuous`, which runs the cursor at the rate past every driver.
*/
play({ rate: nextRate, continuous: runOn = false } = {}) {
if (nextRate !== undefined) api.setRate(nextRate); if (nextRate !== undefined) api.setRate(nextRate);
if (playing) return; if (playing) return;
for (let index = drivers.length - 1; index >= 0; index -= 1) { for (let index = drivers.length - 1; !runOn && index >= 0; index -= 1) {
if (drivers[index].driver.play() !== false) return; if (drivers[index].driver.play() !== false) return;
} }
if (live) { if (live) {
@@ -192,6 +228,40 @@ export function createWorldClock({
togglePlay() { togglePlay() {
return playing ? api.pause() : api.play(); return playing ? api.pause() : api.play();
}, },
/**
* Hold continuous play where it is while layer `id` loads what the cursor
* needs, as a video waits on its buffer; play goes on once every hold is
* released. A seek still moves the cursor. Returns the release. Neither
* tells listeners (play's own ticks do), so a layer can take and release a
* hold mid-change without anyone moving the clock under it.
*/
wait(id) {
settle();
waits.set(id, (waits.get(id) ?? 0) + 1);
let held = true;
return () => {
if (!held) return;
held = false;
const count = waits.get(id) - 1;
if (count > 0) {
waits.set(id, count);
return;
}
waits.delete(id);
// Play goes on from where it held, not from where it would have been.
if (!buffering()) anchorMono = monotonicNow();
};
},
/**
* Keep continuous play from passing `ms` (null lifts it): the time layer
* `id` shows next, which it has yet to load. Play stops there, to wait on
* the load as it starts, rather than rush past frames between ticks.
*/
gate(id, ms) {
settle();
if (ms === null || ms === undefined) gates.delete(id);
else if (isWorldTime(ms)) gates.set(id, ms);
},
/** Simulated ms per real ms for continuous playback. */ /** Simulated ms per real ms for continuous playback. */
setRate(nextRate) { setRate(nextRate) {
if (!Number.isFinite(nextRate) || nextRate <= 0) if (!Number.isFinite(nextRate) || nextRate <= 0)
+82
View File
@@ -305,3 +305,85 @@ test('a coverage that throws counts as none', (t) => {
assert.equal(clock.getState().supported, false); assert.equal(clock.getState().supported, false);
assert.equal(clock.coverage(), null); assert.equal(clock.coverage(), null);
}); });
test('continuous play runs past every driver, and live resets the rate', () => {
const t = fakeTime();
const clock = createWorldClock(t.options);
const calls = [];
clock.attachDriver({ play: () => calls.push('play'), pause() {} });
clock.seek(t.wall - 60_000);
clock.play({ rate: 30, continuous: true });
assert.deepEqual(calls, []);
t.advance(1_000);
assert.equal(clock.now(), t.wall - 61_000 + 30_000);
clock.goLive();
assert.equal(clock.getRate(), 1);
});
test('continuous play holds while a layer waits, and goes on from there', () => {
const t = fakeTime();
const clock = createWorldClock(t.options);
const start = t.wall - 600_000;
clock.seek(start);
clock.play({ rate: 60, continuous: true });
t.advance(1_000);
assert.equal(clock.now(), start + 60_000);
const release = clock.wait('radar');
const other = clock.wait('clouds');
assert.equal(clock.getState().buffering, true);
t.advance(5_000);
assert.equal(clock.now(), start + 60_000, 'held while loading');
release();
release();
t.advance(1_000);
assert.equal(clock.now(), start + 60_000, 'held until every layer is ready');
other();
assert.equal(clock.getState().buffering, false);
t.advance(1_000);
assert.equal(clock.now(), start + 120_000, 'on from where it held');
});
test('a seek still moves a waiting clock', () => {
const t = fakeTime();
const clock = createWorldClock(t.options);
clock.seek(t.wall - 600_000);
clock.play({ continuous: true });
const release = clock.wait('radar');
clock.seek(t.wall - 60_000);
assert.equal(clock.now(), t.wall - 60_000);
release();
});
test('playing, listeners hear of the time every animation frame', () => {
const t = fakeTime();
const frames = new Map();
let nextFrame = 1;
const clock = createWorldClock({
...t.options,
requestFrame: (fn) => {
frames.set(nextFrame, fn);
return nextFrame++;
},
cancelFrame: (id) => frames.delete(id),
});
const heard = [];
clock.subscribe((state) => heard.push(state.timeMs));
const start = t.wall - 60_000;
clock.seek(start);
clock.play({ rate: 60 });
assert.equal(t.timers.size, 0, 'no ticks with frames to go by');
assert.equal(frames.size, 1);
const runFrame = () => {
const [[id, fn]] = frames;
frames.delete(id);
fn();
};
heard.length = 0;
for (let i = 0; i < 3; i++) {
t.advance(16);
runFrame();
}
assert.deepEqual(heard, [start + 960, start + 1_920, start + 2_880]);
clock.pause();
assert.equal(frames.size, 0);
});
+95 -12
View File
@@ -8,10 +8,18 @@ const LIVE_TICK_MS = 1000;
const LIVE_EDGE = 0.005; const LIVE_EDGE = 0.005;
/** The least that counts as at now, past the readout's own second. */ /** The least that counts as at now, past the readout's own second. */
const LIVE_EDGE_MS = 5000; const LIVE_EDGE_MS = 5000;
/** The speeds play offers, in world ms per real ms. */
export const WORLD_TIME_SPEEDS = Object.freeze({
real: 1,
minute: 60,
hour: 3_600,
day: 86_400,
week: 604_800,
});
/** /**
* The world clock as the timeline shows it: `{live, playing, timeMs, * The world clock as the timeline shows it: `{live, playing, stepping,
* 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. It follows the page's world clock, which tells it of every move, and
@@ -20,26 +28,68 @@ const LIVE_EDGE_MS = 5000;
* track's end keep up with now. * track's end keep up with now.
*/ */
export const useWorldTimeStore = defineStore('gev-world-time', () => { export const useWorldTimeStore = defineStore('gev-world-time', () => {
const spanOf = (state) => { // The time the layers cover, reaching to now.
if (!state.supported) return null; const reach = () => {
const coverage = worldClock.coverage(); const coverage = worldClock.coverage();
if (!coverage) return null; if (!coverage) return null;
const now = worldClock.wallNow(); const now = worldClock.wallNow();
return Object.freeze({ return {
start: Math.min(coverage.start, now, state.timeMs), start: Math.min(coverage.start, now),
end: Math.max(coverage.end, now, state.timeMs), end: Math.max(coverage.end, now),
marks: coverage.marks, marks: coverage.marks,
};
};
const spanOf = (state) => {
if (!state.supported) return null;
const covered = reach();
if (!covered) return null;
return Object.freeze({
start: Math.min(covered.start, state.timeMs),
end: Math.max(covered.end, state.timeMs),
marks: covered.marks,
}); });
}; };
const nearNow = (ms, length) =>
Math.abs(ms - worldClock.wallNow()) <=
Math.max(LIVE_EDGE_MS, length * LIVE_EDGE);
// Whether the track ends at now (and moves on with it), not past it.
const endsAtNow = (covered) =>
covered.end - worldClock.wallNow() <= LIVE_EDGE_MS;
// Play stops at the end of the track: at now it goes live, past now (a
// satellite's future) it holds there.
let stopping = false;
const stopAtEnd = (state) => {
if (stopping || !state.playing || state.live || !state.supported)
return false;
const covered = reach();
if (!covered || state.timeMs < covered.end) return false;
stopping = true;
try {
if (endsAtNow(covered)) worldClock.goLive();
else {
// Back to the end first, so nothing hears the overshoot as paused.
worldClock.seek(covered.end);
worldClock.pause();
worldClock.seek(covered.end);
}
} finally {
stopping = false;
}
return true;
};
const read = (state = worldClock.getState()) => const read = (state = worldClock.getState()) =>
Object.freeze({ Object.freeze({
live: state.live, live: state.live,
playing: state.playing, playing: state.playing,
stepping: state.stepping,
buffering: state.buffering,
timeMs: state.timeMs, timeMs: state.timeMs,
supported: state.supported, supported: state.supported,
span: spanOf(state), span: spanOf(state),
}); });
const time = shallowRef(read()); const time = shallowRef(read());
const speed = shallowRef('real');
const rate = () => WORLD_TIME_SPEEDS[speed.value];
let ticker = null; let ticker = null;
const syncTicker = () => { const syncTicker = () => {
// Playing, the clock tells of the time itself. // Playing, the clock tells of the time itself.
@@ -54,6 +104,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
} }
}; };
const unsubscribe = worldClock.subscribe((state) => { const unsubscribe = worldClock.subscribe((state) => {
if (stopAtEnd(state)) return;
time.value = read(state); time.value = read(state);
syncTicker(); syncTicker();
}); });
@@ -66,13 +117,45 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
return { return {
time, time,
togglePlay: () => worldClock.togglePlay(), /**
* Play runs the track at the speed chosen, from the start when it is at
* its end (at live, say), as a video plays again from the top. It runs
* the clock itself, past weather's own replay: weather follows the time
* frame by frame like every other layer the track covers.
*/
togglePlay() {
const { playing, live, timeMs, span } = time.value;
if (playing) return worldClock.pause();
const covered = reach();
if (span && covered && (live || timeMs >= covered.end))
worldClock.seek(span.start);
worldClock.play({ rate: rate(), continuous: true });
},
speed,
/**
* Choose how fast play runs; playing, it changes at once. Weather's own
* replay steps at its own pace, so a speed chosen while it plays takes
* playback over from where it is.
*/
setSpeed(next) {
if (!Object.hasOwn(WORLD_TIME_SPEEDS, next)) return;
speed.value = next;
const { playing, stepping } = worldClock.getState();
if (!playing) return;
if (!stepping) return worldClock.setRate(rate());
worldClock.pause();
worldClock.play({ rate: rate(), continuous: true });
},
goLive: () => worldClock.goLive(), goLive: () => worldClock.goLive(),
/** Scrub to `ms`: playback stops where the hand puts it; at now, live. */ /**
scrub(ms) { * Scrub to `ms`: playback stops where the hand puts it; at now, live.
* How near now counts as now goes by `length`, the stretch the track
* shows, all of the span unless it is zoomed.
*/
scrub(ms, length) {
const span = time.value.span; const span = time.value.span;
const edge = span ? (span.end - span.start) * LIVE_EDGE : 0; length ??= span ? span.end - span.start : 0;
if (Math.abs(ms - worldClock.wallNow()) <= Math.max(LIVE_EDGE_MS, edge)) { if (nearNow(ms, length)) {
worldClock.goLive(); worldClock.goLive();
return; return;
} }
+126 -23
View File
@@ -5,8 +5,9 @@
--world-timeline-lift: 0px; --world-timeline-lift: 0px;
} }
/* The bar and the time's bubble over its handle. */
body:has(#world-timeline) { body:has(#world-timeline) {
--world-timeline-lift: 50px; --world-timeline-lift: 66px;
} }
/* Clean view, recording and the cockpit put the dock away; the bar goes too. */ /* Clean view, recording and the cockpit put the dock away; the bar goes too. */
@@ -22,7 +23,10 @@ body.cockpit-mode #world-timeline {
display: none; display: none;
} }
/* Time runs left to right in every language, so the handle, the fill, the
ticks and the bubble agree under a right-to-left page too. */
#world-timeline { #world-timeline {
direction: ltr;
position: fixed; position: fixed;
bottom: 2vh; bottom: 2vh;
left: 50%; left: 50%;
@@ -77,32 +81,83 @@ body.cockpit-mode #world-timeline {
text-transform: uppercase; text-transform: uppercase;
} }
#world-timeline .world-timeline-time { #world-timeline .world-timeline-speed {
overflow: hidden; flex: none;
font-family: var(--font-mono);
font-size: 13px;
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
} }
#world-timeline .world-timeline-edge { /* The time rides over the handle. It slides across itself as the handle
flex: none; crosses the track (its start at the handle at the track's start, its end at
color: var(--text-secondary); the end), so however wide its date it stays within the track. */
#world-timeline .world-timeline-time {
--inset: 0px;
position: absolute;
bottom: calc(100% + 6px);
left: calc(var(--inset) + (100% - var(--inset)) * var(--at));
padding: 2px 8px;
border: 1px solid rgba(0, 212, 255, 0.4);
border-radius: 6px;
background: var(--glass-bg);
color: var(--text-primary);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
transform: translateX(calc(var(--at) * -100%));
pointer-events: none;
}
/* Playing, held while a layer loads the time's data: a spinner by the time. */
#world-timeline .world-timeline-time.is-waiting {
display: inline-flex;
align-items: center;
gap: 6px;
}
#world-timeline .world-timeline-spinner {
width: 10px;
height: 10px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: var(--accent);
border-radius: 50%;
animation: world-timeline-spin 0.9s linear infinite;
}
@keyframes world-timeline-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
#world-timeline .world-timeline-spinner {
animation: none;
border-color: var(--accent);
}
} }
/* The track: ticks for each launch or frame, the elapsed fill, and a native /* The track: ticks for each launch or frame, the elapsed fill, and a native
range input on top for dragging and the keyboard. */ range input on top for dragging and the keyboard. */
#world-timeline .world-timeline-track { #world-timeline .world-timeline-track {
position: relative; position: relative;
/* Two fingers pinch the track, not the page. */
touch-action: none;
flex: 1 1 auto; flex: 1 1 auto;
cursor: pointer;
min-width: 80px; min-width: 80px;
height: 24px; height: 24px;
} }
/* Zoomed, the track's ends fade, as it goes on past them. */
#world-timeline .world-timeline-track.is-zoomed::before {
background: linear-gradient(
90deg,
transparent,
rgba(123, 189, 211, 0.22) 12px,
rgba(123, 189, 211, 0.22) calc(100% - 12px),
transparent
);
}
#world-timeline .world-timeline-track::before { #world-timeline .world-timeline-track::before {
position: absolute; position: absolute;
top: 50%; top: 50%;
@@ -139,6 +194,17 @@ body.cockpit-mode #world-timeline {
pointer-events: none; pointer-events: none;
} }
/* Zoomed, the track drags along. */
#world-timeline .world-timeline-track.is-zoomed {
cursor: grab;
}
#world-timeline .world-timeline-track.is-zoomed:active {
cursor: grabbing;
}
/* Only the handle takes the pointer as a slider; the track around it takes
taps, drags and pinches itself. */
#world-timeline .world-timeline-scrub { #world-timeline .world-timeline-scrub {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -146,8 +212,29 @@ body.cockpit-mode #world-timeline {
height: 100%; height: 100%;
margin: 0; margin: 0;
background: transparent; background: transparent;
cursor: pointer;
appearance: none; appearance: none;
pointer-events: none;
}
#world-timeline .world-timeline-scrub::-webkit-slider-thumb {
cursor: ew-resize;
pointer-events: auto;
}
#world-timeline .world-timeline-scrub::-moz-range-thumb {
cursor: ew-resize;
pointer-events: auto;
}
/* The time dragged off the track: the handle waits at its edge, unseen. */
#world-timeline .world-timeline-scrub.is-off-view::-webkit-slider-thumb {
opacity: 0;
pointer-events: none;
}
#world-timeline .world-timeline-scrub.is-off-view::-moz-range-thumb {
opacity: 0;
pointer-events: none;
} }
#world-timeline .world-timeline-scrub::-webkit-slider-runnable-track { #world-timeline .world-timeline-scrub::-webkit-slider-runnable-track {
@@ -178,6 +265,20 @@ body.cockpit-mode #world-timeline {
background: #031018; background: #031018;
} }
/* A finger needs a bigger handle. */
@media (pointer: coarse) {
#world-timeline .world-timeline-scrub::-webkit-slider-thumb {
width: 22px;
height: 22px;
margin-top: 1px;
}
#world-timeline .world-timeline-scrub::-moz-range-thumb {
width: 20px;
height: 20px;
}
}
#world-timeline .world-timeline-scrub:focus-visible { #world-timeline .world-timeline-scrub:focus-visible {
outline: 1px solid var(--accent); outline: 1px solid var(--accent);
outline-offset: 2px; outline-offset: 2px;
@@ -190,7 +291,7 @@ body.cockpit-mode #world-timeline {
} }
/* On phones the bar sits just above the tab bar, under the voice dock, with /* On phones the bar sits just above the tab bar, under the voice dock, with
the track on a row of its own beneath play, the time and live. */ the track and the speed on a row of their own beneath play and live. */
body.mobile-shell:has(#world-timeline) { body.mobile-shell:has(#world-timeline) {
--world-timeline-lift: 80px; --world-timeline-lift: 80px;
} }
@@ -202,21 +303,23 @@ body.mobile-shell #world-timeline {
height: auto; height: auto;
} }
/* The bubble rises into the first row, clear of play on its left. */
body.mobile-shell #world-timeline .world-timeline-time { body.mobile-shell #world-timeline .world-timeline-time {
flex: 1 1 0; --inset: 48px;
} }
/* The speed sits on the track's row, a full-size target beside it. */
body.mobile-shell #world-timeline .world-timeline-track { body.mobile-shell #world-timeline .world-timeline-track {
order: 1; order: 1;
flex-basis: 100%; flex: 1 1 calc(100% - 96px);
} }
@media (max-width: 640px) { body.mobile-shell #world-timeline .world-timeline-live {
#world-timeline .world-timeline-edge { margin-inline-start: auto;
display: none;
}
#world-timeline .world-timeline-time {
font-size: 11px;
}
} }
body.mobile-shell #world-timeline .world-timeline-speed {
order: 2;
height: 32px;
}
+398
View File
@@ -148,3 +148,401 @@ test('the track runs over the time the layers show, to now, and scrubs', async (
assert.equal(worldClock.isLive(), false, 'a scrub past now stays there'); assert.equal(worldClock.isLive(), false, 'a scrub past now stays there');
assert.equal(worldClock.now(), ahead); assert.equal(worldClock.now(), ahead);
}); });
test('play runs in real time, from the start of the track when at live', async (t) => {
const bar = mount(t);
const now = Date.now();
const start = now - 10 * 86_400_000;
const release = worldClock.hold('test-launches');
const unregister = worldClock.register({
id: 'test-launches',
coverage: () => ({ start, end: now - 86_400_000 }),
});
t.after(() => {
unregister();
release();
});
await nextTick();
// Weather's replay stepping its own frames does not take the track's play.
const stepped = [];
const driver = worldClock.attachDriver({
play: () => stepped.push('play'),
pause: () => stepped.push('pause'),
});
t.after(() => driver.detach());
bar().querySelector('.world-timeline-play').click();
worldClock.pause();
await nextTick();
assert.deepEqual(stepped, []);
const rate = worldClock.getRate();
assert.ok(worldClock.now() - start < rate * 1_000, 'from the start of the track');
assert.equal(rate, 1, 'real time to start with');
worldClock.play({ continuous: true });
// Playing on to now goes live.
worldClock.seek(Date.now() + 1_000);
await nextTick();
assert.equal(worldClock.isLive(), true);
assert.equal(worldClock.isPlaying(), false);
assert.equal(worldClock.getRate(), 1, 'live plays at the rate of the world');
});
test('play holds at the end of a track that reaches past now', async (t) => {
const bar = mount(t);
const now = Date.now();
// An hour ahead is within the track's share of now, but still ahead of it.
const end = now + 3_600_000;
const release = worldClock.hold('test-ahead');
const unregister = worldClock.register({
id: 'test-ahead',
coverage: () => ({ start: now - 10 * 86_400_000, end }),
});
t.after(() => {
unregister();
release();
});
await nextTick();
// Paused just past now, play plays on rather than from the top.
worldClock.seek(Date.now() + 1_000);
await nextTick();
const from = worldClock.now();
bar().querySelector('.world-timeline-play').click();
worldClock.pause();
assert.ok(worldClock.now() >= from, 'plays on from where it was');
worldClock.play();
worldClock.seek(end + 60_000);
await nextTick();
assert.equal(worldClock.isPlaying(), false);
assert.equal(worldClock.isLive(), false);
assert.equal(worldClock.now(), end);
});
test('the speed chosen sets how fast play runs, at once while playing', async (t) => {
const bar = mount(t);
const now = Date.now();
const release = worldClock.hold('test-launches');
const unregister = worldClock.register({
id: 'test-launches',
coverage: () => ({ start: now - 10 * 86_400_000, end: now - 86_400_000 }),
});
t.after(() => {
unregister();
release();
});
await nextTick();
const select = bar().querySelector('select.world-timeline-speed');
assert.equal(select.value, 'real');
assert.deepEqual(
[...select.options].map((option) => option.value),
['real', 'minute', 'hour', 'day', 'week'],
);
select.value = 'week';
select.dispatchEvent(new Event('change'));
await nextTick();
bar().querySelector('.world-timeline-play').click();
assert.equal(worldClock.getRate(), 604_800, 'a week a second');
select.value = 'hour';
select.dispatchEvent(new Event('change'));
await nextTick();
assert.equal(worldClock.isPlaying(), true);
assert.equal(worldClock.getRate(), 3_600, 'changed while playing');
select.value = 'minute';
select.dispatchEvent(new Event('change'));
await nextTick();
assert.equal(worldClock.getRate(), 60, 'a minute a second');
select.value = 'real';
select.dispatchEvent(new Event('change'));
await nextTick();
assert.equal(worldClock.getRate(), 1, 'the world as it goes');
});
test('a speed chosen while weather replay steps takes playback over', async (t) => {
const bar = mount(t);
const at = Date.now() - 3_600_000;
supportingLayer(t, at);
const calls = [];
const driver = worldClock.attachDriver({
play() {
calls.push('play');
driver.driven(true, at - 600_000);
},
pause() {
calls.push('pause');
driver.driven(false);
},
});
t.after(() => driver.detach());
worldClock.play();
await nextTick();
assert.equal(worldClock.getState().stepping, true);
const select = bar().querySelector('select.world-timeline-speed');
select.value = 'hour';
select.dispatchEvent(new Event('change'));
await nextTick();
assert.deepEqual(calls, ['play', 'pause']);
assert.equal(worldClock.getState().stepping, false);
assert.equal(worldClock.isPlaying(), true);
assert.equal(worldClock.getRate(), 3_600);
assert.ok(worldClock.now() - (at - 600_000) < 3_600 * 1_000, 'on from where it was');
worldClock.pause();
});
test('paused just before the end, play carries on rather than restarting', async (t) => {
const bar = mount(t);
const now = Date.now();
const end = now + 86_400_000;
const release = worldClock.hold('test-ahead');
const unregister = worldClock.register({
id: 'test-ahead',
coverage: () => ({ start: now - 86_400_000, end }),
});
t.after(() => {
unregister();
release();
});
await nextTick();
worldClock.seek(end - 4_000);
await nextTick();
bar().querySelector('.world-timeline-play').click();
worldClock.pause();
assert.ok(worldClock.now() >= end - 4_000, 'not back to the start');
});
test('the track zooms around the handle, follows it, and shows it all again', async (t) => {
const bar = mount(t);
const now = Date.now();
const start = now - 26 * 86_400_000;
const release = worldClock.hold('test-launches');
const unregister = worldClock.register({
id: 'test-launches',
// A month of launches and three hours of lightning, sharing one track.
coverage: () => ({
start,
end: now,
marks: [start, now - 3 * 3_600_000, now - 2 * 3_600_000, now - 3_600_000],
}),
});
t.after(() => {
unregister();
release();
});
await nextTick();
const track = bar().querySelector('.world-timeline-track');
const input = bar().querySelector('input[type=range]');
const length = () => Number(input.max) - Number(input.min);
const full = length();
assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 4);
// Live, the handle is at the end: zooming in keeps the view on now.
for (let i = 0; i < 10; i++) {
track.dispatchEvent(new WheelEvent('wheel', { deltaY: -200, cancelable: true }));
await nextTick();
}
assert.ok(length() < full / 100, 'zoomed in');
assert.ok(Number(input.max) >= now, 'still ending at now');
assert.equal(track.classList.contains('is-zoomed'), true);
assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 3, 'only the ticks in view');
// A seek off the view takes the view along.
worldClock.seek(start + 86_400_000);
await nextTick();
assert.ok(Number(input.min) <= start + 86_400_000 && Number(input.max) >= start + 86_400_000);
assert.ok(length() < full / 100, 'at the same zoom');
// Minus widens, zero and a double-click show it all.
input.dispatchEvent(new KeyboardEvent('keydown', { key: '-' }));
await nextTick();
input.dispatchEvent(new KeyboardEvent('keydown', { key: '0' }));
await nextTick();
assert.ok(Math.abs(length() - full) < 1_000, 'all of it');
input.dispatchEvent(new KeyboardEvent('keydown', { key: '+' }));
await nextTick();
assert.ok(length() < full);
track.dispatchEvent(new MouseEvent('dblclick'));
await nextTick();
assert.equal(track.classList.contains('is-zoomed'), false);
});
test('zoomed, a scrub near now stays off live, and the view keeps a still handle', async (t) => {
const bar = mount(t);
const now = Date.now();
const start = now - 26 * 86_400_000;
let end = now;
const release = worldClock.hold('test-launches');
const unregister = worldClock.register({
id: 'test-launches',
coverage: () => ({ start, end, marks: [start, now - 3_600_000] }),
});
t.after(() => {
unregister();
release();
});
await nextTick();
const track = bar().querySelector('.world-timeline-track');
const input = bar().querySelector('input[type=range]');
// Ten notches of a line-mode wheel, as Firefox sends them.
for (let i = 0; i < 10; i++) {
track.dispatchEvent(new WheelEvent('wheel', { deltaY: -12, deltaMode: 1, cancelable: true }));
await nextTick();
}
assert.ok(Number(input.max) - Number(input.min) < 6 * 3_600_000, 'lines zoom like pixels');
// Twenty minutes back is near now on a month, not on a few hours.
input.value = String(now - 20 * 60_000);
input.dispatchEvent(new Event('input'));
await nextTick();
assert.equal(worldClock.isLive(), false);
assert.ok(Math.abs(worldClock.now() - (now - 20 * 60_000)) < 1_000);
// The span running on past the paused handle takes the view back to it.
end = now + 86_400_000;
worldClock.coverageChanged();
await nextTick();
const at = worldClock.now();
assert.ok(Number(input.min) <= at && Number(input.max) >= at, 'handle in view');
// With a modifier, plus is the browser's zoom.
const browserZoom = new KeyboardEvent('keydown', { key: '+', ctrlKey: true, cancelable: true });
input.dispatchEvent(browserZoom);
assert.equal(browserZoom.defaultPrevented, false);
// A double-click puts back the time its first press moved.
track.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1 }));
track.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1 }));
input.value = String(at - 3_600_000);
input.dispatchEvent(new Event('input'));
track.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
await nextTick();
assert.equal(track.classList.contains('is-zoomed'), false);
assert.ok(Math.abs(worldClock.now() - at) < 1_000, 'time as it was');
});
test('play held for a layer to load shows a spinner once the hold lasts', async (t) => {
const bar = mount(t);
const at = Date.UTC(2026, 9, 30, 6, 15, 42);
supportingLayer(t, at);
worldClock.seek(at - 1_800_000);
worldClock.play({ continuous: true });
await nextTick();
const bubble = () => bar().querySelector('.world-timeline-time');
const release = worldClock.wait('test-radar');
t.after(release);
// A clock tick (a quarter second) tells of the hold; the spinner waits on.
await new Promise((resolve) => setTimeout(resolve, 250));
await nextTick();
assert.equal(bubble().querySelector('.world-timeline-spinner'), null, 'not for a quick frame');
// The clock tells of the hold on its next tick, and the bar shows it after.
const until = async (shown) => {
for (let waited = 0; waited < 3_000; waited += 50) {
if (Boolean(bubble().querySelector('.world-timeline-spinner')) === shown) return true;
await new Promise((resolve) => setTimeout(resolve, 50));
await nextTick();
}
return false;
};
assert.ok(await until(true), 'spinner once the hold lasts');
assert.match(bubble().textContent, /UTC$/, 'the time stays');
release();
assert.ok(await until(false), 'gone once play goes on');
});
test('the track drags along, a tap goes to its time, and the handle waits off it', async (t) => {
const bar = mount(t);
const now = Date.now();
const start = now - 26 * 86_400_000;
const release = worldClock.hold('test-launches');
const unregister = worldClock.register({
id: 'test-launches',
coverage: () => ({ start, end: now, marks: [start] }),
});
t.after(() => {
unregister();
release();
});
await nextTick();
const track = bar().querySelector('.world-timeline-track');
const input = bar().querySelector('input[type=range]');
// A 1000 px track from x = 0.
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 view = () => ({ min: Number(input.min), max: Number(input.max) });
// All of it shown, a tap goes to the time under it.
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');
// Zoomed to a day around it, dragging right by half the track goes back half a day.
for (let i = 0; i < 6; i++) {
input.dispatchEvent(new KeyboardEvent('keydown', { key: '+' }));
await nextTick();
}
const before = view();
const day = before.max - before.min;
press('pointerdown', 600, 2);
press('pointermove', 700, 2);
press('pointermove', 1_100, 2);
press('pointerup', 1_100, 2);
await nextTick();
assert.ok(Math.abs(view().max - (before.max - day / 2)) < 1_000, 'dragged half a view');
assert.ok(Math.abs(worldClock.now() - middle) < 60_000, 'a drag leaves the time');
// Dragged off the handle, it waits unseen at the edge; a sideways swipe drags too.
track.dispatchEvent(new WheelEvent('wheel', { deltaX: -2_000, deltaY: 0, cancelable: true }));
await nextTick();
assert.ok(view().max < worldClock.now(), 'the handle is past the view');
assert.equal(input.classList.contains('is-off-view'), true);
// Moving the time takes the view back to it.
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp' }));
await nextTick();
assert.equal(input.classList.contains('is-off-view'), false);
});
test('a pinch measures from where the fingers are, and a lifted finger ends it', async (t) => {
const bar = mount(t);
const now = Date.now();
const start = now - 26 * 86_400_000;
const release = worldClock.hold('test-launches');
const unregister = worldClock.register({
id: 'test-launches',
coverage: () => ({ start, end: now, marks: [start] }),
});
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 on = (target, type, clientX, pointerId) =>
target.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId }));
const length = () => Number(input.max) - Number(input.min);
for (let i = 0; i < 4; i++) {
input.dispatchEvent(new KeyboardEvent('keydown', { key: '+' }));
await nextTick();
}
const before = length();
// A finger on the handle moves before the second comes down.
on(input, 'pointerdown', 300, 1);
on(input, 'pointermove', 500, 1);
on(track, 'pointerdown', 700, 2);
on(input, 'pointermove', 500, 1);
await nextTick();
assert.ok(Math.abs(length() - before) < 1_000, 'no zoom from a stale finger');
on(track, 'pointermove', 900, 2);
await nextTick();
assert.ok(length() < before, 'spread apart, it zooms in');
// One finger up: the other moving does nothing, and throws nothing.
const zoomedTo = length();
on(track, 'pointerup', 900, 2);
on(input, 'pointermove', 100, 1);
await nextTick();
assert.ok(Math.abs(length() - zoomedTo) < 1_000);
on(input, 'pointercancel', 100, 1);
});
+282 -22
View File
@@ -1,58 +1,316 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, onBeforeUnmount, shallowRef, watch } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { formatDate, t } from '../../../i18n/index.js'; import { formatDate, t } from '../../../i18n/index.js';
import { useWorldTimeStore } from '../../stores/worldTime.js'; import { useWorldTimeStore } from '../../stores/worldTime.js';
import UiButton from '../../kit/UiButton.vue'; import UiButton from '../../kit/UiButton.vue';
import UiIcon from '../../kit/UiIcon.vue'; import UiIcon from '../../kit/UiIcon.vue';
import UiSelect from '../../kit/UiSelect.js';
// The world timeline: play and pause on the left, the time, a track across // The world timeline: play and pause and the speed on the left, a track
// the stretch the layers on can show (each launch or frame a tick) with a // across the stretch the layers on can show (each launch or frame a tick)
// handle to scrub, and the way back to live on the right, lit while live. It // with a handle to scrub and the time in a bubble over it, and the way back
// shows while a layer with a time of its own (weather, satellites, launches, // 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
// 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.
const store = useWorldTimeStore(); const store = useWorldTimeStore();
const { time } = storeToRefs(store); const { time, speed } = storeToRefs(store);
const speeds = computed(() => [
{ value: 'real', label: '1×' },
{ value: 'minute', label: t('1 min/s') },
{ value: 'hour', label: t('1 h/s') },
{ value: 'day', label: t('1 d/s') },
{ value: 'week', label: t('1 wk/s') },
]);
const DATE_TIME = Object.freeze({ const DATE_TIME = Object.freeze({
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'medium', timeStyle: 'medium',
timeZone: 'UTC', timeZone: 'UTC',
}); });
const EDGE_DATE = Object.freeze({ // The bubble over the handle: the date and the minute, kept short.
const BUBBLE_DATE = Object.freeze({
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
timeZone: 'UTC', timeZone: 'UTC',
}); });
const BUBBLE_TIME = Object.freeze({
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
timeZone: 'UTC',
});
// The keyboard's fallback step, as a share of the track, with no mark to go to. // The keyboard's fallback step, as a share of the track, with no mark to go to.
const KEY_STEP = 0.01; const KEY_STEP = 0.01;
const PAGE_STEP = 0.1; const PAGE_STEP = 0.1;
// Zooming: the shortest stretch the track narrows to, the step a key or a
// notch of the wheel takes, and how far a wheel's pixel of scroll zooms.
const MIN_VIEW_MS = 5 * 60_000;
const ZOOM_STEP = 1.5;
const WHEEL_ZOOM = 0.0025;
// Two presses this close are a double-click.
const DOUBLE_PRESS_MS = 500;
const readout = computed(() => const readout = computed(() =>
Number.isFinite(time.value.timeMs) Number.isFinite(time.value.timeMs)
? `${formatDate(time.value.timeMs, DATE_TIME)} UTC` ? `${formatDate(time.value.timeMs, DATE_TIME)} UTC`
: '', : '',
); );
const bubble = computed(() =>
Number.isFinite(time.value.timeMs)
? `${formatDate(time.value.timeMs, BUBBLE_DATE)} · ${formatDate(time.value.timeMs, BUBBLE_TIME)} UTC`
: '',
);
// Play held while a layer loads the time's data shows a spinner by the time,
// once the hold lasts long enough to notice, so a quick frame does not flash
// it, and until play has run a while, so a run of slow frames keeps it still.
const WAIT_SHOWN_MS = 300;
const WAIT_HIDDEN_MS = 500;
const waiting = shallowRef(false);
let waitTimer = null;
watch(
() => time.value.buffering,
(buffering) => {
clearTimeout(waitTimer);
waitTimer = null;
if (buffering === waiting.value) return;
waitTimer = setTimeout(
() => {
waitTimer = null;
waiting.value = buffering;
},
buffering ? WAIT_SHOWN_MS : WAIT_HIDDEN_MS,
);
},
);
onBeforeUnmount(() => clearTimeout(waitTimer));
const span = computed(() => time.value.span); const span = computed(() => time.value.span);
// Where the handle is, or null with no track.
const at = computed(() => { const at = computed(() => {
if (!span.value) return null;
const { start, end } = span.value; const { start, end } = span.value;
return Math.min(end, Math.max(start, time.value.timeMs)); return Math.min(end, Math.max(start, time.value.timeMs));
}); });
const place = (ms) => {
// The track shows the whole span, or zoomed, a stretch of it: `length` long,
// ending at `end`, or at the span's end (and so at now) while `end` is null.
// Everything still plays together; zooming only changes what the track shows.
const zoom = shallowRef(null);
const view = computed(() => {
if (!span.value) return null;
const { start, end } = span.value; const { start, end } = span.value;
const length = zoom.value?.length;
if (!length || length >= end - start) return { start, end };
const viewEnd = Math.min(end, Math.max(start + length, zoom.value.end ?? end));
return { start: viewEnd - length, end: viewEnd };
});
const zoomed = computed(() => zoom.value !== null);
const viewLength = () => view.value.end - view.value.start;
// Show `length` of the span ending at `viewEnd`, held to the span.
function showView(length, viewEnd) {
const { start, end } = span.value;
if (length >= end - start) {
showWhole();
return;
}
const clamped = Math.min(end, Math.max(start + length, viewEnd));
zoom.value = { length, end: clamped >= end ? null : clamped };
}
// Narrow or widen the view to `length`, keeping the handle where it is on it,
// or with the handle off it, the middle.
function zoomTo(length) {
const { start: from, end: to } = view.value;
const pivot = offView(at.value) ? (from + to) / 2 : at.value;
const share = (pivot - from) / (to - from);
const next = Math.max(MIN_VIEW_MS, length);
showView(next, pivot + (1 - share) * next);
}
const zoomBy = (factor) => zoomTo(viewLength() * factor);
// A view dragged off the handle stays where it was put while the handle only
// creeps on with now; playing or a move of the handle takes it back.
let panned = false;
const follow = (ms) => {
panned = false;
showView(zoom.value.length, ms + zoom.value.length / 2);
};
const offView = (ms) => ms < view.value.start || ms > view.value.end;
const showWhole = () => {
zoom.value = null;
panned = false;
};
// The span narrowing to the zoom or less, or going, lets go of the zoom.
watch(span, (next) => {
if (zoom.value && (!next || zoom.value.length >= next.end - next.start))
showWhole();
});
watch(at, (ms) => {
if (!zoom.value || ms === null || !offView(ms)) return;
if (panned && time.value.live && !time.value.playing) return;
follow(ms);
});
// Left behind as now moves the view on, the handle takes the view back.
watch(view, () => {
if (zoom.value && !panned && at.value !== null && offView(at.value))
follow(at.value);
});
const pan = (ms) => {
panned = true;
showView(zoom.value.length, view.value.end + ms);
};
// A wheel's lines and pages, in pixels.
const WHEEL_UNITS = [1, 16, 400];
const onWheel = (event) => {
const unit = WHEEL_UNITS[event.deltaMode] ?? 1;
const sideways = Math.abs(event.deltaX) > Math.abs(event.deltaY);
// A sideways swipe, or a wheel with shift, drags; a wheel zooms.
if (sideways || event.shiftKey) {
if (!zoom.value) return;
event.preventDefault();
const width = event.currentTarget.clientWidth;
const pixels = (sideways ? event.deltaX : event.deltaY) * unit;
if (width > 0) pan((pixels / width) * viewLength());
return;
}
event.preventDefault();
zoomBy(Math.exp(event.deltaY * unit * WHEEL_ZOOM));
};
// A double-click starts with presses that move the time, so it puts back the
// time from before its first; so does a pinch, which may start on the handle.
let before = null;
let pressedAt = -Infinity;
const onPress = (event) => {
if (event.timeStamp - pressedAt > DOUBLE_PRESS_MS)
before = { live: time.value.live, timeMs: time.value.timeMs };
pressedAt = event.timeStamp;
};
const restore = () => {
if (!before) return;
if (before.live) store.goLive();
else store.scrub(before.timeMs, 0);
};
const showAll = () => {
restore();
showWhole();
};
// The handle drags the time. The rest of the track takes the pointer: a tap
// goes to the time under it, a drag drags a zoomed track along (or the time,
// with all of it shown), and two fingers pinch it.
const DRAG_PX = 4;
const pointers = new Map();
let gesture = null;
const timeAt = (clientX, track) => {
const { left, width } = track.getBoundingClientRect();
const share = width > 0 ? Math.min(1, Math.max(0, (clientX - left) / width)) : 1;
return view.value.start + share * viewLength();
};
// Whether the first finger has moved the time, for a pinch to put it back.
let movedTime = false;
const onPointerDown = (event) => {
// Only the main button: a right-click is for the menu.
if (event.button > 0) return;
const onHandle = event.target instanceof HTMLInputElement;
pointers.set(event.pointerId, { x: event.clientX, onHandle });
if (pointers.size === 1) {
onPress(event);
movedTime = onHandle;
// The keys zoom and step the track as soon as it is touched.
if (!onHandle)
event.currentTarget.querySelector('input')?.focus({ preventScroll: true });
}
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
if (movedTime) restore();
gesture = {
kind: 'pinch',
spread: Math.abs(a.x - b.x),
length: viewLength(),
};
return;
}
if (onHandle || pointers.size !== 1) return;
// The drag goes on past the track's ends; without the capture, to them.
try {
event.currentTarget.setPointerCapture?.(event.pointerId);
} catch {
// A pointer the page no longer knows of drags no further than the track.
}
gesture = { kind: 'press', x: event.clientX, end: view.value.end };
};
const onPointerMove = (event) => {
const pointer = pointers.get(event.pointerId);
if (!pointer) return;
pointer.x = event.clientX;
if (!gesture) return;
const track = event.currentTarget;
if (gesture.kind === 'pinch') {
const [a, b] = [...pointers.values()];
const spreadNow = Math.abs(a.x - b.x);
if (spreadNow > 0) zoomTo((gesture.length * gesture.spread) / spreadNow);
return;
}
if (gesture.kind === 'press') {
if (Math.abs(event.clientX - gesture.x) < DRAG_PX) return;
gesture.kind = zoom.value ? 'pan' : 'scrub';
movedTime = gesture.kind === 'scrub';
}
if (gesture.kind === 'pan') {
if (!track.clientWidth) return;
panned = true;
const moved = ((gesture.x - event.clientX) / track.clientWidth) * viewLength();
showView(zoom.value.length, gesture.end + moved);
} else store.scrub(timeAt(event.clientX, track), viewLength());
};
const onPointerUp = (event) => {
if (!pointers.delete(event.pointerId)) return;
if (gesture?.kind === 'press' && event.type === 'pointerup')
store.scrub(timeAt(event.clientX, event.currentTarget), viewLength());
// A finger lifted from a pinch ends it; the other one moves nothing.
gesture = null;
};
const place = (ms) => {
const { start, end } = view.value;
return end > start ? ((ms - start) / (end - start)) * 100 : 100; return end > start ? ((ms - start) / (end - start)) * 100 : 100;
}; };
const ticks = computed(() => const ticks = computed(() => {
span.value.marks.map((mark) => `${place(mark)}%`), const { start, end } = view.value;
return span.value.marks
.filter((mark) => mark >= start && mark <= end)
.map((mark) => `${place(mark)}%`);
});
const shown = computed(() =>
Math.min(view.value.end, Math.max(view.value.start, at.value)),
); );
const scrub = (event) => store.scrub(Number(event.target.value)); const handleOff = computed(() => at.value !== shown.value);
const scrub = (event) => {
// A pinch only zooms; the input goes back to the handle.
if (gesture?.kind === 'pinch') {
event.target.value = shown.value;
return;
}
store.scrub(Number(event.target.value), viewLength());
};
// Arrows go to the previous or next mark (a launch, a frame), or a step along // Arrows go to the previous or next mark (a launch, a frame), or a step along
// the track; Page keys take a tenth of it; Home and End go to its ends. // the track as shown; 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'];
const onKey = (event) => { const onKey = (event) => {
if (ZOOM_KEYS.includes(event.key)) {
if (event.ctrlKey || event.metaKey || event.altKey) return;
event.preventDefault();
if (event.key === '-') zoomBy(ZOOM_STEP);
else if (event.key === '0') showWhole();
else zoomBy(1 / ZOOM_STEP);
return;
}
const { start, end, marks } = span.value; const { start, end, marks } = span.value;
const now = at.value; const now = at.value;
const length = end - start; const length = viewLength();
let to = null; let to = null;
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') if (event.key === 'ArrowLeft' || event.key === 'ArrowDown')
to = marks.findLast((mark) => mark < now) ?? now - length * KEY_STEP; to = marks.findLast((mark) => mark < now) ?? now - length * KEY_STEP;
@@ -64,7 +322,7 @@ const onKey = (event) => {
else if (event.key === 'End') to = end; else if (event.key === 'End') to = end;
if (to === null) return; if (to === null) return;
event.preventDefault(); event.preventDefault();
store.scrub(Math.min(end, Math.max(start, to))); store.scrub(Math.min(end, Math.max(start, to)), length);
}; };
</script> </script>
@@ -72,24 +330,26 @@ const onKey = (event) => {
<div v-if="time.supported && time.span" id="world-timeline" role="group" :aria-label="t('World time')" <div v-if="time.supported && time.span" id="world-timeline" role="group" :aria-label="t('World time')"
><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
><time class="world-timeline-time" :datetime="new Date(time.timeMs).toISOString()" aria-live="off">{{ readout }}</time ><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)"
><span class="world-timeline-edge">{{ formatDate(span.start, EDGE_DATE) }}</span /><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" ><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-for="(left, index) in ticks" :key="index" class="world-timeline-tick" :style="{ left }" aria-hidden="true"></span
><span class="world-timeline-fill" :style="{ width: `${place(at)}%` }" 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 }"
type="range" type="range"
:aria-label="t('World time')" :aria-label="t('World time')"
:aria-valuetext="readout" :aria-valuetext="readout"
:min="span.start" :min="view.start"
:max="span.end" :max="view.end"
step="any" step="any"
:value="at" :value="shown"
@input="scrub" @input="scrub"
aria-keyshortcuts="+ - 0"
@keydown="onKey" @keydown="onKey"
/></div /></div
><span class="world-timeline-edge">{{ formatDate(span.end, EDGE_DATE) }}</span
><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>
</template> </template>