diff --git a/changelog.d/1512-world-clock-timeline.md b/changelog.d/1512-world-clock-timeline.md new file mode 100644 index 000000000..a43231150 --- /dev/null +++ b/changelog.d/1512-world-clock-timeline.md @@ -0,0 +1,5 @@ +- One world clock now drives everything with a time of its own: the sky, + the HUD clock, satellites, launches and weather. A timeline along the + bottom of the screen, shown while one of those layers is on, plays, pauses + and scrubs it, with a tick for each launch or frame and a way back to + live. Share links made away from live carry the time (samehkhamis, #1512). 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/changelog.d/1634-layer-state-registered-only.md b/changelog.d/1634-layer-state-registered-only.md new file mode 100644 index 000000000..4d8eaa897 --- /dev/null +++ b/changelog.d/1634-layer-state-registered-only.md @@ -0,0 +1,3 @@ +- An application built with fewer layers no longer restores, saves or shares a + layer it does not have when a link or saved state from a full build names it + (samehkhamis, #1634). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a5940a95f..52dc4da9f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5803,6 +5803,13 @@ and unreachable upstream (502/504) separately from road geometry. explicit-empty form); unknown option tokens are ignored. Restoration settles independently per layer so one failed or unavailable source cannot block its siblings. +- An application that registers fewer layers (an embedder with a smaller + catalog) keeps only its own: `LayerStateCoordinator` drops enabled-layer ids + the application did not register before it normalizes a share, saved state or + explicit change, so such a layer is never restored, written to local storage + or carried into a link this application shares. Options that depend on a + dropped layer (a tracked selection) go with it. An application with every + layer registered is unchanged. - Layer tokens are permanent public compatibility identifiers. The reservation JSON ledger at `src/data/layerStateTokenReservations.json` pins all existing one-character assignments (all letters plus `0`, `1` and diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md index 5728d1ec3..bf69653f9 100644 --- a/docs/SHARE-LINK-FORMAT.md +++ b/docs/SHARE-LINK-FORMAT.md @@ -119,6 +119,7 @@ probability of about 255 in 256 (many of them already fail as base64url). | 25 | `scopeFeatherPct` | uint | 100 | 0–200 | | 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus | | 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs | +| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z | Field 1 absent means `thing` when field 13 is present and `place` otherwise, so v1 never writes it; a later view kind writes its code. @@ -132,6 +133,10 @@ style also extends the pinned style-defaults table. `hudVariant` code 1 (`operator`) is retired: the app no longer offers that layout and restores it as `tactical`. The code stays reserved. +Field 28 is the world time the link was made at, written only when it was away +from live. A link without it opens live. Unlike field 8 it may lie ahead of the +link's creation (a forecast moment), and it opens paused at that time. + Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3 `celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). diff --git a/index.html b/index.html index 39ee7e01d..291b99708 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - +
diff --git a/package.json b/package.json index f6362281f..3d107667a 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "./infrastructure/lod": "./src/data/localGeojsonLod.js", "./application": "./src/app/application.js", "./application/viewer": "./src/app/viewer.js", + "./time": "./src/time/worldClock.js", "./build/vite": { "node": "./build/vite.js" }, diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 466606b92..d9cb8729d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -158,10 +158,19 @@ "src/ui/stores/recording.js", "src/ui/styleParameters.js", "src/ui/i18n.js", + "src/time/worldClock.js", + "src/ui/kit/UiIcon.vue", + "src/ui/stores/worldTime.js", + "src/ui/views/panels/WorldTimeline.vue", "src/ui/surfaceKeyboard.js" ], "external": ["vue", "pinia", "vue-i18n", "flag-icons"] }, + "time": { + "exports": ["./time"], + "modules": ["src/time/worldClock.js"], + "external": [] + }, "viewer": { "exports": ["./application/viewer"], "modules": [ @@ -1205,6 +1214,7 @@ "src/ui/stores/globeActions.js", "src/ui/stores/recording.js", "src/ui/i18n.js", + "src/time/worldClock.js", "src/ui/kit/UiSegmented.vue", "src/ui/wildlifeSightingCard.js", "src/layers/wildlifeSightings/records.js", @@ -1428,7 +1438,9 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/ui/unitLabels.js" + "src/ui/unitLabels.js", + "src/time/worldClock.js", + "src/time/julian.js" ], "external": ["cesium"] }, @@ -1486,7 +1498,9 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js", + "src/time/julian.js" ], "external": ["cesium"] }, @@ -1684,7 +1698,8 @@ "src/layers/satellites/classes.js", "src/data/ucsSatellites.js", "src/data/bundledJson.js", - "src/data/retryableLoad.js" + "src/data/retryableLoad.js", + "src/time/worldClock.js" ], "external": ["cesium", "satellite.js"] }, @@ -1724,7 +1739,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": ["cesium"] }, @@ -2049,7 +2065,8 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/layers/streetLevel/providers/mapillary/policy.js" + "src/layers/streetLevel/providers/mapillary/policy.js", + "src/time/worldClock.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] }, @@ -3333,6 +3350,9 @@ "src/ui/stores/globeActions.js", "src/ui/stores/recording.js", "src/ui/i18n.js", + "src/time/viewerClock.js", + "src/time/worldClock.js", + "src/time/julian.js", "src/annotations/outlineRungs.js", "src/sources/openFreeMapOutlines.js", "src/sources/nominatimOutlines.js", @@ -4000,6 +4020,8 @@ "src/ui/stores/bhoteKoshiPanel.js", "src/hudReadouts.js", "src/ui/i18n.js", + "src/time/worldClock.js", + "src/time/julian.js", "src/annotations/outlineRungs.js", "src/sources/openFreeMapOutlines.js", "src/sources/nominatimOutlines.js", @@ -4189,7 +4211,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": [] }, @@ -4744,6 +4767,7 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js", "src/overlays/worldOverlayChrome.js" ], "external": ["cesium", "egm96-universal", "pbf"] @@ -4797,7 +4821,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": ["cesium", "pbf"] }, @@ -4920,7 +4945,8 @@ "src/data/detectionPolicy.js", "src/scopeMaskPolicy.js", "src/i18n/marker.js", - "src/visualStylePolicy.js" + "src/visualStylePolicy.js", + "src/time/worldClock.js" ], "external": [] }, diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index f368d260e..a76b37bd4 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -1,5 +1,6 @@ import { resolveCatalogSources } from './sourceComposition.js'; import { createWeatherClock } from '../layers/weather/clock.js'; +import { worldClock } from '../time/worldClock.js'; import { createWeatherLayer } from '../layers/weather/index.js'; import { createApplicationCyclones } from './layers/cyclones.js'; import { createWindLayer } from '../layers/wind/index.js'; @@ -78,7 +79,7 @@ export function createApplicationCatalog({ const sourceComposition = resolveCatalogSources(sources); sources = sourceComposition.sources; const militaryRegistry = createMilitaryRegistry(); - const weatherClock = createWeatherClock(); + const weatherClock = createWeatherClock({ world: worldClock }); const dispose = () => { signal.removeEventListener('abort', dispose); militaryRegistry.dispose(); diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js index 46983953b..72071e86a 100644 --- a/src/app/layers/gnss.js +++ b/src/app/layers/gnss.js @@ -1,6 +1,10 @@ import { createGnssIntegrityLayer } from '../../layers/gnss/index.js'; +import { worldTimeFor } from '../../time/worldClock.js'; /** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */ export function createApplicationGnssIntegrity(options) { - return createGnssIntegrityLayer(options); + return createGnssIntegrityLayer({ + worldNow: worldTimeFor('gnss-integrity'), + ...options, + }); } diff --git a/src/app/layers/recentImagery.js b/src/app/layers/recentImagery.js index cc4659727..6a730bc7c 100644 --- a/src/app/layers/recentImagery.js +++ b/src/app/layers/recentImagery.js @@ -2,6 +2,9 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js'; import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js'; import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js'; import { governorRequestRender } from '../../renderGovernor.js'; +import { worldTimeFor } from '../../time/worldClock.js'; + +const imageryTime = worldTimeFor('recent-imagery'); /** Construct imagery presentation over one explicitly supplied acquisition source. */ export function createApplicationRecentImagery({ source }) { @@ -15,5 +18,6 @@ export function createApplicationRecentImagery({ source }) { thumbnails: createThumbnailLoader({ requestThumbnail: (...args) => source.getThumbnail(...args), }), + now: () => new Date(imageryTime()), }); } diff --git a/src/app/scene.js b/src/app/scene.js index 8792a1f4d..b9799b8ac 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -15,6 +15,8 @@ import { governorRequestRender, } from '../renderGovernor.js'; import { describeError } from './errors.js'; +import { worldClock, worldTimeFor } from '../time/worldClock.js'; +import { followWorldTime } from '../time/viewerClock.js'; import { onLocaleChange, t } from '../i18n/index.js'; /** Construct the application globe using the caller's local configuration. */ @@ -59,6 +61,12 @@ export async function createApplicationScene({ if (!viewer.isDestroyed()) viewer.destroy(); }); defer(installTrackpadPinchZoom(viewer)); + defer( + followWorldTime(viewer, worldTimeFor('sky'), { + subscribe: worldClock.subscribe, + requestRender: () => governorRequestRender('world-time'), + }), + ); defer(installFreeLookHeightLimit(viewer)); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); 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 c031593d4..525ded44e 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -1,5 +1,9 @@ import * as Cesium from 'cesium'; import { governorRequestRender } from './renderGovernor.js'; +import { worldClock, worldTimeFor } from './time/worldClock.js'; + +// The ring shows the sun and moon at the time the sky is drawn. +const skyTime = worldTimeFor('sky'); /** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */ export const KEYHOLE_OUTER_RADIUS = 1.05; @@ -28,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; @@ -445,6 +452,20 @@ export class CelestialRing { if (typeof document !== 'undefined' && document.hidden) return; governorRequestRender('celestial-ephemeris'); }, 60_000); + // A jump in the world time moves the sun and moon at once. + this._skyDate = new Date(); + this._skyTime = new Cesium.JulianDate(); + // 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); } @@ -801,7 +822,8 @@ export class CelestialRing { return; } - const time = Cesium.JulianDate.now(); + this._skyDate.setTime(skyTime()); + const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime); if (!this._updateEphemeris(time)) return; this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) }); const camera = this.viewer.camera; @@ -890,6 +912,8 @@ export class CelestialRing { clearInterval(this._ephemerisTimer); this._ephemerisTimer = null; } + this._unsubscribeWorldTime?.(); + this._unsubscribeWorldTime = null; if (this._removePostRender) this._removePostRender(); this._removePostRender = null; if (this._shown()) { diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index 3032a25fe..dafc51df1 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -229,13 +229,30 @@ const GUARDED_PROPS = new Set([ // Custom properties allowed inside a modelled offset. Each is a pinned-tray // stack height, proven non-negative below, so evaluating them at 0 is a -// conservative floor rather than a guess. +// conservative floor rather than a guess. The world timeline's lift raises +// the dock, its trays and the credit together, so it moves no gap either. const VETTED_VARS = new Set([ '--dock-pinned-stack-height', '--dock-lower-pinned-height', '--dock-location-pinned-height', '--dock-presets-pinned-height', + '--world-timeline-lift', ]); +// 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, 66, 80]; +let liftPx = 0; +function eachLift(check) { + for (const lift of LIFTS_PX) { + liftPx = lift; + try { + check(lift); + } finally { + liftPx = 0; + } + } +} /** Rules whose final compound targets a modelled element (pseudo-elements aside). */ function ownBoxEntries() { @@ -315,6 +332,7 @@ function toPx(value, viewportHeight, where) { if (term.startsWith('var(')) { const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim(); assert.ok(VETTED_VARS.has(name), `unvetted custom property ${name} in ${where}: "${value}"`); + if (name === '--world-timeline-lift') total += liftPx; continue; // proven non-negative; 0 is the conservative floor } assert.match(term, /^-?[\d.]+(px|rem|vh)$/, `unmodelled length term "${term}" in ${where}: "${value}"`); @@ -444,7 +462,8 @@ test('custom properties inside modelled offsets are provably non-negative', () = assert.ok(declared.length >= VETTED_VARS.size, 'every vetted custom property needs a CSS default'); for (const decl of declared) { assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`); - assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`); + const shape = FIXED_LENGTH_VARS.has(decl.prop) ? /^(0|\d+px)$/ : /^(0|0px)$/; + assert.match(decl.value, shape, `${decl.prop} default is not a vetted shape: ${decl.value}`); } const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'); const start = layout.indexOf('_updateCommandDockTrayStack() {'); @@ -517,16 +536,18 @@ test('every open dock tray clears the required credit at every modelled viewport // number: the dock is anchored at 2vh down to 721px and re-anchors to a flat // 8px at 720px, while the credit keeps its 2vh base throughout. const failures = []; - for (const scenario of TRAY_SCENARIOS) { - for (const width of WIDTHS.filter((w) => w <= 900)) { - for (const height of HEIGHTS) { - const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height); - if (clearance < MIN_CLEARANCE_PX) { - failures.push(`${scenario.name} @ ${width}x${height}: ${clearance.toFixed(1)}px`); + eachLift((lift) => { + for (const scenario of TRAY_SCENARIOS) { + for (const width of WIDTHS.filter((w) => w <= 900)) { + for (const height of HEIGHTS) { + const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height); + if (clearance < MIN_CLEARANCE_PX) { + failures.push(`${scenario.name} @ ${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`); + } } } } - } + }); assert.deepEqual(failures, [], `a dock tray re-enters the credit band:\n ${failures.join('\n ')}\n`); }); @@ -566,21 +587,24 @@ test('the full-width context rail clears the required credit at every modelled v 'none', `at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`, ); - for (const height of HEIGHTS) { - const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail'); - const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height); - if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height}: ${clearance.toFixed(1)}px`); - } + eachLift((lift) => { + for (const height of HEIGHTS) { + const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail'); + const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height); + if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`); + } + }); } assert.deepEqual(failures, [], `context rail re-enters the credit band at ${failures.join(', ')}`); }); test('the dock anchor changes at 720px — the 2vh cancellation is band-limited', () => { - assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, '2vh'); - assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, '8px'); + const lift = 'var(--world-timeline-lift, 0px)'; + assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, `calc(2vh + ${lift})`); + assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, `calc(8px + ${lift})`); assert.equal( resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value, - 'calc(2vh + 5rem)', + `calc(2vh + 5rem + ${lift})`, 'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard', ); }); diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 7deb73e7f..dc7e679b7 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -3156,3 +3156,70 @@ test('Active Fires: the codec accepts whole megawatts in bounds and the three co confidence: 'nominal', }); }); + +// An application built without a layer (a smaller catalog) receives a link or +// saved state from a build that has it: the layer is neither restored nor +// stored nor carried into the links this application shares. +function managerWithout(withheld) { + const manager = new DataLayerManager({}); + for (const id of REGISTERED_LAYER_IDS) + if (!withheld.includes(id)) manager.register(fakeLayer(id)); + manager.finalizeRegistrations( + LAYER_STATE_REGISTRY.filter(({ id }) => !withheld.includes(id)), + ); + return manager; +} + +test('layers the application did not register are never restored, stored or shared', async () => { + const manager = managerWithout(['earthquakes']); + const storage = memoryStorage(); + const share = shareSink(); + const coordinator = new LayerStateCoordinator(manager, share, { storage }); + const incoming = createDefaultLayerState(); + incoming.enabledLayerIds = ['earthquakes', 'traffic']; + await coordinator.start({ shareLayerState: incoming }); + + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['traffic']); + assert.deepEqual(share.provider().enabledLayerIds, ['traffic']); + assert.equal(manager.layers.has('earthquakes'), false); + + await manager.setEnabled('cctv', true, { origin: 'user' }); + const [[, written]] = storage.writes; + assert.deepEqual(parseStoredLayerState(written).enabledLayerIds, [ + 'cctv', + 'traffic', + ]); + assert.deepEqual(share.provider().enabledLayerIds, ['cctv', 'traffic']); + + await coordinator.applyShareLayerState({ shareLayerState: incoming }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['traffic']); + coordinator.destroy(); + + const saved = createDefaultLayerState(); + saved.enabledLayerIds = ['earthquakes', 'traffic']; + const local = new LayerStateCoordinator( + managerWithout(['earthquakes']), + shareSink(), + { storage: memoryStorage(serializeStoredLayerState(saved)) }, + ); + await local.start(); + assert.equal(local.source, 'local'); + assert.deepEqual(local.getDurableState().enabledLayerIds, ['traffic']); + local.destroy(); +}); + +test('an application with every layer registered keeps every requested layer', async () => { + const coordinator = new LayerStateCoordinator( + productionManager(), + shareSink(), + { storage: memoryStorage() }, + ); + const incoming = createDefaultLayerState(); + incoming.enabledLayerIds = ['earthquakes', 'traffic']; + await coordinator.start({ shareLayerState: incoming }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [ + 'earthquakes', + 'traffic', + ]); + coordinator.destroy(); +}); diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 40208831a..7cd554539 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -106,9 +106,7 @@ export class LayerStateCoordinator { } = {}) { if (this._destroyed) throw new Error('Layer-state coordinator is destroyed'); - let selected = shareLayerState - ? normalizeLayerState(shareLayerState) - : null; + let selected = shareLayerState ? this._normalize(shareLayerState) : null; if (selected) { this._source = 'share'; this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs) @@ -124,7 +122,7 @@ export class LayerStateCoordinator { /* best effort */ } if (stored) { - selected = stored; + selected = this._normalize(stored); this._source = 'local'; } } else { @@ -163,9 +161,7 @@ export class LayerStateCoordinator { for (const controller of this._restoreControllers.values()) controller.abort('superseded-by-share-navigation'); this._revokePendingTrackingWatch('superseded-by-share-navigation'); - const selected = shareLayerState - ? normalizeLayerState(shareLayerState) - : null; + const selected = shareLayerState ? this._normalize(shareLayerState) : null; this._source = selected ? 'share' : 'legacy-share'; this._shareCreatedAtMs = selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null; @@ -182,6 +178,25 @@ export class LayerStateCoordinator { return this._source; } + /** + * Canonical layer state holding only the layers this application + * registered. An application built without a layer never restores, stores + * or shares it, even when a link or saved state from a build that has it + * names it. Filtering before normalizing lets normalization drop options + * that depend on a layer that is not here (a tracked selection). + */ + _normalize(candidate) { + const input = candidate && typeof candidate === 'object' ? candidate : {}; + return normalizeLayerState({ + ...input, + enabledLayerIds: Array.isArray(input.enabledLayerIds) + ? input.enabledLayerIds.filter((id) => + this.dataManager.layers.has(String(id)), + ) + : [], + }); + } + getDurableState() { return cloneLayerState(this._durableState); } @@ -345,7 +360,7 @@ export class LayerStateCoordinator { } _commitExplicit(candidate) { - this._durableState = normalizeLayerState(candidate); + this._durableState = this._normalize(candidate); const serialized = serializeStoredLayerState(this._durableState); try { if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) { @@ -478,7 +493,7 @@ export class LayerStateCoordinator { ...this._durableState.options[selected.optionOwner], [selected.optionKey]: null, }; - this._durableState = normalizeLayerState({ + this._durableState = this._normalize({ ...this._durableState, options: { ...this._durableState.options, diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index 38ff4d24c..f1c66f032 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { gstime } from 'satellite.js'; +import { worldClock } from '../time/worldClock.js'; import { createModel } from '../layers/launches/model.js'; import rocketLaunchesLayer, { _setRocketMissionOverlayHostForTest, @@ -1240,7 +1241,11 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths rocketLaunchesLayer.init(viewer); initialized = true; await rocketLaunchesLayer.enable(); + assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show'); await rocketLaunchesLayer.update(); + const at = Date.parse(launchTime); + assert.deepEqual(worldClock.coverage(), { start: at, end: at, marks: [at] }); + assert.equal(worldClock.isSupported(), true, 'loaded launches bring up the timeline'); const entities = dataSources[0].entities.values; assert.ok(entities.length >= 7, 'runtime guard requires the populated mission build path'); @@ -1379,6 +1384,7 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths assert.equal(deselectedPublication[2][0].protected, false); await rocketLaunchesLayer.disable(); + assert.equal(worldClock.coverage(), null, 'off, launches show no time'); assert.deepEqual(hostCalls.slice(-4), [ ['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID], ['visible', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false], @@ -1500,3 +1506,11 @@ test('missing payload details stay unknown without inventing mass or classificat const [launch] = normalizeRocketLaunches([{ id: 'missing', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z' }], NOW); assert.deepEqual(launch.payloads, []); }); + +test('away from live the feed keeps its own window of launches', (t) => { + t.after(() => worldClock.goLive()); + worldClock.seek(Date.now() - 20 * 86_400_000); + const net = new Date(Date.now() - 86_400_000).toISOString(); + const launches = normalizeRocketLaunches([{ id: 'recent', pad: { latitude: 1, longitude: 2 }, net }]); + assert.deepEqual(launches.map((launch) => launch.id), ['recent']); +}); diff --git a/src/hud.js b/src/hud.js index 7e61fe91b..37e9dfcb4 100644 --- a/src/hud.js +++ b/src/hud.js @@ -23,6 +23,11 @@ import * as Cesium from 'cesium'; import { greatCircleKm } from './geoDistance.js'; import { forward as toMGRS } from 'mgrs'; import { CITY_POIS } from './cityPois.js'; +import { worldTimeFor } from './time/worldClock.js'; + +// The HUD stamps its readouts with the world time; its sun is the sky's. +const hudTime = worldTimeFor('hud'); +const skyTime = worldTimeFor('sky'); import { composeLocalityTag } from './hudLocality.js'; import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js'; import { @@ -447,11 +452,11 @@ export class IntelHUD { } /** - * Format the current wall-clock time as a UTC Zulu string. + * Format the HUD's world time as a UTC Zulu string. * @returns {string} Timestamp in `YYYY-MM-DD HH:MM:SSZ` format. */ _formatUTC() { - const now = new Date(); + const now = new Date(hudTime()); const y = now.getUTCFullYear(); const mo = String(now.getUTCMonth() + 1).padStart(2, '0'); const d = String(now.getUTCDate()).padStart(2, '0'); @@ -554,7 +559,7 @@ export class IntelHUD { readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`; // Collection timestamp - const now = new Date(); + const now = new Date(hudTime()); const h = String(now.getUTCHours()).padStart(2, '0'); const m = String(now.getUTCMinutes()).padStart(2, '0'); const s = String(now.getUTCSeconds()).padStart(2, '0'); @@ -661,7 +666,7 @@ export class IntelHUD { * @returns {number} Estimated sun elevation in degrees (negative = below horizon). */ _estimateSunElevation(lat, lon) { - const now = new Date(); + const now = new Date(skyTime()); // Approximate local solar time by shifting UTC hours by longitude offset const hours = now.getUTCHours() + now.getUTCMinutes() / 60 + lon / 15; const solarNoon = 12; diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 569726ca2..a634c0695 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3581,5 +3581,14 @@ "COURSE HOLD · AIS": "تثبيت المسار · AIS", "Zoom in (+)": "تكبير (+)", "Zoom out (−)": "تصغير (−)", - "ORBITAL VIEW · OFF-NADIR": "العرض المداري · انحراف عن النظير" + "ORBITAL VIEW · OFF-NADIR": "العرض المداري · انحراف عن النظير", + "World time": "وقت العالم", + "Back to 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 fb1694c57..6568aac1c 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3277,5 +3277,14 @@ "COURSE HOLD · AIS": "KURS FIX · AIS", "Zoom in (+)": "Hineinzoomen (+)", "Zoom out (−)": "Herauszoomen (−)", - "ORBITAL VIEW · OFF-NADIR": "ORBITALANSICHT · NADIRABWEICHUNG" + "ORBITAL VIEW · OFF-NADIR": "ORBITALANSICHT · NADIRABWEICHUNG", + "World time": "Weltzeit", + "Back to live": "Zurück zu 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 52dc211c2..2ba0c2692 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3353,5 +3353,14 @@ "COURSE HOLD · AIS": "BLOQUEO DE RUMBO · AIS", "Zoom in (+)": "Acercar (+)", "Zoom out (−)": "Alejar (−)", - "ORBITAL VIEW · OFF-NADIR": "VISTA ORBITAL · FUERA DEL NADIR" + "ORBITAL VIEW · OFF-NADIR": "VISTA ORBITAL · FUERA DEL NADIR", + "World time": "Hora mundial", + "Back to live": "Volver al 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 58d4f4f34..9965e6e25 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3353,5 +3353,14 @@ "COURSE HOLD · AIS": "VERROU ROUTE · AIS", "Zoom in (+)": "Zoomer (+)", "Zoom out (−)": "Dézoomer (−)", - "ORBITAL VIEW · OFF-NADIR": "VUE ORBITALE · HORS NADIR" + "ORBITAL VIEW · OFF-NADIR": "VUE ORBITALE · HORS NADIR", + "World time": "Heure du monde", + "Back to live": "Revenir au 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 d334a4e96..586441a7c 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3353,5 +3353,14 @@ "COURSE HOLD · AIS": "BLOCCO ROTTA · AIS", "Zoom in (+)": "Avvicina (+)", "Zoom out (−)": "Allontana (−)", - "ORBITAL VIEW · OFF-NADIR": "VISTA ORBITALE · FUORI NADIR" + "ORBITAL VIEW · OFF-NADIR": "VISTA ORBITALE · FUORI NADIR", + "World time": "Ora del mondo", + "Back to live": "Torna alla 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 bb384a245..2c5d2b7dc 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3201,5 +3201,14 @@ "COURSE HOLD · AIS": "対地針路保持 · AIS", "Zoom in (+)": "ズームイン (+)", "Zoom out (−)": "ズームアウト (−)", - "ORBITAL VIEW · OFF-NADIR": "軌道ビュー · オフナディア" + "ORBITAL VIEW · OFF-NADIR": "軌道ビュー · オフナディア", + "World time": "世界時刻", + "Back to 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 d16177783..3a7adcc77 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3353,5 +3353,14 @@ "COURSE HOLD · AIS": "TRAVA DE RUMO · AIS", "Zoom in (+)": "Aproximar (+)", "Zoom out (−)": "Afastar (−)", - "ORBITAL VIEW · OFF-NADIR": "VISTA ORBITAL · FORA DO NADIR" + "ORBITAL VIEW · OFF-NADIR": "VISTA ORBITAL · FORA DO NADIR", + "World time": "Hora do mundo", + "Back to live": "Voltar 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 7a09c88ad..0f4d86e82 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3429,5 +3429,14 @@ "COURSE HOLD · AIS": "УДЕРЖАНИЕ ПУТЕВОГО УГЛА · AIS", "Zoom in (+)": "Приблизить (+)", "Zoom out (−)": "Отдалить (−)", - "ORBITAL VIEW · OFF-NADIR": "ОРБИТАЛЬНЫЙ ВИД · ОТКЛОНЕНИЕ ОТ НАДИРА" + "ORBITAL VIEW · OFF-NADIR": "ОРБИТАЛЬНЫЙ ВИД · ОТКЛОНЕНИЕ ОТ НАДИРА", + "World time": "Мировое время", + "Back to 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 38da15586..212eb10ae 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3429,5 +3429,14 @@ "COURSE HOLD · AIS": "УТРИМАННЯ ШЛЯХОВОГО КУТА · AIS", "Zoom in (+)": "Наблизити (+)", "Zoom out (−)": "Віддалити (−)", - "ORBITAL VIEW · OFF-NADIR": "ОРБІТАЛЬНИЙ ВИГЛЯД · ВІДХИЛЕННЯ ВІД НАДИРА" + "ORBITAL VIEW · OFF-NADIR": "ОРБІТАЛЬНИЙ ВИГЛЯД · ВІДХИЛЕННЯ ВІД НАДИРА", + "World time": "Світовий час", + "Back to 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 fed98f5a8..9fd614858 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3201,5 +3201,14 @@ "COURSE HOLD · AIS": "对地航向保持 · AIS", "Zoom in (+)": "放大 (+)", "Zoom out (−)": "缩小 (−)", - "ORBITAL VIEW · OFF-NADIR": "轨道视图 · 偏离天底" + "ORBITAL VIEW · OFF-NADIR": "轨道视图 · 偏离天底", + "World time": "世界时间", + "Back to 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 930a7065e..376e35a3c 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3201,5 +3201,14 @@ "COURSE HOLD · AIS": "對地航向保持 · AIS", "Zoom in (+)": "放大 (+)", "Zoom out (−)": "縮小 (−)", - "ORBITAL VIEW · OFF-NADIR": "軌道視圖 · 偏離天底" + "ORBITAL VIEW · OFF-NADIR": "軌道視圖 · 偏離天底", + "World time": "世界時間", + "Back to 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 a1f8cf417..c916d67da 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -105,6 +105,7 @@ "Landsat 8/9", "Latin", "Layout", + "Live", "London", "Los Angeles", "MIL", @@ -231,6 +232,7 @@ "▲ {frp} MW" ], "es": [ + "1 min/s", "1090 MHz", "978 MHz UAT", "AIS: --", @@ -380,6 +382,7 @@ "iNaturalist" ], "fr": [ + "1 min/s", "1090 MHz", "978 MHz UAT", "ALTITUDE", @@ -583,6 +586,7 @@ "▲ {frp} MW" ], "it": [ + "1 min/s", "1090 MHz", "978 MHz UAT", "AFRICA", @@ -835,6 +839,7 @@ "▲ {frp} MW" ], "pt": [ + "1 min/s", "1090 MHz", "978 MHz UAT", "AIS: --", diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index b66775f1e..a14c8c4ac 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { COURSE_HOLD_SPEED_MPS, lerpAngleDeg, @@ -156,7 +157,7 @@ export function createMotion({ const renderTime = sampledRenderTime ?? Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(flightState._scratchRenderTime), + julianAt(flightState.now(), flightState._scratchRenderTime), -RENDER_DELAY_SEC, flightState._scratchRenderTime, ); @@ -332,7 +333,7 @@ export function createMotion({ const history = flightState._positionHistory.get(flightState._trackedIcao); if (!history || history.length === 0) return true; const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchWarmupTime, ); diff --git a/src/layers/flights/motion.test.mjs b/src/layers/flights/motion.test.mjs index dcb809d86..dd7a0ab03 100644 --- a/src/layers/flights/motion.test.mjs +++ b/src/layers/flights/motion.test.mjs @@ -12,6 +12,7 @@ function fixture() { _scratchArc: {}, _scratchOffset: new Cesium.Cartesian3(), _scratchRenderTime: new Cesium.JulianDate(), + now: Date.now, }; const motion = createMotion({ flightState, @@ -138,7 +139,7 @@ test('fleet render time is shared without resampling or mutating the supplied in assert.ok(Cesium.JulianDate.equals(renderTime, savedTime)); }); -test('tracked callers still sample a fresh delayed time without a fleet epoch', (t) => { +test('tracked callers still sample a fresh delayed time without a fleet epoch', () => { const { flightState, motion } = fixture(); const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z'); const history = [ @@ -156,10 +157,11 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch', flightState.records.data.set('tracked', { velocity: 100, true_track: 90 }); flightState._positionHistory.set('tracked', history); let elapsed = 60; - const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => { - assert.equal(result, flightState._scratchRenderTime); - return Cesium.JulianDate.addSeconds(start, elapsed, result); - }); + let reads = 0; + flightState.now = () => { + reads += 1; + return Cesium.JulianDate.toDate(start).getTime() + elapsed * 1000; + }; const out = new Cesium.Cartesian3(); close( motion._deadReckon('tracked', out), @@ -180,5 +182,5 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch', new Cesium.Cartesian3(), ), ); - assert.equal(spy.mock.callCount(), 2); + assert.equal(reads, 2); }); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 6709f9647..333935bc1 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; import { civilAircraftModelSpec } from './modelSpec.js'; @@ -871,7 +872,7 @@ export function createRendering({ flightState._lastFleetTickMs = nowMs; // Every contact in this pass shares the same delayed display instant. const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(flightState._scratchFleetRenderTime), + julianAt(flightState.now(), flightState._scratchFleetRenderTime), -RENDER_DELAY_SEC, flightState._scratchFleetRenderTime, ); diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index 25d1c1aa3..c259c44f1 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -1,6 +1,7 @@ import { FlightRecords } from './records.js'; import { createFlightFeed } from './ingestion.js'; import * as Cesium from 'cesium'; +import { worldTimeFor } from '../../time/worldClock.js'; import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js'; export function createFlightState({ source, services }) { @@ -12,6 +13,8 @@ export function createFlightState({ source, services }) { ...services.groundFloor, }); flightState._cullPositions = new Map(); + /** The time contacts are drawn behind by the render delay, in epoch ms. */ + flightState.now = worldTimeFor('flights'); /** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs * vertex-baked to real-world METERS in the airplane.glb axis convention, so diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index 59270892e..f81249e88 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { formatNumber, t } from '../../i18n/index.js'; import { unitLabel } from '../../ui/unitLabels.js'; import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; @@ -383,7 +384,7 @@ export function createTracking({ // would draw the trail in front of the plane. They join the trail via _appendTrailFix // as they age past the delay. const seedRenderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchWarmupTime, ); diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index ba0e9ff5d..7a9087998 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -69,6 +69,8 @@ export function createGnssIntegrityLayer({ source, viewAnchor = defaultGnssViewAnchor, now = () => Date.now(), + // The end of the window shown; retries and receipt times stay on `now`. + worldNow = now, monotonicNow = () => performance.now(), windowMs: requestedWindowMs, } = {}) { @@ -163,7 +165,7 @@ export function createGnssIntegrityLayer({ /** Age the store without new evidence, so expired cells leave the globe. */ function expire() { - pruneGnssObservations(_observations, now(), { windowMs }); + pruneGnssObservations(_observations, worldNow(), { windowMs }); _cells = binGnssCells(_observations); render(); } diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index 9adc88922..93f667951 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -29,7 +29,7 @@ export function createControls({ state: layerState, services, parts, source }) { */ getAnalystRecords(maxCount = Infinity) { if (!layerState._enabled) return []; - const now = Date.now(); + const now = layerState.now(); const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null); const result = []; for (const launch of layerState._launches || []) { diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index fbd3778e1..3bf6b9014 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -224,6 +224,7 @@ export function createIngestion({ } layerState._focusAfterActiveLookup = false; parts.panel.renderMissionPanel(); + layerState.coverageChanged(); layerState._entryCameraView = null; } catch (error) { if (layerState._enabled && token === layerState._lifecycleToken) { diff --git a/src/layers/launches/launchPad.js b/src/layers/launches/launchPad.js index f0a87ce6a..9fd438762 100644 --- a/src/layers/launches/launchPad.js +++ b/src/layers/launches/launchPad.js @@ -105,7 +105,7 @@ export function createLaunchPad({ } const nowMs = Date.now(); if (nowMs - layerState._lastMissionRingRotationMs >= 1000) { - layerState._missionRingDate.setTime(nowMs); + layerState._missionRingDate.setTime(layerState.orbitNow()); parts.orbitRendering.updateMissionOrbitPrimitiveFrames( layerState._missionRingDate, ); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 252ca7ab1..2bff72090 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; +import { worldClock } from '../../time/worldClock.js'; const HOVER_PICK_INTERVAL_MS = 120; @@ -156,6 +157,21 @@ export function createLifecycle({ }) { const { holdContinuousRender, releaseContinuousRender } = services.render; let hover = null; + // While on, the launches loaded span the time from the first to the last, + // each one a mark. Registered once; off, the layer has no time to show, and + // each step tells the clock once it has settled. + worldClock.register({ + id: 'rocket-launches', + coverage: () => { + if (!layerState._enabled) return null; + const marks = layerState._launches + .map((launch) => Date.parse(launch.launchTime)) + .filter(Number.isFinite) + .sort((a, b) => a - b); + if (!marks.length) return null; + return { start: marks[0], end: marks.at(-1), marks }; + }, + }); const methods = { init(viewer) { @@ -231,6 +247,7 @@ export function createLifecycle({ } throw error; } + layerState.coverageChanged(); }, async _enableBody() { @@ -297,6 +314,7 @@ export function createLifecycle({ parts.overlays.clearMissionOverlaySources(); await parts.ingestion.restoreSatelliteDependency(); parts.panel.renderMissionPanel(); + layerState.coverageChanged(); }, async destroy(viewer) { diff --git a/src/layers/launches/orbitRendering.js b/src/layers/launches/orbitRendering.js index d907904eb..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), ); @@ -124,7 +127,7 @@ export function createOrbitRendering({ collection.show = missionOrbitPrimitiveVisible(launch.id); orbitFrameModelMatrix( satelliteTrack.gmstAtBake, - new Date(), + new Date(layerState.orbitNow()), collection.modelMatrix, ); layerState._viewer.scene.primitives.add(collection); 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 6379e78e1..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. @@ -394,7 +421,7 @@ export function createRendering({ orbitCurrent.latitude, orbitCurrent.altitude, ); - const liveTime = new Date(); + const liveTime = new Date(layerState.orbitNow()); const liveTelemetry = { speedMps: null }; layerState._satelliteTelemetry.set(launch.id, liveTelemetry); let liveFrameNumber = -1; @@ -405,7 +432,7 @@ export function createRendering({ layerState._viewer?.scene?.frameState?.frameNumber ?? -1; if (frameNumber !== -1 && frameNumber === liveFrameNumber) return; liveFrameNumber = frameNumber; - const nowMs = Date.now(); + const nowMs = layerState.orbitNow(); liveTime.setTime(nowMs); if (satelliteTrack) { const propagated = diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index fd2ae7176..3349af81d 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { worldClock, worldTimeFor } from '../../time/worldClock.js'; export function createState({ services }) { const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = @@ -156,6 +157,38 @@ export function createState({ services }) { state._missionRingDate = new Date(0); + /** The time the launch window ends at, in epoch ms. */ + + state.now = worldTimeFor('rocket-launches'); + + /** A launched mission's satellite flies on the satellites layer's time. */ + + state.orbitNow = worldTimeFor('satellites'); + + /** The launches loaded changed, and with them the time they cover. */ + + 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/military/motion.js b/src/layers/military/motion.js index 27489400b..a13508e54 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { lerpAngleDeg, speedRamp, @@ -67,7 +68,7 @@ export function createMotion({ // Render one poll interval behind real time so we interpolate between // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchRenderTime, ); @@ -213,7 +214,7 @@ export function createMotion({ const history = flightState._positionHistory.get(flightState._trackedIcao); if (!history || history.length === 0) return true; const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchWarmupTime, ); diff --git a/src/layers/military/state.js b/src/layers/military/state.js index f1a2d691f..f27595d2b 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -1,6 +1,7 @@ import { MilitaryFlightRecords } from './records.js'; import { createMilitaryFeed } from './ingestion.js'; import * as Cesium from 'cesium'; +import { worldTimeFor } from '../../time/worldClock.js'; export function createFlightState({ source, services }) { const { createGroundSnap } = services.groundSnap; @@ -11,6 +12,8 @@ export function createFlightState({ source, services }) { ...services.groundFloor, }); flightState._cullPositions = new Map(); + /** The time contacts are drawn behind by the render delay, in epoch ms. */ + flightState.now = worldTimeFor('military'); /** Per-class model spec for THIS layer (2026-08-16, owner playtest ask: * military contacts should read as their WEIGHT CLASS, always in this layer's diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 5ad60adf9..ed40ff4ac 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { t } from '../../i18n/index.js'; import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; @@ -500,7 +501,7 @@ export function createTracking({ // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them // would draw the trail in front of the plane. They join via _appendTrailFix as they age. const seedRenderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchWarmupTime, ); diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index bb20e646a..850768f5e 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -32,7 +32,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { 1, Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES), ); - const now = new Date(); + const now = new Date(layerState.now()); for (let i = 0; i < perFrame; i++) { if (layerState._denseCursor >= layerState._denseIds.length) layerState._denseCursor = 0; @@ -97,7 +97,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { const entries = parts.orbits.parseTLE(text); const style = POINT_STYLES.dense; - const now = new Date(); + const now = new Date(layerState.now()); let added = 0; for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) { @@ -171,6 +171,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { layerState._count = layerState._points.size; layerState._catalogRevision++; + layerState.coverageChanged(); layerState._denseStatus = 'ready'; console.log( `[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`, @@ -246,6 +247,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { layerState._denseCursor = 0; layerState._count = layerState._points.size; layerState._catalogRevision++; + layerState.coverageChanged(); } return { _abortActiveUpdates, diff --git a/src/layers/satellites/classes.test.mjs b/src/layers/satellites/classes.test.mjs index 6243181f9..66f983a6f 100644 --- a/src/layers/satellites/classes.test.mjs +++ b/src/layers/satellites/classes.test.mjs @@ -326,6 +326,7 @@ test('a failed Starlink load hides the core catalog Starlink points again', asyn _detectionObjects: new Map(), _trackedNorad: null, _activeUpdateControllers: new Set(), + coverageChanged() {}, }; const catalog = createCatalog({ state, @@ -384,6 +385,7 @@ test('enabling the layer leaves a hidden Stations class without its ISS ring', a [90001, other], ]), _points: new Map(), + coverageChanged() {}, }; const { methods } = createLifecycle({ state, diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js index 230c6a68a..dee92342a 100644 --- a/src/layers/satellites/controls.js +++ b/src/layers/satellites/controls.js @@ -314,7 +314,10 @@ export function createControls({ state: layerState, services, parts, source }) { if (noradId === null) return null; const sat = layerState._catalog.get(noradId); - const pos = parts.orbits.propagatePosition(sat.satrec, new Date()); + const pos = parts.orbits.propagatePosition( + sat.satrec, + new Date(layerState.now()), + ); if (!pos) return null; return { diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 493817bb3..202acbbb6 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -275,7 +275,7 @@ export function createIngestion({ layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog parts.labels._syncIssOverlay(); - const now = new Date(); + const now = new Date(layerState.now()); // Process all TLE entries in CATALOG_GROUPS order const allEntries = []; @@ -402,6 +402,7 @@ export function createIngestion({ // the rebuild must not block on that. void parts.tracking._reconcileTrackedSubjectContext(); parts.tracking._applyPendingTrackingRestore(); + layerState.coverageChanged(); } catch (e) { if (updateSignal.aborted || e?.name === 'AbortError') { throw new DOMException('Satellite update aborted', 'AbortError'); diff --git a/src/layers/satellites/ingestion.test.mjs b/src/layers/satellites/ingestion.test.mjs index f7e9094cc..e8fc71305 100644 --- a/src/layers/satellites/ingestion.test.mjs +++ b/src/layers/satellites/ingestion.test.mjs @@ -40,6 +40,8 @@ function ingestionFor(catalogText, { source: sourceExtras = {} } = {}) { _params: { catalog: 'core', showOrbits: false }, _satcat: new Map(), _ucs: null, + now: Date.now, + coverageChanged() {}, }; const parts = { orbits: { diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index 294043aa5..ae3088109 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -1,5 +1,10 @@ import * as Cesium from 'cesium'; -import { ISS_NORAD, ISS_OVERLAY_SOURCE_ID } from './policy.js'; +import { + ISS_NORAD, + ISS_OVERLAY_SOURCE_ID, + SATELLITE_COVERAGE_MS, +} from './policy.js'; +import { worldClock } from '../../time/worldClock.js'; export function createLifecycle({ state: layerState, @@ -8,6 +13,20 @@ export function createLifecycle({ source, }) { const { clearFocusTarget } = services.focus; + // While on with a catalog, satellites can be shown at any time in reach of + // their element sets. Registered once; off, the layer has no time to show, + // and each step tells the clock once it has settled. + worldClock.register({ + id: 'satellites', + coverage: () => { + if (!layerState._enabled || !layerState._catalog.size) return null; + const now = worldClock.wallNow(); + return { + start: now - SATELLITE_COVERAGE_MS, + end: now + SATELLITE_COVERAGE_MS, + }; + }, + }); const { holdContinuousRender, releaseContinuousRender } = services.render; const { registerPickOwner, unregisterPickOwner } = services.picking; @@ -50,6 +69,7 @@ export function createLifecycle({ layerState._denseStatus = 'idle'; layerState._denseError = null; layerState._catalogRevision++; + layerState.coverageChanged(); // Point primitives for satellite dots layerState._pointCollection = new Cesium.PointPrimitiveCollection(); @@ -99,6 +119,7 @@ export function createLifecycle({ ); } parts.tracking._applyPendingTrackingRestore(); + layerState.coverageChanged(); }, disable(viewer) { @@ -126,6 +147,7 @@ export function createLifecycle({ layerState._preRenderListener(); layerState._preRenderListener = null; } + layerState.coverageChanged(); }, destroy(viewer) { @@ -183,6 +205,7 @@ export function createLifecycle({ failedGroups: [], }; layerState._viewer = null; + layerState.coverageChanged(); }, }; diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index fe4de6789..ccf5c166e 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -197,7 +197,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { lonDeg, minElevDeg = 10, requireVisible = false, - fromMs = Date.now(), + fromMs = layerState.now(), }) { return getNextSatellitePass(ISS_NORAD, { latDeg, @@ -227,7 +227,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { lonDeg, minElevDeg = 10, requireVisible = false, - fromMs = Date.now(), + fromMs = layerState.now(), }, ) { const id = Number(noradId); @@ -331,7 +331,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { } function orbitTrackFromRecord(name, satrec) { - const referenceDate = new Date(); + const referenceDate = new Date(layerState.now()); const current = propagatePosition(satrec, referenceDate); if (!current) return null; return { diff --git a/src/layers/satellites/policy.js b/src/layers/satellites/policy.js index 48152e818..44b31d814 100644 --- a/src/layers/satellites/policy.js +++ b/src/layers/satellites/policy.js @@ -32,6 +32,15 @@ export const ORBIT_PATH_STEPS = 180; // points per orbital path export const POSITION_UPDATE_MS = 1000; +/** How far either side of now SGP4 on current element sets stays meaningful. */ +export const SATELLITE_COVERAGE_MS = 14 * 24 * 3_600_000; + +/** + * How far the world time may move from when an orbit ring was drawn before it + * is drawn again: the Earth's turn is followed exactly, but the orbit's plane + * drifts a few degrees a day. + */ +export const ORBIT_REBAKE_MS = 30 * 60_000; // re-propagate every 1s (SGP4 is smooth at this rate) export const RING_ROTATION_MS = 1000; diff --git a/src/layers/satellites/records.js b/src/layers/satellites/records.js index 84ca0e3ff..7371eb246 100644 --- a/src/layers/satellites/records.js +++ b/src/layers/satellites/records.js @@ -82,7 +82,7 @@ export function createRecords({ state, parts }) { const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; - const now = new Date(); + const now = new Date(state.now()); const result = []; const emit = (noradId, sat) => { if (result.length >= limit) return false; diff --git a/src/layers/satellites/rendering.js b/src/layers/satellites/rendering.js index 4c612a74a..177884b48 100644 --- a/src/layers/satellites/rendering.js +++ b/src/layers/satellites/rendering.js @@ -1,6 +1,11 @@ import * as Cesium from 'cesium'; import { gstime } from 'satellite.js'; -import { ISS_NORAD, POSITION_UPDATE_MS, RING_ROTATION_MS } from './policy.js'; +import { + ISS_NORAD, + ORBIT_REBAKE_MS, + POSITION_UPDATE_MS, + RING_ROTATION_MS, +} from './policy.js'; export function createRendering({ state: layerState, @@ -42,7 +47,7 @@ export function createRendering({ const sat = layerState._catalog.get(noradId); if (!sat || !layerState._viewer) return; - const bakeDate = new Date(); + const bakeDate = new Date(layerState.now()); const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate); if (basePositions.length < 2) return; @@ -77,6 +82,8 @@ export function createRendering({ layerState._orbitPaths.set(noradId, { primitive, + color: pathColor, + bakeMs: bakeDate.getTime(), // Same Date object as computeOrbitPath's internal fixedGmst → identical // GMST value (gstime is pure), so delta starts at exactly 0 and the // initial identity modelMatrix is correct until the first ring tick. @@ -120,8 +127,15 @@ export function createRendering({ function _updateOrbitPathRotations(nowDate) { if (layerState._orbitPaths.size === 0) return; - for (const path of layerState._orbitPaths.values()) { + for (const [noradId, path] of [...layerState._orbitPaths]) { if (!path.primitive) continue; + // The rotation keeps the ring under the dot for minutes; the plane + // itself precesses, so a world time moved far from the bake re-bakes. + if (Math.abs(nowDate.getTime() - path.bakeMs) > ORBIT_REBAKE_MS) { + _hideOrbitPath(noradId); + _showOrbitPath(noradId, path.color); + continue; + } parts.orbits.orbitFrameModelMatrix( path.gmstAtBake, nowDate, @@ -151,7 +165,7 @@ export function createRendering({ */ function _propagateAll() { - const now = new Date(); + const now = new Date(layerState.now()); let updated = 0; for (const [noradId, sat] of layerState._catalog) { @@ -223,7 +237,7 @@ export function createRendering({ layerState._params.showOrbits && now - layerState._lastRingRotation >= RING_ROTATION_MS ) { - _updateOrbitPathRotations(new Date(now)); + _updateOrbitPathRotations(new Date(layerState.now())); layerState._lastRingRotation = now; } } diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index e5b0410cf..744ad47a0 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { DEFAULT_SATELLITE_CLASSES } from './classes.js'; +import { worldClock, worldTimeFor } from '../../time/worldClock.js'; export function createState({ services }) { const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = @@ -184,6 +185,14 @@ export function createState({ services }) { state._cockpitHiddenDot = false; + /** The time satellites are propagated to, in epoch ms. */ + + state.now = worldTimeFor('satellites'); + + /** The catalog changed, so the time satellites can be shown at may have. */ + + state.coverageChanged = () => worldClock.coverageChanged(); + // Scratch variables state._scratchCartesian = new Cesium.Cartesian3(); diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index ccdd8b100..0431e0c7d 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -159,7 +159,7 @@ export function createTracking({ state: layerState, services, parts, source }) { sat.satrec, layerState._trackedFrameNowForTest ? new Date(layerState._trackedFrameNowForTest()) - : new Date(), + : new Date(layerState.now()), ); if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample layerState._trackedFrameGeo = pos; @@ -440,7 +440,10 @@ export function createTracking({ state: layerState, services, parts, source }) { // Comfortable tracking landing: ~726 km back for LEO (user-validated // "slightly zoomed out" framing — no stutter, label reads cleanly), scaled // up for MEO/GEO so the camera doesn't land on top of a high-orbit dot. - const initialPos = parts.orbits.propagatePosition(sat.satrec, new Date()); + const initialPos = parts.orbits.propagatePosition( + sat.satrec, + new Date(layerState.now()), + ); const viewScale = initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M ? TRACK_VIEW_FROM_HIGH_SCALE diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 9c22b95a5..f3b421991 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -8,6 +8,7 @@ import { createSelection } from './selection.js'; import { createViewerHost } from './viewerHost.js'; import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js'; import { normalizeFilter, resolveFilter, sameFilter } from './filter.js'; +import { worldTimeFor } from '../../time/worldClock.js'; import { decodeParams, encodeParams } from './params.js'; import { composeUIState, @@ -41,6 +42,9 @@ const GROUND_RETRY_MS = 1000; * a time. * @param {{providers: Array, services?: object}} options */ +// "Since N days" counts back from the world time. +const streetLevelTime = worldTimeFor(STREET_LEVEL_LAYER_ID); + export function createStreetLevelLayer({ providers: definitions, services = {}, @@ -93,7 +97,7 @@ export function createStreetLevelLayer({ return Object.freeze({ services: state.services, /** Resolved against now, so "since N days" keeps up with the clock. */ - getFilter: () => resolveFilter(state.filter), + getFilter: () => resolveFilter(state.filter, streetLevelTime()), /** The layer and this provider's switch are both on. */ isActive: () => state.enabled && entry.on, notify, @@ -365,7 +369,7 @@ export function createStreetLevelLayer({ const filter = normalizeFilter(next, state.filter); if (sameFilter(filter, state.filter)) return; state.filter = filter; - const resolved = resolveFilter(filter); + const resolved = resolveFilter(filter, streetLevelTime()); for (const entry of state.providers.values()) entry.instance.setFilter(resolved); notify(); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index ea547f85d..d1c14dd64 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -3927,6 +3927,7 @@ test('parked transit reprojects held and reported courses at the bounded camera _rotationRevision: -1, _cameraRevision: 0, _rotationDirty: false, + now: () => Date.now(), }; const noop = () => {}; const rendering = createRendering({ diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 2309978a5..f3868444a 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -108,7 +108,7 @@ export function createRendering({ state, services, parts }) { } function sampleIdle(entry) { if (!state._moving.has(entry)) { - updatePlayback(entry, Date.now(), performance.now()); + updatePlayback(entry, state.now(), performance.now()); placeSample(entry); } } @@ -172,7 +172,7 @@ export function createRendering({ state, services, parts }) { () => { entry.wakeTimer = null; if (!state._enabled || !state._vehicles.has(entry.key)) return; - updatePlayback(entry, Date.now(), performance.now()); + updatePlayback(entry, state.now(), performance.now()); placeSample(entry); schedulePlayback(entry); syncRenderHold(); @@ -388,7 +388,7 @@ export function createRendering({ state, services, parts }) { ) return; if (!state._enabled) return; - const now = Date.now(); + const now = state.now(); if (state._vehicles.size === 0) { state._moving.clear(); syncRenderHold(); @@ -424,7 +424,7 @@ export function createRendering({ state, services, parts }) { } parts.trails.update(); - refreshRotations(now); + refreshRotations(Date.now()); const selected = state._vehicles.get(state._selectedKey); if (selected && state._visible.has(selected)) { selected.marker.rotation = Number.isFinite(selected.courseDeg) @@ -489,7 +489,7 @@ export function createRendering({ state, services, parts }) { const noSample = !entry.sample || !Number.isFinite(entry.sample.lat); if (entry.track?.count && (!state._visible.has(entry) || noSample)) { entry.sample ||= {}; - updatePlayback(entry, now, performance.now()); + updatePlayback(entry, state.now(), performance.now()); } sphere.center = state._visible.has(entry) || !entry.sample @@ -557,7 +557,7 @@ export function createRendering({ state, services, parts }) { } function resumePlayback() { if (!state._enabled) return; - const now = Date.now(), + const now = state.now(), mono = performance.now(); const entries = new Set(state._visible); const selected = state._vehicles.get(state._selectedKey); diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index 75b13d84b..5bb152015 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { createHistoryBudget } from '../../data/contactPlayback.js'; +import { worldTimeFor } from '../../time/worldClock.js'; /** * One Transit layer's mutable scene state. Every field lives on the instance, @@ -14,6 +15,8 @@ export function createState({ services }) { const state = {}; state._historyBudget = createHistoryBudget(); + /** The time vehicles play back behind by their display delay, in epoch ms. */ + state.now = worldTimeFor('transit'); state.DEFAULT_OVERLAY_HOST = Object.freeze({ setEntries: setOverlayEntries, diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js index a758dddd2..7384935b0 100644 --- a/src/layers/weather/clock.js +++ b/src/layers/weather/clock.js @@ -1,22 +1,75 @@ +import { createWorldClock } from '../../time/worldClock.js'; + 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. -/** Transient observed time. Products must honor the signal before installing a frame. */ +let clocks = 0; + +/** + * Transient observed time. Products must honor the signal before installing a + * frame. Its time is the world clock's: latest while the world is live, + * history at the world's time otherwise. Replay steps the observed frames and + * moves the world clock as it goes, as the world clock's driver. + */ export function createWeatherClock({ now = Date.now, setTimeout = globalThis.setTimeout, clearTimeout = globalThis.clearTimeout, + world = createWorldClock({ now }), } = {}) { const registry = new Map(); const listeners = new Set(); let mode = 'latest'; let target = null; let playing = false; + // The world time last applied; while this clock moves the world, the + // transition its move started. + let seen = 'latest'; + let moving = false; + let moved = null; + const releases = new Map(); + // Attached at the end, once the API exists. + let driver = null; 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())] @@ -49,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, @@ -65,11 +121,32 @@ export function createWeatherClock({ timer = null; }; const canPlay = () => getTimeline().length >= 2; + const stopPlaying = () => { + if (!playing) return; + playing = false; + driver?.driven(false); + }; + // Move the world clock; the change comes back through followWorld. Returns + // the transition the move started, if it started one. + const move = (fn) => { + const outer = [moving, moved]; + moving = true; + moved = null; + try { + fn(); + return moved; + } finally { + [moving, moved] = outer; + } + }; + const selections = () => + [...registry.values()].map(({ product }) => selectedFor(product)).join(); + let applied = null; function schedule() { cancelTimer(); if (!playing || destroyed || pending) return; if (!canPlay()) { - playing = false; + stopPlaying(); notify(); return; } @@ -92,7 +169,11 @@ export function createWeatherClock({ if (destroyed) return Promise.resolve(false); cancelTimer(); const owner = ++generation; - if (!canPlay()) playing = false; + 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) => { @@ -106,13 +187,17 @@ export function createWeatherClock({ }); const result = Promise.allSettled(tasks).then(() => { if (destroyed || owner !== generation) return false; + stopWaiting(); pending = null; - if (!canPlay()) playing = false; + 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; + if (moving) moved ??= result; notify(); return result; } @@ -123,11 +208,41 @@ export function createWeatherClock({ throw new Error(`Duplicate weather product: ${product.id}`); const entry = { product, controller: null }; registry.set(product.id, entry); + // The product moves with the world clock, and its frames are time the + // world clock's timeline can show while it is on, suspended or not, so + // the way back to live stays on screen. Both calls notify the world's + // listeners, which may destroy this clock: the release is in place + // first, and anything acquired after a destroy is let go at once. + let unhold = null; + let uncover = null; + const release = () => { + uncover?.(); + unhold?.(); + uncover = unhold = null; + }; + releases.set(product.id, release); + unhold = world.hold(product.id); + if (!destroyed) + uncover = world.register({ + id: product.id, + coverage: () => { + const marks = timesFor(product).map((time) => Date.parse(time)); + return marks.length + ? { start: marks[0], end: marks.at(-1), marks } + : null; + }, + }); + if (destroyed) { + release(); + return () => {}; + } void api.refresh(); return () => { if (registry.get(product.id) !== entry) return; entry.controller?.abort(); registry.delete(product.id); + releases.get(product.id)?.(); + releases.delete(product.id); void api.refresh(); }; }, @@ -140,9 +255,11 @@ export function createWeatherClock({ const normalized = new Date(time).toISOString(); if (mode === 'history' && target === normalized) return pending ?? Promise.resolve(true); - mode = 'history'; - target = normalized; - return transition(); + return ( + move(() => world.seek(Date.parse(normalized))) ?? + pending ?? + Promise.resolve(true) + ); }, step(direction) { if (destroyed || ![-1, 1].includes(direction)) @@ -159,10 +276,9 @@ export function createWeatherClock({ }, latest() { if (destroyed) return Promise.resolve(false); - mode = 'latest'; - target = null; - playing = false; - return transition(); + stopPlaying(); + if (mode === 'latest') return transition(); + return move(() => world.goLive()) ?? pending ?? Promise.resolve(true); }, play() { if (destroyed || playing || !canPlay()) @@ -170,18 +286,28 @@ export function createWeatherClock({ playing = true; if (mode === 'latest') { const timeline = getTimeline(); - return api.setTarget( - timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0], + const start = + timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0]; + // Playing and at the first frame in one move of the world. + return ( + move(() => driver.driven(true, Date.parse(start))) ?? + pending ?? + Promise.resolve(true) ); } + driver.driven(true); notify(); schedule(); return pending ?? Promise.resolve(true); }, pause() { cancelTimer(); - if (!playing) return; - playing = false; + if (!playing) { + // The world may be playing on its own; a pause or step here holds it. + if (world.isPlaying()) world.pause(); + return; + } + stopPlaying(); notify(); }, togglePlay() { @@ -189,8 +315,12 @@ export function createWeatherClock({ }, refresh() { if (destroyed) return Promise.resolve(false); + // New frames, or products added or dropped, and with them the next. + world.coverageChanged(); + gated = undefined; + gateNext(world.getState()); if (!canPlay()) { - playing = false; + stopPlaying(); cancelTimer(); } if (mode === 'history') return transition(); @@ -204,15 +334,86 @@ export function createWeatherClock({ }, destroy() { if (destroyed) return; + stopPlaying(); destroyed = true; ++generation; - playing = false; + stopWaiting(); + world.gate(holdId, null); cancelTimer(); + unfollowWorld(); + // The last weather clock leaves the world live, as a new one expects. + if (driver.detach() === 0) world.goLive(); + for (const release of releases.values()) release(); + releases.clear(); for (const entry of registry.values()) entry.controller?.abort(); registry.clear(); listeners.clear(); pending = null; }, }; + // Follow the world clock. A move of this clock's own always applies; one + // from elsewhere (a host's seek, continuous play) applies only when it + // 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) { + if (playChanged) notify(); + return; + } + seen = key; + const modeChanged = nextMode !== mode; + mode = nextMode; + target = nextTarget; + 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({ + play: () => (canPlay() ? void api.play() : false), + pause: () => api.pause(), + }); + followWorld(world.getState()); return api; } diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs index 24bd124b8..dd2535ffc 100644 --- a/src/layers/weather/clock.test.mjs +++ b/src/layers/weather/clock.test.mjs @@ -6,7 +6,9 @@ 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'; const time = (minutes) => new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString(); @@ -37,7 +39,15 @@ function harness(t) { let at = Date.parse(time(20)); const timers = new Map(); let id = 0; + let mono = 0; + const world = createWorldClock({ + now: () => at, + monotonicNow: () => mono, + setInterval: () => 0, + clearInterval() {}, + }); const clock = createWeatherClock({ + world, now: () => at, setTimeout(fn, delay) { timers.set(++id, { fn, due: at + delay }); @@ -50,9 +60,11 @@ function harness(t) { t.after(() => clock.destroy()); return { clock, + world, timers, tick(ms) { at += ms; + mono += ms; for (const [id, timer] of [...timers]) { if (timer.due > at) continue; timers.delete(id); @@ -275,3 +287,423 @@ test('registration joins history; unregister and destroy abort work and release assert.deepEqual(clock.getTimeline(), []); assert.equal(await clock.setTarget(time(5)), false); }); + +test('the weather time is the world time, both ways', async (t) => { + const { clock, world } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + assert.equal(world.follows('radar'), true); + await clock.setTarget(time(10)); + assert.equal(world.isLive(), false); + assert.equal(world.now(), Date.parse(time(10))); + world.seek(Date.parse(time(3))); + await flush(); + assert.equal(clock.getState().target, time(3)); + assert.equal(radar.shown, time(0)); + world.goLive(); + await flush(); + assert.equal(clock.getState().mode, 'latest'); + assert.equal(radar.shown, time(20)); +}); + +test('world play and pause drive the weather replay', async (t) => { + const { clock, world, tick } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + world.seek(Date.parse(time(0))); + await flush(); + world.play(); + assert.equal(clock.getState().playing, true); + assert.equal(world.isPlaying(), true); + tick(2000); + await flush(); + assert.equal(radar.shown, time(10)); + assert.equal(world.now(), Date.parse(time(10))); + world.pause(); + assert.equal(clock.getState().playing, false); + clock.play(); + assert.equal(world.isPlaying(), true); + clock.pause(); + assert.equal(world.isPlaying(), false); + clock.play(); + world.goLive(); + await flush(); + assert.equal(clock.getState().playing, false); + assert.equal(clock.getState().mode, 'latest'); +}); + +test('a moving world re-applies frames only when a product changes frame', async (t) => { + const { clock, world } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + world.seek(Date.parse(time(0))); + await flush(); + const applied = radar.calls.length; + world.seek(Date.parse(time(0)) + 60_000); + await flush(); + assert.equal(radar.calls.length, applied); + assert.equal( + clock.getState().target, + new Date(Date.parse(time(1))).toISOString(), + ); + world.seek(Date.parse(time(11))); + await flush(); + assert.equal(radar.calls.length, applied + 1); + assert.equal(radar.shown, time(10)); +}); + +test('a world without frames to step plays on its own', async (t) => { + const { clock, world, tick } = harness(t); + world.seek(Date.parse(time(0))); + world.play(); + assert.equal(clock.getState().playing, false); + tick(1000); + assert.equal(world.now(), Date.parse(time(0)) + 1000); +}); + +function worldAt(at) { + return createWorldClock({ + now: () => at, + monotonicNow: () => 0, + setInterval: () => 0, + clearInterval() {}, + }); +} + +test('weather clocks sharing a world hand play over, and the last leaves it live', async () => { + const world = worldAt(Date.parse(time(20))); + const a = createWeatherClock({ world, now: () => Date.parse(time(20)) }); + const b = createWeatherClock({ world, now: () => Date.parse(time(20)) }); + const radarA = product('radar', [0, 10, 20]); + const radarB = product('radar', [0, 10, 20]); + a.register(radarA); + b.register(radarB); + await a.play(); + assert.equal(world.isPlaying(), true); + world.pause(); + assert.equal(a.getState().playing, false); + assert.equal(world.isPlaying(), false); + await a.play(); + world.goLive(); + assert.equal(a.getState().playing, false); + b.destroy(); + assert.equal(world.follows('radar'), true); + world.seek(Date.parse(time(0))); + world.play(); + assert.equal(a.getState().playing, true); + a.destroy(); + assert.equal(world.follows('radar'), false); + assert.equal(world.isLive(), true); + assert.equal(world.isPlaying(), false); +}); + +test('pausing from inside the first playing notice stops the world too', async (t) => { + const { clock, world } = harness(t); + clock.register(product('radar', [0, 10, 20])); + const off = clock.subscribe((state) => { + if (state.playing) { + off(); + clock.pause(); + } + }); + await clock.play(); + assert.equal(clock.getState().playing, false); + assert.equal(world.isPlaying(), false); +}); + +test('a step holds a world that was playing on its own', async (t) => { + const { clock, world } = harness(t); + world.seek(Date.parse(time(0))); + world.play(); + clock.register(product('radar', [0, 10, 20])); + await clock.step(1); + assert.equal(world.isPlaying(), false); + assert.equal(world.now(), Date.parse(time(10))); +}); + +test('a target superseded from a listener resolves false', async (t) => { + const { clock } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + await flush(); + let redirected = false; + clock.subscribe((state) => { + if (state.target === time(0) && !redirected) { + redirected = true; + void clock.setTarget(time(10)); + } + }); + assert.equal(await clock.setTarget(time(0)), false); + assert.equal(radar.shown, time(10)); +}); + +test('each product’s frames are world clock coverage, and support the timeline while shown', (t) => { + const { clock, world } = harness(t); + assert.equal(world.coverage(), null); + assert.equal(world.getState().supported, false); + const radar = product('radar', [0, 10]); + const unregister = clock.register(radar); + clock.register(product('lightning', [5])); + assert.deepEqual(world.coverage(), { + start: Date.parse(time(0)), + end: Date.parse(time(10)), + marks: [0, 5, 10].map((m) => Date.parse(time(m))), + }); + assert.equal(world.getState().supported, true); + unregister(); + assert.deepEqual(world.coverage().marks, [Date.parse(time(5))]); + clock.destroy(); + assert.equal(world.coverage(), null); + assert.equal(world.getState().supported, false); +}); + +test('a suspended product keeps its coverage, so the way back to live stays', (t) => { + const { clock, world } = harness(t); + const radar = product('radar', [0, 10]); + clock.register(radar); + radar.suspended = true; + void clock.refresh(); + assert.equal(world.getState().supported, true); + assert.deepEqual( + world.coverage().marks, + [0, 10].map((m) => Date.parse(time(m))), + ); +}); + +test('new frames reach the world clock through refresh', (t) => { + const { clock, world } = harness(t); + const radar = product('radar', []); + clock.register(radar); + const supported = []; + world.subscribe((state) => supported.push(state.supported)); + assert.equal(world.getState().supported, false); + radar.times = [time(0)]; + void clock.refresh(); + assert.equal(supported.at(-1), true); +}); + +test('a clock destroyed while it registers a product lets go of it', (t) => { + const { clock, world } = harness(t); + const off = world.subscribe(() => { + off(); + clock.destroy(); + }); + const unregister = clock.register(product('radar', [0, 10])); + unregister(); + assert.equal(world.follows('radar'), false); + 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 e318e3087..fb1447827 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -52,7 +52,8 @@ const utc = (value) => : t('Unavailable'); /** A per-application observation layer, using the existing layer lifecycle and - * row controls. History is transient: shared links always open latest imagery. */ + * row controls. Its time is the world clock's, so a link made away from live + * opens its imagery at that time; one made live opens the latest. */ export function createWeatherLayer({ feed, clock, @@ -108,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 = () => @@ -210,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/share/viewCodec.js b/src/share/viewCodec.js index af18adcab..e908bce31 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -31,6 +31,7 @@ import { } from '../data/detectionPolicy.js'; import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; import { VIEW_STYLES } from '../view/index.js'; +import { isWorldTime } from '../time/worldClock.js'; import { RETIRED_STYLES } from '../visualStylePolicy.js'; import { GLOBAL_POST_DEFAULTS, @@ -379,6 +380,7 @@ export const SHARE_VIEW_FIELDS = freezeAll([ { number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 }, { number: 26, key: 'scopeTerminusPct', type: 'uint', default: null }, { number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE }, + { number: 28, key: 'worldTime', type: 'epoch', default: null }, ]); const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f])); const fieldNumber = (key) => FIELD_BY_KEY.get(key).number; @@ -738,6 +740,9 @@ export function normalizeShareViewState(input = {}) { : null; state.panelState = normalizePanelState(source.panelState); state.sharedAtMs = epochMsOr(source.sharedAtMs); + state.worldTime = isWorldTime(source.worldTime) + ? Math.floor(source.worldTime / 1000) * 1000 + : null; let view = normalizeView(source.view); if (state.layerState) view = liftTrackedEntity(state.layerState, view); if (view.kind === 'thing') { @@ -954,8 +959,9 @@ export function encodeShareView(input) { writer.sintField(field.number, signedAngle(state[field.key])); break; case 'sharedAtMs': - if (state.sharedAtMs !== null) - writer.sintField(field.number, epochSeconds(state.sharedAtMs)); + case 'worldTime': + if (state[field.key] !== null) + writer.sintField(field.number, epochSeconds(state[field.key])); break; case 'flags': { let bits = 0; @@ -1353,6 +1359,12 @@ export function decodeShareView(code, { nowMs = Date.now() } = {}) { raw.sharedAtMs = createdAtS >= 1 && Number.isSafeInteger(ms) && ms <= nowMs ? ms : null; } + // The world time the link was made at, when it was away from live. It + // may lie ahead of the link's creation (a forecast) or of now. + if (get('worldTime')) { + const ms = (SHARE_VIEW_EPOCH_S + unz(get('worldTime').varint)) * 1000; + raw.worldTime = isWorldTime(ms) ? ms : null; + } if (get('layerState')) raw.layerState = decodeLayers( diff --git a/src/share/viewCodec.test.mjs b/src/share/viewCodec.test.mjs index 6cc6fd9b1..f9fd81d38 100644 --- a/src/share/viewCodec.test.mjs +++ b/src/share/viewCodec.test.mjs @@ -361,6 +361,7 @@ test('every registered field round-trips at once', () => { })), }, sharedAtMs: NOW_MS, + worldTime: NOW_MS - 6 * 3_600_000, }; // A thing view derives its camera (roll 0), so the camera fields are // checked on the same state as a place view. @@ -501,6 +502,7 @@ test('the field registry is well formed and permanent', () => { [25, 'scopeFeatherPct', 100], [26, 'scopeTerminusPct', null], [27, 'styleParams', BY_STYLE], + [28, 'worldTime', null], ], ); assert.deepEqual( @@ -1338,3 +1340,22 @@ test('a layer option filed under a mirror layer is ignored', () => { { scope: 'layer-option', code: `${military}.${layerTokenNumber('m')}` }, ]); }); + +test('the world time is written only away from live, and may lie ahead', () => { + const at = (worldTime) => + decodeShareView(encodeShareView({ sharedAtMs: NOW_MS, worldTime }), { + nowMs: NOW_MS, + }).state.worldTime; + assert.equal(at(null), null); + assert.equal(at(NOW_MS - 3_600_000), NOW_MS - 3_600_000); + // A forecast moment ahead of the link's creation is kept. + assert.equal(at(NOW_MS + 86_400_000), NOW_MS + 86_400_000); + // Before the 2026 epoch too: the field is zigzag. + assert.equal(at(Date.UTC(2024, 0, 1)), Date.UTC(2024, 0, 1)); + // Past what a Date can hold: dropped, so the link opens live. + assert.equal(at(8.8e15), null); + assert.ok( + encodeShareView({ sharedAtMs: NOW_MS, worldTime: NOW_MS - 1000 }).length > + encodeShareView({ sharedAtMs: NOW_MS }).length, + ); +}); diff --git a/src/sharelink.js b/src/sharelink.js index 4a0291a84..fcef04ea5 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -5,6 +5,7 @@ import { normalizeAllocationStrategy, } from './data/detectionPolicy.js'; import { clampScopeTerminusPct } from './scopeMask.js'; +import { worldClock as pageWorldClock } from './time/worldClock.js'; import { clampScopeFeatherPct } from './scopeMaskPolicy.js'; import { decodeLayerStateParams, @@ -68,9 +69,11 @@ export class ShareLinkManager { isNavigationCurrent, cancelOwnedNavigation, initialShareCode = null, + worldClock = pageWorldClock, } = {}, ) { this.viewer = viewer; + this._worldClock = worldClock; // A host may hand over the share code to open instead of the address. this._initialShareCode = typeof initialShareCode === 'string' ? initialShareCode : null; @@ -163,8 +166,9 @@ export class ShareLinkManager { /** * The restore state for a share code, in the long form's parsed shape plus - * `shareView` (what the view follows, with its offset and last-known fix), - * or null when the code is refused. + * `shareView` (what the view follows, with its offset and last-known fix) + * and `worldTime` (null when the link was made live), or null when the + * code is refused. */ _parseShareCode(code) { const result = decodeShareView(code); @@ -174,6 +178,7 @@ export class ShareLinkManager { } const state = toLegacyShareState(result.state); state.shareView = result.state.view; + state.worldTime = result.state.worldTime; return state; } @@ -592,6 +597,10 @@ export class ShareLinkManager { // that state in the share format. const state = fromLegacyShareState(this._parseShareHash(params.toString())); state.sharedAtMs = Math.floor(nowMs / 1000) * 1000; + // Away from live, the link opens at the same world time. + state.worldTime = this._worldClock.isLive() + ? null + : Math.floor(this._worldClock.now() / 1000) * 1000; let subject = null; try { subject = this._followSubjectProvider?.({ nowMs }) ?? null; diff --git a/src/sharelink.navigation.test.mjs b/src/sharelink.navigation.test.mjs index e4e70b629..e486ba58d 100644 --- a/src/sharelink.navigation.test.mjs +++ b/src/sharelink.navigation.test.mjs @@ -423,3 +423,28 @@ test('the layer coordinator replaces in-flight share layers with the navigated s ); coordinator.destroy(); }); + +test('a link made away from live carries its world time to the page that opens it', (t) => { + installTab(t, SHARE_A); + const worldTime = Date.UTC(2026, 9, 30, 6, 15, 42); + const sharer = new ShareLinkManager(fakeViewer(), { + worldClock: { isLive: () => false, now: () => worldTime + 400 }, + }); + const link = sharer.buildLink({ nowMs: Date.UTC(2026, 10, 1) }); + sharer.destroy(); + const code = new URL(link).pathname.split('/').at(-1); + const opener = new ShareLinkManager(fakeViewer(), { initialShareCode: code }); + assert.equal(opener.parseInitialHash().worldTime, worldTime); + opener.destroy(); + + const live = new ShareLinkManager(fakeViewer(), { + worldClock: { isLive: () => true, now: () => worldTime }, + }); + const liveCode = new URL(live.buildLink()).pathname.split('/').at(-1); + live.destroy(); + const liveOpener = new ShareLinkManager(fakeViewer(), { + initialShareCode: liveCode, + }); + assert.equal(liveOpener.parseInitialHash().worldTime, null); + liveOpener.destroy(); +}); diff --git a/src/time/julian.js b/src/time/julian.js new file mode 100644 index 000000000..b63a53621 --- /dev/null +++ b/src/time/julian.js @@ -0,0 +1,13 @@ +import * as Cesium from 'cesium'; + +const scratch = new Date(); + +/** + * Epoch ms as a Cesium JulianDate, like `JulianDate.now(result)` for any time. + * @param {number} ms + * @param {Cesium.JulianDate} [result] + */ +export function julianAt(ms, result) { + scratch.setTime(ms); + return Cesium.JulianDate.fromDate(scratch, result); +} diff --git a/src/time/viewerClock.js b/src/time/viewerClock.js new file mode 100644 index 000000000..e4c456d42 --- /dev/null +++ b/src/time/viewerClock.js @@ -0,0 +1,46 @@ +import * as Cesium from 'cesium'; + +/** + * Draw Cesium's sky at the world time: the sun, moon, stars, atmosphere and + * the lighting on the tiles all read `frameState.time`, which Cesium takes + * from its own clock. That clock never animates in GEV, so before this the + * sky stayed at the moment the page loaded. + * + * Each frame's time is set in `preUpdate`, before the scene stamps the frame + * with it, so the sky is drawn at the same moment the layers read that frame. + * The clock's `currentTime` follows too, for anything that samples it. + * @param {Cesium.Viewer} viewer + * @param {() => number} readTime Epoch ms the sky is drawn at. + * @param {{subscribe?: Function, requestRender?: () => void}} [options] + * A world clock to hear moves from, so a resting scene repaints when the + * world time jumps. + * @returns {() => void} Stop following. + */ +export function followWorldTime( + viewer, + readTime, + { subscribe, requestRender = () => viewer.scene.requestRender() } = {}, +) { + const date = new Date(); + const removePreUpdate = viewer.scene.preUpdate.addEventListener( + (_scene, time) => { + date.setTime(readTime()); + const clock = viewer.clock; + Cesium.JulianDate.fromDate(date, clock.currentTime); + // The frame's own time: Cesium stamps the frame with this object next. + if (time) Cesium.JulianDate.clone(clock.currentTime, time); + }, + ); + let last = readTime(); + const unsubscribe = + subscribe?.(() => { + const next = readTime(); + if (next === last) return; + last = next; + requestRender(); + }) ?? (() => {}); + return () => { + removePreUpdate(); + unsubscribe(); + }; +} diff --git a/src/time/worldClock.js b/src/time/worldClock.js new file mode 100644 index 000000000..edfe4c283 --- /dev/null +++ b/src/time/worldClock.js @@ -0,0 +1,419 @@ +/** + * The world's one time, in epoch ms. Every layer that draws something at a + * moment reads it here instead of calling `Date.now()` itself, so layers can + * never disagree about when the world is. + * + * Live is a mode, not a timestamp: while live the clock is the wall clock and + * every layer behaves as it would reading `Date.now()`. Away from live the + * clock holds a cursor, fixed or playing. Only the layers that follow the clock + * move with the cursor; the rest keep reading the live edge through + * `timeFor(id)`. On the page's clock the layers whose data has a time of its + * own (the sky, satellites, launches and weather) follow it; live feeds such + * as flights and vessels have no time to move to and stay live. + * + * Playback is continuous (`cursor + elapsed × rate`) unless a driver takes + * it: weather replay steps its observed frames itself and moves the clock as + * it goes, so play and pause hand over to it. Several drivers may be attached; + * play goes to the newest that can play, pause to the one playing. + */ + +/** The span a JS Date can hold, either side of 1970. */ +const MAX_DATE_MS = 8.64e15; + +/** Whether `ms` is a time the world clock can hold. */ +export const isWorldTime = (ms) => + Number.isFinite(ms) && Math.abs(ms) <= MAX_DATE_MS; + +/** + * 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. 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) + // reaches every layer as it did when they called Date.now themselves. + now = () => Date.now(), + 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(); + // Layers a host follows, and layers a part of the app holds following. + const following = new Set(follow); + const holds = new Map(); + const readers = new Map(); + const sources = new Set(); + const drivers = []; + let live = true; + // The cursor while fixed; while playing continuously, the cursor at `anchorMono`. + let cursorMs = null; + let anchorMono = 0; + let rate = 1; + let playing = false; + // The driver handle stepping the clock, or null. + 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)]), + ]; + // A host's coverage that throws counts as none, so it cannot break the + // listeners every state reaches. + const spanOf = (source) => { + let span; + try { + span = source.coverage(); + } catch (error) { + console.error(`World time coverage failed: ${source.id}`, error); + return null; + } + return span && span.end >= span.start ? span : null; + }; + // Whether a layer that moves with the clock has time to show: only then is + // there anything for a timeline to play. + const isSupported = () => { + for (const { source } of sources) + if (isFollowed(source.id) && spanOf(source)) return true; + return false; + }; + const time = () => { + if (live) return now(); + 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({ + live, + 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(), + }); + // Each listener hears the state as it is when called. A change made from + // inside a listener notifies everyone afresh, and the outer round stops, so + // nobody is left holding a state that has since changed. + // A round's state is read once: any change made during it starts a new one. + const notify = () => { + const round = ++notifying; + let state = null; + for (const listener of [...listeners]) { + if (round !== notifying) return; + if (!listeners.has(listener)) continue; + try { + listener((state ??= getState())); + } catch (error) { + console.error('World clock listener failed', error); + } + } + }; + // 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) { + if (ticker === null) + ticker = frames + ? requestFrame(frame) + : setInterval(notify, WORLD_CLOCK_TICK_MS); + } else if (ticker !== null) { + if (frames) cancelFrame(ticker); + else clearInterval(ticker); + ticker = null; + } + }; + const changed = () => { + syncTicker(); + notify(); + }; + // Freeze a continuously playing cursor where it is. + const settle = () => { + if (continuous()) cursorMs = time(); + anchorMono = monotonicNow(); + }; + + const api = { + /** The live edge: the wall clock, whatever the cursor does. */ + wallNow: () => now(), + /** The world time: the wall clock while live, else the cursor. */ + now: time, + isLive: () => live, + isPlaying: () => playing, + getRate: () => rate, + + /** Hold the world at `ms`; a playing clock plays on from there. */ + seek(ms) { + if (!isWorldTime(ms)) throw new TypeError('World time must be epoch ms'); + if (!live && cursorMs === ms && !continuous()) return; + live = false; + cursorMs = ms; + anchorMono = monotonicNow(); + changed(); + }, + /** Back to the wall clock; playback stops and its rate goes back to 1. */ + goLive() { + 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). 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; !runOn && index >= 0; index -= 1) { + if (drivers[index].driver.play() !== false) return; + } + if (live) { + live = false; + cursorMs = now(); + } + playing = true; + anchorMono = monotonicNow(); + changed(); + }, + pause() { + if (!playing) return; + if (active) { + active.driver.pause(); + return; + } + settle(); + playing = false; + changed(); + }, + 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) + throw new RangeError('World clock rate must be positive'); + if (nextRate === rate) return; + settle(); + rate = nextRate; + changed(); + }, + + /** + * Offer a stepping player. The driver moves the clock with `seek()` and + * reports through its handle's `driven(playing, atMs?)`; its `play()` + * returns false when it cannot play, and the clock tries an older driver + * or plays continuously instead. + * @param {{play: () => (boolean|void), pause: () => void}} driver + * @returns {{driven: (playing: boolean, atMs?: number) => void, + * detach: () => number}} `detach()` returns how many drivers remain. + */ + attachDriver(driver) { + const handle = { + driver, + /** This driver started stepping (at `atMs`, if given) or stopped. */ + driven(isPlaying, atMs) { + if (!drivers.includes(handle)) return; + if (!isPlaying) { + if (active !== handle) return; + active = null; + playing = false; + changed(); + return; + } + // A continuous run hands over where it is; another driver stops. + settle(); + const previous = active; + active = handle; + playing = true; + live = false; + if (Number.isFinite(atMs)) cursorMs = atMs; + else cursorMs ??= now(); + anchorMono = monotonicNow(); + if (previous && previous !== handle) previous.driver.pause(); + changed(); + }, + detach() { + const index = drivers.indexOf(handle); + if (index < 0) return drivers.length; + handle.driven(false); + drivers.splice(index, 1); + return drivers.length; + }, + }; + drivers.push(handle); + return handle; + }, + + /** Let layer `id` move with the cursor instead of reading the live edge. */ + follow(id, on = true) { + if (on === following.has(id)) return; + if (on) following.add(id); + else following.delete(id); + changed(); + }, + /** + * Keep layer `id` following until the returned release is called, however + * many parts of the app hold it. + */ + hold(id) { + const was = isFollowed(id); + holds.set(id, (holds.get(id) ?? 0) + 1); + if (!was) changed(); + let held = true; + return () => { + if (!held) return; + held = false; + const count = holds.get(id) - 1; + if (count > 0) holds.set(id, count); + else holds.delete(id); + if (!isFollowed(id)) changed(); + }; + }, + follows: isFollowed, + /** + * The time layer `id` draws at: the cursor when it follows the clock, + * otherwise the live edge. + * @returns {() => number} + */ + timeFor(id) { + let reader = readers.get(id); + if (!reader) { + reader = () => (isFollowed(id) ? time() : now()); + readers.set(id, reader); + } + return reader; + }, + + /** + * A layer's span of time it can show, for a host's timeline; null while + * it has none (off, or nothing loaded). Returns the unregister. + * @param {{id: string, coverage: () => ({start: number, end: number, marks?: number[]} | null)}} source + */ + register(source) { + // Each registration is its own, even under a shared id. + const entry = { source }; + sources.add(entry); + changed(); + return () => { + if (sources.delete(entry)) changed(); + }; + }, + /** A source's coverage changed (frames loaded, the layer paused). */ + coverageChanged: () => changed(), + /** Whether a followed layer has time to show; see getState().supported. */ + isSupported, + /** The union of the registered sources' coverage, or null with none. */ + coverage() { + let start = Infinity; + let end = -Infinity; + const marks = new Set(); + for (const { source } of sources) { + const span = spanOf(source); + if (!span) continue; + start = Math.min(start, span.start); + end = Math.max(end, span.end); + for (const mark of span.marks ?? []) marks.add(mark); + } + if (start > end) return null; + return { start, end, marks: [...marks].sort((a, b) => a - b) }; + }, + + getState, + subscribe(listener) { + listeners.add(listener); + syncTicker(); + return () => { + listeners.delete(listener); + syncTicker(); + }; + }, + }; + return api; +} + +/** + * The page's world clock. Layers, the sky and a host all read this one. The + * sky and the HUD's clock show the world time; satellites (and the launched + * missions flying on their time) and launches have a time of their own and + * move with it; weather holds each product it shows. + */ +export const worldClock = createWorldClock({ + follow: ['sky', 'hud', 'satellites', 'rocket-launches'], +}); + +/** The time layer `id` draws at on the page's clock. */ +export const worldTimeFor = (id) => worldClock.timeFor(id); diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs new file mode 100644 index 000000000..f81fa85a9 --- /dev/null +++ b/src/time/worldClock.test.mjs @@ -0,0 +1,389 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createWorldClock, WORLD_CLOCK_TICK_MS } from './worldClock.js'; + +function fakeTime() { + const t = { + wall: Date.UTC(2026, 10, 1, 12), + mono: 1_000, + timers: new Map(), + nextId: 1, + advance(ms) { + t.wall += ms; + t.mono += ms; + }, + options: { + now: () => t.wall, + monotonicNow: () => t.mono, + setInterval: (fn, ms) => { + const id = t.nextId++; + t.timers.set(id, { fn, ms }); + return id; + }, + clearInterval: (id) => t.timers.delete(id), + }, + }; + return t; +} + +test('live is the wall clock for every layer', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + assert.equal(clock.isLive(), true); + assert.equal(clock.now(), t.wall); + t.advance(5_000); + assert.equal(clock.now(), t.wall); + assert.equal(clock.timeFor('flights')(), t.wall); + clock.follow('flights'); + assert.equal(clock.timeFor('flights')(), t.wall); +}); + +test('away from live only following layers move with the cursor', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const satellites = clock.timeFor('satellites'); + const flights = clock.timeFor('flights'); + clock.follow('satellites'); + const past = t.wall - 3_600_000; + clock.seek(past); + assert.equal(clock.isLive(), false); + assert.equal(clock.now(), past); + assert.equal(satellites(), past); + assert.equal(flights(), t.wall); + assert.equal(clock.timeFor('satellites'), satellites); + clock.goLive(); + assert.equal(satellites(), t.wall); +}); + +test('layers named at creation follow from the start', () => { + const t = fakeTime(); + const clock = createWorldClock({ ...t.options, follow: ['satellites'] }); + clock.seek(t.wall - 1_000); + assert.equal(clock.timeFor('satellites')(), t.wall - 1_000); + assert.equal(clock.timeFor('flights')(), t.wall); + clock.follow('satellites', false); + assert.equal(clock.follows('satellites'), false); +}); + +test('the page clock moves the layers with a time of their own', async () => { + const { worldClock } = await import('./worldClock.js'); + for (const id of ['sky', 'hud', 'satellites', 'rocket-launches']) + assert.equal(worldClock.follows(id), true, id); + for (const id of ['flights', 'military', 'vessels', 'transit']) + assert.equal(worldClock.follows(id), false, id); +}); + +test('continuous play advances the cursor at the rate and pause holds it', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const start = t.wall - 60_000; + clock.seek(start); + clock.play({ rate: 60 }); + t.advance(1_000); + assert.equal(clock.now(), start + 60_000); + clock.setRate(120); + t.advance(1_000); + assert.equal(clock.now(), start + 180_000); + clock.pause(); + t.advance(1_000); + assert.equal(clock.now(), start + 180_000); + assert.equal(clock.isPlaying(), false); +}); + +test('play from live starts at now, and goLive returns to the wall clock', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const from = t.wall; + clock.play({ rate: 2 }); + assert.equal(clock.isLive(), false); + t.advance(1_000); + assert.equal(clock.now(), from + 2_000); + clock.goLive(); + assert.equal(clock.isPlaying(), false); + assert.equal(clock.now(), t.wall); +}); + +test('subscribers hear changes, and ticks only while playing continuously', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const states = []; + const off = clock.subscribe((state) => states.push(state)); + clock.seek(t.wall - 1_000); + assert.equal(states.length, 1); + assert.equal(states[0].live, false); + assert.equal(t.timers.size, 0); + clock.play(); + assert.equal(t.timers.size, 1); + assert.equal([...t.timers.values()][0].ms, WORLD_CLOCK_TICK_MS); + clock.pause(); + assert.equal(t.timers.size, 0); + clock.play(); + off(); + assert.equal(t.timers.size, 0); +}); + +test('a driver takes play and pause, and moves the clock with seek', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const calls = []; + const handle = clock.attachDriver({ + play() { + calls.push('play'); + handle.driven(true, t.wall - 10_000); + }, + pause() { + calls.push('pause'); + handle.driven(false); + }, + }); + clock.play(); + assert.deepEqual(calls, ['play']); + assert.equal(clock.isPlaying(), true); + assert.equal(clock.now(), t.wall - 10_000); + const at = clock.now(); + t.advance(5_000); + // Stepped, not continuous: the cursor waits for the driver. + assert.equal(clock.now(), at); + clock.pause(); + assert.deepEqual(calls, ['play', 'pause']); + assert.equal(clock.isPlaying(), false); + clock.play(); + clock.goLive(); + assert.deepEqual(calls, ['play', 'pause', 'play', 'pause']); + assert.equal(clock.isLive(), true); + assert.equal(clock.isPlaying(), false); + assert.equal(handle.detach(), 0); + clock.play(); + assert.equal(calls.length, 4); + t.advance(1_000); + assert.equal(clock.now(), t.wall); +}); + +test('play goes to the newest driver that can play, pause to the one playing', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const calls = []; + const driver = (name, canPlay) => { + const handle = clock.attachDriver({ + play() { + if (!canPlay()) return false; + calls.push(`${name} play`); + handle.driven(true); + }, + pause() { + calls.push(`${name} pause`); + handle.driven(false); + }, + }); + return handle; + }; + let bCanPlay = false; + const a = driver('a', () => true); + const b = driver('b', () => bCanPlay); + clock.seek(t.wall - 1_000); + clock.play(); + assert.deepEqual(calls, ['a play']); + // b starting takes over from a. + bCanPlay = true; + b.driven(true); + assert.deepEqual(calls, ['a play', 'a pause']); + clock.pause(); + assert.deepEqual(calls, ['a play', 'a pause', 'b pause']); + assert.equal(clock.isPlaying(), false); + // A detached driver stops, and the other one plays next. + clock.play(); + assert.equal(b.detach(), 1); + assert.equal(clock.isPlaying(), false); + clock.play(); + assert.equal(calls.at(-1), 'a play'); + assert.equal(a.detach(), 0); + assert.equal(clock.isPlaying(), false); +}); + +test('a listener that changes the clock leaves nobody with a stale state', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const seen = []; + clock.subscribe((state) => { + if (state.timeMs === 0) clock.seek(1_000); + }); + clock.subscribe((state) => seen.push(state.timeMs)); + clock.seek(0); + assert.deepEqual(seen, [1_000]); +}); + +test('holds keep a layer following until every holder releases it', () => { + const clock = createWorldClock(fakeTime().options); + const first = clock.hold('radar'); + const second = clock.hold('radar'); + first(); + first(); + assert.equal(clock.follows('radar'), true); + second(); + assert.equal(clock.follows('radar'), false); + clock.follow('radar'); + const third = clock.hold('radar'); + third(); + assert.equal(clock.follows('radar'), true); + assert.deepEqual(clock.getState().following, ['radar']); +}); + +test('a driver that cannot play leaves continuous playback', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + clock.attachDriver({ play: () => false, pause() {} }); + clock.seek(t.wall - 10_000); + clock.play(); + assert.equal(clock.isPlaying(), true); + t.advance(1_000); + // Wall and cursor moved the same second at rate 1. + assert.equal(clock.now(), t.wall - 10_000); +}); + +test('coverage is the union of the registered sources', () => { + const clock = createWorldClock(fakeTime().options); + assert.equal(clock.coverage(), null); + const off = clock.register({ + id: 'radar', + coverage: () => ({ start: 100, end: 200, marks: [100, 150, 200] }), + }); + clock.register({ id: 'satellites', coverage: () => ({ start: 50, end: 180 }) }); + clock.register({ id: 'empty', coverage: () => null }); + assert.deepEqual(clock.coverage(), { + start: 50, + end: 200, + marks: [100, 150, 200], + }); + off(); + assert.deepEqual(clock.coverage(), { start: 50, end: 180, marks: [] }); +}); + +test('seek refuses anything but epoch ms and rate must be positive', () => { + const clock = createWorldClock(fakeTime().options); + assert.throws(() => clock.seek(Number.NaN), TypeError); + assert.throws(() => clock.setRate(0), RangeError); +}); + +test('seek refuses a time no Date can hold', () => { + const clock = createWorldClock(fakeTime().options); + assert.throws(() => clock.seek(8.8e15), TypeError); + clock.seek(-8.64e15); + assert.equal(clock.now(), -8.64e15); +}); + +test('only a followed layer with time to show supports the timeline', () => { + const clock = createWorldClock(fakeTime().options); + let span = null; + const states = []; + clock.subscribe((state) => states.push(state.supported)); + const off = clock.register({ id: 'satellites', coverage: () => span }); + span = { start: 0, end: 10 }; + clock.coverageChanged(); + assert.equal(clock.isSupported(), false, 'covered but not followed'); + clock.follow('satellites'); + assert.equal(clock.isSupported(), true); + assert.equal(states.at(-1), true); + span = null; + clock.coverageChanged(); + assert.equal(states.at(-1), false); + span = { start: 0, end: 10 }; + off(); + assert.equal(clock.isSupported(), false); + assert.equal(states.at(-1), false); +}); + +test('a coverage that throws counts as none', (t) => { + t.mock.method(console, 'error', () => {}); + const clock = createWorldClock(fakeTime().options); + clock.follow('broken'); + clock.register({ + id: 'broken', + coverage() { + throw new Error('no'); + }, + }); + 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/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 75746b6e3..4fecad9e7 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -434,12 +434,12 @@ test('Cyber lower-left telemetry tracks the credit anchor with clearance for its cyberStyles, /#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/, ); - const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/); + const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem \+ var\(--world-timeline-lift, 0px\)\);/); assert.ok(offset, 'telemetry must retain the attribution viewport anchor'); const credits = read('./styles/command-dock-trays.css'); const creditOffsets = [ ...credits.matchAll( - /#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/g, + /#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem \+ var\(--world-timeline-lift, 0px\)\);/g, ), ]; assert.ok(creditOffsets.length > 0); @@ -738,7 +738,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', () ); assert.match( cyberStyles, - /#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem\);/, + /#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem \+ var\(--world-timeline-lift, 0px\)\);/, ); assert.match( cyberStyles, @@ -793,7 +793,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', () cyberStyles, /#right-context-rail:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-inline-end-color:/, ); - assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem\);/); + assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem \+ var\(--world-timeline-lift, 0px\)\);/); assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/); assert.match(cyberStyles, /data-layer-id='satellites'/); assert.match(cyberStyles, /#search-toggle::before/); diff --git a/src/ui/mobileShell.test.mjs b/src/ui/mobileShell.test.mjs index a391a6ba7..87286240f 100644 --- a/src/ui/mobileShell.test.mjs +++ b/src/ui/mobileShell.test.mjs @@ -586,7 +586,7 @@ test('the phone shell keeps the required attribution visible', () => { }; const lift = (block) => Number( - /bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px\)/.exec(block)?.[1], + /bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px \+ var\(--world-timeline-lift, 0px\)\)/.exec(block)?.[1], ); const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]); const idleCredit = rule( diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index dddedf575..ee0d5541b 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -32,6 +32,7 @@ const LEFT_STACK_OBSTACLE_SELECTOR = [ '#gev-voice-control', '#pp-toggles', '#param-slider-panel', + '#world-timeline', ].join(', '); /** * Fixed UI regions that can occupy the right control lane. Runtime rectangle @@ -59,6 +60,7 @@ const RIGHT_STACK_OBSTACLE_SELECTOR = [ '#cesium-credits .cesium-credit-textContainer', '#command-dock', '#gev-voice-control', + '#world-timeline', ].join(', '); export class PanelLayoutController { constructor({ diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 12f09c18b..9fc4c6f45 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -4,6 +4,16 @@ import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; import { t, msg, formatDate } from '../i18n/index.js'; import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js'; import { UiLifetime } from './uiLifetime.js'; +import { isWorldTime, worldClock } from '../time/worldClock.js'; + +// A link made away from live opens at its world time, paused. One without a +// world time leaves the clock as it is: a page opens live, and Back or Forward +// through long-form hashes (which never carry one) does not stop a replay. +function applySharedWorldTime(state) { + if (!isWorldTime(state?.worldTime)) return; + worldClock.pause(); + worldClock.seek(state.worldTime); +} /** How long a shared vessel may take to appear in the vessel feed. */ export const SHARED_VESSEL_WAIT_MS = 120_000; @@ -146,6 +156,7 @@ export class ShareRestoration { : Promise.resolve({ status: 'not-requested', share: null, layers: [] }); if (savedState) { this._hasShareState = true; + applySharedWorldTime(savedState); // Reserve camera authority now; the delayed mesh-friendly flight may // run only if no newer user, voice, or tracking navigation has won. this._initialShareNavigationGeneration = @@ -301,6 +312,7 @@ export class ShareRestoration { layers: [], }); this._initialShareState = state; + applySharedWorldTime(state); this._initialShareSelectionSuperseded = false; this._hasShareState = true; this._navigatedShareRestoreActive = true; diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js new file mode 100644 index 000000000..80f51ac71 --- /dev/null +++ b/src/ui/stores/worldTime.js @@ -0,0 +1,166 @@ +import { defineStore } from 'pinia'; +import { onScopeDispose, shallowRef } from 'vue'; +import { worldClock } from '../../time/worldClock.js'; + +/** How often a shown timeline moves its readout and its end on with now. */ +const LIVE_TICK_MS = 1000; +/** A scrub this close to now, as a share of the track, is a scrub to live. */ +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, 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 + * of the time a few times a second while it plays on its own. While a + * timeline shows, it reads the clock each second itself, so live and the + * track's end keep up with now. + */ +export const useWorldTimeStore = defineStore('gev-world-time', () => { + // The time the layers cover, reaching to now. + const reach = () => { + const coverage = worldClock.coverage(); + if (!coverage) return null; + const now = worldClock.wallNow(); + 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. + const ticking = Boolean(time.value.span) && !time.value.playing; + if (ticking && ticker === null) + ticker = setInterval(() => { + time.value = read(); + }, LIVE_TICK_MS); + else if (!ticking && ticker !== null) { + clearInterval(ticker); + ticker = null; + } + }; + const unsubscribe = worldClock.subscribe((state) => { + if (stopAtEnd(state)) return; + time.value = read(state); + syncTicker(); + }); + syncTicker(); + onScopeDispose(() => { + unsubscribe(); + if (ticker !== null) clearInterval(ticker); + ticker = null; + }); + + return { + time, + /** + * 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. + * 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; + } + worldClock.pause(); + worldClock.seek(ms); + }, + }; +}); diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index f6e466e43..ca76a555e 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -904,7 +904,7 @@ html:not([lang='en']) .cockpit-roll-token.is-separator { position: fixed; z-index: 146; right: 36px; - bottom: calc(2vh + 4.5rem); + bottom: calc(2vh + 4.5rem + var(--world-timeline-lift, 0px)); display: grid; gap: 6px; } @@ -2083,7 +2083,7 @@ body.cockpit-mode #intel-hud .hud-right-edge { @media (max-width: 760px) { #view-switcher { right: 16px; - bottom: 16px; + bottom: calc(16px + var(--world-timeline-lift, 0px)); } body.cockpit-mode #view-switcher { right: auto; diff --git a/src/ui/styles/command-dock-compact.css b/src/ui/styles/command-dock-compact.css index ced41df8f..0c40799de 100644 --- a/src/ui/styles/command-dock-compact.css +++ b/src/ui/styles/command-dock-compact.css @@ -377,15 +377,15 @@ /* Compact dock clearance keeps the reference-like low profile. */ body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { - bottom: calc(2vh + 4.7rem); + bottom: calc(2vh + 4.7rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 5.4rem); + bottom: calc(2vh + 5.4rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-bar { - bottom: calc(2vh + 8.9rem); + bottom: calc(2vh + 8.9rem + var(--world-timeline-lift, 0px)); } @media (max-width: 900px) { diff --git a/src/ui/styles/command-dock-trays.css b/src/ui/styles/command-dock-trays.css index fb41acb8e..08580ecb2 100644 --- a/src/ui/styles/command-dock-trays.css +++ b/src/ui/styles/command-dock-trays.css @@ -455,7 +455,7 @@ body:not(.ui-clean-view):not(.recording-mode):has( ) #cesium-credits { right: auto; - bottom: calc(2vh + 5rem); + bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px)); left: 1.5rem; width: auto !important; max-width: calc(100vw - 3rem); @@ -464,21 +464,21 @@ body:not(.ui-clean-view):not(.recording-mode):has( #intel-hud[data-variant='tactical'] .hud-bottom-left, #intel-hud[data-variant='tactical'] .hud-bottom-right { - bottom: calc(2vh + 6.5rem); + bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 5.4rem); + bottom: calc(2vh + 5.4rem + var(--world-timeline-lift, 0px)); } :root[data-scope-mask='off'] #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 6.5rem); + bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-bar { - bottom: calc(2vh + 9rem); + bottom: calc(2vh + 9rem + var(--world-timeline-lift, 0px)); } @media (max-width: 900px) { @@ -487,7 +487,7 @@ body:not(.ui-clean-view):not(.recording-mode):has( #intel-hud[data-variant='minimal'].active ) #cesium-credits { - bottom: calc(2vh + 5rem); + bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px)); left: 0.75rem; max-width: calc(100vw - 1.5rem); } diff --git a/src/ui/styles/command-dock.css b/src/ui/styles/command-dock.css index 3c6c8e531..808b5f480 100644 --- a/src/ui/styles/command-dock.css +++ b/src/ui/styles/command-dock.css @@ -2,7 +2,8 @@ #command-dock { position: fixed; right: auto; - bottom: 2vh; + /* Above the world timeline while it shows (world-timeline.css). */ + bottom: calc(2vh + var(--world-timeline-lift, 0px)); left: 50%; z-index: 145; display: grid; @@ -216,7 +217,7 @@ /* Keep required map attribution clear of the full-width command surface. */ body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { - bottom: calc(2vh + 7rem); + bottom: calc(2vh + 7rem + var(--world-timeline-lift, 0px)); } body:not(.ui-clean-view):not(.recording-mode):has( @@ -244,11 +245,11 @@ body.recording-mode #cesium-credits { } #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 7.5rem); + bottom: calc(2vh + 7.5rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-bar { - bottom: calc(2vh + 11.5rem); + bottom: calc(2vh + 11.5rem + var(--world-timeline-lift, 0px)); } @media (max-width: 980px) { @@ -275,21 +276,21 @@ body.recording-mode #cesium-credits { } body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { - bottom: calc(2vh + 9.5rem); + bottom: calc(2vh + 9.5rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 12rem); + bottom: calc(2vh + 12rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-bar { - bottom: calc(2vh + 16rem); + bottom: calc(2vh + 16rem + var(--world-timeline-lift, 0px)); } } @media (max-width: 720px) { #command-dock { - bottom: 8px; + bottom: calc(8px + var(--world-timeline-lift, 0px)); } #command-dock .location-search-wrap { diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 4aaf5b1c2..9c80ddf4a 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -1590,7 +1590,7 @@ } #intel-hud[data-variant='cyber'] .hud-bottom-bar { - bottom: calc(2vh + 9.8rem); + bottom: calc(2vh + 9.8rem + var(--world-timeline-lift, 0px)); width: min(48vw, 680px); } @@ -1601,13 +1601,13 @@ #intel-hud[data-variant='cyber'] .hud-bottom-left, #intel-hud[data-variant='cyber'] .hud-bottom-right { - bottom: calc(2vh + 6.5rem); + bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px)); } /* Attribution's logo row extends above its text baseline. Reserve the whole row plus a gap above the dock-relative 2vh + 5rem credit position. */ #intel-hud[data-variant='cyber'] .hud-bottom-left { - bottom: calc(2vh + 7.75rem); + bottom: calc(2vh + 7.75rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='cyber'] .hud-corner { @@ -1652,7 +1652,7 @@ below its 650px threshold. Keep the left rail inside the same telemetry and attribution-safe floor while its panel bodies take over scrolling. */ :root[data-ui-theme='cyber'] #left-panel-stack { - bottom: calc(2vh + 13rem); + bottom: calc(2vh + 13rem + var(--world-timeline-lift, 0px)); } } @@ -1675,7 +1675,7 @@ /* The stack holds still, so its rail needs no offset (leftPanelRail.js). */ --panel-stack-top-pinned: 1; top: var(--cyber-left-rail-top) !important; - bottom: calc(2vh + 13rem); + bottom: calc(2vh + 13rem + var(--world-timeline-lift, 0px)); inset-inline-start: calc(54px + var(--panel-rail-clearance)); width: 300px; padding-inline-start: 10px; diff --git a/src/ui/styles/mobile-shell.css b/src/ui/styles/mobile-shell.css index 707b0f43f..b607391de 100644 --- a/src/ui/styles/mobile-shell.css +++ b/src/ui/styles/mobile-shell.css @@ -200,7 +200,7 @@ body.mobile-shell #command-dock { /* The dock's tray rules size voice by this basis; a column makes it a height. */ --dock-voice-width: auto; top: auto; - bottom: calc(var(--mobile-tab-space) + 10px); + bottom: calc(var(--mobile-tab-space) + 10px + var(--world-timeline-lift, 0px)); display: flex; flex-direction: column; align-items: center; @@ -380,7 +380,7 @@ body.mobile-shell #command-dock #gev-voice-control { body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits { right: auto; - bottom: calc(var(--mobile-tab-space) + 106px) !important; + bottom: calc(var(--mobile-tab-space) + 106px + var(--world-timeline-lift, 0px)) !important; left: 10px; /* With the base rule's nowrap the credits run off the right edge, and a max-width alone wrapped them into a narrow column: the line needs wrapping @@ -407,7 +407,7 @@ body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not( /* The view switcher clears the tab bar and the voice bar, opposite the credit. */ body.mobile-shell:not(.cockpit-mode) #view-switcher { right: 12px; - bottom: calc(var(--mobile-tab-space) + 106px); + bottom: calc(var(--mobile-tab-space) + 106px + var(--world-timeline-lift, 0px)); } body.mobile-shell #corner-actions { diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index 27a8ded3c..d3d986f44 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -177,7 +177,7 @@ drawn straight over the notice. Track the credit's own anchor instead and the 12px of clear air holds regardless of height; the rail already scrolls (overflow-y below), so it loses reach, not content. */ - bottom: calc(2vh + 7.5rem); + bottom: calc(2vh + 7.5rem + var(--world-timeline-lift, 0px)); left: 16px; width: auto; max-height: none; diff --git a/src/ui/styles/status.css b/src/ui/styles/status.css index bba1ef788..404b9cd2c 100644 --- a/src/ui/styles/status.css +++ b/src/ui/styles/status.css @@ -390,7 +390,7 @@ html:not([lang='en']) #global-loading-detail { /* ── Toast Notification ──────────────────────── */ #toast { position: fixed; - bottom: 100px; + bottom: calc(100px + var(--world-timeline-lift, 0px)); left: 50%; transform: translateX(-50%) translateY(20px); z-index: 200; diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css new file mode 100644 index 000000000..f644f8543 --- /dev/null +++ b/src/ui/styles/world-timeline.css @@ -0,0 +1,325 @@ +/* ── World timeline ─────────────────────────── */ +/* Shown while a layer with time to show is on, centred along the bottom of + the screen; the command dock and what sits above it rise by its height. */ +:root { + --world-timeline-lift: 0px; +} + +/* The bar and the time's bubble over its handle. */ +body:has(#world-timeline) { + --world-timeline-lift: 66px; +} + +/* Clean view, recording and the cockpit put the dock away; the bar goes too. */ +body.ui-clean-view, +body.recording-mode, +body.cockpit-mode { + --world-timeline-lift: 0px !important; +} + +body.ui-clean-view #world-timeline, +body.recording-mode #world-timeline, +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%; + z-index: 146; + display: flex; + align-items: center; + gap: 10px; + width: min(820px, calc(100vw - 24px)); + height: 42px; + box-sizing: border-box; + padding: 4px 6px; + border: 1px solid var(--glass-border); + border-radius: 10px; + background: var(--glass-bg); + backdrop-filter: blur(24px) saturate(1.4); + -webkit-backdrop-filter: blur(24px) saturate(1.4); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); + transform: translateX(-50%); +} + +#world-timeline .world-timeline-play, +#world-timeline .world-timeline-live { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + padding: 0 8px; + border: 1px solid rgba(123, 189, 211, 0.28); + border-radius: 8px; + background: rgba(0, 212, 255, 0.08); + color: var(--text-primary); + font: inherit; + cursor: pointer; +} + +#world-timeline .world-timeline-play:hover, +#world-timeline .world-timeline-live:hover { + background: rgba(0, 212, 255, 0.18); +} + +#world-timeline .world-timeline-live.is-live { + border-color: rgba(0, 212, 255, 0.6); + background: rgba(0, 212, 255, 0.2); + color: var(--accent); + cursor: default; +} + +#world-timeline .world-timeline-live { + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +#world-timeline .world-timeline-speed { + 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 { + --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%; + right: 0; + left: 0; + height: 4px; + border-radius: 2px; + background: rgba(123, 189, 211, 0.22); + content: ''; + transform: translateY(-50%); +} + +#world-timeline .world-timeline-fill { + position: absolute; + top: 50%; + left: 0; + height: 4px; + border-radius: 2px; + background: var(--accent); + opacity: 0.7; + transform: translateY(-50%); + pointer-events: none; +} + +#world-timeline .world-timeline-tick { + position: absolute; + top: 50%; + width: 2px; + height: 12px; + margin-left: -1px; + border-radius: 1px; + background: rgba(0, 212, 255, 0.75); + transform: translateY(-50%); + 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; + width: 100%; + height: 100%; + margin: 0; + background: transparent; + 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 { + height: 100%; + background: transparent; +} + +#world-timeline .world-timeline-scrub::-moz-range-track { + background: transparent; +} + +#world-timeline .world-timeline-scrub::-webkit-slider-thumb { + width: 14px; + height: 14px; + margin-top: 5px; + border: 2px solid var(--accent); + border-radius: 50%; + background: #031018; + box-shadow: 0 0 8px rgba(0, 212, 255, 0.6); + appearance: none; +} + +#world-timeline .world-timeline-scrub::-moz-range-thumb { + width: 12px; + height: 12px; + border: 2px solid var(--accent); + border-radius: 50%; + 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; +} + +@media (max-width: 720px) { + #world-timeline { + bottom: 8px; + } +} + +/* On phones the bar sits just above the tab bar, under the voice dock, with + 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; +} + +body.mobile-shell #world-timeline { + bottom: calc(var(--mobile-tab-space) + 6px); + flex-wrap: wrap; + row-gap: 4px; + height: auto; +} + +/* The bubble rises into the first row, clear of play on its left. */ +body.mobile-shell #world-timeline .world-timeline-time { + --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: 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; +} + diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index f77dad3d2..32900f42a 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -18,6 +18,7 @@ import ScreenAnnotations from './panels/ScreenAnnotations.vue'; import AwarenessDirection from './panels/AwarenessDirection.vue'; import UiButton from '../kit/UiButton.vue'; import GlobeActions from './panels/GlobeActions.vue'; +import WorldTimeline from './panels/WorldTimeline.vue'; // The ACTIVE STYLE corner shows the style VisualSettings publishes. const { style } = storeToRefs(useDisplayStore()); @@ -117,4 +118,5 @@ const vToastText = { + diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs new file mode 100644 index 000000000..073d6f95e --- /dev/null +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -0,0 +1,548 @@ +// The world timeline shows the world time while a layer with time to show is +// on, with play and pause on the left and the way back to live on the right. +import '../../../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { worldClock } from '../../../time/worldClock.js'; +import WorldTimeline from './WorldTimeline.vue'; +import { useWorldTimeStore } from '../../stores/worldTime.js'; + +function mount(t) { + document.body.replaceChildren(); + const pinia = createPinia(); + const app = createApp(WorldTimeline).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => { + app.unmount(); + // The store outlives the view; dispose it with its clock subscription. + useWorldTimeStore(pinia).$dispose(); + worldClock.goLive(); + }); + return () => document.getElementById('world-timeline'); +} + +// A layer that moves with the clock and has an hour of frames. +function supportingLayer(t, at) { + const release = worldClock.hold('test-radar'); + const unregister = worldClock.register({ + id: 'test-radar', + coverage: () => ({ start: at - 3_600_000, end: at }), + }); + t.after(() => { + unregister(); + release(); + }); + return () => { + unregister(); + release(); + }; +} + +test('the timeline shows live, and plays, pauses and goes back', async (t) => { + const bar = mount(t); + const at = Date.UTC(2026, 9, 30, 6, 15, 42); + supportingLayer(t, at); + await nextTick(); + assert.ok(bar(), 'shown while live'); + const live = bar().querySelector('.world-timeline-live'); + assert.equal(live.disabled, true, 'already live'); + assert.equal(live.getAttribute('aria-pressed'), 'true'); + + worldClock.seek(at); + await nextTick(); + assert.ok(bar()); + const time = bar().querySelector('time'); + assert.equal(time.getAttribute('datetime'), new Date(at).toISOString()); + assert.match(time.textContent, /UTC$/); + const play = bar().querySelector('.world-timeline-play'); + assert.equal(play.getAttribute('aria-label'), 'Play'); + + play.click(); + await nextTick(); + assert.equal(worldClock.isPlaying(), true); + assert.equal(play.getAttribute('aria-label'), 'Pause'); + play.click(); + await nextTick(); + assert.equal(worldClock.isPlaying(), false); + + assert.equal(bar().querySelector('.world-timeline-live').disabled, false); + bar().querySelector('.world-timeline-live').click(); + await nextTick(); + assert.equal(worldClock.isLive(), true); + assert.ok(bar(), 'still shown, live'); + assert.equal(bar().querySelector('.world-timeline-live').disabled, true); +}); + +test('the timeline hides while no layer with time to show is on', async (t) => { + const bar = mount(t); + const at = Date.UTC(2026, 9, 30, 6, 15, 42); + await nextTick(); + assert.equal(bar(), null, 'live, nothing to play'); + worldClock.seek(at); + await nextTick(); + assert.equal(bar(), null, 'away from live, but nothing to play'); + const remove = supportingLayer(t, at); + await nextTick(); + assert.ok(bar()); + remove(); + await nextTick(); + assert.equal(bar(), null, 'the layer went off'); +}); + +test('the track runs over the time the layers show, to now, and scrubs', async (t) => { + const bar = mount(t); + const now = Date.now(); + const at = now - 2 * 86_400_000; + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + coverage: () => ({ start: at - 86_400_000, end: at, marks: [at - 86_400_000, at] }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const input = bar().querySelector('input[type=range]'); + assert.equal(Number(input.min), at - 86_400_000); + assert.ok(Number(input.max) >= now, 'reaches to now'); + assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 2); + + worldClock.play(); + input.value = String(at); + input.dispatchEvent(new Event('input')); + await nextTick(); + assert.equal(worldClock.isPlaying(), false, 'a scrub stops playback'); + assert.equal(worldClock.now(), at); + assert.equal(bar().querySelector('time').getAttribute('datetime'), new Date(at).toISOString()); + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); + await nextTick(); + assert.equal(worldClock.now(), at - 86_400_000, 'the arrow goes to the previous mark'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + await nextTick(); + assert.equal(worldClock.now(), at, 'and back to the next'); + + input.value = String(Date.now() + 2_000); + input.dispatchEvent(new Event('input')); + await nextTick(); + assert.equal(worldClock.isLive(), true, 'a scrub to now goes live'); + + // A layer that reaches ahead of now, as satellites do. + const holdAhead = worldClock.hold('test-ahead'); + const unregisterAhead = worldClock.register({ + id: 'test-ahead', + coverage: () => ({ start: now, end: now + 3 * 86_400_000 }), + }); + t.after(() => { + unregisterAhead(); + holdAhead(); + }); + await nextTick(); + const ahead = Date.now() + 86_400_000; + input.value = String(ahead); + input.dispatchEvent(new Event('input')); + await nextTick(); + 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 new file mode 100644 index 000000000..866380638 --- /dev/null +++ b/src/ui/views/panels/WorldTimeline.vue @@ -0,0 +1,355 @@ + + + diff --git a/src/voice/analystAccessors.test.mjs b/src/voice/analystAccessors.test.mjs index bd8ac611a..4d9abe453 100644 --- a/src/voice/analystAccessors.test.mjs +++ b/src/voice/analystAccessors.test.mjs @@ -74,9 +74,11 @@ test('bikeshare stations join coordinates from station information', () => { assert.deepEqual(methods.getAnalystRecords(), []); }); -test('launches carry time and hours until launch', () => { - const at = Date.now() + 5 * 3_600_000; +test('launches carry time and hours until launch at the world time', () => { + const now = Date.UTC(2026, 10, 1); + const at = now + 5 * 3_600_000; const state = { + now: () => now, _enabled: true, _launches: [ { diff --git a/style.css b/style.css index 30b7190fb..0537c30e1 100644 --- a/style.css +++ b/style.css @@ -33,6 +33,7 @@ @import './src/ui/styles/provider-settings.css'; @import './src/ui/styles/locale-picker.css'; @import './src/ui/styles/weather.css'; +@import './src/ui/styles/world-timeline.css'; @import './src/ui/styles/panel-surfaces.css'; @import './src/ui/styles/segmented-control.css'; @import './src/ui/styles/kit.css';