mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
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:
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).
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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": "دقيقة/ث"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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分/秒"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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 мин/с"
|
||||||
}
|
}
|
||||||
@@ -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 хв/с"
|
||||||
}
|
}
|
||||||
@@ -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 分钟/秒"
|
||||||
}
|
}
|
||||||
@@ -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 分鐘/秒"
|
||||||
}
|
}
|
||||||
@@ -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: --",
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user