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