From 0aa54dfa97d9f2eb09ac062bfaa52689163d933f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 07:21:01 -0800 Subject: [PATCH 1/9] Bring launches up as the world time passes them, and play the whole track Away from live, a launch shows from its lift-off on: its marker, label, paths and orbit stay hidden while the world time is before it, so scrubbing or playing brings the launches up in the order they flew. The selected mission stays whenever it flies, and live shows every launch as before. Play on the timeline runs the whole track in about a minute, from its start when at live, and runs the clock itself past weather's own replay. It goes live when it reaches now, or holds at an end that lies ahead of now. Going live puts the rate back to 1. --- src/layers/launches/orbitRendering.js | 3 + src/layers/launches/overlays.js | 5 +- src/layers/launches/rendering.js | 47 ++++++++-- src/layers/launches/state.js | 20 ++++ src/layers/launches/worldTime.test.mjs | 102 +++++++++++++++++++++ src/time/worldClock.js | 14 ++- src/time/worldClock.test.mjs | 14 +++ src/ui/stores/worldTime.js | 76 +++++++++++++-- src/ui/views/panels/WorldTimeline.test.mjs | 70 ++++++++++++++ 9 files changed, 326 insertions(+), 25 deletions(-) create mode 100644 src/layers/launches/worldTime.test.mjs 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 59d32bea7..a2f0dfa66 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -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(); 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/time/worldClock.js b/src/time/worldClock.js index a9be9296d..68fa976fa 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -152,23 +152,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) { diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs index 19bc8ba93..0e7a544a3 100644 --- a/src/time/worldClock.test.mjs +++ b/src/time/worldClock.test.mjs @@ -305,3 +305,17 @@ 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); +}); diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index 62fb4528c..bec2a6a82 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -8,6 +8,8 @@ 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; +/** How long play takes to run the whole track, in real ms. */ +const PLAY_THROUGH_MS = 60_000; /** * The world clock as the timeline shows it: `{live, playing, timeMs, @@ -20,17 +22,55 @@ 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, span) => + Math.abs(ms - worldClock.wallNow()) <= + Math.max(LIVE_EDGE_MS, span ? (span.end - span.start) * LIVE_EDGE : 0); + // 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, @@ -54,6 +94,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { } }; const unsubscribe = worldClock.subscribe((state) => { + if (stopAtEnd(state)) return; time.value = read(state); syncTicker(); }); @@ -66,13 +107,30 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { return { time, - togglePlay: () => worldClock.togglePlay(), + /** + * Play runs the whole track in about a minute, 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) return worldClock.play({ rate: 1 }); + const length = span.end - span.start; + if (live || timeMs >= covered.end - LIVE_EDGE_MS) + worldClock.seek(span.start); + // An end at now moves on with it, so play outruns it by the track. + const rate = endsAtNow(covered) + ? 1 + length / PLAY_THROUGH_MS + : Math.max(1, length / PLAY_THROUGH_MS); + worldClock.play({ rate, continuous: true }); + }, goLive: () => worldClock.goLive(), /** Scrub to `ms`: playback stops where the hand puts it; at now, live. */ scrub(ms) { - 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)) { + if (nearNow(ms, time.value.span)) { worldClock.goLive(); return; } diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 60a73540b..1d495d9a2 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -148,3 +148,73 @@ 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 the whole track in a minute, from the start 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'); + // The end is now and moves on with it: play outruns it by the track. + const length = Date.now() - start; + assert.ok(Math.abs(rate - (1 + length / 60_000)) < rate * 0.01, 'a minute end to end'); + 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); +}); From e7973e81cf08fcebc011178d762a48de0b27b2cf Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 07:27:53 -0800 Subject: [PATCH 2/9] Stop zooming out while the globe is still in view Cesium sets no limit, so zooming out left the globe a speck among the stars. On the globe a hand now stops zooming out where the globe spans 30% of the screen's height, which follows the screen's shape as the app's own whole-globe view does. A camera already past it, after a flight or a turned screen, stops there rather than jumping in at the next zoom. Flat maps keep zooming out unlimited. --- src/app/viewer.js | 43 ++++++++++++++++++++++++ src/app/viewer.test.mjs | 73 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/app/viewer.test.mjs diff --git a/src/app/viewer.js b/src/app/viewer.js index c8b841352..96c9851d9 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -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. 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); +}); From d04e30450272aa9545ab8031408ccc25bbf7b0c5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 07:41:50 -0800 Subject: [PATCH 3/9] Choose how fast the world timeline plays A speed menu beside play offers real time, an hour, a day or a week a second, a day a second to start with. A speed chosen while playing applies at once; chosen while weather's own replay steps its frames, it takes playback over from where it is. Play starts again from the top only at live or at the very end, so a pause just before the end plays on. On phones the menu sits beside the track, a full-size target. --- src/i18n/locales/ar.json | 6 +- src/i18n/locales/de.json | 6 +- src/i18n/locales/es.json | 6 +- src/i18n/locales/fr.json | 6 +- src/i18n/locales/it.json | 6 +- src/i18n/locales/ja.json | 6 +- src/i18n/locales/pt.json | 6 +- src/i18n/locales/ru.json | 6 +- src/i18n/locales/uk.json | 6 +- src/i18n/locales/zh-CN.json | 6 +- src/i18n/locales/zh-Hant.json | 6 +- src/time/worldClock.js | 2 + src/ui/stores/worldTime.js | 45 +++++++---- src/ui/styles/world-timeline.css | 12 ++- src/ui/views/panels/WorldTimeline.test.mjs | 89 +++++++++++++++++++++- src/ui/views/panels/WorldTimeline.vue | 12 ++- 16 files changed, 194 insertions(+), 32 deletions(-) diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 69ae84544..b25dc7319 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3221,5 +3221,9 @@ "Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد", "World time": "وقت العالم", "Back to live": "العودة إلى البث المباشر", - "Live": "مباشر" + "Live": "مباشر", + "1 h/s": "ساعة/ث", + "1 d/s": "يوم/ث", + "1 wk/s": "أسبوع/ث", + "Play speed": "سرعة التشغيل" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index da1bebb4a..d4d0e6949 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2941,5 +2941,9 @@ "PAD / RTLS": "STARTPLATZ / RTLS", "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" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f592a72c5..d27be0acd 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3011,5 +3011,9 @@ "Right panels": "Paneles derechos", "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" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 4d7289ff7..8b225e347 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3011,5 +3011,9 @@ "PAD / RTLS": "PAS DE TIR / RTLS", "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" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 065137689..29f832bcc 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3011,5 +3011,9 @@ "PAD / RTLS": "SITO DI LANCIO / RTLS", "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" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3ff743076..efbf7d3a2 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2871,5 +2871,9 @@ "PAD / RTLS": "射場 / RTLS", "World time": "世界時刻", "Back to live": "ライブに戻る", - "Live": "ライブ" + "Live": "ライブ", + "1 h/s": "1時間/秒", + "1 d/s": "1日/秒", + "1 wk/s": "1週間/秒", + "Play speed": "再生速度" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 455515ab2..7606a3069 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3011,5 +3011,9 @@ "PAD / RTLS": "PLATAFORMA / RTLS", "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" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 90099d66c..b79bcc88c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3081,5 +3081,9 @@ "Right panels": "Правые панели", "World time": "Мировое время", "Back to live": "Вернуться к прямому эфиру", - "Live": "Сейчас" + "Live": "Сейчас", + "1 h/s": "1 ч/с", + "1 d/s": "1 дн/с", + "1 wk/s": "1 нед/с", + "Play speed": "Скорость воспроизведения" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 6ca398f07..fda4b5a9b 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3081,5 +3081,9 @@ "PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS", "World time": "Світовий час", "Back to live": "Повернутися до прямого ефіру", - "Live": "Зараз" + "Live": "Зараз", + "1 h/s": "1 год/с", + "1 d/s": "1 дн/с", + "1 wk/s": "1 тиж/с", + "Play speed": "Швидкість відтворення" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 019e97701..fc5101fa8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2871,5 +2871,9 @@ "Right panels": "右侧面板", "World time": "世界时间", "Back to live": "返回实时", - "Live": "实时" + "Live": "实时", + "1 h/s": "1 小时/秒", + "1 d/s": "1 天/秒", + "1 wk/s": "1 周/秒", + "Play speed": "播放速度" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 3e41803e6..3411c3838 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2871,5 +2871,9 @@ "Right panels": "右側面板", "World time": "世界時間", "Back to live": "返回即時", - "Live": "即時" + "Live": "即時", + "1 h/s": "1 小時/秒", + "1 d/s": "1 天/秒", + "1 wk/s": "1 週/秒", + "Play speed": "播放速度" } diff --git a/src/time/worldClock.js b/src/time/worldClock.js index 68fa976fa..f9c16c5d8 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -96,6 +96,8 @@ export function createWorldClock({ timeMs: time(), playing, rate, + // A driver steps the clock, at its own pace rather than the rate. + stepping: Boolean(active), following: followedIds(), supported: isSupported(), }); diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index bec2a6a82..46c620ab6 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -8,12 +8,17 @@ 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; -/** How long play takes to run the whole track, in real ms. */ -const PLAY_THROUGH_MS = 60_000; +/** The speeds play offers, in world ms per real ms. */ +export const WORLD_TIME_SPEEDS = Object.freeze({ + real: 1, + 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, + * 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 @@ -75,11 +80,14 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { Object.freeze({ live: state.live, playing: state.playing, + stepping: state.stepping, timeMs: state.timeMs, supported: state.supported, span: spanOf(state), }); const time = shallowRef(read()); + const speed = shallowRef('day'); + const rate = () => WORLD_TIME_SPEEDS[speed.value]; let ticker = null; const syncTicker = () => { // Playing, the clock tells of the time itself. @@ -108,8 +116,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { return { time, /** - * Play runs the whole track in about a minute, from the start when it is - * at its end (at live, say), as a video plays again from the top. It runs + * 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. */ @@ -117,15 +125,24 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { const { playing, live, timeMs, span } = time.value; if (playing) return worldClock.pause(); const covered = reach(); - if (!span || !covered) return worldClock.play({ rate: 1 }); - const length = span.end - span.start; - if (live || timeMs >= covered.end - LIVE_EDGE_MS) + if (span && covered && (live || timeMs >= covered.end)) worldClock.seek(span.start); - // An end at now moves on with it, so play outruns it by the track. - const rate = endsAtNow(covered) - ? 1 + length / PLAY_THROUGH_MS - : Math.max(1, length / PLAY_THROUGH_MS); - worldClock.play({ rate, continuous: true }); + 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. */ diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css index e7560a467..0ae69baf4 100644 --- a/src/ui/styles/world-timeline.css +++ b/src/ui/styles/world-timeline.css @@ -77,6 +77,10 @@ body.cockpit-mode #world-timeline { text-transform: uppercase; } +#world-timeline .world-timeline-speed { + flex: none; +} + #world-timeline .world-timeline-time { overflow: hidden; font-family: var(--font-mono); @@ -206,9 +210,15 @@ body.mobile-shell #world-timeline .world-timeline-time { flex: 1 1 0; } +/* 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); +} + +body.mobile-shell #world-timeline .world-timeline-speed { + order: 2; + height: 32px; } @media (max-width: 640px) { diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 1d495d9a2..251ede717 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -149,7 +149,7 @@ test('the track runs over the time the layers show, to now, and scrubs', async ( assert.equal(worldClock.now(), ahead); }); -test('play runs the whole track in a minute, from the start when at live', async (t) => { +test('play runs a day a second, 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; @@ -176,9 +176,7 @@ test('play runs the whole track in a minute, from the start when at live', async assert.deepEqual(stepped, []); const rate = worldClock.getRate(); assert.ok(worldClock.now() - start < rate * 1_000, 'from the start of the track'); - // The end is now and moves on with it: play outruns it by the track. - const length = Date.now() - start; - assert.ok(Math.abs(rate - (1 + length / 60_000)) < rate * 0.01, 'a minute end to end'); + assert.equal(rate, 86_400, 'a day a second'); worldClock.play({ continuous: true }); // Playing on to now goes live. @@ -218,3 +216,86 @@ test('play holds at the end of a track that reaches past now', async (t) => { 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, 'day'); + 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 = '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'); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 1c4a8bdc0..0cedda02d 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -5,6 +5,7 @@ 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 @@ -13,7 +14,13 @@ import UiIcon from '../../kit/UiIcon.vue'; // 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: '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', @@ -72,7 +79,8 @@ const onKey = (event) => {
{{ formatDate(span.start, EDGE_DATE) }}
Date: Mon, 2 Nov 2026 08:03:46 -0800 Subject: [PATCH 4/9] Wait for each frame while the world timeline plays, and quiet the bar Play no longer runs ahead of the data. The world clock gains a wait, which holds continuous play where it is while a layer loads what the time needs, and a gate, which keeps play from passing a time a layer has yet to load. Weather gates play at its next frame and waits there until the frame shows, so every frame is seen however fast play runs; it waits no more than eight seconds on a frame that never comes, and takes a wait only when its frames change. Play pressed while a frame loads waits for it too. A scrub still drops what loads and moves on. The bar loses its long date and the dates at its ends: the time rides in a bubble over the handle, which pulses while play waits, and the bar runs left to right in every language. Play starts at real time. --- src/creditAttribution.test.mjs | 2 +- src/layers/weather/clock.js | 60 ++++++++- src/layers/weather/clock.test.mjs | 147 +++++++++++++++++++++ src/time/worldClock.js | 48 ++++++- src/time/worldClock.test.mjs | 34 +++++ src/ui/stores/worldTime.js | 5 +- src/ui/styles/world-timeline.css | 68 ++++++---- src/ui/views/panels/WorldTimeline.test.mjs | 6 +- src/ui/views/panels/WorldTimeline.vue | 27 ++-- 9 files changed, 357 insertions(+), 40 deletions(-) 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/layers/weather/clock.js b/src/layers/weather/clock.js index 67973bf94..21a081b48 100644 --- a/src/layers/weather/clock.js +++ b/src/layers/weather/clock.js @@ -4,6 +4,10 @@ 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; + +let clocks = 0; /** * Transient observed time. Products must honor the signal before installing a @@ -33,6 +37,30 @@ 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 as frames for its time load, and + // the timer that lets play go on should a frame never come. + let waiting = null; + let waitTimer = null; + 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(); + }, 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())] @@ -130,7 +158,13 @@ export function createWeatherClock({ cancelTimer(); const owner = ++generation; if (!canPlay()) stopPlaying(); + // Playing, the world waits for new frames; a later move takes over the + // wait, and a scrub (not playing) drops what loads and moves on. A refresh + // that shows the same frames does not hold play. + const framesChange = selections() !== applied; applied = selections(); + stopWaiting(); + if (framesChange && worldPlaysOn()) startWaiting(); 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,6 +178,7 @@ export function createWeatherClock({ }); const result = Promise.allSettled(tasks).then(() => { if (destroyed || owner !== generation) return false; + stopWaiting(); pending = null; if (!canPlay()) stopPlaying(); notify(); @@ -269,8 +304,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 +326,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,6 +345,11 @@ export function createWeatherClock({ // changes the mode or a product's frame. function followWorld(state) { if (destroyed) return; + gateNext(state); + // Play pressed while a frame still loads waits for it too. + const playsOn = worldPlaysOn(state); + if (playsOn && !playedOn && pending) startWaiting(); + playedOn = playsOn; const nextMode = state.live ? 'latest' : 'history'; const nextTarget = state.live ? null @@ -319,6 +363,20 @@ export function createWeatherClock({ if (moving || modeChanged || selections() !== applied) void transition(); else notify(); } + // 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({ play: () => (canPlay() ? void api.play() : false), diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs index c803e5d72..55b79a87b 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,149 @@ 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); }); + +test('playing, the world waits for each frame; a scrub drops it and moves on', async (t) => { + const { clock, world, tick } = harness(t); + const radar = product('radar', [0, 10, 20]); + const loads = []; + radar.apply = (selected, { signal }) => + new Promise((resolve) => { + loads.push({ selected, signal, done: () => resolve(true) }); + }); + clock.register(radar); + world.seek(Date.parse(time(9))); + await flush(); + loads.at(-1).done(); + await flush(); + world.play({ rate: 60, continuous: true }); + tick(2_000); + // Into the next frame (the page's ticker tells of it): the world holds + // there while it loads. + world.coverageChanged(); + await flush(); + assert.equal(loads.at(-1).selected, time(10)); + assert.equal(world.getState().buffering, true); + const held = world.now(); + tick(5_000); + assert.equal(world.now(), held, 'held while the frame loads'); + loads.at(-1).done(); + await flush(); + assert.equal(world.getState().buffering, false); + tick(1_000); + assert.equal(world.now(), held + 60_000, 'and plays on once it shows'); + + // A scrub aborts what loads and loads where the hand is, with no wait. + world.pause(); + world.seek(Date.parse(time(20))); + await flush(); + const scrubbed = loads.at(-1); + world.seek(Date.parse(time(0))); + await flush(); + assert.equal(scrubbed.signal.aborted, true); + assert.equal(loads.at(-1).selected, time(0)); + assert.equal(world.getState().buffering, 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('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(); + assert.equal(world.getState().buffering, true); + tick(PLAY_WAIT_MS); + assert.equal(world.getState().buffering, false, 'play goes on without it'); +}); + +test('play pressed while a frame loads waits for it; a refresh does not', 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, + true, + 'waits for the frame in flight', + ); + radar.loads.at(-1).done(); + await flush(); + assert.equal(world.getState().buffering, false); + 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', + ); +}); diff --git a/src/time/worldClock.js b/src/time/worldClock.js index f9c16c5d8..7d493bbc7 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -60,8 +60,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 +92,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({ @@ -98,6 +106,8 @@ export function createWorldClock({ 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(), }); @@ -198,6 +208,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 0e7a544a3..8b5154854 100644 --- a/src/time/worldClock.test.mjs +++ b/src/time/worldClock.test.mjs @@ -319,3 +319,37 @@ test('continuous play runs past every driver, and live resets the rate', () => { 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(); +}); diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index 46c620ab6..ea73e262f 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -18,7 +18,7 @@ export const WORLD_TIME_SPEEDS = Object.freeze({ /** * The world clock as the timeline shows it: `{live, playing, stepping, - * timeMs, supported, span}`, where `supported` says a layer that moves with the clock + * buffering, timeMs, supported, span}`, where `supported` says a layer that moves with the clock * has time to show and `span` is the stretch the timeline runs over: the * 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 @@ -81,12 +81,13 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { 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('day'); + const speed = shallowRef('real'); const rate = () => WORLD_TIME_SPEEDS[speed.value]; let ticker = null; const syncTicker = () => { diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css index 0ae69baf4..0fd12fe28 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%; @@ -81,21 +85,43 @@ body.cockpit-mode #world-timeline { flex: none; } +/* 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 { - 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-edge { - flex: none; - color: var(--text-secondary); + --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. */ +#world-timeline .world-timeline-time.is-buffering { + animation: world-timeline-buffering 1s ease-in-out infinite alternate; +} + +@keyframes world-timeline-buffering { + to { + opacity: 0.45; + } +} + +@media (prefers-reduced-motion: reduce) { + #world-timeline .world-timeline-time.is-buffering { + animation: none; + opacity: 0.6; + } } /* The track: ticks for each launch or frame, the elapsed fill, and a native @@ -194,7 +220,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; } @@ -206,8 +232,9 @@ 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. */ @@ -216,17 +243,12 @@ body.mobile-shell #world-timeline .world-timeline-track { flex: 1 1 calc(100% - 96px); } +body.mobile-shell #world-timeline .world-timeline-live { + margin-inline-start: auto; +} + body.mobile-shell #world-timeline .world-timeline-speed { order: 2; height: 32px; } -@media (max-width: 640px) { - #world-timeline .world-timeline-edge { - display: none; - } - - #world-timeline .world-timeline-time { - font-size: 11px; - } -} diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 251ede717..746158ddb 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -149,7 +149,7 @@ test('the track runs over the time the layers show, to now, and scrubs', async ( assert.equal(worldClock.now(), ahead); }); -test('play runs a day a second, from the start of the track when at live', async (t) => { +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; @@ -176,7 +176,7 @@ test('play runs a day a second, from the start of the track when at live', async assert.deepEqual(stepped, []); const rate = worldClock.getRate(); assert.ok(worldClock.now() - start < rate * 1_000, 'from the start of the track'); - assert.equal(rate, 86_400, 'a day a second'); + assert.equal(rate, 1, 'real time to start with'); worldClock.play({ continuous: true }); // Playing on to now goes live. @@ -231,7 +231,7 @@ test('the speed chosen sets how fast play runs, at once while playing', async (t }); await nextTick(); const select = bar().querySelector('select.world-timeline-speed'); - assert.equal(select.value, 'day'); + assert.equal(select.value, 'real'); select.value = 'week'; select.dispatchEvent(new Event('change')); await nextTick(); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 0cedda02d..e56737fda 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -7,9 +7,10 @@ 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 +// 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. 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. @@ -27,11 +28,18 @@ const DATE_TIME = Object.freeze({ 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; @@ -41,6 +49,11 @@ const readout = computed(() => ? `${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` + : '', +); const span = computed(() => time.value.span); const at = computed(() => { const { start, end } = span.value; @@ -80,9 +93,8 @@ const onKey = (event) => { >{{ formatDate(span.start, EDGE_DATE) }}
{ @input="scrub" @keydown="onKey" />
{{ formatDate(span.end, EDGE_DATE) }}{{ t('Live') }}
From 497016b0848f32affbc4dcb53b55da7b0881ff3a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 13:17:28 -0800 Subject: [PATCH 5/9] Add the changelog entry for launches, speed and buffering on the timeline --- changelog.d/1513-timeline-launches-speed-buffering.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1513-timeline-launches-speed-buffering.md 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..f4663019b --- /dev/null +++ b/changelog.d/1513-timeline-launches-speed-buffering.md @@ -0,0 +1,5 @@ +- The timeline brings launches up as the time passes them, offers play + speeds from real time to a week a second, and waits on each weather frame + until it loads instead of running past it. The time now rides in a bubble + over the handle. Zooming out on the globe stops while the globe is still in + view instead of leaving it a speck (samehkhamis, #1513). From 287c881c17b88ae84916c007970e06ddd317e123 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 13:53:08 -0800 Subject: [PATCH 6/9] Zoom the timeline track around the handle With layers of very different reach on together, a month of launches and a few hours of lightning, the short-range ticks were crushed at the end of the one shared track. Scroll or pinch on the track now zooms it around the handle, plus and minus zoom from the keyboard, and zero or a double-click shows it all again. Everything still plays together; zooming only changes what the track shows, and the view follows the handle as it plays, steps or is left behind by now. A scrub's live edge goes by the stretch shown, so a zoomed track can land minutes from now, and a double-click or pinch puts back the time its first press moved. --- src/i18n/locales/ar.json | 3 +- src/i18n/locales/de.json | 3 +- src/i18n/locales/es.json | 3 +- src/i18n/locales/fr.json | 3 +- src/i18n/locales/it.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/pt.json | 3 +- src/i18n/locales/ru.json | 3 +- src/i18n/locales/uk.json | 3 +- src/i18n/locales/zh-CN.json | 3 +- src/i18n/locales/zh-Hant.json | 3 +- src/ui/stores/worldTime.js | 16 +- src/ui/styles/world-timeline.css | 13 ++ src/ui/views/panels/WorldTimeline.test.mjs | 112 ++++++++++++++ src/ui/views/panels/WorldTimeline.vue | 161 +++++++++++++++++++-- 15 files changed, 303 insertions(+), 32 deletions(-) diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index ce7902009..3715c31b0 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3230,5 +3230,6 @@ "1 h/s": "ساعة/ث", "1 d/s": "يوم/ث", "1 wk/s": "أسبوع/ث", - "Play speed": "سرعة التشغيل" + "Play speed": "سرعة التشغيل", + "Scroll or pinch to zoom · double-click to see it all": "مرّر أو اقرص للتكبير · انقر نقرًا مزدوجًا لرؤية الكل" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index ae988568d..ec76e1298 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2950,5 +2950,6 @@ "1 h/s": "1 Std./s", "1 d/s": "1 Tag/s", "1 wk/s": "1 Wo./s", - "Play speed": "Wiedergabegeschwindigkeit" + "Play speed": "Wiedergabegeschwindigkeit", + "Scroll or pinch to zoom · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen · Doppelklick zeigt alles" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ad207bdb0..81f0e6403 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3020,5 +3020,6 @@ "1 h/s": "1 h/s", "1 d/s": "1 d/s", "1 wk/s": "1 sem/s", - "Play speed": "Velocidad de reproducción" + "Play speed": "Velocidad de reproducción", + "Scroll or pinch to zoom · double-click to see it all": "Desplaza o pellizca para ampliar · doble clic para verlo todo" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3345cf46b..c95e70e93 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3020,5 +3020,6 @@ "1 h/s": "1 h/s", "1 d/s": "1 j/s", "1 wk/s": "1 sem/s", - "Play speed": "Vitesse de lecture" + "Play speed": "Vitesse de lecture", + "Scroll or pinch to zoom · double-click to see it all": "Faites défiler ou pincez pour zoomer · double-clic pour tout voir" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4c1717f5b..47145cc25 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3020,5 +3020,6 @@ "1 h/s": "1 h/s", "1 d/s": "1 g/s", "1 wk/s": "1 sett/s", - "Play speed": "Velocità di riproduzione" + "Play speed": "Velocità di riproduzione", + "Scroll or pinch to zoom · double-click to see it all": "Scorri o pizzica per lo zoom · doppio clic per vedere tutto" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index fd70f8541..fc4ad110b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2880,5 +2880,6 @@ "1 h/s": "1時間/秒", "1 d/s": "1日/秒", "1 wk/s": "1週間/秒", - "Play speed": "再生速度" + "Play speed": "再生速度", + "Scroll or pinch to zoom · double-click to see it all": "スクロールまたはピンチでズーム · ダブルクリックで全体表示" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 3b9a03e29..c01472e42 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3020,5 +3020,6 @@ "1 h/s": "1 h/s", "1 d/s": "1 d/s", "1 wk/s": "1 sem/s", - "Play speed": "Velocidade de reprodução" + "Play speed": "Velocidade de reprodução", + "Scroll or pinch to zoom · double-click to see it all": "Role ou faça pinça para ampliar · clique duplo para ver tudo" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index c4b0e2046..83d18b2d2 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3090,5 +3090,6 @@ "1 h/s": "1 ч/с", "1 d/s": "1 дн/с", "1 wk/s": "1 нед/с", - "Play speed": "Скорость воспроизведения" + "Play speed": "Скорость воспроизведения", + "Scroll or pinch to zoom · double-click to see it all": "Прокрутите или сведите пальцы для масштаба · двойной щелчок — показать всё" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 1bad7daa6..ab598a937 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3090,5 +3090,6 @@ "1 h/s": "1 год/с", "1 d/s": "1 дн/с", "1 wk/s": "1 тиж/с", - "Play speed": "Швидкість відтворення" + "Play speed": "Швидкість відтворення", + "Scroll or pinch to zoom · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу · подвійне клацання — показати все" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index ec77d015d..cb34c135d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2880,5 +2880,6 @@ "1 h/s": "1 小时/秒", "1 d/s": "1 天/秒", "1 wk/s": "1 周/秒", - "Play speed": "播放速度" + "Play speed": "播放速度", + "Scroll or pinch to zoom · double-click to see it all": "滚动或双指缩放 · 双击查看全部" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 7753a6f95..7d82fbcd9 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2880,5 +2880,6 @@ "1 h/s": "1 小時/秒", "1 d/s": "1 天/秒", "1 wk/s": "1 週/秒", - "Play speed": "播放速度" + "Play speed": "播放速度", + "Scroll or pinch to zoom · double-click to see it all": "捲動或雙指縮放 · 按兩下查看全部" } diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index ea73e262f..35cad2edc 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -48,9 +48,9 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { marks: covered.marks, }); }; - const nearNow = (ms, span) => + const nearNow = (ms, length) => Math.abs(ms - worldClock.wallNow()) <= - Math.max(LIVE_EDGE_MS, span ? (span.end - span.start) * LIVE_EDGE : 0); + 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; @@ -146,9 +146,15 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { worldClock.play({ rate: rate(), continuous: true }); }, goLive: () => worldClock.goLive(), - /** Scrub to `ms`: playback stops where the hand puts it; at now, live. */ - scrub(ms) { - if (nearNow(ms, time.value.span)) { + /** + * 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; + 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 0fd12fe28..f6c379e8e 100644 --- a/src/ui/styles/world-timeline.css +++ b/src/ui/styles/world-timeline.css @@ -128,11 +128,24 @@ body.cockpit-mode #world-timeline { 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; 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%; diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 746158ddb..06301f3a2 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -299,3 +299,115 @@ test('paused just before the end, play carries on rather than restarting', async 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'); + // A sideways swipe leaves the zoom alone. + const sideways = new WheelEvent('wheel', { deltaX: 50, deltaY: 1, cancelable: true }); + track.dispatchEvent(sideways); + assert.equal(sideways.defaultPrevented, false); + + // 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 MouseEvent('mousedown', { bubbles: true })); + 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'); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index e56737fda..9588b7e64 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -1,5 +1,5 @@ @@ -93,20 +221,21 @@ const onKey = (event) => { >
{{ t('Live') }} Date: Mon, 2 Nov 2026 15:11:47 -0800 Subject: [PATCH 7/9] Show a spinner by the time while play waits on data Play holds while a layer loads the frame for its time, and the bar's only sign of it was a faint pulse, so a stopped handle read as a stall. The bubble now shows a small spinner beside the time while play is held. It comes on once a hold lasts long enough to notice and stays through a run of slow frames, so neither a quick frame nor the gaps between frames flash it. --- src/ui/styles/world-timeline.css | 25 +++++++++++++------ src/ui/views/panels/WorldTimeline.test.mjs | 29 ++++++++++++++++++++++ src/ui/views/panels/WorldTimeline.vue | 28 +++++++++++++++++++-- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css index f6c379e8e..2eeea8504 100644 --- a/src/ui/styles/world-timeline.css +++ b/src/ui/styles/world-timeline.css @@ -106,21 +106,32 @@ body.cockpit-mode #world-timeline { pointer-events: none; } -/* Playing, held while a layer loads the time's data. */ -#world-timeline .world-timeline-time.is-buffering { - animation: world-timeline-buffering 1s ease-in-out infinite alternate; +/* 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; } -@keyframes world-timeline-buffering { +#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 { - opacity: 0.45; + transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { - #world-timeline .world-timeline-time.is-buffering { + #world-timeline .world-timeline-spinner { animation: none; - opacity: 0.6; + border-color: var(--accent); } } diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 06301f3a2..40e7d3ad1 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -411,3 +411,32 @@ test('zoomed, a scrub near now stays off live, and the view keeps a still handle 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'); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 9588b7e64..40baecaae 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -1,5 +1,5 @@