Compare commits

...
Author SHA1 Message Date
Sameh Khamis bc61a39738 Merge branch 'sameh/world-clock' into sameh/world-timeline-launches 2026-11-02 17:01:07 -08:00
Sameh Khamis 9ab54675ba Merge remote-tracking branch 'origin/main' into sameh/world-clock
# Conflicts:
#	scripts/package-boundaries.json
2026-11-02 17:01:02 -08:00
Sameh Khamis 3cecc8efbe Offer a minute a second as a play speed
Between real time and an hour a second there was nothing to watch a few
minutes of weather or a launch play out at. The speed menu now offers a
minute a second as its second choice.
2026-11-02 16:47:35 -08:00
Sameh Khamis 8eab6a76a8 Drag the timeline along, and play through it smoothly
The zoomed track now drags: the handle still drags the time, while the
track around it drags the view along (or the time, with all of it shown),
a tap goes to the time under it, two fingers pinch it, and a sideways swipe
or a shift-wheel drags it too, the same with a mouse, a trackpad or a
finger. A view dragged off the handle stays put until the time moves.

Play moved in steps: the world clock told of its time four times a second,
so the handle and the globe jumped, and play stopped at every weather frame
while it drew, loaded ahead or not. The clock now tells of its time each
animation frame, and play no longer waits on the frame it is at: that
frame loads as play goes on, and play holds only at the next frame while
the one before it still loads. Weather loads its next frame ahead while the
world plays too, tells its listeners of frames as they change rather than
every frame, and the sun and moon move on each minute of world time.
2026-11-02 16:13:27 -08:00
Sameh Khamis 4fc9722356 Show a spinner by the time while play waits on data
Play holds while a layer loads the frame for its time, and the bar's only
sign of it was a faint pulse, so a stopped handle read as a stall. The
bubble now shows a small spinner beside the time while play is held. It
comes on once a hold lasts long enough to notice and stays through a run of
slow frames, so neither a quick frame nor the gaps between frames flash it.
2026-11-02 15:11:47 -08:00
Sameh Khamis 287c881c17 Zoom the timeline track around the handle
With layers of very different reach on together, a month of launches and
a few hours of lightning, the short-range ticks were crushed at the end of
the one shared track. Scroll or pinch on the track now zooms it around the
handle, plus and minus zoom from the keyboard, and zero or a double-click
shows it all again. Everything still plays together; zooming only changes
what the track shows, and the view follows the handle as it plays, steps or
is left behind by now. A scrub's live edge goes by the stretch shown, so a
zoomed track can land minutes from now, and a double-click or pinch puts
back the time its first press moved.
2026-11-02 13:53:08 -08:00
Sameh Khamis 497016b084 Add the changelog entry for launches, speed and buffering on the timeline 2026-11-02 13:17:28 -08:00
Sameh Khamis 724109ee17 Merge branch 'sameh/world-clock' into sameh/world-timeline-launches 2026-11-02 13:17:08 -08:00
Sameh Khamis 09d833ed96 Add the changelog entry for the world clock and timeline 2026-11-02 13:17:06 -08:00
Sameh Khamis 0d96fe6acd Merge branch 'sameh/world-clock' into sameh/world-timeline-launches 2026-11-02 13:14:32 -08:00
Sameh Khamis 32e08f5043 Merge remote-tracking branch 'origin/main' into sameh/world-clock
# Conflicts:
#	index.html
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
2026-11-02 13:14:32 -08:00
Sameh Khamis 056b3b58c6 Wait for each frame while the world timeline plays, and quiet the bar
Play no longer runs ahead of the data. The world clock gains a wait, which
holds continuous play where it is while a layer loads what the time needs,
and a gate, which keeps play from passing a time a layer has yet to load.
Weather gates play at its next frame and waits there until the frame shows,
so every frame is seen however fast play runs; it waits no more than eight
seconds on a frame that never comes, and takes a wait only when its frames
change. Play pressed while a frame loads waits for it too. A scrub still
drops what loads and moves on.

The bar loses its long date and the dates at its ends: the time rides in a
bubble over the handle, which pulses while play waits, and the bar runs
left to right in every language. Play starts at real time.
2026-11-02 08:03:46 -08:00
Sameh Khamis d04e304502 Choose how fast the world timeline plays
A speed menu beside play offers real time, an hour, a day or a week a
second, a day a second to start with. A speed chosen while playing applies
at once; chosen while weather's own replay steps its frames, it takes
playback over from where it is. Play starts again from the top only at live
or at the very end, so a pause just before the end plays on. On phones the
menu sits beside the track, a full-size target.
2026-11-02 07:41:50 -08:00
Sameh Khamis e7973e81cf Stop zooming out while the globe is still in view
Cesium sets no limit, so zooming out left the globe a speck among the stars.
On the globe a hand now stops zooming out where the globe spans 30% of the
screen's height, which follows the screen's shape as the app's own
whole-globe view does. A camera already past it, after a flight or a turned
screen, stops there rather than jumping in at the next zoom. Flat maps keep
zooming out unlimited.
2026-11-02 07:27:53 -08:00
Sameh Khamis 0aa54dfa97 Bring launches up as the world time passes them, and play the whole track
Away from live, a launch shows from its lift-off on: its marker, label,
paths and orbit stay hidden while the world time is before it, so scrubbing
or playing brings the launches up in the order they flew. The selected
mission stays whenever it flies, and live shows every launch as before.

Play on the timeline runs the whole track in about a minute, from its start
when at live, and runs the clock itself past weather's own replay. It goes
live when it reaches now, or holds at an end that lies ahead of now. Going
live puts the rate back to 1.
2026-11-02 07:21:01 -08:00
Sameh Khamis 48093108b1 Make the world timeline a scrubber under the dock
The timeline shows whenever a layer with time to show is on, live or not.
Its track runs over the time those layers cover, reaching to now, with a
tick for each launch or frame and a handle to drag; arrows step between the
ticks, and a scrub to now goes live. The bar sits along the bottom of the
screen, and the dock and everything anchored above it rise by its height;
clean view, recording and the cockpit put it away with the dock.
2026-11-01 23:37:12 -08:00
Sameh Khamis 9faec3028e Show the world time on the HUD clock
The HUD's UTC and collection readouts follow the world clock with the sky,
so away from live they read the time the globe is drawn at.
2026-11-01 23:17:53 -08:00
Sameh Khamis 3534398771 Move satellites, launches and the sky with the world time
Layers whose data has a time of its own follow the page's world clock from
the start; live feeds such as flights, military, vessels and transit stay
live. Launches register the span of the launches loaded, so turning them on
brings up the timeline. Each timed layer registers its coverage once and
reports none while off, telling the clock after each step has settled. The
launch feed keeps its own window, and an orbit ring is drawn again once the
world time has moved far from when it was drawn.
2026-11-01 22:53:51 -08:00
Sameh Khamis 690a6637e2 Show the timeline only while a layer with time to show is on
The bar now needs more than the world clock being away from live: a layer
that moves with the clock must be on with time to show. In Core that is
weather radar, satellite or lightning with frames; a host adds layers by
following them. Turning the last one off hides the bar; turning it back
on brings it back.

The world clock reports this as `supported` in its state. Weather products
register their frames as coverage one by one, suspended or not, so the way
back to live stays on screen with reduced motion. Weather refreshes and
satellite catalog changes tell the clock their coverage may have changed.
A coverage that throws counts as none, and the clock reads its state once
per notification round.
2026-11-01 22:36:45 -08:00
Sameh Khamis 434fc9602d Show a basic timeline while the world time is away from live
A bar above the command dock, like a video player's: play and pause on
the left, the world time's date and time in UTC beside it, and Live to
return. It stays hidden while live, so Core looks as before until weather
history is scrubbed or replayed, a host moves the clock, or a link made
away from live is opened. Adds the pause and play_arrow glyphs and the
strings in every catalog.
2026-11-01 21:55:33 -08:00
Sameh Khamis 32ec6ed436 Share links carry the world time when made away from live
Field 28, worldTime, holds the world time of a link made away from live:
zigzag seconds since 2026-01-01, like field 8, but it may lie ahead of the
link's creation (a forecast moment). Opening such a link pauses the world
clock at that time; a link without it leaves the clock alone, so a page
opens live and Back or Forward does not stop a replay. Times no JS Date
can hold are dropped.
2026-11-01 21:50:09 -08:00
Sameh Khamis 0c4da3cdb2 Keep one world clock that every timed layer reads
Core told time eight ways: Date.now() in most layers, JulianDate.now() for
flights and military, Cesium's clock frozen at page load for the sky, the
weather clock for observed imagery. They now read one clock,
src/time/worldClock.js, exported as ./time.

Live is a mode, not a timestamp: while live the clock is the wall clock,
so every layer draws as before. Away from live only the layers that follow
the clock move with it. The weather products follow it, as they followed
the weather transport; a host opts others in with follow(id).

- The weather clock takes its time from the world clock and drives its
  replay, stepping its frames as before.
- Satellites, launches, flights and military (still 30 s and 15 s behind),
  transit, GNSS, Recent Imagery, street level, the HUD and the celestial
  ring read worldTimeFor(id). Cadences stay on the wall clock.
- Cesium's sky (stars, sun, moon, atmosphere, lighting) draws at the world
  time each frame instead of the moment the page loaded.
- Weather frames and satellites declare coverage() for a host's timeline.
2026-11-01 21:47:32 -08:00
91 changed files with 3747 additions and 144 deletions

No files matched your search

+5
View File
@@ -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).
@@ -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).
+5
View File
@@ -119,6 +119,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 | | 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus | | 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs | | 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, 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. 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 `hudVariant` code 1 (`operator`) is retired: the app no longer offers that
layout and restores it as `tactical`. The code stays reserved. 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 Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). `celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
+1 -1
View File
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source 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 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. --> a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,play_arrow,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
</head> </head>
<body> <body>
<div class="gev-view" data-gev-view="scene-chrome"></div> <div class="gev-view" data-gev-view="scene-chrome"></div>
+1
View File
@@ -89,6 +89,7 @@
"./infrastructure/lod": "./src/data/localGeojsonLod.js", "./infrastructure/lod": "./src/data/localGeojsonLod.js",
"./application": "./src/app/application.js", "./application": "./src/app/application.js",
"./application/viewer": "./src/app/viewer.js", "./application/viewer": "./src/app/viewer.js",
"./time": "./src/time/worldClock.js",
"./build/vite": { "./build/vite": {
"node": "./build/vite.js" "node": "./build/vite.js"
}, },
+37 -11
View File
@@ -149,10 +149,19 @@
"src/ui/views/panels/GlobeCompass.vue", "src/ui/views/panels/GlobeCompass.vue",
"src/ui/stores/recording.js", "src/ui/stores/recording.js",
"src/ui/styleParameters.js", "src/ui/styleParameters.js",
"src/ui/i18n.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"
], ],
"external": ["vue", "pinia", "vue-i18n"] "external": ["vue", "pinia", "vue-i18n"]
}, },
"time": {
"exports": ["./time"],
"modules": ["src/time/worldClock.js"],
"external": []
},
"viewer": { "viewer": {
"exports": ["./application/viewer"], "exports": ["./application/viewer"],
"modules": [ "modules": [
@@ -1123,7 +1132,8 @@
"src/ui/kit/UiBadge.vue", "src/ui/kit/UiBadge.vue",
"src/ui/stores/globeActions.js", "src/ui/stores/globeActions.js",
"src/ui/stores/recording.js", "src/ui/stores/recording.js",
"src/ui/i18n.js" "src/ui/i18n.js",
"src/time/worldClock.js"
], ],
"external": ["cesium", "vue", "pinia", "vue-i18n"] "external": ["cesium", "vue", "pinia", "vue-i18n"]
}, },
@@ -1334,7 +1344,9 @@
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.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"] "external": ["cesium"]
}, },
@@ -1390,7 +1402,9 @@
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.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"] "external": ["cesium"]
}, },
@@ -1575,7 +1589,8 @@
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js",
"src/time/worldClock.js"
], ],
"external": ["cesium", "satellite.js"] "external": ["cesium", "satellite.js"]
}, },
@@ -1615,7 +1630,8 @@
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.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"] "external": ["cesium"]
}, },
@@ -1935,7 +1951,8 @@
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.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"] "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
}, },
@@ -3175,6 +3192,9 @@
"src/ui/stores/globeActions.js", "src/ui/stores/globeActions.js",
"src/ui/stores/recording.js", "src/ui/stores/recording.js",
"src/ui/i18n.js", "src/ui/i18n.js",
"src/time/viewerClock.js",
"src/time/worldClock.js",
"src/time/julian.js",
"src/annotations/outlineRungs.js", "src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js", "src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js", "src/sources/nominatimOutlines.js",
@@ -3782,6 +3802,8 @@
"src/ui/stores/bhoteKoshiPanel.js", "src/ui/stores/bhoteKoshiPanel.js",
"src/hudReadouts.js", "src/hudReadouts.js",
"src/ui/i18n.js", "src/ui/i18n.js",
"src/time/worldClock.js",
"src/time/julian.js",
"src/annotations/outlineRungs.js", "src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js", "src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js", "src/sources/nominatimOutlines.js",
@@ -3941,7 +3963,8 @@
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json",
"src/time/worldClock.js"
], ],
"external": [] "external": []
}, },
@@ -4460,7 +4483,8 @@
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.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", "egm96-universal", "pbf"] "external": ["cesium", "egm96-universal", "pbf"]
}, },
@@ -4512,7 +4536,8 @@
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.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"] "external": ["cesium", "pbf"]
}, },
@@ -4632,7 +4657,8 @@
"src/data/detectionPolicy.js", "src/data/detectionPolicy.js",
"src/scopeMaskPolicy.js", "src/scopeMaskPolicy.js",
"src/i18n/marker.js", "src/i18n/marker.js",
"src/visualStylePolicy.js" "src/visualStylePolicy.js",
"src/time/worldClock.js"
], ],
"external": [] "external": []
}, },
+2 -1
View File
@@ -1,5 +1,6 @@
import { resolveCatalogSources } from './sourceComposition.js'; import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js'; import { createWeatherClock } from '../layers/weather/clock.js';
import { worldClock } from '../time/worldClock.js';
import { createWeatherLayer } from '../layers/weather/index.js'; import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js'; import { createCyclonesLayer } from '../layers/cyclones/index.js';
import { createWindLayer } from '../layers/wind/index.js'; import { createWindLayer } from '../layers/wind/index.js';
@@ -72,7 +73,7 @@ export function createApplicationCatalog({
const sourceComposition = resolveCatalogSources(sources); const sourceComposition = resolveCatalogSources(sources);
sources = sourceComposition.sources; sources = sourceComposition.sources;
const militaryRegistry = createMilitaryRegistry(); const militaryRegistry = createMilitaryRegistry();
const weatherClock = createWeatherClock(); const weatherClock = createWeatherClock({ world: worldClock });
const dispose = () => { const dispose = () => {
signal.removeEventListener('abort', dispose); signal.removeEventListener('abort', dispose);
militaryRegistry.dispose(); militaryRegistry.dispose();
+5 -1
View File
@@ -1,6 +1,10 @@
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js'; 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. */ /** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
export function createApplicationGnssIntegrity(options) { export function createApplicationGnssIntegrity(options) {
return createGnssIntegrityLayer(options); return createGnssIntegrityLayer({
worldNow: worldTimeFor('gnss-integrity'),
...options,
});
} }
+4
View File
@@ -2,6 +2,9 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js'; import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js'; import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.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. */ /** Construct imagery presentation over one explicitly supplied acquisition source. */
export function createApplicationRecentImagery({ source }) { export function createApplicationRecentImagery({ source }) {
@@ -15,5 +18,6 @@ export function createApplicationRecentImagery({ source }) {
thumbnails: createThumbnailLoader({ thumbnails: createThumbnailLoader({
requestThumbnail: (...args) => source.getThumbnail(...args), requestThumbnail: (...args) => source.getThumbnail(...args),
}), }),
now: () => new Date(imageryTime()),
}); });
} }
+8
View File
@@ -14,6 +14,8 @@ import {
governorRequestRender, governorRequestRender,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { describeError } from './errors.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'; import { onLocaleChange, t } from '../i18n/index.js';
/** Construct the application globe using the caller's local configuration. */ /** Construct the application globe using the caller's local configuration. */
@@ -58,6 +60,12 @@ export async function createApplicationScene({
if (!viewer.isDestroyed()) viewer.destroy(); if (!viewer.isDestroyed()) viewer.destroy();
}); });
defer(installTrackpadPinchZoom(viewer)); defer(installTrackpadPinchZoom(viewer));
defer(
followWorldTime(viewer, worldTimeFor('sky'), {
subscribe: worldClock.subscribe,
requestRender: () => governorRequestRender('world-time'),
}),
);
registerDataCredits(viewer, credits); registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document); configureCreditKeyboardAccess(document);
defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
+43
View File
@@ -2,6 +2,48 @@ import * as Cesium from 'cesium';
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js'; import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
import { StarrySky } from '../sky/starrySky.js'; import { StarrySky } from '../sky/starrySky.js';
/**
* At the farthest a hand can zoom out, the globe is this share of the
* screen's height across. Cesium sets no limit, so zooming on left the globe
* a speck among the stars.
*/
const MIN_GLOBE_SHARE = 0.3;
/**
* The farthest a hand can zoom out, in metres above the ellipsoid, for a
* vertical field of view. It follows the screen's shape, as the app's own
* whole-globe view does, so that view always lies within it.
* @param {number} fovy Vertical field of view, radians.
*/
export function zoomOutLimit(fovy) {
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
const angular = Math.atan(MIN_GLOBE_SHARE * Math.tan(fovy / 2));
return radius / Math.sin(angular) - radius;
}
/**
* Hold a hand's zoom out to `zoomOutLimit` on the globe, each frame, since the
* field of view follows the screen's shape. The limit never sits below the
* camera: one already past it (a flight, a screen turned) stops there rather
* than jumping in at the next zoom. Flat maps keep Cesium's own, unlimited.
* @returns {() => void} Stop limiting.
*/
export function limitZoomOut(viewer) {
const { scene, camera } = viewer;
return scene.preRender.addEventListener(() => {
const controller = scene.screenSpaceCameraController;
const fovy = camera.frustum.fovy;
if (scene.mode !== Cesium.SceneMode.SCENE3D || !Number.isFinite(fovy)) {
controller.maximumZoomDistance = Number.POSITIVE_INFINITY;
return;
}
controller.maximumZoomDistance = Math.max(
zoomOutLimit(fovy),
camera.positionCartographic?.height ?? 0,
);
});
}
const PINCH_ZOOM_MULTIPLIER = 8; const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120; const MAX_PINCH_PIXEL_DELTA = 120;
@@ -142,6 +184,7 @@ export function createApplicationViewer({ container, creditContainer }) {
// required on touch-first screens. Mouse/trackpad sensitivity stays native. // required on touch-first screens. Mouse/trackpad sensitivity stays native.
if (globalThis.matchMedia?.('(pointer: coarse)').matches) if (globalThis.matchMedia?.('(pointer: coarse)').matches)
viewer.scene.screenSpaceCameraController.zoomFactor *= 3; viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
limitZoomOut(viewer);
// Before any tile builds a draw command: Cesium's per-vertex model // Before any tile builds a draw command: Cesium's per-vertex model
// atmosphere fails to LINK on Apple's Metal backend and kills the // atmosphere fails to LINK on Apple's Metal backend and kills the
// render loop. See app/atmosphereCompat.js. // render loop. See app/atmosphereCompat.js.
+73
View File
@@ -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);
});
+25 -1
View File
@@ -1,5 +1,9 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { governorRequestRender } from './renderGovernor.js'; 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. */ /** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
export const KEYHOLE_OUTER_RADIUS = 1.05; 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; export const KEYHOLE_OUTSIDE_OPACITY_DEFAULT = 0.01;
// How far the world time moves before the sun and moon move with it: a
// minute moves the sun a quarter of a degree.
const EPHEMERIS_WORLD_STEP_MS = 60_000;
const RING_INSET_PX = 11; const RING_INSET_PX = 11;
const MARKER_INSET_PX = 36; const MARKER_INSET_PX = 36;
const COLLIDING_MARKER_EXTRA_INSET_PX = 32; const COLLIDING_MARKER_EXTRA_INSET_PX = 32;
@@ -445,6 +452,20 @@ export class CelestialRing {
if (typeof document !== 'undefined' && document.hidden) return; if (typeof document !== 'undefined' && document.hidden) return;
governorRequestRender('celestial-ephemeris'); governorRequestRender('celestial-ephemeris');
}, 60_000); }, 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); this.setEnabled(this.enabled);
} }
@@ -801,7 +822,8 @@ export class CelestialRing {
return; return;
} }
const time = Cesium.JulianDate.now(); this._skyDate.setTime(skyTime());
const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime);
if (!this._updateEphemeris(time)) return; if (!this._updateEphemeris(time)) return;
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) }); this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
const camera = this.viewer.camera; const camera = this.viewer.camera;
@@ -890,6 +912,8 @@ export class CelestialRing {
clearInterval(this._ephemerisTimer); clearInterval(this._ephemerisTimer);
this._ephemerisTimer = null; this._ephemerisTimer = null;
} }
this._unsubscribeWorldTime?.();
this._unsubscribeWorldTime = null;
if (this._removePostRender) this._removePostRender(); if (this._removePostRender) this._removePostRender();
this._removePostRender = null; this._removePostRender = null;
if (this._shown()) { if (this._shown()) {
+31 -7
View File
@@ -229,13 +229,30 @@ const GUARDED_PROPS = new Set([
// Custom properties allowed inside a modelled offset. Each is a pinned-tray // 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 // 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([ const VETTED_VARS = new Set([
'--dock-pinned-stack-height', '--dock-pinned-stack-height',
'--dock-lower-pinned-height', '--dock-lower-pinned-height',
'--dock-location-pinned-height', '--dock-location-pinned-height',
'--dock-presets-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). */ /** Rules whose final compound targets a modelled element (pseudo-elements aside). */
function ownBoxEntries() { function ownBoxEntries() {
@@ -315,6 +332,7 @@ function toPx(value, viewportHeight, where) {
if (term.startsWith('var(')) { if (term.startsWith('var(')) {
const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim(); const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim();
assert.ok(VETTED_VARS.has(name), `unvetted custom property ${name} in ${where}: "${value}"`); 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 continue; // proven non-negative; 0 is the conservative floor
} }
assert.match(term, /^-?[\d.]+(px|rem|vh)$/, `unmodelled length term "${term}" in ${where}: "${value}"`); 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'); assert.ok(declared.length >= VETTED_VARS.size, 'every vetted custom property needs a CSS default');
for (const decl of declared) { for (const decl of declared) {
assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`); 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 layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
const start = layout.indexOf('_updateCommandDockTrayStack() {'); 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 // 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. // 8px at 720px, while the credit keeps its 2vh base throughout.
const failures = []; const failures = [];
eachLift((lift) => {
for (const scenario of TRAY_SCENARIOS) { for (const scenario of TRAY_SCENARIOS) {
for (const width of WIDTHS.filter((w) => w <= 900)) { for (const width of WIDTHS.filter((w) => w <= 900)) {
for (const height of HEIGHTS) { for (const height of HEIGHTS) {
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height); const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) { if (clearance < MIN_CLEARANCE_PX) {
failures.push(`${scenario.name} @ ${width}x${height}: ${clearance.toFixed(1)}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`); 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', 'none',
`at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`, `at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`,
); );
eachLift((lift) => {
for (const height of HEIGHTS) { for (const height of HEIGHTS) {
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail'); const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height); 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`); 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(', ')}`); 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', () => { test('the dock anchor changes at 720px — the 2vh cancellation is band-limited', () => {
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, '2vh'); const lift = 'var(--world-timeline-lift, 0px)';
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, '8px'); 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( assert.equal(
resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value, 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', 'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard',
); );
}); });
+14
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { gstime } from 'satellite.js'; import { gstime } from 'satellite.js';
import { worldClock } from '../time/worldClock.js';
import { createModel } from '../layers/launches/model.js'; import { createModel } from '../layers/launches/model.js';
import rocketLaunchesLayer, { import rocketLaunchesLayer, {
_setRocketMissionOverlayHostForTest, _setRocketMissionOverlayHostForTest,
@@ -1240,7 +1241,11 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
rocketLaunchesLayer.init(viewer); rocketLaunchesLayer.init(viewer);
initialized = true; initialized = true;
await rocketLaunchesLayer.enable(); await rocketLaunchesLayer.enable();
assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show');
await rocketLaunchesLayer.update(); 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; const entities = dataSources[0].entities.values;
assert.ok(entities.length >= 7, 'runtime guard requires the populated mission build path'); 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); assert.equal(deselectedPublication[2][0].protected, false);
await rocketLaunchesLayer.disable(); await rocketLaunchesLayer.disable();
assert.equal(worldClock.coverage(), null, 'off, launches show no time');
assert.deepEqual(hostCalls.slice(-4), [ assert.deepEqual(hostCalls.slice(-4), [
['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID], ['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID],
['visible', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false], ['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); const [launch] = normalizeRocketLaunches([{ id: 'missing', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z' }], NOW);
assert.deepEqual(launch.payloads, []); 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']);
});
+9 -4
View File
@@ -23,6 +23,11 @@ import * as Cesium from 'cesium';
import { greatCircleKm } from './geoDistance.js'; import { greatCircleKm } from './geoDistance.js';
import { forward as toMGRS } from 'mgrs'; import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './cityPois.js'; 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 { composeLocalityTag } from './hudLocality.js';
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js'; import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
import { import {
@@ -393,11 +398,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. * @returns {string} Timestamp in `YYYY-MM-DD HH:MM:SSZ` format.
*/ */
_formatUTC() { _formatUTC() {
const now = new Date(); const now = new Date(hudTime());
const y = now.getUTCFullYear(); const y = now.getUTCFullYear();
const mo = String(now.getUTCMonth() + 1).padStart(2, '0'); const mo = String(now.getUTCMonth() + 1).padStart(2, '0');
const d = String(now.getUTCDate()).padStart(2, '0'); const d = String(now.getUTCDate()).padStart(2, '0');
@@ -500,7 +505,7 @@ export class IntelHUD {
readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`; readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
// Collection timestamp // Collection timestamp
const now = new Date(); const now = new Date(hudTime());
const h = String(now.getUTCHours()).padStart(2, '0'); const h = String(now.getUTCHours()).padStart(2, '0');
const m = String(now.getUTCMinutes()).padStart(2, '0'); const m = String(now.getUTCMinutes()).padStart(2, '0');
const s = String(now.getUTCSeconds()).padStart(2, '0'); const s = String(now.getUTCSeconds()).padStart(2, '0');
@@ -607,7 +612,7 @@ export class IntelHUD {
* @returns {number} Estimated sun elevation in degrees (negative = below horizon). * @returns {number} Estimated sun elevation in degrees (negative = below horizon).
*/ */
_estimateSunElevation(lat, lon) { _estimateSunElevation(lat, lon) {
const now = new Date(); const now = new Date(skyTime());
// Approximate local solar time by shifting UTC hours by longitude offset // Approximate local solar time by shifting UTC hours by longitude offset
const hours = now.getUTCHours() + now.getUTCMinutes() / 60 + lon / 15; const hours = now.getUTCHours() + now.getUTCMinutes() / 60 + lon / 15;
const solarNoon = 12; const solarNoon = 12;
+10 -1
View File
@@ -3223,5 +3223,14 @@
"Audio": "الصوت", "Audio": "الصوت",
"UTILITIES": "الأدوات", "UTILITIES": "الأدوات",
"Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم", "Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم",
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة" "Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
"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": "دقيقة/ث"
} }
+10 -1
View File
@@ -2943,5 +2943,14 @@
"Audio": "Audio", "Audio": "Audio",
"UTILITIES": "WERKZEUGE", "UTILITIES": "WERKZEUGE",
"Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen", "Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen",
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien" "Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien",
"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"
} }
+10 -1
View File
@@ -3013,5 +3013,14 @@
"Right panels": "Paneles derechos", "Right panels": "Paneles derechos",
"UTILITIES": "UTILIDADES", "UTILITIES": "UTILIDADES",
"Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo", "Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo",
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos" "Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos",
"World time": "Hora mundial",
"Back to live": "Volver al directo",
"Live": "En directo",
"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"
} }
+10 -1
View File
@@ -3013,5 +3013,14 @@
"Audio": "Audio", "Audio": "Audio",
"UTILITIES": "OUTILS", "UTILITIES": "OUTILS",
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin", "Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers" "Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
"World time": "Heure du monde",
"Back to live": "Revenir au direct",
"Live": "Direct",
"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"
} }
+10 -1
View File
@@ -3013,5 +3013,14 @@
"Audio": "Audio", "Audio": "Audio",
"UTILITIES": "STRUMENTI", "UTILITIES": "STRUMENTI",
"Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno", "Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno",
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file" "Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file",
"World time": "Ora del mondo",
"Back to live": "Torna alla diretta",
"Live": "Diretta",
"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"
} }
+10 -1
View File
@@ -2873,5 +2873,14 @@
"Audio": "オーディオ", "Audio": "オーディオ",
"UTILITIES": "ユーティリティ", "UTILITIES": "ユーティリティ",
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください", "Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル" "Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
"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分/秒"
} }
+10 -1
View File
@@ -3013,5 +3013,14 @@
"Audio": "Áudio", "Audio": "Áudio",
"UTILITIES": "UTILITÁRIOS", "UTILITIES": "UTILITÁRIOS",
"Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho", "Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho",
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos" "Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos",
"World time": "Hora do mundo",
"Back to live": "Voltar ao vivo",
"Live": "Ao vivo",
"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"
} }
+10 -1
View File
@@ -3083,5 +3083,14 @@
"Right panels": "Правые панели", "Right panels": "Правые панели",
"UTILITIES": "ИНСТРУМЕНТЫ", "UTILITIES": "ИНСТРУМЕНТЫ",
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку", "Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку",
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы" "Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
"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 мин/с"
} }
+10 -1
View File
@@ -3083,5 +3083,14 @@
"Audio": "Аудіо", "Audio": "Аудіо",
"UTILITIES": "ІНСТРУМЕНТИ", "UTILITIES": "ІНСТРУМЕНТИ",
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка", "Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка",
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли" "Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
"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 хв/с"
} }
+10 -1
View File
@@ -2873,5 +2873,14 @@
"Right panels": "右侧面板", "Right panels": "右侧面板",
"UTILITIES": "工具", "UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置", "Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置",
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件" "Directions, drawing and your own files": "路线导航、绘制和你自己的文件",
"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 分钟/秒"
} }
+10 -1
View File
@@ -2873,5 +2873,14 @@
"Right panels": "右側面板", "Right panels": "右側面板",
"UTILITIES": "工具", "UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置", "Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件" "Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
"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 分鐘/秒"
} }
+5
View File
@@ -89,6 +89,7 @@
"Landsat 8/9", "Landsat 8/9",
"Latin", "Latin",
"Layout", "Layout",
"Live",
"London", "London",
"Los Angeles", "Los Angeles",
"MIL", "MIL",
@@ -202,6 +203,7 @@
"▲ {frp} MW" "▲ {frp} MW"
], ],
"es": [ "es": [
"1 min/s",
"1090 MHz", "1090 MHz",
"978 MHz UAT", "978 MHz UAT",
"AIS: --", "AIS: --",
@@ -330,6 +332,7 @@
"▲ {frp} MW" "▲ {frp} MW"
], ],
"fr": [ "fr": [
"1 min/s",
"1090 MHz", "1090 MHz",
"978 MHz UAT", "978 MHz UAT",
"ALTITUDE", "ALTITUDE",
@@ -511,6 +514,7 @@
"▲ {frp} MW" "▲ {frp} MW"
], ],
"it": [ "it": [
"1 min/s",
"1090 MHz", "1090 MHz",
"978 MHz UAT", "978 MHz UAT",
"AFRICA", "AFRICA",
@@ -731,6 +735,7 @@
"▲ {frp} MW" "▲ {frp} MW"
], ],
"pt": [ "pt": [
"1 min/s",
"1090 MHz", "1090 MHz",
"978 MHz UAT", "978 MHz UAT",
"AIS: --", "AIS: --",
+3 -2
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { import {
COURSE_HOLD_SPEED_MPS, COURSE_HOLD_SPEED_MPS,
lerpAngleDeg, lerpAngleDeg,
@@ -156,7 +157,7 @@ export function createMotion({
const renderTime = const renderTime =
sampledRenderTime ?? sampledRenderTime ??
Cesium.JulianDate.addSeconds( Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(flightState._scratchRenderTime), julianAt(flightState.now(), flightState._scratchRenderTime),
-RENDER_DELAY_SEC, -RENDER_DELAY_SEC,
flightState._scratchRenderTime, flightState._scratchRenderTime,
); );
@@ -332,7 +333,7 @@ export function createMotion({
const history = flightState._positionHistory.get(flightState._trackedIcao); const history = flightState._positionHistory.get(flightState._trackedIcao);
if (!history || history.length === 0) return true; if (!history || history.length === 0) return true;
const renderTime = Cesium.JulianDate.addSeconds( const renderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(), julianAt(flightState.now()),
-RENDER_DELAY_SEC, -RENDER_DELAY_SEC,
flightState._scratchWarmupTime, flightState._scratchWarmupTime,
); );
+8 -6
View File
@@ -12,6 +12,7 @@ function fixture() {
_scratchArc: {}, _scratchArc: {},
_scratchOffset: new Cesium.Cartesian3(), _scratchOffset: new Cesium.Cartesian3(),
_scratchRenderTime: new Cesium.JulianDate(), _scratchRenderTime: new Cesium.JulianDate(),
now: Date.now,
}; };
const motion = createMotion({ const motion = createMotion({
flightState, 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)); 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 { flightState, motion } = fixture();
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z'); const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
const history = [ 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.records.data.set('tracked', { velocity: 100, true_track: 90 });
flightState._positionHistory.set('tracked', history); flightState._positionHistory.set('tracked', history);
let elapsed = 60; let elapsed = 60;
const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => { let reads = 0;
assert.equal(result, flightState._scratchRenderTime); flightState.now = () => {
return Cesium.JulianDate.addSeconds(start, elapsed, result); reads += 1;
}); return Cesium.JulianDate.toDate(start).getTime() + elapsed * 1000;
};
const out = new Cesium.Cartesian3(); const out = new Cesium.Cartesian3();
close( close(
motion._deadReckon('tracked', out), motion._deadReckon('tracked', out),
@@ -180,5 +182,5 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch',
new Cesium.Cartesian3(), new Cesium.Cartesian3(),
), ),
); );
assert.equal(spy.mock.callCount(), 2); assert.equal(reads, 2);
}); });
+2 -1
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { selectModelEligible } from '../../data/modelEligibility.js'; import { selectModelEligible } from '../../data/modelEligibility.js';
import { civilAircraftModelSpec } from './modelSpec.js'; import { civilAircraftModelSpec } from './modelSpec.js';
@@ -863,7 +864,7 @@ export function createRendering({
flightState._lastFleetTickMs = nowMs; flightState._lastFleetTickMs = nowMs;
// Every contact in this pass shares the same delayed display instant. // Every contact in this pass shares the same delayed display instant.
const renderTime = Cesium.JulianDate.addSeconds( const renderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(flightState._scratchFleetRenderTime), julianAt(flightState.now(), flightState._scratchFleetRenderTime),
-RENDER_DELAY_SEC, -RENDER_DELAY_SEC,
flightState._scratchFleetRenderTime, flightState._scratchFleetRenderTime,
); );
+3
View File
@@ -1,6 +1,7 @@
import { FlightRecords } from './records.js'; import { FlightRecords } from './records.js';
import { createFlightFeed } from './ingestion.js'; import { createFlightFeed } from './ingestion.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { worldTimeFor } from '../../time/worldClock.js';
import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js'; import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js';
export function createFlightState({ source, services }) { export function createFlightState({ source, services }) {
@@ -12,6 +13,8 @@ export function createFlightState({ source, services }) {
...services.groundFloor, ...services.groundFloor,
}); });
flightState._cullPositions = new Map(); 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 /** 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 * vertex-baked to real-world METERS in the airplane.glb axis convention, so
+2 -1
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { formatNumber, t } from '../../i18n/index.js'; import { formatNumber, t } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js'; import { unitLabel } from '../../ui/unitLabels.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.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 // would draw the trail in front of the plane. They join the trail via _appendTrailFix
// as they age past the delay. // as they age past the delay.
const seedRenderTime = Cesium.JulianDate.addSeconds( const seedRenderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(), julianAt(flightState.now()),
-RENDER_DELAY_SEC, -RENDER_DELAY_SEC,
flightState._scratchWarmupTime, flightState._scratchWarmupTime,
); );
+3 -1
View File
@@ -69,6 +69,8 @@ export function createGnssIntegrityLayer({
source, source,
viewAnchor = defaultGnssViewAnchor, viewAnchor = defaultGnssViewAnchor,
now = () => Date.now(), now = () => Date.now(),
// The end of the window shown; retries and receipt times stay on `now`.
worldNow = now,
monotonicNow = () => performance.now(), monotonicNow = () => performance.now(),
windowMs: requestedWindowMs, windowMs: requestedWindowMs,
} = {}) { } = {}) {
@@ -163,7 +165,7 @@ export function createGnssIntegrityLayer({
/** Age the store without new evidence, so expired cells leave the globe. */ /** Age the store without new evidence, so expired cells leave the globe. */
function expire() { function expire() {
pruneGnssObservations(_observations, now(), { windowMs }); pruneGnssObservations(_observations, worldNow(), { windowMs });
_cells = binGnssCells(_observations); _cells = binGnssCells(_observations);
render(); render();
} }
+1 -1
View File
@@ -26,7 +26,7 @@ export function createControls({ state: layerState, services, parts, source }) {
*/ */
getAnalystRecords(maxCount = Infinity) { getAnalystRecords(maxCount = Infinity) {
if (!layerState._enabled) return []; if (!layerState._enabled) return [];
const now = Date.now(); const now = layerState.now();
const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null); const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
const result = []; const result = [];
for (const launch of layerState._launches || []) { for (const launch of layerState._launches || []) {
+1
View File
@@ -222,6 +222,7 @@ export function createIngestion({
layerState._lastUpdate = Date.now(); layerState._lastUpdate = Date.now();
layerState._lastError = null; layerState._lastError = null;
layerState._stale = stale === true; layerState._stale = stale === true;
layerState.coverageChanged();
} catch (error) { } catch (error) {
if (layerState._enabled && token === layerState._lifecycleToken) { if (layerState._enabled && token === layerState._lifecycleToken) {
layerState._lastError = error.message; layerState._lastError = error.message;
+1 -1
View File
@@ -105,7 +105,7 @@ export function createLaunchPad({
} }
const nowMs = Date.now(); const nowMs = Date.now();
if (nowMs - layerState._lastMissionRingRotationMs >= 1000) { if (nowMs - layerState._lastMissionRingRotationMs >= 1000) {
layerState._missionRingDate.setTime(nowMs); layerState._missionRingDate.setTime(layerState.orbitNow());
parts.orbitRendering.updateMissionOrbitPrimitiveFrames( parts.orbitRendering.updateMissionOrbitPrimitiveFrames(
layerState._missionRingDate, layerState._missionRingDate,
); );
+18
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js'; import { isPointerFree } from '../../data/inputOwnership.js';
import { worldClock } from '../../time/worldClock.js';
const HOVER_PICK_INTERVAL_MS = 120; const HOVER_PICK_INTERVAL_MS = 120;
@@ -119,6 +120,21 @@ export function createLifecycle({
}) { }) {
const { holdContinuousRender, releaseContinuousRender } = services.render; const { holdContinuousRender, releaseContinuousRender } = services.render;
let hover = null; 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 = { const methods = {
init(viewer) { init(viewer) {
@@ -194,6 +210,7 @@ export function createLifecycle({
} }
throw error; throw error;
} }
layerState.coverageChanged();
}, },
async _enableBody() { async _enableBody() {
@@ -231,6 +248,7 @@ export function createLifecycle({
parts.overlays.clearMissionOverlaySources(); parts.overlays.clearMissionOverlaySources();
await parts.ingestion.restoreSatelliteDependency(); await parts.ingestion.restoreSatelliteDependency();
parts.panel.renderMissionPanel(); parts.panel.renderMissionPanel();
layerState.coverageChanged();
}, },
async destroy(viewer) { async destroy(viewer) {
+4 -1
View File
@@ -67,6 +67,9 @@ export function createOrbitRendering({
return Boolean( return Boolean(
layerState._enabled && layerState._enabled &&
layerState._dataSource?.show && layerState._dataSource?.show &&
!layerState.launchHidden(
layerState._missionOverlayRecords.get(launchId)?.launch,
) &&
(!layerState._selectedLaunchId || (!layerState._selectedLaunchId ||
layerState._selectedLaunchId === launchId), layerState._selectedLaunchId === launchId),
); );
@@ -124,7 +127,7 @@ export function createOrbitRendering({
collection.show = missionOrbitPrimitiveVisible(launch.id); collection.show = missionOrbitPrimitiveVisible(launch.id);
orbitFrameModelMatrix( orbitFrameModelMatrix(
satelliteTrack.gmstAtBake, satelliteTrack.gmstAtBake,
new Date(), new Date(layerState.orbitNow()),
collection.modelMatrix, collection.modelMatrix,
); );
layerState._viewer.scene.primitives.add(collection); layerState._viewer.scene.primitives.add(collection);
+4 -1
View File
@@ -411,8 +411,11 @@ export function createOverlays({ state: layerState, services, parts, source }) {
false, false,
); );
layerState._selectedMissionOverlayTimeText = null; layerState._selectedMissionOverlayTimeText = null;
const records = [...layerState._missionOverlayRecords.values()].filter(
(record) => !layerState.launchHidden(record.launch),
);
const entries = selectRocketMissionMarkerOverlayCohort( const entries = selectRocketMissionMarkerOverlayCohort(
Array.from(layerState._missionOverlayRecords.values(), (record) => { records.map((record) => {
const entry = createRocketMissionMarkerOverlayEntry( const entry = createRocketMissionMarkerOverlayEntry(
record.launch, record.launch,
record.anchorPosition, record.anchorPosition,
+32 -5
View File
@@ -22,6 +22,25 @@ export function createRendering({
if (current !== next) graphic.show = next; if (current !== next) graphic.show = next;
} }
// Away from live, a launch shows from its lift-off on, so scrubbing the
// world time forward brings the launches up in the order they flew. The
// selected mission stays, whenever it flies.
function syncPendingLaunches() {
const pending = new Set();
for (const { launch } of layerState._missionOverlayRecords.values()) {
if (layerState.launchHidden(launch)) pending.add(launch.id);
}
const previous = layerState._pendingLaunchIds;
if (
pending.size === previous.size &&
[...pending].every((id) => previous.has(id))
)
return;
layerState._pendingLaunchIds = pending;
parts.overlays.syncMissionOverlayEntries();
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
}
function updateMissionFrame() { function updateMissionFrame() {
if ( if (
!layerState._enabled || !layerState._enabled ||
@@ -31,6 +50,7 @@ export function createRendering({
parts.overlays.hideReplayVehicleOverlay(); parts.overlays.hideReplayVehicleOverlay();
return; return;
} }
syncPendingLaunches();
parts.panel.updateMissionTelemetry(); parts.panel.updateMissionTelemetry();
const time = Cesium.JulianDate.now(layerState._declutterTime); const time = Cesium.JulianDate.now(layerState._declutterTime);
layerState._declutterOccluder.cameraPosition = layerState._declutterOccluder.cameraPosition =
@@ -39,16 +59,23 @@ export function createRendering({
parts.overlays.refreshSelectedMissionOverlayText(); parts.overlays.refreshSelectedMissionOverlayText();
for (const entity of layerState._dataSource.entities.values) { for (const entity of layerState._dataSource.entities.values) {
if (!entity.show) continue; if (!entity.show) continue;
if (!entity.position) continue;
const id = parts.selection.entityLaunchId(entity); const id = parts.selection.entityLaunchId(entity);
if (!id) continue; if (!id) continue;
const pending = layerState._pendingLaunchIds.has(id);
if (entity.polyline)
setGraphicVisibility(entity.polyline, !pending, time);
if (!entity.position) continue;
const position = entity.position.getValue(time); const position = entity.position.getValue(time);
let horizonVisible = Boolean( let horizonVisible =
!pending &&
Boolean(
position && layerState._declutterOccluder.isPointVisible(position), position && layerState._declutterOccluder.isPointVisible(position),
); );
const launchAnchor = entity.id.startsWith('rocket-launch:'); const launchAnchor = entity.id.startsWith('rocket-launch:');
if (launchAnchor) { if (launchAnchor) {
horizonVisible = parts.overlays.missionAnchorVisible( horizonVisible =
!pending &&
parts.overlays.missionAnchorVisible(
layerState._viewer.scene.camera.positionWC, layerState._viewer.scene.camera.positionWC,
position, position,
id, id,
@@ -394,7 +421,7 @@ export function createRendering({
orbitCurrent.latitude, orbitCurrent.latitude,
orbitCurrent.altitude, orbitCurrent.altitude,
); );
const liveTime = new Date(); const liveTime = new Date(layerState.orbitNow());
const liveTelemetry = { speedMps: null }; const liveTelemetry = { speedMps: null };
layerState._satelliteTelemetry.set(launch.id, liveTelemetry); layerState._satelliteTelemetry.set(launch.id, liveTelemetry);
let liveFrameNumber = -1; let liveFrameNumber = -1;
@@ -405,7 +432,7 @@ export function createRendering({
layerState._viewer?.scene?.frameState?.frameNumber ?? -1; layerState._viewer?.scene?.frameState?.frameNumber ?? -1;
if (frameNumber !== -1 && frameNumber === liveFrameNumber) return; if (frameNumber !== -1 && frameNumber === liveFrameNumber) return;
liveFrameNumber = frameNumber; liveFrameNumber = frameNumber;
const nowMs = Date.now(); const nowMs = layerState.orbitNow();
liveTime.setTime(nowMs); liveTime.setTime(nowMs);
if (satelliteTrack) { if (satelliteTrack) {
const propagated = const propagated =
+33
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { worldClock, worldTimeFor } from '../../time/worldClock.js';
export function createState({ services }) { export function createState({ services }) {
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
@@ -147,6 +148,38 @@ export function createState({ services }) {
state._missionRingDate = new Date(0); 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._missionOrbitPatternRegistered = false;
state._pathDistanceCache = new WeakMap(); state._pathDistanceCache = new WeakMap();
+102
View File
@@ -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 });
});
+3 -2
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { import {
lerpAngleDeg, lerpAngleDeg,
speedRamp, speedRamp,
@@ -67,7 +68,7 @@ export function createMotion({
// Render one poll interval behind real time so we interpolate between // Render one poll interval behind real time so we interpolate between
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
const renderTime = Cesium.JulianDate.addSeconds( const renderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(), julianAt(flightState.now()),
-RENDER_DELAY_SEC, -RENDER_DELAY_SEC,
flightState._scratchRenderTime, flightState._scratchRenderTime,
); );
@@ -213,7 +214,7 @@ export function createMotion({
const history = flightState._positionHistory.get(flightState._trackedIcao); const history = flightState._positionHistory.get(flightState._trackedIcao);
if (!history || history.length === 0) return true; if (!history || history.length === 0) return true;
const renderTime = Cesium.JulianDate.addSeconds( const renderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(), julianAt(flightState.now()),
-RENDER_DELAY_SEC, -RENDER_DELAY_SEC,
flightState._scratchWarmupTime, flightState._scratchWarmupTime,
); );
+3
View File
@@ -1,6 +1,7 @@
import { MilitaryFlightRecords } from './records.js'; import { MilitaryFlightRecords } from './records.js';
import { createMilitaryFeed } from './ingestion.js'; import { createMilitaryFeed } from './ingestion.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { worldTimeFor } from '../../time/worldClock.js';
export function createFlightState({ source, services }) { export function createFlightState({ source, services }) {
const { createGroundSnap } = services.groundSnap; const { createGroundSnap } = services.groundSnap;
@@ -11,6 +12,8 @@ export function createFlightState({ source, services }) {
...services.groundFloor, ...services.groundFloor,
}); });
flightState._cullPositions = new Map(); 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: /** 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 * military contacts should read as their WEIGHT CLASS, always in this layer's
+2 -1
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.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 // 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. // would draw the trail in front of the plane. They join via _appendTrailFix as they age.
const seedRenderTime = Cesium.JulianDate.addSeconds( const seedRenderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(), julianAt(flightState.now()),
-RENDER_DELAY_SEC, -RENDER_DELAY_SEC,
flightState._scratchWarmupTime, flightState._scratchWarmupTime,
); );
+4 -2
View File
@@ -32,7 +32,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
1, 1,
Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES), 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++) { for (let i = 0; i < perFrame; i++) {
if (layerState._denseCursor >= layerState._denseIds.length) if (layerState._denseCursor >= layerState._denseIds.length)
layerState._denseCursor = 0; layerState._denseCursor = 0;
@@ -97,7 +97,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
const entries = parts.orbits.parseTLE(text); const entries = parts.orbits.parseTLE(text);
const style = POINT_STYLES.dense; const style = POINT_STYLES.dense;
const now = new Date(); const now = new Date(layerState.now());
let added = 0; let added = 0;
for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) { for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) {
@@ -165,6 +165,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
layerState._count = layerState._points.size; layerState._count = layerState._points.size;
layerState._catalogRevision++; layerState._catalogRevision++;
layerState.coverageChanged();
layerState._denseStatus = 'ready'; layerState._denseStatus = 'ready';
console.log( console.log(
`[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`, `[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`,
@@ -232,6 +233,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
layerState._denseCursor = 0; layerState._denseCursor = 0;
layerState._count = layerState._points.size; layerState._count = layerState._points.size;
layerState._catalogRevision++; layerState._catalogRevision++;
layerState.coverageChanged();
} }
return { return {
_abortActiveUpdates, _abortActiveUpdates,
+4 -1
View File
@@ -190,7 +190,10 @@ export function createControls({ state: layerState, services, parts, source }) {
if (noradId === null) return null; if (noradId === null) return null;
const sat = layerState._catalog.get(noradId); 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; if (!pos) return null;
return { return {
+2 -1
View File
@@ -103,7 +103,7 @@ export function createIngestion({
layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog
parts.labels._syncIssOverlay(); parts.labels._syncIssOverlay();
const now = new Date(); const now = new Date(layerState.now());
// Process all TLE entries in CATALOG_GROUPS order // Process all TLE entries in CATALOG_GROUPS order
const allEntries = []; const allEntries = [];
@@ -195,6 +195,7 @@ export function createIngestion({
// the rebuild must not block on that. // the rebuild must not block on that.
void parts.tracking._reconcileTrackedSubjectContext(); void parts.tracking._reconcileTrackedSubjectContext();
parts.tracking._applyPendingTrackingRestore(); parts.tracking._applyPendingTrackingRestore();
layerState.coverageChanged();
} catch (e) { } catch (e) {
if (updateSignal.aborted || e?.name === 'AbortError') { if (updateSignal.aborted || e?.name === 'AbortError') {
throw new DOMException('Satellite update aborted', 'AbortError'); throw new DOMException('Satellite update aborted', 'AbortError');
+2
View File
@@ -33,6 +33,8 @@ function ingestionFor(catalogText) {
_denseLoadToken: 0, _denseLoadToken: 0,
_catalogRevision: 0, _catalogRevision: 0,
_params: { catalog: 'core', showOrbits: false }, _params: { catalog: 'core', showOrbits: false },
now: Date.now,
coverageChanged() {},
}; };
const parts = { const parts = {
orbits: { orbits: {
+20 -1
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { ISS_OVERLAY_SOURCE_ID } from './policy.js'; import { ISS_OVERLAY_SOURCE_ID, SATELLITE_COVERAGE_MS } from './policy.js';
import { worldClock } from '../../time/worldClock.js';
export function createLifecycle({ export function createLifecycle({
state: layerState, state: layerState,
@@ -8,6 +9,20 @@ export function createLifecycle({
source, source,
}) { }) {
const { clearFocusTarget } = services.focus; 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 { holdContinuousRender, releaseContinuousRender } = services.render;
const { registerPickOwner, unregisterPickOwner } = services.picking; const { registerPickOwner, unregisterPickOwner } = services.picking;
@@ -46,6 +61,7 @@ export function createLifecycle({
layerState._denseStatus = 'idle'; layerState._denseStatus = 'idle';
layerState._denseError = null; layerState._denseError = null;
layerState._catalogRevision++; layerState._catalogRevision++;
layerState.coverageChanged();
// Point primitives for satellite dots // Point primitives for satellite dots
layerState._pointCollection = new Cesium.PointPrimitiveCollection(); layerState._pointCollection = new Cesium.PointPrimitiveCollection();
@@ -92,6 +108,7 @@ export function createLifecycle({
); );
} }
parts.tracking._applyPendingTrackingRestore(); parts.tracking._applyPendingTrackingRestore();
layerState.coverageChanged();
}, },
disable(viewer) { disable(viewer) {
@@ -119,6 +136,7 @@ export function createLifecycle({
layerState._preRenderListener(); layerState._preRenderListener();
layerState._preRenderListener = null; layerState._preRenderListener = null;
} }
layerState.coverageChanged();
}, },
destroy(viewer) { destroy(viewer) {
@@ -176,6 +194,7 @@ export function createLifecycle({
failedGroups: [], failedGroups: [],
}; };
layerState._viewer = null; layerState._viewer = null;
layerState.coverageChanged();
}, },
}; };
+3 -3
View File
@@ -137,7 +137,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
lonDeg, lonDeg,
minElevDeg = 10, minElevDeg = 10,
requireVisible = false, requireVisible = false,
fromMs = Date.now(), fromMs = layerState.now(),
}) { }) {
return getNextSatellitePass(ISS_NORAD, { return getNextSatellitePass(ISS_NORAD, {
latDeg, latDeg,
@@ -167,7 +167,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
lonDeg, lonDeg,
minElevDeg = 10, minElevDeg = 10,
requireVisible = false, requireVisible = false,
fromMs = Date.now(), fromMs = layerState.now(),
}, },
) { ) {
const id = Number(noradId); const id = Number(noradId);
@@ -271,7 +271,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
} }
function orbitTrackFromRecord(name, satrec) { function orbitTrackFromRecord(name, satrec) {
const referenceDate = new Date(); const referenceDate = new Date(layerState.now());
const current = propagatePosition(satrec, referenceDate); const current = propagatePosition(satrec, referenceDate);
if (!current) return null; if (!current) return null;
return { return {
+9
View File
@@ -28,6 +28,15 @@ export const ORBIT_PATH_STEPS = 180;
// points per orbital path // points per orbital path
export const POSITION_UPDATE_MS = 1000; 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) // re-propagate every 1s (SGP4 is smooth at this rate)
export const RING_ROTATION_MS = 1000; export const RING_ROTATION_MS = 1000;
+1 -1
View File
@@ -79,7 +79,7 @@ export function createRecords({ state, parts }) {
const limit = Number.isFinite(maxCount) const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount)) ? Math.max(1, Math.floor(maxCount))
: 2000; : 2000;
const now = new Date(); const now = new Date(state.now());
const result = []; const result = [];
const emit = (noradId, sat) => { const emit = (noradId, sat) => {
if (result.length >= limit) return false; if (result.length >= limit) return false;
+19 -5
View File
@@ -1,6 +1,11 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { gstime } from 'satellite.js'; 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({ export function createRendering({
state: layerState, state: layerState,
@@ -42,7 +47,7 @@ export function createRendering({
const sat = layerState._catalog.get(noradId); const sat = layerState._catalog.get(noradId);
if (!sat || !layerState._viewer) return; if (!sat || !layerState._viewer) return;
const bakeDate = new Date(); const bakeDate = new Date(layerState.now());
const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate); const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate);
if (basePositions.length < 2) return; if (basePositions.length < 2) return;
@@ -75,6 +80,8 @@ export function createRendering({
layerState._orbitPaths.set(noradId, { layerState._orbitPaths.set(noradId, {
primitive, primitive,
color: pathColor,
bakeMs: bakeDate.getTime(),
// Same Date object as computeOrbitPath's internal fixedGmst → identical // Same Date object as computeOrbitPath's internal fixedGmst → identical
// GMST value (gstime is pure), so delta starts at exactly 0 and the // GMST value (gstime is pure), so delta starts at exactly 0 and the
// initial identity modelMatrix is correct until the first ring tick. // initial identity modelMatrix is correct until the first ring tick.
@@ -113,8 +120,15 @@ export function createRendering({
function _updateOrbitPathRotations(nowDate) { function _updateOrbitPathRotations(nowDate) {
if (layerState._orbitPaths.size === 0) return; if (layerState._orbitPaths.size === 0) return;
for (const path of layerState._orbitPaths.values()) { for (const [noradId, path] of [...layerState._orbitPaths]) {
if (!path.primitive) continue; 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( parts.orbits.orbitFrameModelMatrix(
path.gmstAtBake, path.gmstAtBake,
nowDate, nowDate,
@@ -143,7 +157,7 @@ export function createRendering({
*/ */
function _propagateAll() { function _propagateAll() {
const now = new Date(); const now = new Date(layerState.now());
let updated = 0; let updated = 0;
for (const [noradId, sat] of layerState._catalog) { for (const [noradId, sat] of layerState._catalog) {
@@ -211,7 +225,7 @@ export function createRendering({
layerState._params.showOrbits && layerState._params.showOrbits &&
now - layerState._lastRingRotation >= RING_ROTATION_MS now - layerState._lastRingRotation >= RING_ROTATION_MS
) { ) {
_updateOrbitPathRotations(new Date(now)); _updateOrbitPathRotations(new Date(layerState.now()));
layerState._lastRingRotation = now; layerState._lastRingRotation = now;
} }
} }
+9
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { worldClock, worldTimeFor } from '../../time/worldClock.js';
export function createState({ services }) { export function createState({ services }) {
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
@@ -151,6 +152,14 @@ export function createState({ services }) {
state._trackedFrameNowForTest = null; state._trackedFrameNowForTest = null;
/** 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 // Scratch variables
state._scratchCartesian = new Cesium.Cartesian3(); state._scratchCartesian = new Cesium.Cartesian3();
+5 -2
View File
@@ -154,7 +154,7 @@ export function createTracking({ state: layerState, services, parts, source }) {
sat.satrec, sat.satrec,
layerState._trackedFrameNowForTest layerState._trackedFrameNowForTest
? new Date(layerState._trackedFrameNowForTest()) ? new Date(layerState._trackedFrameNowForTest())
: new Date(), : new Date(layerState.now()),
); );
if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample
layerState._trackedFrameGeo = pos; layerState._trackedFrameGeo = pos;
@@ -434,7 +434,10 @@ export function createTracking({ state: layerState, services, parts, source }) {
// Comfortable tracking landing: ~726 km back for LEO (user-validated // Comfortable tracking landing: ~726 km back for LEO (user-validated
// "slightly zoomed out" framing — no stutter, label reads cleanly), scaled // "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. // 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 = const viewScale =
initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M
? TRACK_VIEW_FROM_HIGH_SCALE ? TRACK_VIEW_FROM_HIGH_SCALE
+6 -2
View File
@@ -8,6 +8,7 @@ import { createSelection } from './selection.js';
import { createViewerHost } from './viewerHost.js'; import { createViewerHost } from './viewerHost.js';
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js'; import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js'; import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
import { worldTimeFor } from '../../time/worldClock.js';
import { decodeParams, encodeParams } from './params.js'; import { decodeParams, encodeParams } from './params.js';
import { import {
composeUIState, composeUIState,
@@ -41,6 +42,9 @@ const GROUND_RETRY_MS = 1000;
* a time. * a time.
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options * @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
*/ */
// "Since N days" counts back from the world time.
const streetLevelTime = worldTimeFor(STREET_LEVEL_LAYER_ID);
export function createStreetLevelLayer({ export function createStreetLevelLayer({
providers: definitions, providers: definitions,
services = {}, services = {},
@@ -93,7 +97,7 @@ export function createStreetLevelLayer({
return Object.freeze({ return Object.freeze({
services: state.services, services: state.services,
/** Resolved against now, so "since N days" keeps up with the clock. */ /** 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. */ /** The layer and this provider's switch are both on. */
isActive: () => state.enabled && entry.on, isActive: () => state.enabled && entry.on,
notify, notify,
@@ -356,7 +360,7 @@ export function createStreetLevelLayer({
const filter = normalizeFilter(next, state.filter); const filter = normalizeFilter(next, state.filter);
if (sameFilter(filter, state.filter)) return; if (sameFilter(filter, state.filter)) return;
state.filter = filter; state.filter = filter;
const resolved = resolveFilter(filter); const resolved = resolveFilter(filter, streetLevelTime());
for (const entry of state.providers.values()) for (const entry of state.providers.values())
entry.instance.setFilter(resolved); entry.instance.setFilter(resolved);
notify(); notify();
+1
View File
@@ -3927,6 +3927,7 @@ test('parked transit reprojects held and reported courses at the bounded camera
_rotationRevision: -1, _rotationRevision: -1,
_cameraRevision: 0, _cameraRevision: 0,
_rotationDirty: false, _rotationDirty: false,
now: () => Date.now(),
}; };
const noop = () => {}; const noop = () => {};
const rendering = createRendering({ const rendering = createRendering({
+6 -6
View File
@@ -108,7 +108,7 @@ export function createRendering({ state, services, parts }) {
} }
function sampleIdle(entry) { function sampleIdle(entry) {
if (!state._moving.has(entry)) { if (!state._moving.has(entry)) {
updatePlayback(entry, Date.now(), performance.now()); updatePlayback(entry, state.now(), performance.now());
placeSample(entry); placeSample(entry);
} }
} }
@@ -172,7 +172,7 @@ export function createRendering({ state, services, parts }) {
() => { () => {
entry.wakeTimer = null; entry.wakeTimer = null;
if (!state._enabled || !state._vehicles.has(entry.key)) return; if (!state._enabled || !state._vehicles.has(entry.key)) return;
updatePlayback(entry, Date.now(), performance.now()); updatePlayback(entry, state.now(), performance.now());
placeSample(entry); placeSample(entry);
schedulePlayback(entry); schedulePlayback(entry);
syncRenderHold(); syncRenderHold();
@@ -388,7 +388,7 @@ export function createRendering({ state, services, parts }) {
) )
return; return;
if (!state._enabled) return; if (!state._enabled) return;
const now = Date.now(); const now = state.now();
if (state._vehicles.size === 0) { if (state._vehicles.size === 0) {
state._moving.clear(); state._moving.clear();
syncRenderHold(); syncRenderHold();
@@ -424,7 +424,7 @@ export function createRendering({ state, services, parts }) {
} }
parts.trails.update(); parts.trails.update();
refreshRotations(now); refreshRotations(Date.now());
const selected = state._vehicles.get(state._selectedKey); const selected = state._vehicles.get(state._selectedKey);
if (selected && state._visible.has(selected)) { if (selected && state._visible.has(selected)) {
selected.marker.rotation = Number.isFinite(selected.courseDeg) 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); const noSample = !entry.sample || !Number.isFinite(entry.sample.lat);
if (entry.track?.count && (!state._visible.has(entry) || noSample)) { if (entry.track?.count && (!state._visible.has(entry) || noSample)) {
entry.sample ||= {}; entry.sample ||= {};
updatePlayback(entry, now, performance.now()); updatePlayback(entry, state.now(), performance.now());
} }
sphere.center = sphere.center =
state._visible.has(entry) || !entry.sample state._visible.has(entry) || !entry.sample
@@ -557,7 +557,7 @@ export function createRendering({ state, services, parts }) {
} }
function resumePlayback() { function resumePlayback() {
if (!state._enabled) return; if (!state._enabled) return;
const now = Date.now(), const now = state.now(),
mono = performance.now(); mono = performance.now();
const entries = new Set(state._visible); const entries = new Set(state._visible);
const selected = state._vehicles.get(state._selectedKey); const selected = state._vehicles.get(state._selectedKey);
+3
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { createHistoryBudget } from '../../data/contactPlayback.js'; 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, * One Transit layer's mutable scene state. Every field lives on the instance,
@@ -14,6 +15,8 @@ export function createState({ services }) {
const state = {}; const state = {};
state._historyBudget = createHistoryBudget(); 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({ state.DEFAULT_OVERLAY_HOST = Object.freeze({
setEntries: setOverlayEntries, setEntries: setOverlayEntries,
+218 -17
View File
@@ -1,22 +1,75 @@
import { createWorldClock } from '../../time/worldClock.js';
export const RADAR_MAX_GAP_MS = 30 * 60_000; export const RADAR_MAX_GAP_MS = 30 * 60_000;
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000; export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000; export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000; export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
/** The longest play waits on a frame before it goes on without it. */
export const PLAY_WAIT_MS = 8_000;
// Playing on its own, the world does not wait on the frame it is at: that
// frame loads as play goes on. Play holds only at the next frame while the
// one before it still loads, then goes on to it once it shows, so every frame
// shows, play stops only for a load that is slow, and a frame loaded ahead
// costs play nothing.
/** 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({ export function createWeatherClock({
now = Date.now, now = Date.now,
setTimeout = globalThis.setTimeout, setTimeout = globalThis.setTimeout,
clearTimeout = globalThis.clearTimeout, clearTimeout = globalThis.clearTimeout,
world = createWorldClock({ now }),
} = {}) { } = {}) {
const registry = new Map(); const registry = new Map();
const listeners = new Set(); const listeners = new Set();
let mode = 'latest'; let mode = 'latest';
let target = null; let target = null;
let playing = false; 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 generation = 0;
let timer = null; let timer = null;
let pending = null; let pending = null;
// This clock's name on the world, for its holds and its gate.
const holdId = `weather:${++clocks}`;
// While the world plays, the hold on it at the next frame as the one before
// it loads, and the timer that lets play go on should that frame never
// come; and whether the move to the next frame waits on it.
let waiting = null;
let waitTimer = null;
let deferred = false;
const stopWaiting = () => {
const release = waiting;
waiting = null;
if (waitTimer !== null) clearTimeout(waitTimer);
waitTimer = null;
release?.();
};
const startWaiting = () => {
if (waiting || destroyed || !registry.size) return;
waiting = world.wait(holdId);
waitTimer = setTimeout(() => {
waitTimer = null;
stopWaiting();
// The frame that never came gives way to the one play is at.
if (deferred) void transition();
}, PLAY_WAIT_MS);
};
// Playing on its own, the world waits for frames as they load.
const worldPlaysOn = (state = world.getState()) =>
state.playing && !state.live && !state.stepping;
let destroyed = false; let destroyed = false;
const timesFor = (product) => const timesFor = (product) =>
[...new Set(product.getTimes())] [...new Set(product.getTimes())]
@@ -49,6 +102,9 @@ export function createWeatherClock({
mode, mode,
target, target,
playing, playing,
// Frames come on one after another: this clock replays them, or the world
// plays on through them, so the next is worth loading ahead.
advancing: playing || worldPlaysOn(),
timeline: getTimeline(), timeline: getTimeline(),
products: [...registry.values()].map(({ product }) => ({ products: [...registry.values()].map(({ product }) => ({
id: product.id, id: product.id,
@@ -65,11 +121,32 @@ export function createWeatherClock({
timer = null; timer = null;
}; };
const canPlay = () => getTimeline().length >= 2; 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() { function schedule() {
cancelTimer(); cancelTimer();
if (!playing || destroyed || pending) return; if (!playing || destroyed || pending) return;
if (!canPlay()) { if (!canPlay()) {
playing = false; stopPlaying();
notify(); notify();
return; return;
} }
@@ -92,7 +169,11 @@ export function createWeatherClock({
if (destroyed) return Promise.resolve(false); if (destroyed) return Promise.resolve(false);
cancelTimer(); cancelTimer();
const owner = ++generation; 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(); for (const entry of registry.values()) entry.controller?.abort();
// Start all products together; a rejection must not skip the settle barrier. // Start all products together; a rejection must not skip the settle barrier.
const tasks = [...registry.values()].map((entry) => { const tasks = [...registry.values()].map((entry) => {
@@ -106,13 +187,17 @@ export function createWeatherClock({
}); });
const result = Promise.allSettled(tasks).then(() => { const result = Promise.allSettled(tasks).then(() => {
if (destroyed || owner !== generation) return false; if (destroyed || owner !== generation) return false;
stopWaiting();
pending = null; pending = null;
if (!canPlay()) playing = false; if (!canPlay()) stopPlaying();
notify(); notify();
schedule(); schedule();
// Play held at the next frame for this one goes on to it.
if (deferred) void transition();
return true; return true;
}); });
pending = result; pending = result;
if (moving) moved ??= result;
notify(); notify();
return result; return result;
} }
@@ -123,11 +208,41 @@ export function createWeatherClock({
throw new Error(`Duplicate weather product: ${product.id}`); throw new Error(`Duplicate weather product: ${product.id}`);
const entry = { product, controller: null }; const entry = { product, controller: null };
registry.set(product.id, entry); 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(); void api.refresh();
return () => { return () => {
if (registry.get(product.id) !== entry) return; if (registry.get(product.id) !== entry) return;
entry.controller?.abort(); entry.controller?.abort();
registry.delete(product.id); registry.delete(product.id);
releases.get(product.id)?.();
releases.delete(product.id);
void api.refresh(); void api.refresh();
}; };
}, },
@@ -140,9 +255,11 @@ export function createWeatherClock({
const normalized = new Date(time).toISOString(); const normalized = new Date(time).toISOString();
if (mode === 'history' && target === normalized) if (mode === 'history' && target === normalized)
return pending ?? Promise.resolve(true); return pending ?? Promise.resolve(true);
mode = 'history'; return (
target = normalized; move(() => world.seek(Date.parse(normalized))) ??
return transition(); pending ??
Promise.resolve(true)
);
}, },
step(direction) { step(direction) {
if (destroyed || ![-1, 1].includes(direction)) if (destroyed || ![-1, 1].includes(direction))
@@ -159,10 +276,9 @@ export function createWeatherClock({
}, },
latest() { latest() {
if (destroyed) return Promise.resolve(false); if (destroyed) return Promise.resolve(false);
mode = 'latest'; stopPlaying();
target = null; if (mode === 'latest') return transition();
playing = false; return move(() => world.goLive()) ?? pending ?? Promise.resolve(true);
return transition();
}, },
play() { play() {
if (destroyed || playing || !canPlay()) if (destroyed || playing || !canPlay())
@@ -170,18 +286,28 @@ export function createWeatherClock({
playing = true; playing = true;
if (mode === 'latest') { if (mode === 'latest') {
const timeline = getTimeline(); const timeline = getTimeline();
return api.setTarget( const start =
timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0], 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(); notify();
schedule(); schedule();
return pending ?? Promise.resolve(true); return pending ?? Promise.resolve(true);
}, },
pause() { pause() {
cancelTimer(); cancelTimer();
if (!playing) return; if (!playing) {
playing = false; // The world may be playing on its own; a pause or step here holds it.
if (world.isPlaying()) world.pause();
return;
}
stopPlaying();
notify(); notify();
}, },
togglePlay() { togglePlay() {
@@ -189,8 +315,12 @@ export function createWeatherClock({
}, },
refresh() { refresh() {
if (destroyed) return Promise.resolve(false); 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()) { if (!canPlay()) {
playing = false; stopPlaying();
cancelTimer(); cancelTimer();
} }
if (mode === 'history') return transition(); if (mode === 'history') return transition();
@@ -204,15 +334,86 @@ export function createWeatherClock({
}, },
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
stopPlaying();
destroyed = true; destroyed = true;
++generation; ++generation;
playing = false; stopWaiting();
world.gate(holdId, null);
cancelTimer(); 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(); for (const entry of registry.values()) entry.controller?.abort();
registry.clear(); registry.clear();
listeners.clear(); listeners.clear();
pending = null; 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; return api;
} }
+432
View File
@@ -6,7 +6,9 @@ import {
REGIONAL_INFRARED_MAX_GAP_MS, REGIONAL_INFRARED_MAX_GAP_MS,
GLOBAL_INFRARED_MAX_GAP_MS, GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS, LIGHTNING_MAX_GAP_MS,
PLAY_WAIT_MS,
} from './clock.js'; } from './clock.js';
import { createWorldClock } from '../../time/worldClock.js';
const time = (minutes) => const time = (minutes) =>
new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString(); new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString();
@@ -37,7 +39,15 @@ function harness(t) {
let at = Date.parse(time(20)); let at = Date.parse(time(20));
const timers = new Map(); const timers = new Map();
let id = 0; let id = 0;
let mono = 0;
const world = createWorldClock({
now: () => at,
monotonicNow: () => mono,
setInterval: () => 0,
clearInterval() {},
});
const clock = createWeatherClock({ const clock = createWeatherClock({
world,
now: () => at, now: () => at,
setTimeout(fn, delay) { setTimeout(fn, delay) {
timers.set(++id, { fn, due: at + delay }); timers.set(++id, { fn, due: at + delay });
@@ -50,9 +60,11 @@ function harness(t) {
t.after(() => clock.destroy()); t.after(() => clock.destroy());
return { return {
clock, clock,
world,
timers, timers,
tick(ms) { tick(ms) {
at += ms; at += ms;
mono += ms;
for (const [id, timer] of [...timers]) { for (const [id, timer] of [...timers]) {
if (timer.due > at) continue; if (timer.due > at) continue;
timers.delete(id); timers.delete(id);
@@ -275,3 +287,423 @@ test('registration joins history; unregister and destroy abort work and release
assert.deepEqual(clock.getTimeline(), []); assert.deepEqual(clock.getTimeline(), []);
assert.equal(await clock.setTarget(time(5)), false); 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');
});
+5 -3
View File
@@ -52,7 +52,8 @@ const utc = (value) =>
: t('Unavailable'); : t('Unavailable');
/** A per-application observation layer, using the existing layer lifecycle and /** 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({ export function createWeatherLayer({
feed, feed,
clock, clock,
@@ -108,7 +109,8 @@ export function createWeatherLayer({
const notify = () => listener?.(); const notify = () => listener?.();
const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time); const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time);
const unsubscribeClock = clock?.subscribe(() => { const unsubscribeClock = clock?.subscribe(() => {
if (!clock.getState().playing || suspended()) rendering?.cancelPrefetch?.(); if (!clock.getState().advancing || suspended())
rendering?.cancelPrefetch?.();
notify(); notify();
}); });
const observationDelayed = () => const observationDelayed = () =>
@@ -210,7 +212,7 @@ export function createWeatherLayer({
if ( if (
!enabled || !enabled ||
suspended() || suspended() ||
!(clock ? clock.getState().playing : playing) !(clock ? clock.getState().advancing : playing)
) )
return; return;
const times = manifest?.times ?? []; const times = manifest?.times ?? [];
+14 -2
View File
@@ -31,6 +31,7 @@ import {
} from '../data/detectionPolicy.js'; } from '../data/detectionPolicy.js';
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { VIEW_STYLES } from '../view/index.js'; import { VIEW_STYLES } from '../view/index.js';
import { isWorldTime } from '../time/worldClock.js';
import { RETIRED_STYLES } from '../visualStylePolicy.js'; import { RETIRED_STYLES } from '../visualStylePolicy.js';
import { import {
GLOBAL_POST_DEFAULTS, GLOBAL_POST_DEFAULTS,
@@ -373,6 +374,7 @@ export const SHARE_VIEW_FIELDS = freezeAll([
{ number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 }, { number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 },
{ number: 26, key: 'scopeTerminusPct', type: 'uint', default: null }, { number: 26, key: 'scopeTerminusPct', type: 'uint', default: null },
{ number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE }, { 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 FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f]));
const fieldNumber = (key) => FIELD_BY_KEY.get(key).number; const fieldNumber = (key) => FIELD_BY_KEY.get(key).number;
@@ -732,6 +734,9 @@ export function normalizeShareViewState(input = {}) {
: null; : null;
state.panelState = normalizePanelState(source.panelState); state.panelState = normalizePanelState(source.panelState);
state.sharedAtMs = epochMsOr(source.sharedAtMs); state.sharedAtMs = epochMsOr(source.sharedAtMs);
state.worldTime = isWorldTime(source.worldTime)
? Math.floor(source.worldTime / 1000) * 1000
: null;
let view = normalizeView(source.view); let view = normalizeView(source.view);
if (state.layerState) view = liftTrackedEntity(state.layerState, view); if (state.layerState) view = liftTrackedEntity(state.layerState, view);
if (view.kind === 'thing') { if (view.kind === 'thing') {
@@ -948,8 +953,9 @@ export function encodeShareView(input) {
writer.sintField(field.number, signedAngle(state[field.key])); writer.sintField(field.number, signedAngle(state[field.key]));
break; break;
case 'sharedAtMs': case 'sharedAtMs':
if (state.sharedAtMs !== null) case 'worldTime':
writer.sintField(field.number, epochSeconds(state.sharedAtMs)); if (state[field.key] !== null)
writer.sintField(field.number, epochSeconds(state[field.key]));
break; break;
case 'flags': { case 'flags': {
let bits = 0; let bits = 0;
@@ -1347,6 +1353,12 @@ export function decodeShareView(code, { nowMs = Date.now() } = {}) {
raw.sharedAtMs = raw.sharedAtMs =
createdAtS >= 1 && Number.isSafeInteger(ms) && ms <= nowMs ? ms : null; 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')) if (get('layerState'))
raw.layerState = decodeLayers( raw.layerState = decodeLayers(
+21
View File
@@ -361,6 +361,7 @@ test('every registered field round-trips at once', () => {
})), })),
}, },
sharedAtMs: NOW_MS, sharedAtMs: NOW_MS,
worldTime: NOW_MS - 6 * 3_600_000,
}; };
// A thing view derives its camera (roll 0), so the camera fields are // A thing view derives its camera (roll 0), so the camera fields are
// checked on the same state as a place view. // 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], [25, 'scopeFeatherPct', 100],
[26, 'scopeTerminusPct', null], [26, 'scopeTerminusPct', null],
[27, 'styleParams', BY_STYLE], [27, 'styleParams', BY_STYLE],
[28, 'worldTime', null],
], ],
); );
assert.deepEqual( assert.deepEqual(
@@ -1338,3 +1340,22 @@ test('a layer option filed under a mirror layer is ignored', () => {
{ scope: 'layer-option', code: `${military}.${layerTokenNumber('m')}` }, { 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,
);
});
+11 -2
View File
@@ -5,6 +5,7 @@ import {
normalizeAllocationStrategy, normalizeAllocationStrategy,
} from './data/detectionPolicy.js'; } from './data/detectionPolicy.js';
import { clampScopeTerminusPct } from './scopeMask.js'; import { clampScopeTerminusPct } from './scopeMask.js';
import { worldClock as pageWorldClock } from './time/worldClock.js';
import { clampScopeFeatherPct } from './scopeMaskPolicy.js'; import { clampScopeFeatherPct } from './scopeMaskPolicy.js';
import { import {
decodeLayerStateParams, decodeLayerStateParams,
@@ -68,9 +69,11 @@ export class ShareLinkManager {
isNavigationCurrent, isNavigationCurrent,
cancelOwnedNavigation, cancelOwnedNavigation,
initialShareCode = null, initialShareCode = null,
worldClock = pageWorldClock,
} = {}, } = {},
) { ) {
this.viewer = viewer; this.viewer = viewer;
this._worldClock = worldClock;
// A host may hand over the share code to open instead of the address. // A host may hand over the share code to open instead of the address.
this._initialShareCode = this._initialShareCode =
typeof initialShareCode === 'string' ? initialShareCode : null; 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 * 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), * `shareView` (what the view follows, with its offset and last-known fix)
* or null when the code is refused. * and `worldTime` (null when the link was made live), or null when the
* code is refused.
*/ */
_parseShareCode(code) { _parseShareCode(code) {
const result = decodeShareView(code); const result = decodeShareView(code);
@@ -174,6 +178,7 @@ export class ShareLinkManager {
} }
const state = toLegacyShareState(result.state); const state = toLegacyShareState(result.state);
state.shareView = result.state.view; state.shareView = result.state.view;
state.worldTime = result.state.worldTime;
return state; return state;
} }
@@ -592,6 +597,10 @@ export class ShareLinkManager {
// that state in the share format. // that state in the share format.
const state = fromLegacyShareState(this._parseShareHash(params.toString())); const state = fromLegacyShareState(this._parseShareHash(params.toString()));
state.sharedAtMs = Math.floor(nowMs / 1000) * 1000; 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; let subject = null;
try { try {
subject = this._followSubjectProvider?.({ nowMs }) ?? null; subject = this._followSubjectProvider?.({ nowMs }) ?? null;
+25
View File
@@ -423,3 +423,28 @@ test('the layer coordinator replaces in-flight share layers with the navigated s
); );
coordinator.destroy(); 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();
});
+13
View File
@@ -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);
}
+46
View File
@@ -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();
};
}
+419
View File
@@ -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<string>}} [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);
+389
View File
@@ -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);
});
+4 -4
View File
@@ -434,12 +434,12 @@ test('Cyber lower-left telemetry tracks the credit anchor with clearance for its
cyberStyles, cyberStyles,
/#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/, /#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'); assert.ok(offset, 'telemetry must retain the attribution viewport anchor');
const credits = read('./styles/command-dock-trays.css'); const credits = read('./styles/command-dock-trays.css');
const creditOffsets = [ const creditOffsets = [
...credits.matchAll( ...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); assert.ok(creditOffsets.length > 0);
@@ -738,7 +738,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
); );
assert.match( assert.match(
cyberStyles, 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( assert.match(
cyberStyles, cyberStyles,
@@ -793,7 +793,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
cyberStyles, cyberStyles,
/#right-context-rail:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-inline-end-color:/, /#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.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/);
assert.match(cyberStyles, /data-layer-id='satellites'/); assert.match(cyberStyles, /data-layer-id='satellites'/);
assert.match(cyberStyles, /#search-toggle::before/); assert.match(cyberStyles, /#search-toggle::before/);
+1 -1
View File
@@ -586,7 +586,7 @@ test('the phone shell keeps the required attribution visible', () => {
}; };
const lift = (block) => const lift = (block) =>
Number( 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 z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]);
const idleCredit = rule( const idleCredit = rule(
+2
View File
@@ -32,6 +32,7 @@ const LEFT_STACK_OBSTACLE_SELECTOR = [
'#gev-voice-control', '#gev-voice-control',
'#pp-toggles', '#pp-toggles',
'#param-slider-panel', '#param-slider-panel',
'#world-timeline',
].join(', '); ].join(', ');
/** /**
* Fixed UI regions that can occupy the right control lane. Runtime rectangle * 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', '#cesium-credits .cesium-credit-textContainer',
'#command-dock', '#command-dock',
'#gev-voice-control', '#gev-voice-control',
'#world-timeline',
].join(', '); ].join(', ');
export class PanelLayoutController { export class PanelLayoutController {
constructor({ constructor({
+12
View File
@@ -4,6 +4,16 @@ import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
import { t, msg, formatDate } from '../i18n/index.js'; import { t, msg, formatDate } from '../i18n/index.js';
import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js'; import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js';
import { UiLifetime } from './uiLifetime.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. */ /** How long a shared vessel may take to appear in the vessel feed. */
export const SHARED_VESSEL_WAIT_MS = 120_000; export const SHARED_VESSEL_WAIT_MS = 120_000;
@@ -146,6 +156,7 @@ export class ShareRestoration {
: Promise.resolve({ status: 'not-requested', share: null, layers: [] }); : Promise.resolve({ status: 'not-requested', share: null, layers: [] });
if (savedState) { if (savedState) {
this._hasShareState = true; this._hasShareState = true;
applySharedWorldTime(savedState);
// Reserve camera authority now; the delayed mesh-friendly flight may // Reserve camera authority now; the delayed mesh-friendly flight may
// run only if no newer user, voice, or tracking navigation has won. // run only if no newer user, voice, or tracking navigation has won.
this._initialShareNavigationGeneration = this._initialShareNavigationGeneration =
@@ -301,6 +312,7 @@ export class ShareRestoration {
layers: [], layers: [],
}); });
this._initialShareState = state; this._initialShareState = state;
applySharedWorldTime(state);
this._initialShareSelectionSuperseded = false; this._initialShareSelectionSuperseded = false;
this._hasShareState = true; this._hasShareState = true;
this._navigatedShareRestoreActive = true; this._navigatedShareRestoreActive = true;
+166
View File
@@ -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);
},
};
});
+2 -2
View File
@@ -870,7 +870,7 @@ html:not([lang='en']) .cockpit-roll-token.is-separator {
position: fixed; position: fixed;
z-index: 146; z-index: 146;
right: 36px; right: 36px;
bottom: calc(2vh + 4.5rem); bottom: calc(2vh + 4.5rem + var(--world-timeline-lift, 0px));
display: grid; display: grid;
gap: 6px; gap: 6px;
} }
@@ -2049,7 +2049,7 @@ body.cockpit-mode #intel-hud .hud-right-edge {
@media (max-width: 760px) { @media (max-width: 760px) {
#view-switcher { #view-switcher {
right: 16px; right: 16px;
bottom: 16px; bottom: calc(16px + var(--world-timeline-lift, 0px));
} }
body.cockpit-mode #view-switcher { body.cockpit-mode #view-switcher {
right: auto; right: auto;
+3 -3
View File
@@ -377,15 +377,15 @@
/* Compact dock clearance keeps the reference-like low profile. */ /* Compact dock clearance keeps the reference-like low profile. */
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { 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 { #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 { #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) { @media (max-width: 900px) {
+6 -6
View File
@@ -455,7 +455,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(
) )
#cesium-credits { #cesium-credits {
right: auto; right: auto;
bottom: calc(2vh + 5rem); bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px));
left: 1.5rem; left: 1.5rem;
width: auto !important; width: auto !important;
max-width: calc(100vw - 3rem); 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-left,
#intel-hud[data-variant='tactical'] .hud-bottom-right { #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 { #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'] :root[data-scope-mask='off']
#intel-hud[data-variant='minimal'] #intel-hud[data-variant='minimal']
.hud-bottom-left { .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 { #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) { @media (max-width: 900px) {
@@ -487,7 +487,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(
#intel-hud[data-variant='minimal'].active #intel-hud[data-variant='minimal'].active
) )
#cesium-credits { #cesium-credits {
bottom: calc(2vh + 5rem); bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px));
left: 0.75rem; left: 0.75rem;
max-width: calc(100vw - 1.5rem); max-width: calc(100vw - 1.5rem);
} }
+9 -8
View File
@@ -2,7 +2,8 @@
#command-dock { #command-dock {
position: fixed; position: fixed;
right: auto; right: auto;
bottom: 2vh; /* Above the world timeline while it shows (world-timeline.css). */
bottom: calc(2vh + var(--world-timeline-lift, 0px));
left: 50%; left: 50%;
z-index: 145; z-index: 145;
display: grid; display: grid;
@@ -216,7 +217,7 @@
/* Keep required map attribution clear of the full-width command surface. */ /* Keep required map attribution clear of the full-width command surface. */
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { 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( 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 { #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 { #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) { @media (max-width: 980px) {
@@ -275,21 +276,21 @@ body.recording-mode #cesium-credits {
} }
body:not(.ui-clean-view):not(.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 { #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 { #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) { @media (max-width: 720px) {
#command-dock { #command-dock {
bottom: 8px; bottom: calc(8px + var(--world-timeline-lift, 0px));
} }
#command-dock .location-search-wrap { #command-dock .location-search-wrap {
+5 -5
View File
@@ -1585,7 +1585,7 @@
} }
#intel-hud[data-variant='cyber'] .hud-bottom-bar { #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); width: min(48vw, 680px);
} }
@@ -1596,13 +1596,13 @@
#intel-hud[data-variant='cyber'] .hud-bottom-left, #intel-hud[data-variant='cyber'] .hud-bottom-left,
#intel-hud[data-variant='cyber'] .hud-bottom-right { #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 /* Attribution's logo row extends above its text baseline. Reserve the whole
row plus a gap above the dock-relative 2vh + 5rem credit position. */ row plus a gap above the dock-relative 2vh + 5rem credit position. */
#intel-hud[data-variant='cyber'] .hud-bottom-left { #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 { #intel-hud[data-variant='cyber'] .hud-corner {
@@ -1647,7 +1647,7 @@
below its 650px threshold. Keep the left rail inside the same telemetry below its 650px threshold. Keep the left rail inside the same telemetry
and attribution-safe floor while its panel bodies take over scrolling. */ and attribution-safe floor while its panel bodies take over scrolling. */
:root[data-ui-theme='cyber'] #left-panel-stack { :root[data-ui-theme='cyber'] #left-panel-stack {
bottom: calc(2vh + 13rem); bottom: calc(2vh + 13rem + var(--world-timeline-lift, 0px));
} }
} }
@@ -1670,7 +1670,7 @@
/* The stack holds still, so its rail needs no offset (leftPanelRail.js). */ /* The stack holds still, so its rail needs no offset (leftPanelRail.js). */
--panel-stack-top-pinned: 1; --panel-stack-top-pinned: 1;
top: var(--cyber-left-rail-top) !important; 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)); inset-inline-start: calc(54px + var(--panel-rail-clearance));
width: 300px; width: 300px;
padding-inline-start: 10px; padding-inline-start: 10px;
+3 -3
View File
@@ -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. */ /* The dock's tray rules size voice by this basis; a column makes it a height. */
--dock-voice-width: auto; --dock-voice-width: auto;
top: auto; top: auto;
bottom: calc(var(--mobile-tab-space) + 10px); bottom: calc(var(--mobile-tab-space) + 10px + var(--world-timeline-lift, 0px));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; 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) body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view)
#cesium-credits { #cesium-credits {
right: auto; 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; left: 10px;
/* With the base rule's nowrap the credits run off the right edge, and a /* 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 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. */ /* The view switcher clears the tab bar and the voice bar, opposite the credit. */
body.mobile-shell:not(.cockpit-mode) #view-switcher { body.mobile-shell:not(.cockpit-mode) #view-switcher {
right: 12px; 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 { body.mobile-shell #corner-actions {
+1 -1
View File
@@ -177,7 +177,7 @@
drawn straight over the notice. Track the credit's own anchor instead 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 and the 12px of clear air holds regardless of height; the rail already
scrolls (overflow-y below), so it loses reach, not content. */ 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; left: 16px;
width: auto; width: auto;
max-height: none; max-height: none;
+1 -1
View File
@@ -390,7 +390,7 @@ html:not([lang='en']) #global-loading-detail {
/* ── Toast Notification ──────────────────────── */ /* ── Toast Notification ──────────────────────── */
#toast { #toast {
position: fixed; position: fixed;
bottom: 100px; bottom: calc(100px + var(--world-timeline-lift, 0px));
left: 50%; left: 50%;
transform: translateX(-50%) translateY(20px); transform: translateX(-50%) translateY(20px);
z-index: 200; z-index: 200;
+325
View File
@@ -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;
}
+2
View File
@@ -18,6 +18,7 @@ import ScreenAnnotations from './panels/ScreenAnnotations.vue';
import AwarenessDirection from './panels/AwarenessDirection.vue'; import AwarenessDirection from './panels/AwarenessDirection.vue';
import UiButton from '../kit/UiButton.vue'; import UiButton from '../kit/UiButton.vue';
import GlobeActions from './panels/GlobeActions.vue'; import GlobeActions from './panels/GlobeActions.vue';
import WorldTimeline from './panels/WorldTimeline.vue';
// The ACTIVE STYLE corner shows the style VisualSettings publishes. // The ACTIVE STYLE corner shows the style VisualSettings publishes.
const { style } = storeToRefs(useDisplayStore()); const { style } = storeToRefs(useDisplayStore());
@@ -117,4 +118,5 @@ const vToastText = {
<MissionReplayOverlay /> <MissionReplayOverlay />
<ScreenAnnotations /> <ScreenAnnotations />
<AwarenessDirection /> <AwarenessDirection />
<WorldTimeline />
</template> </template>
+548
View File
@@ -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);
});
+355
View File
@@ -0,0 +1,355 @@
<script setup>
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { formatDate, t } from '../../../i18n/index.js';
import { useWorldTimeStore } from '../../stores/worldTime.js';
import UiButton from '../../kit/UiButton.vue';
import UiIcon from '../../kit/UiIcon.vue';
import UiSelect from '../../kit/UiSelect.js';
// The world timeline: play and pause and the speed on the left, a track
// across the stretch the layers on can show (each launch or frame a tick)
// with a handle to scrub and the time in a bubble over it, and the way back
// to live on the right, lit while live. Scrolling or pinching the track
// zooms it around the handle, dragging it moves along it, and a double-click
// shows it all again. It shows
// while a layer with a time of its own (weather, satellites, launches,
// or one a host follows) is on with time to show; with only live feeds such
// as flights on, the bar stays hidden.
const store = useWorldTimeStore();
const { time, speed } = storeToRefs(store);
const speeds = computed(() => [
{ value: 'real', label: '1×' },
{ value: 'minute', label: t('1 min/s') },
{ value: 'hour', label: t('1 h/s') },
{ value: 'day', label: t('1 d/s') },
{ value: 'week', label: t('1 wk/s') },
]);
const DATE_TIME = Object.freeze({
dateStyle: 'medium',
timeStyle: 'medium',
timeZone: 'UTC',
});
// The bubble over the handle: the date and the minute, kept short.
const BUBBLE_DATE = Object.freeze({
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
const BUBBLE_TIME = Object.freeze({
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
timeZone: 'UTC',
});
// The keyboard's fallback step, as a share of the track, with no mark to go to.
const KEY_STEP = 0.01;
const PAGE_STEP = 0.1;
// Zooming: the shortest stretch the track narrows to, the step a key or a
// notch of the wheel takes, and how far a wheel's pixel of scroll zooms.
const MIN_VIEW_MS = 5 * 60_000;
const ZOOM_STEP = 1.5;
const WHEEL_ZOOM = 0.0025;
// Two presses this close are a double-click.
const DOUBLE_PRESS_MS = 500;
const readout = computed(() =>
Number.isFinite(time.value.timeMs)
? `${formatDate(time.value.timeMs, DATE_TIME)} UTC`
: '',
);
const bubble = computed(() =>
Number.isFinite(time.value.timeMs)
? `${formatDate(time.value.timeMs, BUBBLE_DATE)} · ${formatDate(time.value.timeMs, BUBBLE_TIME)} UTC`
: '',
);
// Play held while a layer loads the time's data shows a spinner by the time,
// once the hold lasts long enough to notice, so a quick frame does not flash
// it, and until play has run a while, so a run of slow frames keeps it still.
const WAIT_SHOWN_MS = 300;
const WAIT_HIDDEN_MS = 500;
const waiting = shallowRef(false);
let waitTimer = null;
watch(
() => time.value.buffering,
(buffering) => {
clearTimeout(waitTimer);
waitTimer = null;
if (buffering === waiting.value) return;
waitTimer = setTimeout(
() => {
waitTimer = null;
waiting.value = buffering;
},
buffering ? WAIT_SHOWN_MS : WAIT_HIDDEN_MS,
);
},
);
onBeforeUnmount(() => clearTimeout(waitTimer));
const span = computed(() => time.value.span);
// Where the handle is, or null with no track.
const at = computed(() => {
if (!span.value) return null;
const { start, end } = span.value;
return Math.min(end, Math.max(start, time.value.timeMs));
});
// The track shows the whole span, or zoomed, a stretch of it: `length` long,
// ending at `end`, or at the span's end (and so at now) while `end` is null.
// Everything still plays together; zooming only changes what the track shows.
const zoom = shallowRef(null);
const view = computed(() => {
if (!span.value) return null;
const { start, end } = span.value;
const length = zoom.value?.length;
if (!length || length >= end - start) return { start, end };
const viewEnd = Math.min(end, Math.max(start + length, zoom.value.end ?? end));
return { start: viewEnd - length, end: viewEnd };
});
const zoomed = computed(() => zoom.value !== null);
const viewLength = () => view.value.end - view.value.start;
// Show `length` of the span ending at `viewEnd`, held to the span.
function showView(length, viewEnd) {
const { start, end } = span.value;
if (length >= end - start) {
showWhole();
return;
}
const clamped = Math.min(end, Math.max(start + length, viewEnd));
zoom.value = { length, end: clamped >= end ? null : clamped };
}
// Narrow or widen the view to `length`, keeping the handle where it is on it,
// or with the handle off it, the middle.
function zoomTo(length) {
const { start: from, end: to } = view.value;
const pivot = offView(at.value) ? (from + to) / 2 : at.value;
const share = (pivot - from) / (to - from);
const next = Math.max(MIN_VIEW_MS, length);
showView(next, pivot + (1 - share) * next);
}
const zoomBy = (factor) => zoomTo(viewLength() * factor);
// A view dragged off the handle stays where it was put while the handle only
// creeps on with now; playing or a move of the handle takes it back.
let panned = false;
const follow = (ms) => {
panned = false;
showView(zoom.value.length, ms + zoom.value.length / 2);
};
const offView = (ms) => ms < view.value.start || ms > view.value.end;
const showWhole = () => {
zoom.value = null;
panned = false;
};
// The span narrowing to the zoom or less, or going, lets go of the zoom.
watch(span, (next) => {
if (zoom.value && (!next || zoom.value.length >= next.end - next.start))
showWhole();
});
watch(at, (ms) => {
if (!zoom.value || ms === null || !offView(ms)) return;
if (panned && time.value.live && !time.value.playing) return;
follow(ms);
});
// Left behind as now moves the view on, the handle takes the view back.
watch(view, () => {
if (zoom.value && !panned && at.value !== null && offView(at.value))
follow(at.value);
});
const pan = (ms) => {
panned = true;
showView(zoom.value.length, view.value.end + ms);
};
// A wheel's lines and pages, in pixels.
const WHEEL_UNITS = [1, 16, 400];
const onWheel = (event) => {
const unit = WHEEL_UNITS[event.deltaMode] ?? 1;
const sideways = Math.abs(event.deltaX) > Math.abs(event.deltaY);
// A sideways swipe, or a wheel with shift, drags; a wheel zooms.
if (sideways || event.shiftKey) {
if (!zoom.value) return;
event.preventDefault();
const width = event.currentTarget.clientWidth;
const pixels = (sideways ? event.deltaX : event.deltaY) * unit;
if (width > 0) pan((pixels / width) * viewLength());
return;
}
event.preventDefault();
zoomBy(Math.exp(event.deltaY * unit * WHEEL_ZOOM));
};
// A double-click starts with presses that move the time, so it puts back the
// time from before its first; so does a pinch, which may start on the handle.
let before = null;
let pressedAt = -Infinity;
const onPress = (event) => {
if (event.timeStamp - pressedAt > DOUBLE_PRESS_MS)
before = { live: time.value.live, timeMs: time.value.timeMs };
pressedAt = event.timeStamp;
};
const restore = () => {
if (!before) return;
if (before.live) store.goLive();
else store.scrub(before.timeMs, 0);
};
const showAll = () => {
restore();
showWhole();
};
// The handle drags the time. The rest of the track takes the pointer: a tap
// goes to the time under it, a drag drags a zoomed track along (or the time,
// with all of it shown), and two fingers pinch it.
const DRAG_PX = 4;
const pointers = new Map();
let gesture = null;
const timeAt = (clientX, track) => {
const { left, width } = track.getBoundingClientRect();
const share = width > 0 ? Math.min(1, Math.max(0, (clientX - left) / width)) : 1;
return view.value.start + share * viewLength();
};
// Whether the first finger has moved the time, for a pinch to put it back.
let movedTime = false;
const onPointerDown = (event) => {
// Only the main button: a right-click is for the menu.
if (event.button > 0) return;
const onHandle = event.target instanceof HTMLInputElement;
pointers.set(event.pointerId, { x: event.clientX, onHandle });
if (pointers.size === 1) {
onPress(event);
movedTime = onHandle;
// The keys zoom and step the track as soon as it is touched.
if (!onHandle)
event.currentTarget.querySelector('input')?.focus({ preventScroll: true });
}
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
if (movedTime) restore();
gesture = {
kind: 'pinch',
spread: Math.abs(a.x - b.x),
length: viewLength(),
};
return;
}
if (onHandle || pointers.size !== 1) return;
// The drag goes on past the track's ends; without the capture, to them.
try {
event.currentTarget.setPointerCapture?.(event.pointerId);
} catch {
// A pointer the page no longer knows of drags no further than the track.
}
gesture = { kind: 'press', x: event.clientX, end: view.value.end };
};
const onPointerMove = (event) => {
const pointer = pointers.get(event.pointerId);
if (!pointer) return;
pointer.x = event.clientX;
if (!gesture) return;
const track = event.currentTarget;
if (gesture.kind === 'pinch') {
const [a, b] = [...pointers.values()];
const spreadNow = Math.abs(a.x - b.x);
if (spreadNow > 0) zoomTo((gesture.length * gesture.spread) / spreadNow);
return;
}
if (gesture.kind === 'press') {
if (Math.abs(event.clientX - gesture.x) < DRAG_PX) return;
gesture.kind = zoom.value ? 'pan' : 'scrub';
movedTime = gesture.kind === 'scrub';
}
if (gesture.kind === 'pan') {
if (!track.clientWidth) return;
panned = true;
const moved = ((gesture.x - event.clientX) / track.clientWidth) * viewLength();
showView(zoom.value.length, gesture.end + moved);
} else store.scrub(timeAt(event.clientX, track), viewLength());
};
const onPointerUp = (event) => {
if (!pointers.delete(event.pointerId)) return;
if (gesture?.kind === 'press' && event.type === 'pointerup')
store.scrub(timeAt(event.clientX, event.currentTarget), viewLength());
// A finger lifted from a pinch ends it; the other one moves nothing.
gesture = null;
};
const place = (ms) => {
const { start, end } = view.value;
return end > start ? ((ms - start) / (end - start)) * 100 : 100;
};
const ticks = computed(() => {
const { start, end } = view.value;
return span.value.marks
.filter((mark) => mark >= start && mark <= end)
.map((mark) => `${place(mark)}%`);
});
const shown = computed(() =>
Math.min(view.value.end, Math.max(view.value.start, at.value)),
);
const handleOff = computed(() => at.value !== shown.value);
const scrub = (event) => {
// A pinch only zooms; the input goes back to the handle.
if (gesture?.kind === 'pinch') {
event.target.value = shown.value;
return;
}
store.scrub(Number(event.target.value), viewLength());
};
// Arrows go to the previous or next mark (a launch, a frame), or a step along
// the track as shown; Page keys take a tenth of it; Home and End go to the
// ends of all of it. Plus and minus zoom, and zero shows it all; with a
// modifier they are the browser's own zoom.
const ZOOM_KEYS = ['+', '=', '-', '0'];
const onKey = (event) => {
if (ZOOM_KEYS.includes(event.key)) {
if (event.ctrlKey || event.metaKey || event.altKey) return;
event.preventDefault();
if (event.key === '-') zoomBy(ZOOM_STEP);
else if (event.key === '0') showWhole();
else zoomBy(1 / ZOOM_STEP);
return;
}
const { start, end, marks } = span.value;
const now = at.value;
const length = viewLength();
let to = null;
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown')
to = marks.findLast((mark) => mark < now) ?? now - length * KEY_STEP;
else if (event.key === 'ArrowRight' || event.key === 'ArrowUp')
to = marks.find((mark) => mark > now) ?? now + length * KEY_STEP;
else if (event.key === 'PageDown') to = now - length * PAGE_STEP;
else if (event.key === 'PageUp') to = now + length * PAGE_STEP;
else if (event.key === 'Home') to = start;
else if (event.key === 'End') to = end;
if (to === null) return;
event.preventDefault();
store.scrub(Math.min(end, Math.max(start, to)), length);
};
</script>
<template>
<div v-if="time.supported && time.span" id="world-timeline" role="group" :aria-label="t('World time')"
><UiButton class="world-timeline-play" :label="time.playing ? t('Pause') : t('Play')" @click="store.togglePlay()"
><UiIcon :name="time.playing ? 'pause' : 'play_arrow'" :size="20" /></UiButton
><UiSelect class="gev-field gev-field--compact world-timeline-speed" :aria-label="t('Play speed')" :title="t('Play speed')" :options="speeds" :model-value="speed" @update:model-value="store.setSpeed($event)"
/><div class="world-timeline-track" :class="{ 'is-zoomed': zoomed }" :title="t('Scroll or pinch to zoom, drag to move along · double-click to see it all')" @wheel="onWheel" @dblclick="showAll" @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="onPointerUp" @pointercancel="onPointerUp"
><time class="world-timeline-time" :class="{ 'is-waiting': waiting }" :style="{ '--at': place(shown) / 100 }" :datetime="new Date(time.timeMs).toISOString()" aria-live="off"
><span v-if="waiting" class="world-timeline-spinner" aria-hidden="true"></span>{{ bubble }}</time
><span v-for="(left, index) in ticks" :key="index" class="world-timeline-tick" :style="{ left }" aria-hidden="true"></span
><span class="world-timeline-fill" :style="{ width: `${place(shown)}%` }" aria-hidden="true"></span
><input
class="world-timeline-scrub"
:class="{ 'is-off-view': handleOff }"
type="range"
:aria-label="t('World time')"
:aria-valuetext="readout"
:min="view.start"
:max="view.end"
step="any"
:value="shown"
@input="scrub"
aria-keyshortcuts="+ - 0"
@keydown="onKey"
/></div
><UiButton class="world-timeline-live" :label="time.live ? t('Live') : t('Back to live')" :pressed="time.live" pressed-class="is-live" :disabled="time.live" @click="store.goLive()">{{ t('Live') }}</UiButton
></div>
</template>
+4 -2
View File
@@ -74,9 +74,11 @@ test('bikeshare stations join coordinates from station information', () => {
assert.deepEqual(methods.getAnalystRecords(), []); assert.deepEqual(methods.getAnalystRecords(), []);
}); });
test('launches carry time and hours until launch', () => { test('launches carry time and hours until launch at the world time', () => {
const at = Date.now() + 5 * 3_600_000; const now = Date.UTC(2026, 10, 1);
const at = now + 5 * 3_600_000;
const state = { const state = {
now: () => now,
_enabled: true, _enabled: true,
_launches: [ _launches: [
{ {
+1
View File
@@ -29,6 +29,7 @@
@import './src/ui/styles/provider-settings.css'; @import './src/ui/styles/provider-settings.css';
@import './src/ui/styles/locale-picker.css'; @import './src/ui/styles/locale-picker.css';
@import './src/ui/styles/weather.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/panel-surfaces.css';
@import './src/ui/styles/segmented-control.css'; @import './src/ui/styles/segmented-control.css';
@import './src/ui/styles/kit.css'; @import './src/ui/styles/kit.css';