Compare commits

...
Author SHA1 Message Date
Sameh Khamis bc61a39738 Merge branch 'sameh/world-clock' into sameh/world-timeline-launches 2026-11-02 17:01:07 -08:00
Sameh Khamis 3cecc8efbe Offer a minute a second as a play speed
Between real time and an hour a second there was nothing to watch a few
minutes of weather or a launch play out at. The speed menu now offers a
minute a second as its second choice.
2026-11-02 16:47:35 -08:00
Sameh Khamis 8eab6a76a8 Drag the timeline along, and play through it smoothly
The zoomed track now drags: the handle still drags the time, while the
track around it drags the view along (or the time, with all of it shown),
a tap goes to the time under it, two fingers pinch it, and a sideways swipe
or a shift-wheel drags it too, the same with a mouse, a trackpad or a
finger. A view dragged off the handle stays put until the time moves.

Play moved in steps: the world clock told of its time four times a second,
so the handle and the globe jumped, and play stopped at every weather frame
while it drew, loaded ahead or not. The clock now tells of its time each
animation frame, and play no longer waits on the frame it is at: that
frame loads as play goes on, and play holds only at the next frame while
the one before it still loads. Weather loads its next frame ahead while the
world plays too, tells its listeners of frames as they change rather than
every frame, and the sun and moon move on each minute of world time.
2026-11-02 16:13:27 -08:00
Sameh Khamis 4fc9722356 Show a spinner by the time while play waits on data
Play holds while a layer loads the frame for its time, and the bar's only
sign of it was a faint pulse, so a stopped handle read as a stall. The
bubble now shows a small spinner beside the time while play is held. It
comes on once a hold lasts long enough to notice and stays through a run of
slow frames, so neither a quick frame nor the gaps between frames flash it.
2026-11-02 15:11:47 -08:00
Sameh Khamis 287c881c17 Zoom the timeline track around the handle
With layers of very different reach on together, a month of launches and
a few hours of lightning, the short-range ticks were crushed at the end of
the one shared track. Scroll or pinch on the track now zooms it around the
handle, plus and minus zoom from the keyboard, and zero or a double-click
shows it all again. Everything still plays together; zooming only changes
what the track shows, and the view follows the handle as it plays, steps or
is left behind by now. A scrub's live edge goes by the stretch shown, so a
zoomed track can land minutes from now, and a double-click or pinch puts
back the time its first press moved.
2026-11-02 13:53:08 -08:00
Sameh Khamis 497016b084 Add the changelog entry for launches, speed and buffering on the timeline 2026-11-02 13:17:28 -08:00
Sameh Khamis 724109ee17 Merge branch 'sameh/world-clock' into sameh/world-timeline-launches 2026-11-02 13:17:08 -08:00
Sameh Khamis 0d96fe6acd Merge branch 'sameh/world-clock' into sameh/world-timeline-launches 2026-11-02 13:14:32 -08:00
Sameh Khamis 056b3b58c6 Wait for each frame while the world timeline plays, and quiet the bar
Play no longer runs ahead of the data. The world clock gains a wait, which
holds continuous play where it is while a layer loads what the time needs,
and a gate, which keeps play from passing a time a layer has yet to load.
Weather gates play at its next frame and waits there until the frame shows,
so every frame is seen however fast play runs; it waits no more than eight
seconds on a frame that never comes, and takes a wait only when its frames
change. Play pressed while a frame loads waits for it too. A scrub still
drops what loads and moves on.

The bar loses its long date and the dates at its ends: the time rides in a
bubble over the handle, which pulses while play waits, and the bar runs
left to right in every language. Play starts at real time.
2026-11-02 08:03:46 -08:00
Sameh Khamis d04e304502 Choose how fast the world timeline plays
A speed menu beside play offers real time, an hour, a day or a week a
second, a day a second to start with. A speed chosen while playing applies
at once; chosen while weather's own replay steps its frames, it takes
playback over from where it is. Play starts again from the top only at live
or at the very end, so a pause just before the end plays on. On phones the
menu sits beside the track, a full-size target.
2026-11-02 07:41:50 -08:00
Sameh Khamis e7973e81cf Stop zooming out while the globe is still in view
Cesium sets no limit, so zooming out left the globe a speck among the stars.
On the globe a hand now stops zooming out where the globe spans 30% of the
screen's height, which follows the screen's shape as the app's own
whole-globe view does. A camera already past it, after a flight or a turned
screen, stops there rather than jumping in at the next zoom. Flat maps keep
zooming out unlimited.
2026-11-02 07:27:53 -08:00
Sameh Khamis 0aa54dfa97 Bring launches up as the world time passes them, and play the whole track
Away from live, a launch shows from its lift-off on: its marker, label,
paths and orbit stay hidden while the world time is before it, so scrubbing
or playing brings the launches up in the order they flew. The selected
mission stays whenever it flies, and live shows every launch as before.

Play on the timeline runs the whole track in about a minute, from its start
when at live, and runs the clock itself past weather's own replay. It goes
live when it reaches now, or holds at an end that lies ahead of now. Going
live puts the rate back to 1.
2026-11-02 07:21:01 -08:00
31 changed files with 1758 additions and 98 deletions

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
@@ -2,6 +2,48 @@ import * as Cesium from 'cesium';
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
import { StarrySky } from '../sky/starrySky.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 MAX_PINCH_PIXEL_DELTA = 120;
@@ -142,6 +184,7 @@ export function createApplicationViewer({ container, creditContainer }) {
// required on touch-first screens. Mouse/trackpad sensitivity stays native.
if (globalThis.matchMedia?.('(pointer: coarse)').matches)
viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
limitZoomOut(viewer);
// Before any tile builds a draw command: Cesium's per-vertex model
// atmosphere fails to LINK on Apple's Metal backend and kills the
// 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;
// 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 MARKER_INSET_PX = 36;
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.
this._skyDate = new Date();
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.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
// clearance checks run at each length CSS gives them, not only at 0.
const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
const LIFTS_PX = [0, 50, 80];
const LIFTS_PX = [0, 66, 80];
let liftPx = 0;
function eachLift(check) {
for (const lift of LIFTS_PX) {
+7 -1
View File
@@ -3226,5 +3226,11 @@
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
"World time": "وقت العالم",
"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
@@ -2946,5 +2946,11 @@
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien",
"World time": "Weltzeit",
"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
@@ -3016,5 +3016,11 @@
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos",
"World time": "Hora mundial",
"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
@@ -3016,5 +3016,11 @@
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
"World time": "Heure du monde",
"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
@@ -3016,5 +3016,11 @@
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file",
"World time": "Ora del mondo",
"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
@@ -2876,5 +2876,11 @@
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
"World time": "世界時刻",
"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
@@ -3016,5 +3016,11 @@
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos",
"World time": "Hora do mundo",
"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
@@ -3086,5 +3086,11 @@
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
"World time": "Мировое время",
"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
@@ -3086,5 +3086,11 @@
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
"World time": "Світовий час",
"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
@@ -2876,5 +2876,11 @@
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件",
"World time": "世界时间",
"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
@@ -2876,5 +2876,11 @@
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
"World time": "世界時間",
"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
@@ -203,6 +203,7 @@
"▲ {frp} MW"
],
"es": [
"1 min/s",
"1090 MHz",
"978 MHz UAT",
"AIS: --",
@@ -331,6 +332,7 @@
"▲ {frp} MW"
],
"fr": [
"1 min/s",
"1090 MHz",
"978 MHz UAT",
"ALTITUDE",
@@ -512,6 +514,7 @@
"▲ {frp} MW"
],
"it": [
"1 min/s",
"1090 MHz",
"978 MHz UAT",
"AFRICA",
@@ -732,6 +735,7 @@
"▲ {frp} MW"
],
"pt": [
"1 min/s",
"1090 MHz",
"978 MHz UAT",
"AIS: --",
+3
View File
@@ -67,6 +67,9 @@ export function createOrbitRendering({
return Boolean(
layerState._enabled &&
layerState._dataSource?.show &&
!layerState.launchHidden(
layerState._missionOverlayRecords.get(launchId)?.launch,
) &&
(!layerState._selectedLaunchId ||
layerState._selectedLaunchId === launchId),
);
+4 -1
View File
@@ -411,8 +411,11 @@ export function createOverlays({ state: layerState, services, parts, source }) {
false,
);
layerState._selectedMissionOverlayTimeText = null;
const records = [...layerState._missionOverlayRecords.values()].filter(
(record) => !layerState.launchHidden(record.launch),
);
const entries = selectRocketMissionMarkerOverlayCohort(
Array.from(layerState._missionOverlayRecords.values(), (record) => {
records.map((record) => {
const entry = createRocketMissionMarkerOverlayEntry(
record.launch,
record.anchorPosition,
+37 -10
View File
@@ -22,6 +22,25 @@ export function createRendering({
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() {
if (
!layerState._enabled ||
@@ -31,6 +50,7 @@ export function createRendering({
parts.overlays.hideReplayVehicleOverlay();
return;
}
syncPendingLaunches();
parts.panel.updateMissionTelemetry();
const time = Cesium.JulianDate.now(layerState._declutterTime);
layerState._declutterOccluder.cameraPosition =
@@ -39,21 +59,28 @@ export function createRendering({
parts.overlays.refreshSelectedMissionOverlayText();
for (const entity of layerState._dataSource.entities.values) {
if (!entity.show) continue;
if (!entity.position) continue;
const id = parts.selection.entityLaunchId(entity);
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);
let horizonVisible = Boolean(
position && layerState._declutterOccluder.isPointVisible(position),
);
let horizonVisible =
!pending &&
Boolean(
position && layerState._declutterOccluder.isPointVisible(position),
);
const launchAnchor = entity.id.startsWith('rocket-launch:');
if (launchAnchor) {
horizonVisible = parts.overlays.missionAnchorVisible(
layerState._viewer.scene.camera.positionWC,
position,
id,
layerState._selectedLaunchId,
);
horizonVisible =
!pending &&
parts.overlays.missionAnchorVisible(
layerState._viewer.scene.camera.positionWC,
position,
id,
layerState._selectedLaunchId,
);
// Keep entity.show reserved for mission-selection isolation. Horizon
// state is applied to the individual graphics below so a rear-side
// anchor remains eligible for reevaluation as the camera moves.
+20
View File
@@ -160,6 +160,26 @@ export function createState({ services }) {
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._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 GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 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
@@ -33,6 +42,34 @@ export function createWeatherClock({
let generation = 0;
let timer = 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;
const timesFor = (product) =>
[...new Set(product.getTimes())]
@@ -65,6 +102,9 @@ export function createWeatherClock({
mode,
target,
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(),
products: [...registry.values()].map(({ product }) => ({
id: product.id,
@@ -130,7 +170,10 @@ export function createWeatherClock({
cancelTimer();
const owner = ++generation;
if (!canPlay()) stopPlaying();
// A scrub drops what loads and moves on; play goes on as this loads.
applied = selections();
deferred = false;
stopWaiting();
for (const entry of registry.values()) entry.controller?.abort();
// Start all products together; a rejection must not skip the settle barrier.
const tasks = [...registry.values()].map((entry) => {
@@ -144,10 +187,13 @@ export function createWeatherClock({
});
const result = Promise.allSettled(tasks).then(() => {
if (destroyed || owner !== generation) return false;
stopWaiting();
pending = null;
if (!canPlay()) stopPlaying();
notify();
schedule();
// Play held at the next frame for this one goes on to it.
if (deferred) void transition();
return true;
});
pending = result;
@@ -269,8 +315,10 @@ export function createWeatherClock({
},
refresh() {
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();
gated = undefined;
gateNext(world.getState());
if (!canPlay()) {
stopPlaying();
cancelTimer();
@@ -289,6 +337,8 @@ export function createWeatherClock({
stopPlaying();
destroyed = true;
++generation;
stopWaiting();
world.gate(holdId, null);
cancelTimer();
unfollowWorld();
// 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.
function followWorld(state) {
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 nextTarget = state.live
? null
: new Date(Math.floor(state.timeMs)).toISOString();
const key = nextTarget ?? 'latest';
if (key === seen) return;
if (key === seen) {
if (playChanged) notify();
return;
}
seen = key;
const modeChanged = nextMode !== mode;
mode = nextMode;
target = nextTarget;
if (moving || modeChanged || selections() !== applied) void transition();
else notify();
if (!moving && !modeChanged && selections() === applied) {
// 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);
driver = world.attachDriver({
+214
View File
@@ -6,6 +6,7 @@ import {
REGIONAL_INFRARED_MAX_GAP_MS,
GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS,
PLAY_WAIT_MS,
} from './clock.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.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 shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time);
const unsubscribeClock = clock?.subscribe(() => {
if (!clock.getState().playing || suspended()) rendering?.cancelPrefetch?.();
if (!clock.getState().advancing || suspended())
rendering?.cancelPrefetch?.();
notify();
});
const observationDelayed = () =>
@@ -211,7 +212,7 @@ export function createWeatherLayer({
if (
!enabled ||
suspended() ||
!(clock ? clock.getState().playing : playing)
!(clock ? clock.getState().advancing : playing)
)
return;
const times = manifest?.times ?? [];
+81 -11
View File
@@ -24,15 +24,21 @@ const MAX_DATE_MS = 8.64e15;
export const isWorldTime = (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;
/**
* @param {{now?: () => number, monotonicNow?: () => number,
* setInterval?: typeof globalThis.setInterval,
* clearInterval?: typeof globalThis.clearInterval,
* requestFrame?: typeof globalThis.requestAnimationFrame | null,
* cancelFrame?: typeof globalThis.cancelAnimationFrame | null,
* 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({
// 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(),
setInterval = globalThis.setInterval,
clearInterval = globalThis.clearInterval,
requestFrame = globalThis.requestAnimationFrame?.bind(globalThis) ?? null,
cancelFrame = globalThis.cancelAnimationFrame?.bind(globalThis) ?? null,
follow = [],
} = {}) {
const listeners = new Set();
@@ -60,8 +68,13 @@ export function createWorldClock({
let active = null;
let ticker = null;
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 buffering = () => waits.size > 0;
const isFollowed = (id) => following.has(id) || (holds.get(id) ?? 0) > 0;
const followedIds = () => [
...new Set([...following, ...[...holds.keys()].filter(isFollowed)]),
@@ -87,8 +100,11 @@ export function createWorldClock({
};
const time = () => {
if (live) return now();
if (continuous()) return cursorMs + (monotonicNow() - anchorMono) * rate;
return cursorMs;
if (!continuous() || buffering()) 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 = () =>
Object.freeze({
@@ -96,6 +112,10 @@ export function createWorldClock({
timeMs: time(),
playing,
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(),
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 = () => {
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) {
clearInterval(ticker);
if (frames) cancelFrame(ticker);
else clearInterval(ticker);
ticker = null;
}
};
@@ -152,23 +184,27 @@ export function createWorldClock({
anchorMono = monotonicNow();
changed();
},
/** Back to the wall clock; playback stops. */
/** Back to the wall clock; playback stops and its rate goes back to 1. */
goLive() {
if (live && !playing) return;
if (live && !playing && rate === 1) return;
const stepping = active;
live = true;
cursorMs = null;
playing = false;
active = null;
rate = 1;
// Its driven(false) finds nothing left to stop.
stepping?.driver.pause();
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 (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 (live) {
@@ -192,6 +228,40 @@ export function createWorldClock({
togglePlay() {
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. */
setRate(nextRate) {
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.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;
/** The least that counts as at now, past the readout's own second. */
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,
* supported, span}`, where `supported` says a layer that moves with the clock
* The world clock as the timeline shows it: `{live, playing, stepping,
* buffering, timeMs, supported, span}`, where `supported` says a layer that moves with the clock
* has time to show and `span` is the stretch the timeline runs over: the
* time those layers cover, reaching to now and to the world time, with their
* marks. It follows the page's world clock, which tells it of every move, and
@@ -20,26 +28,68 @@ const LIVE_EDGE_MS = 5000;
* track's end keep up with now.
*/
export const useWorldTimeStore = defineStore('gev-world-time', () => {
const spanOf = (state) => {
if (!state.supported) return null;
// The time the layers cover, reaching to now.
const reach = () => {
const coverage = worldClock.coverage();
if (!coverage) return null;
const now = worldClock.wallNow();
return Object.freeze({
start: Math.min(coverage.start, now, state.timeMs),
end: Math.max(coverage.end, now, state.timeMs),
return {
start: Math.min(coverage.start, now),
end: Math.max(coverage.end, now),
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()) =>
Object.freeze({
live: state.live,
playing: state.playing,
stepping: state.stepping,
buffering: state.buffering,
timeMs: state.timeMs,
supported: state.supported,
span: spanOf(state),
});
const time = shallowRef(read());
const speed = shallowRef('real');
const rate = () => WORLD_TIME_SPEEDS[speed.value];
let ticker = null;
const syncTicker = () => {
// Playing, the clock tells of the time itself.
@@ -54,6 +104,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
}
};
const unsubscribe = worldClock.subscribe((state) => {
if (stopAtEnd(state)) return;
time.value = read(state);
syncTicker();
});
@@ -66,13 +117,45 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
return {
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(),
/** 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 edge = span ? (span.end - span.start) * LIVE_EDGE : 0;
if (Math.abs(ms - worldClock.wallNow()) <= Math.max(LIVE_EDGE_MS, edge)) {
length ??= span ? span.end - span.start : 0;
if (nearNow(ms, length)) {
worldClock.goLive();
return;
}
+126 -23
View File
@@ -5,8 +5,9 @@
--world-timeline-lift: 0px;
}
/* The bar and the time's bubble over its handle. */
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. */
@@ -22,7 +23,10 @@ body.cockpit-mode #world-timeline {
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 {
direction: ltr;
position: fixed;
bottom: 2vh;
left: 50%;
@@ -77,32 +81,83 @@ body.cockpit-mode #world-timeline {
text-transform: uppercase;
}
#world-timeline .world-timeline-time {
overflow: hidden;
font-family: var(--font-mono);
font-size: 13px;
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
#world-timeline .world-timeline-speed {
flex: none;
}
#world-timeline .world-timeline-edge {
flex: none;
color: var(--text-secondary);
/* The time rides over the handle. It slides across itself as the handle
crosses the track (its start at the handle at the track's start, its end at
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-size: 11px;
font-variant-numeric: tabular-nums;
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
range input on top for dragging and the keyboard. */
#world-timeline .world-timeline-track {
position: relative;
/* Two fingers pinch the track, not the page. */
touch-action: none;
flex: 1 1 auto;
cursor: pointer;
min-width: 80px;
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 {
position: absolute;
top: 50%;
@@ -139,6 +194,17 @@ body.cockpit-mode #world-timeline {
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 {
position: absolute;
inset: 0;
@@ -146,8 +212,29 @@ body.cockpit-mode #world-timeline {
height: 100%;
margin: 0;
background: transparent;
cursor: pointer;
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 {
@@ -178,6 +265,20 @@ body.cockpit-mode #world-timeline {
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 {
outline: 1px solid var(--accent);
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
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) {
--world-timeline-lift: 80px;
}
@@ -202,21 +303,23 @@ body.mobile-shell #world-timeline {
height: auto;
}
/* The bubble rises into the first row, clear of play on its left. */
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 {
order: 1;
flex-basis: 100%;
flex: 1 1 calc(100% - 96px);
}
@media (max-width: 640px) {
#world-timeline .world-timeline-edge {
display: none;
}
#world-timeline .world-timeline-time {
font-size: 11px;
}
body.mobile-shell #world-timeline .world-timeline-live {
margin-inline-start: auto;
}
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.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>
import { computed } from 'vue';
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { formatDate, t } from '../../../i18n/index.js';
import { useWorldTimeStore } from '../../stores/worldTime.js';
import UiButton from '../../kit/UiButton.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 stretch the layers on can show (each launch or frame a tick) with a
// handle to scrub, and the way back to live on the right, lit while live. It
// shows while a layer with a time of its own (weather, satellites, launches,
// The world timeline: play and pause and the speed on the left, a track
// across the stretch the layers on can show (each launch or frame a tick)
// with a handle to scrub and the time in a bubble over it, and the way back
// to live on the right, lit while live. Scrolling or pinching the track
// zooms it around the handle, dragging it moves along it, and a double-click
// shows it all again. It shows
// while a layer with a time of its own (weather, satellites, launches,
// or one a host follows) is on with time to show; with only live feeds such
// as flights on, the bar stays hidden.
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({
dateStyle: 'medium',
timeStyle: 'medium',
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',
day: 'numeric',
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.
const KEY_STEP = 0.01;
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(() =>
Number.isFinite(time.value.timeMs)
? `${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);
// Where the handle is, or null with no track.
const at = computed(() => {
if (!span.value) return null;
const { start, end } = span.value;
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 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;
};
const ticks = computed(() =>
span.value.marks.map((mark) => `${place(mark)}%`),
const ticks = computed(() => {
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
// 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) => {
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 now = at.value;
const length = end - start;
const length = viewLength();
let to = null;
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown')
to = marks.findLast((mark) => mark < now) ?? now - length * KEY_STEP;
@@ -64,7 +322,7 @@ const onKey = (event) => {
else if (event.key === 'End') to = end;
if (to === null) return;
event.preventDefault();
store.scrub(Math.min(end, Math.max(start, to)));
store.scrub(Math.min(end, Math.max(start, to)), length);
};
</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')"
><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
><time class="world-timeline-time" :datetime="new Date(time.timeMs).toISOString()" aria-live="off">{{ readout }}</time
><span class="world-timeline-edge">{{ formatDate(span.start, EDGE_DATE) }}</span
><div class="world-timeline-track"
><UiSelect class="gev-field gev-field--compact world-timeline-speed" :aria-label="t('Play speed')" :title="t('Play speed')" :options="speeds" :model-value="speed" @update:model-value="store.setSpeed($event)"
/><div class="world-timeline-track" :class="{ 'is-zoomed': zoomed }" :title="t('Scroll or pinch to zoom, drag to move along · double-click to see it all')" @wheel="onWheel" @dblclick="showAll" @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="onPointerUp" @pointercancel="onPointerUp"
><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 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
class="world-timeline-scrub"
:class="{ 'is-off-view': handleOff }"
type="range"
:aria-label="t('World time')"
:aria-valuetext="readout"
:min="span.start"
:max="span.end"
:min="view.start"
:max="view.end"
step="any"
:value="at"
:value="shown"
@input="scrub"
aria-keyshortcuts="+ - 0"
@keydown="onKey"
/></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
></div>
</template>