diff --git a/changelog.d/1513-timeline-launches-speed-buffering.md b/changelog.d/1513-timeline-launches-speed-buffering.md new file mode 100644 index 000000000..e84d8e205 --- /dev/null +++ b/changelog.d/1513-timeline-launches-speed-buffering.md @@ -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). diff --git a/src/app/viewer.js b/src/app/viewer.js index fc75c0498..e4288e8a3 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -7,6 +7,48 @@ import { isFreeGlobeCamera, } from '../globeInView.js'; +/** + * At the farthest a hand can zoom out, the globe is this share of the + * screen's height across. Cesium sets no limit, so zooming on left the globe + * a speck among the stars. + */ +const MIN_GLOBE_SHARE = 0.3; + +/** + * The farthest a hand can zoom out, in metres above the ellipsoid, for a + * vertical field of view. It follows the screen's shape, as the app's own + * whole-globe view does, so that view always lies within it. + * @param {number} fovy Vertical field of view, radians. + */ +export function zoomOutLimit(fovy) { + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const angular = Math.atan(MIN_GLOBE_SHARE * Math.tan(fovy / 2)); + return radius / Math.sin(angular) - radius; +} + +/** + * Hold a hand's zoom out to `zoomOutLimit` on the globe, each frame, since the + * field of view follows the screen's shape. The limit never sits below the + * camera: one already past it (a flight, a screen turned) stops there rather + * than jumping in at the next zoom. Flat maps keep Cesium's own, unlimited. + * @returns {() => void} Stop limiting. + */ +export function limitZoomOut(viewer) { + const { scene, camera } = viewer; + return scene.preRender.addEventListener(() => { + const controller = scene.screenSpaceCameraController; + const fovy = camera.frustum.fovy; + if (scene.mode !== Cesium.SceneMode.SCENE3D || !Number.isFinite(fovy)) { + controller.maximumZoomDistance = Number.POSITIVE_INFINITY; + return; + } + controller.maximumZoomDistance = Math.max( + zoomOutLimit(fovy), + camera.positionCartographic?.height ?? 0, + ); + }); +} + const PINCH_ZOOM_MULTIPLIER = 8; const MAX_PINCH_PIXEL_DELTA = 120; @@ -372,6 +414,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. diff --git a/src/app/viewer.test.mjs b/src/app/viewer.test.mjs new file mode 100644 index 000000000..94d80e20a --- /dev/null +++ b/src/app/viewer.test.mjs @@ -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); +}); diff --git a/src/celestialRing.js b/src/celestialRing.js index f470e7d18..525ded44e 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -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); diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index 39fd5b8f2..dafc51df1 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -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) { diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 995c75e83..ea257608a 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3487,5 +3487,11 @@ "Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك.", "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": "دقيقة/ث" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 8c02f91e5..215aaef9e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3187,5 +3187,11 @@ "Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht.", "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" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 1f1c15981..0e6ac9952 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3262,5 +3262,11 @@ "Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP.", "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" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index e8daee830..91f42d918 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3262,5 +3262,11 @@ "Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.", "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" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 5dc1ed6b4..15f194a6e 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3262,5 +3262,11 @@ "Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP.", "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" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index a116806a9..a5d6d15a6 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3112,5 +3112,11 @@ "Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。", "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分/秒" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 65f052f70..31cb296f5 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3262,5 +3262,11 @@ "Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP.", "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" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index cd3bfbee3..178a7e1c4 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3337,5 +3337,11 @@ "Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес.", "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 мин/с" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 9677fd677..bdc5ce5fe 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3337,5 +3337,11 @@ "Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу.", "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 хв/с" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index efdb166c2..174268fd3 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3112,5 +3112,11 @@ "Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。", "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 分钟/秒" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 6b4c24211..c9cd783b9 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3112,5 +3112,11 @@ "Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。", "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 分鐘/秒" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 97e081b18..945eb0368 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -216,6 +216,7 @@ "▲ {frp} MW" ], "es": [ + "1 min/s", "1090 MHz", "978 MHz UAT", "AIS: --", @@ -355,6 +356,7 @@ "iNaturalist" ], "fr": [ + "1 min/s", "1090 MHz", "978 MHz UAT", "ALTITUDE", @@ -549,6 +551,7 @@ "▲ {frp} MW" ], "it": [ + "1 min/s", "1090 MHz", "978 MHz UAT", "AFRICA", @@ -784,6 +787,7 @@ "▲ {frp} MW" ], "pt": [ + "1 min/s", "1090 MHz", "978 MHz UAT", "AIS: --", diff --git a/src/layers/launches/orbitRendering.js b/src/layers/launches/orbitRendering.js index b9a6cab09..f02494fff 100644 --- a/src/layers/launches/orbitRendering.js +++ b/src/layers/launches/orbitRendering.js @@ -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), ); diff --git a/src/layers/launches/overlays.js b/src/layers/launches/overlays.js index 4b246e702..50c9f91e2 100644 --- a/src/layers/launches/overlays.js +++ b/src/layers/launches/overlays.js @@ -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, diff --git a/src/layers/launches/rendering.js b/src/layers/launches/rendering.js index 52eb39961..2ea0b86d5 100644 --- a/src/layers/launches/rendering.js +++ b/src/layers/launches/rendering.js @@ -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. diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index 17026dcfd..3349af81d 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -169,6 +169,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(); diff --git a/src/layers/launches/worldTime.test.mjs b/src/layers/launches/worldTime.test.mjs new file mode 100644 index 000000000..d351c92b7 --- /dev/null +++ b/src/layers/launches/worldTime.test.mjs @@ -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 }); +}); diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js index 67973bf94..7384935b0 100644 --- a/src/layers/weather/clock.js +++ b/src/layers/weather/clock.js @@ -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({ diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs index c803e5d72..dd2535ffc 100644 --- a/src/layers/weather/clock.test.mjs +++ b/src/layers/weather/clock.test.mjs @@ -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'); +}); diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 4ccfdef6e..fb1447827 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -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 ?? []; diff --git a/src/time/worldClock.js b/src/time/worldClock.js index a9be9296d..edfe4c283 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -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}} [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) diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs index 19bc8ba93..f81fa85a9 100644 --- a/src/time/worldClock.test.mjs +++ b/src/time/worldClock.test.mjs @@ -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); +}); diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index 62fb4528c..80f51ac71 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -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; } diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css index e7560a467..f644f8543 100644 --- a/src/ui/styles/world-timeline.css +++ b/src/ui/styles/world-timeline.css @@ -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; +} + diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 60a73540b..073d6f95e 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -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); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 1c4a8bdc0..866380638 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -1,58 +1,316 @@ @@ -72,24 +330,26 @@ const onKey = (event) => {
{{ formatDate(span.start, EDGE_DATE) }}
{{ formatDate(span.end, EDGE_DATE) }}{{ t('Live') }}