mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab54675ba | ||
|
|
09d833ed96 | ||
|
|
32e08f5043 | ||
|
|
48093108b1 | ||
|
|
9faec3028e | ||
|
|
3534398771 | ||
|
|
690a6637e2 | ||
|
|
434fc9602d | ||
|
|
32ec6ed436 | ||
|
|
0c4da3cdb2 |
No files matched your search
@@ -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).
|
||||
@@ -119,6 +119,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
|
||||
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
|
||||
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
|
||||
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
|
||||
| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
|
||||
|
||||
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
|
||||
so v1 never writes it; a later view kind writes its code.
|
||||
@@ -132,6 +133,10 @@ style also extends the pinned style-defaults table.
|
||||
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
|
||||
layout and restores it as `tactical`. The code stays reserved.
|
||||
|
||||
Field 28 is the world time the link was made at, written only when it was away
|
||||
from live. A link without it opens live. Unlike field 8 it may lie ahead of the
|
||||
link's creation (a forecast moment), and it opens paused at that time.
|
||||
|
||||
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
|
||||
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||
names a glyph this list is missing, because a missing glyph does not draw
|
||||
a box — the ligature never forms and the element renders the literal word. -->
|
||||
<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>
|
||||
<body>
|
||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
|
||||
"./application": "./src/app/application.js",
|
||||
"./application/viewer": "./src/app/viewer.js",
|
||||
"./time": "./src/time/worldClock.js",
|
||||
"./build/vite": {
|
||||
"node": "./build/vite.js"
|
||||
},
|
||||
|
||||
@@ -149,10 +149,19 @@
|
||||
"src/ui/views/panels/GlobeCompass.vue",
|
||||
"src/ui/stores/recording.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"]
|
||||
},
|
||||
"time": {
|
||||
"exports": ["./time"],
|
||||
"modules": ["src/time/worldClock.js"],
|
||||
"external": []
|
||||
},
|
||||
"viewer": {
|
||||
"exports": ["./application/viewer"],
|
||||
"modules": [
|
||||
@@ -1123,7 +1132,8 @@
|
||||
"src/ui/kit/UiBadge.vue",
|
||||
"src/ui/stores/globeActions.js",
|
||||
"src/ui/stores/recording.js",
|
||||
"src/ui/i18n.js"
|
||||
"src/ui/i18n.js",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": ["cesium", "vue", "pinia", "vue-i18n"]
|
||||
},
|
||||
@@ -1334,7 +1344,9 @@
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/unitLabels.js"
|
||||
"src/ui/unitLabels.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1390,7 +1402,9 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1575,7 +1589,8 @@
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/unitLabels.js"
|
||||
"src/ui/unitLabels.js",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": ["cesium", "satellite.js"]
|
||||
},
|
||||
@@ -1615,7 +1630,8 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1935,7 +1951,8 @@
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js"
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
||||
},
|
||||
@@ -3175,6 +3192,9 @@
|
||||
"src/ui/stores/globeActions.js",
|
||||
"src/ui/stores/recording.js",
|
||||
"src/ui/i18n.js",
|
||||
"src/time/viewerClock.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js",
|
||||
"src/annotations/outlineRungs.js",
|
||||
"src/sources/openFreeMapOutlines.js",
|
||||
"src/sources/nominatimOutlines.js",
|
||||
@@ -3782,6 +3802,8 @@
|
||||
"src/ui/stores/bhoteKoshiPanel.js",
|
||||
"src/hudReadouts.js",
|
||||
"src/ui/i18n.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js",
|
||||
"src/annotations/outlineRungs.js",
|
||||
"src/sources/openFreeMapOutlines.js",
|
||||
"src/sources/nominatimOutlines.js",
|
||||
@@ -3941,7 +3963,8 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -4460,7 +4483,8 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": ["cesium", "egm96-universal", "pbf"]
|
||||
},
|
||||
@@ -4512,7 +4536,8 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": ["cesium", "pbf"]
|
||||
},
|
||||
@@ -4632,7 +4657,8 @@
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/scopeMaskPolicy.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/visualStylePolicy.js"
|
||||
"src/visualStylePolicy.js",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolveCatalogSources } from './sourceComposition.js';
|
||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||
import { worldClock } from '../time/worldClock.js';
|
||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||
import { createWindLayer } from '../layers/wind/index.js';
|
||||
@@ -72,7 +73,7 @@ export function createApplicationCatalog({
|
||||
const sourceComposition = resolveCatalogSources(sources);
|
||||
sources = sourceComposition.sources;
|
||||
const militaryRegistry = createMilitaryRegistry();
|
||||
const weatherClock = createWeatherClock();
|
||||
const weatherClock = createWeatherClock({ world: worldClock });
|
||||
const dispose = () => {
|
||||
signal.removeEventListener('abort', dispose);
|
||||
militaryRegistry.dispose();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
|
||||
export function createApplicationGnssIntegrity(options) {
|
||||
return createGnssIntegrityLayer(options);
|
||||
return createGnssIntegrityLayer({
|
||||
worldNow: worldTimeFor('gnss-integrity'),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
|
||||
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
|
||||
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
||||
import { governorRequestRender } from '../../renderGovernor.js';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
const imageryTime = worldTimeFor('recent-imagery');
|
||||
|
||||
/** Construct imagery presentation over one explicitly supplied acquisition source. */
|
||||
export function createApplicationRecentImagery({ source }) {
|
||||
@@ -15,5 +18,6 @@ export function createApplicationRecentImagery({ source }) {
|
||||
thumbnails: createThumbnailLoader({
|
||||
requestThumbnail: (...args) => source.getThumbnail(...args),
|
||||
}),
|
||||
now: () => new Date(imageryTime()),
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
import { describeError } from './errors.js';
|
||||
import { worldClock, worldTimeFor } from '../time/worldClock.js';
|
||||
import { followWorldTime } from '../time/viewerClock.js';
|
||||
import { onLocaleChange, t } from '../i18n/index.js';
|
||||
|
||||
/** Construct the application globe using the caller's local configuration. */
|
||||
@@ -58,6 +60,12 @@ export async function createApplicationScene({
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
});
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
defer(
|
||||
followWorldTime(viewer, worldTimeFor('sky'), {
|
||||
subscribe: worldClock.subscribe,
|
||||
requestRender: () => governorRequestRender('world-time'),
|
||||
}),
|
||||
);
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
||||
|
||||
+14
-1
@@ -1,5 +1,9 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { governorRequestRender } from './renderGovernor.js';
|
||||
import { worldClock, worldTimeFor } from './time/worldClock.js';
|
||||
|
||||
// The ring shows the sun and moon at the time the sky is drawn.
|
||||
const skyTime = worldTimeFor('sky');
|
||||
|
||||
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
|
||||
export const KEYHOLE_OUTER_RADIUS = 1.05;
|
||||
@@ -445,6 +449,12 @@ export class CelestialRing {
|
||||
if (typeof document !== 'undefined' && document.hidden) return;
|
||||
governorRequestRender('celestial-ephemeris');
|
||||
}, 60_000);
|
||||
// A jump in the world time moves the sun and moon at once.
|
||||
this._skyDate = new Date();
|
||||
this._skyTime = new Cesium.JulianDate();
|
||||
this._unsubscribeWorldTime = worldClock.subscribe(() => {
|
||||
this._ephemerisDirty = true;
|
||||
});
|
||||
this.setEnabled(this.enabled);
|
||||
}
|
||||
|
||||
@@ -801,7 +811,8 @@ export class CelestialRing {
|
||||
return;
|
||||
}
|
||||
|
||||
const time = Cesium.JulianDate.now();
|
||||
this._skyDate.setTime(skyTime());
|
||||
const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime);
|
||||
if (!this._updateEphemeris(time)) return;
|
||||
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
|
||||
const camera = this.viewer.camera;
|
||||
@@ -890,6 +901,8 @@ export class CelestialRing {
|
||||
clearInterval(this._ephemerisTimer);
|
||||
this._ephemerisTimer = null;
|
||||
}
|
||||
this._unsubscribeWorldTime?.();
|
||||
this._unsubscribeWorldTime = null;
|
||||
if (this._removePostRender) this._removePostRender();
|
||||
this._removePostRender = null;
|
||||
if (this._shown()) {
|
||||
|
||||
@@ -229,13 +229,30 @@ const GUARDED_PROPS = new Set([
|
||||
|
||||
// Custom properties allowed inside a modelled offset. Each is a pinned-tray
|
||||
// stack height, proven non-negative below, so evaluating them at 0 is a
|
||||
// conservative floor rather than a guess.
|
||||
// conservative floor rather than a guess. The world timeline's lift raises
|
||||
// the dock, its trays and the credit together, so it moves no gap either.
|
||||
const VETTED_VARS = new Set([
|
||||
'--dock-pinned-stack-height',
|
||||
'--dock-lower-pinned-height',
|
||||
'--dock-location-pinned-height',
|
||||
'--dock-presets-pinned-height',
|
||||
'--world-timeline-lift',
|
||||
]);
|
||||
// Custom properties CSS sets to a fixed length besides their 0 default. The
|
||||
// clearance checks run at each length CSS gives them, not only at 0.
|
||||
const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
|
||||
const LIFTS_PX = [0, 50, 80];
|
||||
let liftPx = 0;
|
||||
function eachLift(check) {
|
||||
for (const lift of LIFTS_PX) {
|
||||
liftPx = lift;
|
||||
try {
|
||||
check(lift);
|
||||
} finally {
|
||||
liftPx = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Rules whose final compound targets a modelled element (pseudo-elements aside). */
|
||||
function ownBoxEntries() {
|
||||
@@ -315,6 +332,7 @@ function toPx(value, viewportHeight, where) {
|
||||
if (term.startsWith('var(')) {
|
||||
const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim();
|
||||
assert.ok(VETTED_VARS.has(name), `unvetted custom property ${name} in ${where}: "${value}"`);
|
||||
if (name === '--world-timeline-lift') total += liftPx;
|
||||
continue; // proven non-negative; 0 is the conservative floor
|
||||
}
|
||||
assert.match(term, /^-?[\d.]+(px|rem|vh)$/, `unmodelled length term "${term}" in ${where}: "${value}"`);
|
||||
@@ -444,7 +462,8 @@ test('custom properties inside modelled offsets are provably non-negative', () =
|
||||
assert.ok(declared.length >= VETTED_VARS.size, 'every vetted custom property needs a CSS default');
|
||||
for (const decl of declared) {
|
||||
assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`);
|
||||
assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`);
|
||||
const shape = FIXED_LENGTH_VARS.has(decl.prop) ? /^(0|\d+px)$/ : /^(0|0px)$/;
|
||||
assert.match(decl.value, shape, `${decl.prop} default is not a vetted shape: ${decl.value}`);
|
||||
}
|
||||
const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
|
||||
const start = layout.indexOf('_updateCommandDockTrayStack() {');
|
||||
@@ -517,16 +536,18 @@ test('every open dock tray clears the required credit at every modelled viewport
|
||||
// number: the dock is anchored at 2vh down to 721px and re-anchors to a flat
|
||||
// 8px at 720px, while the credit keeps its 2vh base throughout.
|
||||
const failures = [];
|
||||
for (const scenario of TRAY_SCENARIOS) {
|
||||
for (const width of WIDTHS.filter((w) => w <= 900)) {
|
||||
for (const height of HEIGHTS) {
|
||||
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
|
||||
if (clearance < MIN_CLEARANCE_PX) {
|
||||
failures.push(`${scenario.name} @ ${width}x${height}: ${clearance.toFixed(1)}px`);
|
||||
eachLift((lift) => {
|
||||
for (const scenario of TRAY_SCENARIOS) {
|
||||
for (const width of WIDTHS.filter((w) => w <= 900)) {
|
||||
for (const height of HEIGHTS) {
|
||||
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
|
||||
if (clearance < MIN_CLEARANCE_PX) {
|
||||
failures.push(`${scenario.name} @ ${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
assert.deepEqual(failures, [], `a dock tray re-enters the credit band:\n ${failures.join('\n ')}\n`);
|
||||
});
|
||||
|
||||
@@ -566,21 +587,24 @@ test('the full-width context rail clears the required credit at every modelled v
|
||||
'none',
|
||||
`at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`,
|
||||
);
|
||||
for (const height of HEIGHTS) {
|
||||
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
|
||||
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
|
||||
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height}: ${clearance.toFixed(1)}px`);
|
||||
}
|
||||
eachLift((lift) => {
|
||||
for (const height of HEIGHTS) {
|
||||
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
|
||||
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
|
||||
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
|
||||
}
|
||||
});
|
||||
}
|
||||
assert.deepEqual(failures, [], `context rail re-enters the credit band at ${failures.join(', ')}`);
|
||||
});
|
||||
|
||||
test('the dock anchor changes at 720px — the 2vh cancellation is band-limited', () => {
|
||||
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, '2vh');
|
||||
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, '8px');
|
||||
const lift = 'var(--world-timeline-lift, 0px)';
|
||||
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, `calc(2vh + ${lift})`);
|
||||
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, `calc(8px + ${lift})`);
|
||||
assert.equal(
|
||||
resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value,
|
||||
'calc(2vh + 5rem)',
|
||||
`calc(2vh + 5rem + ${lift})`,
|
||||
'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { gstime } from 'satellite.js';
|
||||
import { worldClock } from '../time/worldClock.js';
|
||||
import { createModel } from '../layers/launches/model.js';
|
||||
import rocketLaunchesLayer, {
|
||||
_setRocketMissionOverlayHostForTest,
|
||||
@@ -1240,7 +1241,11 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
||||
rocketLaunchesLayer.init(viewer);
|
||||
initialized = true;
|
||||
await rocketLaunchesLayer.enable();
|
||||
assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show');
|
||||
await rocketLaunchesLayer.update();
|
||||
const at = Date.parse(launchTime);
|
||||
assert.deepEqual(worldClock.coverage(), { start: at, end: at, marks: [at] });
|
||||
assert.equal(worldClock.isSupported(), true, 'loaded launches bring up the timeline');
|
||||
|
||||
const entities = dataSources[0].entities.values;
|
||||
assert.ok(entities.length >= 7, 'runtime guard requires the populated mission build path');
|
||||
@@ -1379,6 +1384,7 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
||||
assert.equal(deselectedPublication[2][0].protected, false);
|
||||
|
||||
await rocketLaunchesLayer.disable();
|
||||
assert.equal(worldClock.coverage(), null, 'off, launches show no time');
|
||||
assert.deepEqual(hostCalls.slice(-4), [
|
||||
['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID],
|
||||
['visible', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false],
|
||||
@@ -1500,3 +1506,11 @@ test('missing payload details stay unknown without inventing mass or classificat
|
||||
const [launch] = normalizeRocketLaunches([{ id: 'missing', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z' }], NOW);
|
||||
assert.deepEqual(launch.payloads, []);
|
||||
});
|
||||
|
||||
test('away from live the feed keeps its own window of launches', (t) => {
|
||||
t.after(() => worldClock.goLive());
|
||||
worldClock.seek(Date.now() - 20 * 86_400_000);
|
||||
const net = new Date(Date.now() - 86_400_000).toISOString();
|
||||
const launches = normalizeRocketLaunches([{ id: 'recent', pad: { latitude: 1, longitude: 2 }, net }]);
|
||||
assert.deepEqual(launches.map((launch) => launch.id), ['recent']);
|
||||
});
|
||||
+9
-4
@@ -23,6 +23,11 @@ import * as Cesium from 'cesium';
|
||||
import { greatCircleKm } from './geoDistance.js';
|
||||
import { forward as toMGRS } from 'mgrs';
|
||||
import { CITY_POIS } from './cityPois.js';
|
||||
import { worldTimeFor } from './time/worldClock.js';
|
||||
|
||||
// The HUD stamps its readouts with the world time; its sun is the sky's.
|
||||
const hudTime = worldTimeFor('hud');
|
||||
const skyTime = worldTimeFor('sky');
|
||||
import { composeLocalityTag } from './hudLocality.js';
|
||||
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
|
||||
import {
|
||||
@@ -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.
|
||||
*/
|
||||
_formatUTC() {
|
||||
const now = new Date();
|
||||
const now = new Date(hudTime());
|
||||
const y = now.getUTCFullYear();
|
||||
const mo = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getUTCDate()).padStart(2, '0');
|
||||
@@ -500,7 +505,7 @@ export class IntelHUD {
|
||||
readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
||||
|
||||
// Collection timestamp
|
||||
const now = new Date();
|
||||
const now = new Date(hudTime());
|
||||
const h = String(now.getUTCHours()).padStart(2, '0');
|
||||
const m = String(now.getUTCMinutes()).padStart(2, '0');
|
||||
const s = String(now.getUTCSeconds()).padStart(2, '0');
|
||||
@@ -607,7 +612,7 @@ export class IntelHUD {
|
||||
* @returns {number} Estimated sun elevation in degrees (negative = below horizon).
|
||||
*/
|
||||
_estimateSunElevation(lat, lon) {
|
||||
const now = new Date();
|
||||
const now = new Date(skyTime());
|
||||
// Approximate local solar time by shifting UTC hours by longitude offset
|
||||
const hours = now.getUTCHours() + now.getUTCMinutes() / 60 + lon / 15;
|
||||
const solarNoon = 12;
|
||||
|
||||
@@ -3223,5 +3223,8 @@
|
||||
"Audio": "الصوت",
|
||||
"UTILITIES": "الأدوات",
|
||||
"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": "مباشر"
|
||||
}
|
||||
@@ -2943,5 +2943,8 @@
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "WERKZEUGE",
|
||||
"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"
|
||||
}
|
||||
@@ -3013,5 +3013,8 @@
|
||||
"Right panels": "Paneles derechos",
|
||||
"UTILITIES": "UTILIDADES",
|
||||
"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"
|
||||
}
|
||||
@@ -3013,5 +3013,8 @@
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "OUTILS",
|
||||
"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"
|
||||
}
|
||||
@@ -3013,5 +3013,8 @@
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "STRUMENTI",
|
||||
"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"
|
||||
}
|
||||
@@ -2873,5 +2873,8 @@
|
||||
"Audio": "オーディオ",
|
||||
"UTILITIES": "ユーティリティ",
|
||||
"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": "ライブ"
|
||||
}
|
||||
@@ -3013,5 +3013,8 @@
|
||||
"Audio": "Áudio",
|
||||
"UTILITIES": "UTILITÁRIOS",
|
||||
"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"
|
||||
}
|
||||
@@ -3083,5 +3083,8 @@
|
||||
"Right panels": "Правые панели",
|
||||
"UTILITIES": "ИНСТРУМЕНТЫ",
|
||||
"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": "Сейчас"
|
||||
}
|
||||
@@ -3083,5 +3083,8 @@
|
||||
"Audio": "Аудіо",
|
||||
"UTILITIES": "ІНСТРУМЕНТИ",
|
||||
"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": "Зараз"
|
||||
}
|
||||
@@ -2873,5 +2873,8 @@
|
||||
"Right panels": "右侧面板",
|
||||
"UTILITIES": "工具",
|
||||
"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": "实时"
|
||||
}
|
||||
@@ -2873,5 +2873,8 @@
|
||||
"Right panels": "右側面板",
|
||||
"UTILITIES": "工具",
|
||||
"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": "即時"
|
||||
}
|
||||
@@ -89,6 +89,7 @@
|
||||
"Landsat 8/9",
|
||||
"Latin",
|
||||
"Layout",
|
||||
"Live",
|
||||
"London",
|
||||
"Los Angeles",
|
||||
"MIL",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { julianAt } from '../../time/julian.js';
|
||||
import {
|
||||
COURSE_HOLD_SPEED_MPS,
|
||||
lerpAngleDeg,
|
||||
@@ -156,7 +157,7 @@ export function createMotion({
|
||||
const renderTime =
|
||||
sampledRenderTime ??
|
||||
Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(flightState._scratchRenderTime),
|
||||
julianAt(flightState.now(), flightState._scratchRenderTime),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchRenderTime,
|
||||
);
|
||||
@@ -332,7 +333,7 @@ export function createMotion({
|
||||
const history = flightState._positionHistory.get(flightState._trackedIcao);
|
||||
if (!history || history.length === 0) return true;
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(),
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchWarmupTime,
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ function fixture() {
|
||||
_scratchArc: {},
|
||||
_scratchOffset: new Cesium.Cartesian3(),
|
||||
_scratchRenderTime: new Cesium.JulianDate(),
|
||||
now: Date.now,
|
||||
};
|
||||
const motion = createMotion({
|
||||
flightState,
|
||||
@@ -138,7 +139,7 @@ test('fleet render time is shared without resampling or mutating the supplied in
|
||||
assert.ok(Cesium.JulianDate.equals(renderTime, savedTime));
|
||||
});
|
||||
|
||||
test('tracked callers still sample a fresh delayed time without a fleet epoch', (t) => {
|
||||
test('tracked callers still sample a fresh delayed time without a fleet epoch', () => {
|
||||
const { flightState, motion } = fixture();
|
||||
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
|
||||
const history = [
|
||||
@@ -156,10 +157,11 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch',
|
||||
flightState.records.data.set('tracked', { velocity: 100, true_track: 90 });
|
||||
flightState._positionHistory.set('tracked', history);
|
||||
let elapsed = 60;
|
||||
const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => {
|
||||
assert.equal(result, flightState._scratchRenderTime);
|
||||
return Cesium.JulianDate.addSeconds(start, elapsed, result);
|
||||
});
|
||||
let reads = 0;
|
||||
flightState.now = () => {
|
||||
reads += 1;
|
||||
return Cesium.JulianDate.toDate(start).getTime() + elapsed * 1000;
|
||||
};
|
||||
const out = new Cesium.Cartesian3();
|
||||
close(
|
||||
motion._deadReckon('tracked', out),
|
||||
@@ -180,5 +182,5 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch',
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
);
|
||||
assert.equal(spy.mock.callCount(), 2);
|
||||
assert.equal(reads, 2);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { julianAt } from '../../time/julian.js';
|
||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||
import { civilAircraftModelSpec } from './modelSpec.js';
|
||||
@@ -863,7 +864,7 @@ export function createRendering({
|
||||
flightState._lastFleetTickMs = nowMs;
|
||||
// Every contact in this pass shares the same delayed display instant.
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(flightState._scratchFleetRenderTime),
|
||||
julianAt(flightState.now(), flightState._scratchFleetRenderTime),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchFleetRenderTime,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FlightRecords } from './records.js';
|
||||
import { createFlightFeed } from './ingestion.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js';
|
||||
|
||||
export function createFlightState({ source, services }) {
|
||||
@@ -12,6 +13,8 @@ export function createFlightState({ source, services }) {
|
||||
...services.groundFloor,
|
||||
});
|
||||
flightState._cullPositions = new Map();
|
||||
/** The time contacts are drawn behind by the render delay, in epoch ms. */
|
||||
flightState.now = worldTimeFor('flights');
|
||||
|
||||
/** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs
|
||||
* vertex-baked to real-world METERS in the airplane.glb axis convention, so
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { julianAt } from '../../time/julian.js';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
import { unitLabel } from '../../ui/unitLabels.js';
|
||||
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
|
||||
@@ -383,7 +384,7 @@ export function createTracking({
|
||||
// would draw the trail in front of the plane. They join the trail via _appendTrailFix
|
||||
// as they age past the delay.
|
||||
const seedRenderTime = Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(),
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchWarmupTime,
|
||||
);
|
||||
|
||||
@@ -69,6 +69,8 @@ export function createGnssIntegrityLayer({
|
||||
source,
|
||||
viewAnchor = defaultGnssViewAnchor,
|
||||
now = () => Date.now(),
|
||||
// The end of the window shown; retries and receipt times stay on `now`.
|
||||
worldNow = now,
|
||||
monotonicNow = () => performance.now(),
|
||||
windowMs: requestedWindowMs,
|
||||
} = {}) {
|
||||
@@ -163,7 +165,7 @@ export function createGnssIntegrityLayer({
|
||||
|
||||
/** Age the store without new evidence, so expired cells leave the globe. */
|
||||
function expire() {
|
||||
pruneGnssObservations(_observations, now(), { windowMs });
|
||||
pruneGnssObservations(_observations, worldNow(), { windowMs });
|
||||
_cells = binGnssCells(_observations);
|
||||
render();
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -26,7 +26,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
getAnalystRecords(maxCount = Infinity) {
|
||||
if (!layerState._enabled) return [];
|
||||
const now = Date.now();
|
||||
const now = layerState.now();
|
||||
const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
||||
const result = [];
|
||||
for (const launch of layerState._launches || []) {
|
||||
|
||||
@@ -222,6 +222,7 @@ export function createIngestion({
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._lastError = null;
|
||||
layerState._stale = stale === true;
|
||||
layerState.coverageChanged();
|
||||
} catch (error) {
|
||||
if (layerState._enabled && token === layerState._lifecycleToken) {
|
||||
layerState._lastError = error.message;
|
||||
|
||||
@@ -105,7 +105,7 @@ export function createLaunchPad({
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
if (nowMs - layerState._lastMissionRingRotationMs >= 1000) {
|
||||
layerState._missionRingDate.setTime(nowMs);
|
||||
layerState._missionRingDate.setTime(layerState.orbitNow());
|
||||
parts.orbitRendering.updateMissionOrbitPrimitiveFrames(
|
||||
layerState._missionRingDate,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
|
||||
const HOVER_PICK_INTERVAL_MS = 120;
|
||||
|
||||
@@ -119,6 +120,21 @@ export function createLifecycle({
|
||||
}) {
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
let hover = null;
|
||||
// While on, the launches loaded span the time from the first to the last,
|
||||
// each one a mark. Registered once; off, the layer has no time to show, and
|
||||
// each step tells the clock once it has settled.
|
||||
worldClock.register({
|
||||
id: 'rocket-launches',
|
||||
coverage: () => {
|
||||
if (!layerState._enabled) return null;
|
||||
const marks = layerState._launches
|
||||
.map((launch) => Date.parse(launch.launchTime))
|
||||
.filter(Number.isFinite)
|
||||
.sort((a, b) => a - b);
|
||||
if (!marks.length) return null;
|
||||
return { start: marks[0], end: marks.at(-1), marks };
|
||||
},
|
||||
});
|
||||
|
||||
const methods = {
|
||||
init(viewer) {
|
||||
@@ -194,6 +210,7 @@ export function createLifecycle({
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
layerState.coverageChanged();
|
||||
},
|
||||
|
||||
async _enableBody() {
|
||||
@@ -231,6 +248,7 @@ export function createLifecycle({
|
||||
parts.overlays.clearMissionOverlaySources();
|
||||
await parts.ingestion.restoreSatelliteDependency();
|
||||
parts.panel.renderMissionPanel();
|
||||
layerState.coverageChanged();
|
||||
},
|
||||
|
||||
async destroy(viewer) {
|
||||
|
||||
@@ -124,7 +124,7 @@ export function createOrbitRendering({
|
||||
collection.show = missionOrbitPrimitiveVisible(launch.id);
|
||||
orbitFrameModelMatrix(
|
||||
satelliteTrack.gmstAtBake,
|
||||
new Date(),
|
||||
new Date(layerState.orbitNow()),
|
||||
collection.modelMatrix,
|
||||
);
|
||||
layerState._viewer.scene.primitives.add(collection);
|
||||
|
||||
@@ -394,7 +394,7 @@ export function createRendering({
|
||||
orbitCurrent.latitude,
|
||||
orbitCurrent.altitude,
|
||||
);
|
||||
const liveTime = new Date();
|
||||
const liveTime = new Date(layerState.orbitNow());
|
||||
const liveTelemetry = { speedMps: null };
|
||||
layerState._satelliteTelemetry.set(launch.id, liveTelemetry);
|
||||
let liveFrameNumber = -1;
|
||||
@@ -405,7 +405,7 @@ export function createRendering({
|
||||
layerState._viewer?.scene?.frameState?.frameNumber ?? -1;
|
||||
if (frameNumber !== -1 && frameNumber === liveFrameNumber) return;
|
||||
liveFrameNumber = frameNumber;
|
||||
const nowMs = Date.now();
|
||||
const nowMs = layerState.orbitNow();
|
||||
liveTime.setTime(nowMs);
|
||||
if (satelliteTrack) {
|
||||
const propagated =
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { worldClock, worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
export function createState({ services }) {
|
||||
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
|
||||
@@ -147,6 +148,18 @@ export function createState({ services }) {
|
||||
|
||||
state._missionRingDate = new Date(0);
|
||||
|
||||
/** The time the launch window ends at, in epoch ms. */
|
||||
|
||||
state.now = worldTimeFor('rocket-launches');
|
||||
|
||||
/** A launched mission's satellite flies on the satellites layer's time. */
|
||||
|
||||
state.orbitNow = worldTimeFor('satellites');
|
||||
|
||||
/** The launches loaded changed, and with them the time they cover. */
|
||||
|
||||
state.coverageChanged = () => worldClock.coverageChanged();
|
||||
|
||||
state._missionOrbitPatternRegistered = false;
|
||||
|
||||
state._pathDistanceCache = new WeakMap();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { julianAt } from '../../time/julian.js';
|
||||
import {
|
||||
lerpAngleDeg,
|
||||
speedRamp,
|
||||
@@ -67,7 +68,7 @@ export function createMotion({
|
||||
// Render one poll interval behind real time so we interpolate between
|
||||
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(),
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchRenderTime,
|
||||
);
|
||||
@@ -213,7 +214,7 @@ export function createMotion({
|
||||
const history = flightState._positionHistory.get(flightState._trackedIcao);
|
||||
if (!history || history.length === 0) return true;
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(),
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchWarmupTime,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MilitaryFlightRecords } from './records.js';
|
||||
import { createMilitaryFeed } from './ingestion.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
export function createFlightState({ source, services }) {
|
||||
const { createGroundSnap } = services.groundSnap;
|
||||
@@ -11,6 +12,8 @@ export function createFlightState({ source, services }) {
|
||||
...services.groundFloor,
|
||||
});
|
||||
flightState._cullPositions = new Map();
|
||||
/** The time contacts are drawn behind by the render delay, in epoch ms. */
|
||||
flightState.now = worldTimeFor('military');
|
||||
|
||||
/** Per-class model spec for THIS layer (2026-08-16, owner playtest ask:
|
||||
* military contacts should read as their WEIGHT CLASS, always in this layer's
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { julianAt } from '../../time/julian.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
|
||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||
@@ -500,7 +501,7 @@ export function createTracking({
|
||||
// newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them
|
||||
// would draw the trail in front of the plane. They join via _appendTrailFix as they age.
|
||||
const seedRenderTime = Cesium.JulianDate.addSeconds(
|
||||
Cesium.JulianDate.now(),
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
flightState._scratchWarmupTime,
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
1,
|
||||
Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES),
|
||||
);
|
||||
const now = new Date();
|
||||
const now = new Date(layerState.now());
|
||||
for (let i = 0; i < perFrame; i++) {
|
||||
if (layerState._denseCursor >= layerState._denseIds.length)
|
||||
layerState._denseCursor = 0;
|
||||
@@ -97,7 +97,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
|
||||
const entries = parts.orbits.parseTLE(text);
|
||||
const style = POINT_STYLES.dense;
|
||||
const now = new Date();
|
||||
const now = new Date(layerState.now());
|
||||
let added = 0;
|
||||
|
||||
for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) {
|
||||
@@ -165,6 +165,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
|
||||
layerState._count = layerState._points.size;
|
||||
layerState._catalogRevision++;
|
||||
layerState.coverageChanged();
|
||||
layerState._denseStatus = 'ready';
|
||||
console.log(
|
||||
`[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`,
|
||||
@@ -232,6 +233,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
layerState._denseCursor = 0;
|
||||
layerState._count = layerState._points.size;
|
||||
layerState._catalogRevision++;
|
||||
layerState.coverageChanged();
|
||||
}
|
||||
return {
|
||||
_abortActiveUpdates,
|
||||
|
||||
Vendored
+4
-1
@@ -190,7 +190,10 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
if (noradId === null) return null;
|
||||
|
||||
const sat = layerState._catalog.get(noradId);
|
||||
const pos = parts.orbits.propagatePosition(sat.satrec, new Date());
|
||||
const pos = parts.orbits.propagatePosition(
|
||||
sat.satrec,
|
||||
new Date(layerState.now()),
|
||||
);
|
||||
if (!pos) return null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -103,7 +103,7 @@ export function createIngestion({
|
||||
layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog
|
||||
parts.labels._syncIssOverlay();
|
||||
|
||||
const now = new Date();
|
||||
const now = new Date(layerState.now());
|
||||
|
||||
// Process all TLE entries in CATALOG_GROUPS order
|
||||
const allEntries = [];
|
||||
@@ -195,6 +195,7 @@ export function createIngestion({
|
||||
// the rebuild must not block on that.
|
||||
void parts.tracking._reconcileTrackedSubjectContext();
|
||||
parts.tracking._applyPendingTrackingRestore();
|
||||
layerState.coverageChanged();
|
||||
} catch (e) {
|
||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||
throw new DOMException('Satellite update aborted', 'AbortError');
|
||||
|
||||
@@ -33,6 +33,8 @@ function ingestionFor(catalogText) {
|
||||
_denseLoadToken: 0,
|
||||
_catalogRevision: 0,
|
||||
_params: { catalog: 'core', showOrbits: false },
|
||||
now: Date.now,
|
||||
coverageChanged() {},
|
||||
};
|
||||
const parts = {
|
||||
orbits: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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({
|
||||
state: layerState,
|
||||
@@ -8,6 +9,20 @@ export function createLifecycle({
|
||||
source,
|
||||
}) {
|
||||
const { clearFocusTarget } = services.focus;
|
||||
// While on with a catalog, satellites can be shown at any time in reach of
|
||||
// their element sets. Registered once; off, the layer has no time to show,
|
||||
// and each step tells the clock once it has settled.
|
||||
worldClock.register({
|
||||
id: 'satellites',
|
||||
coverage: () => {
|
||||
if (!layerState._enabled || !layerState._catalog.size) return null;
|
||||
const now = worldClock.wallNow();
|
||||
return {
|
||||
start: now - SATELLITE_COVERAGE_MS,
|
||||
end: now + SATELLITE_COVERAGE_MS,
|
||||
};
|
||||
},
|
||||
});
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||
|
||||
@@ -46,6 +61,7 @@ export function createLifecycle({
|
||||
layerState._denseStatus = 'idle';
|
||||
layerState._denseError = null;
|
||||
layerState._catalogRevision++;
|
||||
layerState.coverageChanged();
|
||||
|
||||
// Point primitives for satellite dots
|
||||
layerState._pointCollection = new Cesium.PointPrimitiveCollection();
|
||||
@@ -92,6 +108,7 @@ export function createLifecycle({
|
||||
);
|
||||
}
|
||||
parts.tracking._applyPendingTrackingRestore();
|
||||
layerState.coverageChanged();
|
||||
},
|
||||
|
||||
disable(viewer) {
|
||||
@@ -119,6 +136,7 @@ export function createLifecycle({
|
||||
layerState._preRenderListener();
|
||||
layerState._preRenderListener = null;
|
||||
}
|
||||
layerState.coverageChanged();
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
@@ -176,6 +194,7 @@ export function createLifecycle({
|
||||
failedGroups: [],
|
||||
};
|
||||
layerState._viewer = null;
|
||||
layerState.coverageChanged();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
lonDeg,
|
||||
minElevDeg = 10,
|
||||
requireVisible = false,
|
||||
fromMs = Date.now(),
|
||||
fromMs = layerState.now(),
|
||||
}) {
|
||||
return getNextSatellitePass(ISS_NORAD, {
|
||||
latDeg,
|
||||
@@ -167,7 +167,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
lonDeg,
|
||||
minElevDeg = 10,
|
||||
requireVisible = false,
|
||||
fromMs = Date.now(),
|
||||
fromMs = layerState.now(),
|
||||
},
|
||||
) {
|
||||
const id = Number(noradId);
|
||||
@@ -271,7 +271,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
|
||||
function orbitTrackFromRecord(name, satrec) {
|
||||
const referenceDate = new Date();
|
||||
const referenceDate = new Date(layerState.now());
|
||||
const current = propagatePosition(satrec, referenceDate);
|
||||
if (!current) return null;
|
||||
return {
|
||||
|
||||
@@ -28,6 +28,15 @@ export const ORBIT_PATH_STEPS = 180;
|
||||
// points per orbital path
|
||||
|
||||
export const POSITION_UPDATE_MS = 1000;
|
||||
/** How far either side of now SGP4 on current element sets stays meaningful. */
|
||||
export const SATELLITE_COVERAGE_MS = 14 * 24 * 3_600_000;
|
||||
|
||||
/**
|
||||
* How far the world time may move from when an orbit ring was drawn before it
|
||||
* is drawn again: the Earth's turn is followed exactly, but the orbit's plane
|
||||
* drifts a few degrees a day.
|
||||
*/
|
||||
export const ORBIT_REBAKE_MS = 30 * 60_000;
|
||||
// re-propagate every 1s (SGP4 is smooth at this rate)
|
||||
|
||||
export const RING_ROTATION_MS = 1000;
|
||||
|
||||
@@ -79,7 +79,7 @@ export function createRecords({ state, parts }) {
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 2000;
|
||||
const now = new Date();
|
||||
const now = new Date(state.now());
|
||||
const result = [];
|
||||
const emit = (noradId, sat) => {
|
||||
if (result.length >= limit) return false;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { gstime } from 'satellite.js';
|
||||
import { ISS_NORAD, POSITION_UPDATE_MS, RING_ROTATION_MS } from './policy.js';
|
||||
import {
|
||||
ISS_NORAD,
|
||||
ORBIT_REBAKE_MS,
|
||||
POSITION_UPDATE_MS,
|
||||
RING_ROTATION_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
@@ -42,7 +47,7 @@ export function createRendering({
|
||||
const sat = layerState._catalog.get(noradId);
|
||||
if (!sat || !layerState._viewer) return;
|
||||
|
||||
const bakeDate = new Date();
|
||||
const bakeDate = new Date(layerState.now());
|
||||
const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate);
|
||||
if (basePositions.length < 2) return;
|
||||
|
||||
@@ -75,6 +80,8 @@ export function createRendering({
|
||||
|
||||
layerState._orbitPaths.set(noradId, {
|
||||
primitive,
|
||||
color: pathColor,
|
||||
bakeMs: bakeDate.getTime(),
|
||||
// Same Date object as computeOrbitPath's internal fixedGmst → identical
|
||||
// GMST value (gstime is pure), so delta starts at exactly 0 and the
|
||||
// initial identity modelMatrix is correct until the first ring tick.
|
||||
@@ -113,8 +120,15 @@ export function createRendering({
|
||||
function _updateOrbitPathRotations(nowDate) {
|
||||
if (layerState._orbitPaths.size === 0) return;
|
||||
|
||||
for (const path of layerState._orbitPaths.values()) {
|
||||
for (const [noradId, path] of [...layerState._orbitPaths]) {
|
||||
if (!path.primitive) continue;
|
||||
// The rotation keeps the ring under the dot for minutes; the plane
|
||||
// itself precesses, so a world time moved far from the bake re-bakes.
|
||||
if (Math.abs(nowDate.getTime() - path.bakeMs) > ORBIT_REBAKE_MS) {
|
||||
_hideOrbitPath(noradId);
|
||||
_showOrbitPath(noradId, path.color);
|
||||
continue;
|
||||
}
|
||||
parts.orbits.orbitFrameModelMatrix(
|
||||
path.gmstAtBake,
|
||||
nowDate,
|
||||
@@ -143,7 +157,7 @@ export function createRendering({
|
||||
*/
|
||||
|
||||
function _propagateAll() {
|
||||
const now = new Date();
|
||||
const now = new Date(layerState.now());
|
||||
let updated = 0;
|
||||
|
||||
for (const [noradId, sat] of layerState._catalog) {
|
||||
@@ -211,7 +225,7 @@ export function createRendering({
|
||||
layerState._params.showOrbits &&
|
||||
now - layerState._lastRingRotation >= RING_ROTATION_MS
|
||||
) {
|
||||
_updateOrbitPathRotations(new Date(now));
|
||||
_updateOrbitPathRotations(new Date(layerState.now()));
|
||||
layerState._lastRingRotation = now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { worldClock, worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
export function createState({ services }) {
|
||||
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
|
||||
@@ -151,6 +152,14 @@ export function createState({ services }) {
|
||||
|
||||
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
|
||||
|
||||
state._scratchCartesian = new Cesium.Cartesian3();
|
||||
|
||||
@@ -154,7 +154,7 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
sat.satrec,
|
||||
layerState._trackedFrameNowForTest
|
||||
? new Date(layerState._trackedFrameNowForTest())
|
||||
: new Date(),
|
||||
: new Date(layerState.now()),
|
||||
);
|
||||
if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample
|
||||
layerState._trackedFrameGeo = pos;
|
||||
@@ -434,7 +434,10 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
// Comfortable tracking landing: ~726 km back for LEO (user-validated
|
||||
// "slightly zoomed out" framing — no stutter, label reads cleanly), scaled
|
||||
// up for MEO/GEO so the camera doesn't land on top of a high-orbit dot.
|
||||
const initialPos = parts.orbits.propagatePosition(sat.satrec, new Date());
|
||||
const initialPos = parts.orbits.propagatePosition(
|
||||
sat.satrec,
|
||||
new Date(layerState.now()),
|
||||
);
|
||||
const viewScale =
|
||||
initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M
|
||||
? TRACK_VIEW_FROM_HIGH_SCALE
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createSelection } from './selection.js';
|
||||
import { createViewerHost } from './viewerHost.js';
|
||||
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
|
||||
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
import { decodeParams, encodeParams } from './params.js';
|
||||
import {
|
||||
composeUIState,
|
||||
@@ -41,6 +42,9 @@ const GROUND_RETRY_MS = 1000;
|
||||
* a time.
|
||||
* @param {{providers: Array<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({
|
||||
providers: definitions,
|
||||
services = {},
|
||||
@@ -93,7 +97,7 @@ export function createStreetLevelLayer({
|
||||
return Object.freeze({
|
||||
services: state.services,
|
||||
/** Resolved against now, so "since N days" keeps up with the clock. */
|
||||
getFilter: () => resolveFilter(state.filter),
|
||||
getFilter: () => resolveFilter(state.filter, streetLevelTime()),
|
||||
/** The layer and this provider's switch are both on. */
|
||||
isActive: () => state.enabled && entry.on,
|
||||
notify,
|
||||
@@ -356,7 +360,7 @@ export function createStreetLevelLayer({
|
||||
const filter = normalizeFilter(next, state.filter);
|
||||
if (sameFilter(filter, state.filter)) return;
|
||||
state.filter = filter;
|
||||
const resolved = resolveFilter(filter);
|
||||
const resolved = resolveFilter(filter, streetLevelTime());
|
||||
for (const entry of state.providers.values())
|
||||
entry.instance.setFilter(resolved);
|
||||
notify();
|
||||
|
||||
@@ -3927,6 +3927,7 @@ test('parked transit reprojects held and reported courses at the bounded camera
|
||||
_rotationRevision: -1,
|
||||
_cameraRevision: 0,
|
||||
_rotationDirty: false,
|
||||
now: () => Date.now(),
|
||||
};
|
||||
const noop = () => {};
|
||||
const rendering = createRendering({
|
||||
|
||||
@@ -108,7 +108,7 @@ export function createRendering({ state, services, parts }) {
|
||||
}
|
||||
function sampleIdle(entry) {
|
||||
if (!state._moving.has(entry)) {
|
||||
updatePlayback(entry, Date.now(), performance.now());
|
||||
updatePlayback(entry, state.now(), performance.now());
|
||||
placeSample(entry);
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,7 @@ export function createRendering({ state, services, parts }) {
|
||||
() => {
|
||||
entry.wakeTimer = null;
|
||||
if (!state._enabled || !state._vehicles.has(entry.key)) return;
|
||||
updatePlayback(entry, Date.now(), performance.now());
|
||||
updatePlayback(entry, state.now(), performance.now());
|
||||
placeSample(entry);
|
||||
schedulePlayback(entry);
|
||||
syncRenderHold();
|
||||
@@ -388,7 +388,7 @@ export function createRendering({ state, services, parts }) {
|
||||
)
|
||||
return;
|
||||
if (!state._enabled) return;
|
||||
const now = Date.now();
|
||||
const now = state.now();
|
||||
if (state._vehicles.size === 0) {
|
||||
state._moving.clear();
|
||||
syncRenderHold();
|
||||
@@ -424,7 +424,7 @@ export function createRendering({ state, services, parts }) {
|
||||
}
|
||||
|
||||
parts.trails.update();
|
||||
refreshRotations(now);
|
||||
refreshRotations(Date.now());
|
||||
const selected = state._vehicles.get(state._selectedKey);
|
||||
if (selected && state._visible.has(selected)) {
|
||||
selected.marker.rotation = Number.isFinite(selected.courseDeg)
|
||||
@@ -489,7 +489,7 @@ export function createRendering({ state, services, parts }) {
|
||||
const noSample = !entry.sample || !Number.isFinite(entry.sample.lat);
|
||||
if (entry.track?.count && (!state._visible.has(entry) || noSample)) {
|
||||
entry.sample ||= {};
|
||||
updatePlayback(entry, now, performance.now());
|
||||
updatePlayback(entry, state.now(), performance.now());
|
||||
}
|
||||
sphere.center =
|
||||
state._visible.has(entry) || !entry.sample
|
||||
@@ -557,7 +557,7 @@ export function createRendering({ state, services, parts }) {
|
||||
}
|
||||
function resumePlayback() {
|
||||
if (!state._enabled) return;
|
||||
const now = Date.now(),
|
||||
const now = state.now(),
|
||||
mono = performance.now();
|
||||
const entries = new Set(state._visible);
|
||||
const selected = state._vehicles.get(state._selectedKey);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createHistoryBudget } from '../../data/contactPlayback.js';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
/**
|
||||
* One Transit layer's mutable scene state. Every field lives on the instance,
|
||||
@@ -14,6 +15,8 @@ export function createState({ services }) {
|
||||
|
||||
const state = {};
|
||||
state._historyBudget = createHistoryBudget();
|
||||
/** The time vehicles play back behind by their display delay, in epoch ms. */
|
||||
state.now = worldTimeFor('transit');
|
||||
|
||||
state.DEFAULT_OVERLAY_HOST = Object.freeze({
|
||||
setEntries: setOverlayEntries,
|
||||
|
||||
+128
-17
@@ -1,19 +1,35 @@
|
||||
import { createWorldClock } from '../../time/worldClock.js';
|
||||
|
||||
export const RADAR_MAX_GAP_MS = 30 * 60_000;
|
||||
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
|
||||
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
|
||||
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
|
||||
|
||||
/** Transient observed time. Products must honor the signal before installing a frame. */
|
||||
/**
|
||||
* Transient observed time. Products must honor the signal before installing a
|
||||
* frame. Its time is the world clock's: latest while the world is live,
|
||||
* history at the world's time otherwise. Replay steps the observed frames and
|
||||
* moves the world clock as it goes, as the world clock's driver.
|
||||
*/
|
||||
export function createWeatherClock({
|
||||
now = Date.now,
|
||||
setTimeout = globalThis.setTimeout,
|
||||
clearTimeout = globalThis.clearTimeout,
|
||||
world = createWorldClock({ now }),
|
||||
} = {}) {
|
||||
const registry = new Map();
|
||||
const listeners = new Set();
|
||||
let mode = 'latest';
|
||||
let target = null;
|
||||
let playing = false;
|
||||
// The world time last applied; while this clock moves the world, the
|
||||
// transition its move started.
|
||||
let seen = 'latest';
|
||||
let moving = false;
|
||||
let moved = null;
|
||||
const releases = new Map();
|
||||
// Attached at the end, once the API exists.
|
||||
let driver = null;
|
||||
let generation = 0;
|
||||
let timer = null;
|
||||
let pending = null;
|
||||
@@ -65,11 +81,32 @@ export function createWeatherClock({
|
||||
timer = null;
|
||||
};
|
||||
const canPlay = () => getTimeline().length >= 2;
|
||||
const stopPlaying = () => {
|
||||
if (!playing) return;
|
||||
playing = false;
|
||||
driver?.driven(false);
|
||||
};
|
||||
// Move the world clock; the change comes back through followWorld. Returns
|
||||
// the transition the move started, if it started one.
|
||||
const move = (fn) => {
|
||||
const outer = [moving, moved];
|
||||
moving = true;
|
||||
moved = null;
|
||||
try {
|
||||
fn();
|
||||
return moved;
|
||||
} finally {
|
||||
[moving, moved] = outer;
|
||||
}
|
||||
};
|
||||
const selections = () =>
|
||||
[...registry.values()].map(({ product }) => selectedFor(product)).join();
|
||||
let applied = null;
|
||||
function schedule() {
|
||||
cancelTimer();
|
||||
if (!playing || destroyed || pending) return;
|
||||
if (!canPlay()) {
|
||||
playing = false;
|
||||
stopPlaying();
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
@@ -92,7 +129,8 @@ export function createWeatherClock({
|
||||
if (destroyed) return Promise.resolve(false);
|
||||
cancelTimer();
|
||||
const owner = ++generation;
|
||||
if (!canPlay()) playing = false;
|
||||
if (!canPlay()) stopPlaying();
|
||||
applied = selections();
|
||||
for (const entry of registry.values()) entry.controller?.abort();
|
||||
// Start all products together; a rejection must not skip the settle barrier.
|
||||
const tasks = [...registry.values()].map((entry) => {
|
||||
@@ -107,12 +145,13 @@ export function createWeatherClock({
|
||||
const result = Promise.allSettled(tasks).then(() => {
|
||||
if (destroyed || owner !== generation) return false;
|
||||
pending = null;
|
||||
if (!canPlay()) playing = false;
|
||||
if (!canPlay()) stopPlaying();
|
||||
notify();
|
||||
schedule();
|
||||
return true;
|
||||
});
|
||||
pending = result;
|
||||
if (moving) moved ??= result;
|
||||
notify();
|
||||
return result;
|
||||
}
|
||||
@@ -123,11 +162,41 @@ export function createWeatherClock({
|
||||
throw new Error(`Duplicate weather product: ${product.id}`);
|
||||
const entry = { product, controller: null };
|
||||
registry.set(product.id, entry);
|
||||
// The product moves with the world clock, and its frames are time the
|
||||
// world clock's timeline can show while it is on, suspended or not, so
|
||||
// the way back to live stays on screen. Both calls notify the world's
|
||||
// listeners, which may destroy this clock: the release is in place
|
||||
// first, and anything acquired after a destroy is let go at once.
|
||||
let unhold = null;
|
||||
let uncover = null;
|
||||
const release = () => {
|
||||
uncover?.();
|
||||
unhold?.();
|
||||
uncover = unhold = null;
|
||||
};
|
||||
releases.set(product.id, release);
|
||||
unhold = world.hold(product.id);
|
||||
if (!destroyed)
|
||||
uncover = world.register({
|
||||
id: product.id,
|
||||
coverage: () => {
|
||||
const marks = timesFor(product).map((time) => Date.parse(time));
|
||||
return marks.length
|
||||
? { start: marks[0], end: marks.at(-1), marks }
|
||||
: null;
|
||||
},
|
||||
});
|
||||
if (destroyed) {
|
||||
release();
|
||||
return () => {};
|
||||
}
|
||||
void api.refresh();
|
||||
return () => {
|
||||
if (registry.get(product.id) !== entry) return;
|
||||
entry.controller?.abort();
|
||||
registry.delete(product.id);
|
||||
releases.get(product.id)?.();
|
||||
releases.delete(product.id);
|
||||
void api.refresh();
|
||||
};
|
||||
},
|
||||
@@ -140,9 +209,11 @@ export function createWeatherClock({
|
||||
const normalized = new Date(time).toISOString();
|
||||
if (mode === 'history' && target === normalized)
|
||||
return pending ?? Promise.resolve(true);
|
||||
mode = 'history';
|
||||
target = normalized;
|
||||
return transition();
|
||||
return (
|
||||
move(() => world.seek(Date.parse(normalized))) ??
|
||||
pending ??
|
||||
Promise.resolve(true)
|
||||
);
|
||||
},
|
||||
step(direction) {
|
||||
if (destroyed || ![-1, 1].includes(direction))
|
||||
@@ -159,10 +230,9 @@ export function createWeatherClock({
|
||||
},
|
||||
latest() {
|
||||
if (destroyed) return Promise.resolve(false);
|
||||
mode = 'latest';
|
||||
target = null;
|
||||
playing = false;
|
||||
return transition();
|
||||
stopPlaying();
|
||||
if (mode === 'latest') return transition();
|
||||
return move(() => world.goLive()) ?? pending ?? Promise.resolve(true);
|
||||
},
|
||||
play() {
|
||||
if (destroyed || playing || !canPlay())
|
||||
@@ -170,18 +240,28 @@ export function createWeatherClock({
|
||||
playing = true;
|
||||
if (mode === 'latest') {
|
||||
const timeline = getTimeline();
|
||||
return api.setTarget(
|
||||
timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0],
|
||||
const start =
|
||||
timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0];
|
||||
// Playing and at the first frame in one move of the world.
|
||||
return (
|
||||
move(() => driver.driven(true, Date.parse(start))) ??
|
||||
pending ??
|
||||
Promise.resolve(true)
|
||||
);
|
||||
}
|
||||
driver.driven(true);
|
||||
notify();
|
||||
schedule();
|
||||
return pending ?? Promise.resolve(true);
|
||||
},
|
||||
pause() {
|
||||
cancelTimer();
|
||||
if (!playing) return;
|
||||
playing = false;
|
||||
if (!playing) {
|
||||
// The world may be playing on its own; a pause or step here holds it.
|
||||
if (world.isPlaying()) world.pause();
|
||||
return;
|
||||
}
|
||||
stopPlaying();
|
||||
notify();
|
||||
},
|
||||
togglePlay() {
|
||||
@@ -189,8 +269,10 @@ export function createWeatherClock({
|
||||
},
|
||||
refresh() {
|
||||
if (destroyed) return Promise.resolve(false);
|
||||
// New frames, or products added or dropped.
|
||||
world.coverageChanged();
|
||||
if (!canPlay()) {
|
||||
playing = false;
|
||||
stopPlaying();
|
||||
cancelTimer();
|
||||
}
|
||||
if (mode === 'history') return transition();
|
||||
@@ -204,15 +286,44 @@ export function createWeatherClock({
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
stopPlaying();
|
||||
destroyed = true;
|
||||
++generation;
|
||||
playing = false;
|
||||
cancelTimer();
|
||||
unfollowWorld();
|
||||
// The last weather clock leaves the world live, as a new one expects.
|
||||
if (driver.detach() === 0) world.goLive();
|
||||
for (const release of releases.values()) release();
|
||||
releases.clear();
|
||||
for (const entry of registry.values()) entry.controller?.abort();
|
||||
registry.clear();
|
||||
listeners.clear();
|
||||
pending = null;
|
||||
},
|
||||
};
|
||||
// Follow the world clock. A move of this clock's own always applies; one
|
||||
// from elsewhere (a host's seek, continuous play) applies only when it
|
||||
// changes the mode or a product's frame.
|
||||
function followWorld(state) {
|
||||
if (destroyed) return;
|
||||
const nextMode = state.live ? 'latest' : 'history';
|
||||
const nextTarget = state.live
|
||||
? null
|
||||
: new Date(Math.floor(state.timeMs)).toISOString();
|
||||
const key = nextTarget ?? 'latest';
|
||||
if (key === seen) return;
|
||||
seen = key;
|
||||
const modeChanged = nextMode !== mode;
|
||||
mode = nextMode;
|
||||
target = nextTarget;
|
||||
if (moving || modeChanged || selections() !== applied) void transition();
|
||||
else notify();
|
||||
}
|
||||
const unfollowWorld = world.subscribe(followWorld);
|
||||
driver = world.attachDriver({
|
||||
play: () => (canPlay() ? void api.play() : false),
|
||||
pause: () => api.pause(),
|
||||
});
|
||||
followWorld(world.getState());
|
||||
return api;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
GLOBAL_INFRARED_MAX_GAP_MS,
|
||||
LIGHTNING_MAX_GAP_MS,
|
||||
} from './clock.js';
|
||||
import { createWorldClock } from '../../time/worldClock.js';
|
||||
|
||||
const time = (minutes) =>
|
||||
new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString();
|
||||
@@ -37,7 +38,15 @@ function harness(t) {
|
||||
let at = Date.parse(time(20));
|
||||
const timers = new Map();
|
||||
let id = 0;
|
||||
let mono = 0;
|
||||
const world = createWorldClock({
|
||||
now: () => at,
|
||||
monotonicNow: () => mono,
|
||||
setInterval: () => 0,
|
||||
clearInterval() {},
|
||||
});
|
||||
const clock = createWeatherClock({
|
||||
world,
|
||||
now: () => at,
|
||||
setTimeout(fn, delay) {
|
||||
timers.set(++id, { fn, due: at + delay });
|
||||
@@ -50,9 +59,11 @@ function harness(t) {
|
||||
t.after(() => clock.destroy());
|
||||
return {
|
||||
clock,
|
||||
world,
|
||||
timers,
|
||||
tick(ms) {
|
||||
at += ms;
|
||||
mono += ms;
|
||||
for (const [id, timer] of [...timers]) {
|
||||
if (timer.due > at) continue;
|
||||
timers.delete(id);
|
||||
@@ -275,3 +286,210 @@ test('registration joins history; unregister and destroy abort work and release
|
||||
assert.deepEqual(clock.getTimeline(), []);
|
||||
assert.equal(await clock.setTarget(time(5)), false);
|
||||
});
|
||||
|
||||
test('the weather time is the world time, both ways', async (t) => {
|
||||
const { clock, world } = harness(t);
|
||||
const radar = product('radar', [0, 10, 20]);
|
||||
clock.register(radar);
|
||||
assert.equal(world.follows('radar'), true);
|
||||
await clock.setTarget(time(10));
|
||||
assert.equal(world.isLive(), false);
|
||||
assert.equal(world.now(), Date.parse(time(10)));
|
||||
world.seek(Date.parse(time(3)));
|
||||
await flush();
|
||||
assert.equal(clock.getState().target, time(3));
|
||||
assert.equal(radar.shown, time(0));
|
||||
world.goLive();
|
||||
await flush();
|
||||
assert.equal(clock.getState().mode, 'latest');
|
||||
assert.equal(radar.shown, time(20));
|
||||
});
|
||||
|
||||
test('world play and pause drive the weather replay', async (t) => {
|
||||
const { clock, world, tick } = harness(t);
|
||||
const radar = product('radar', [0, 10, 20]);
|
||||
clock.register(radar);
|
||||
world.seek(Date.parse(time(0)));
|
||||
await flush();
|
||||
world.play();
|
||||
assert.equal(clock.getState().playing, true);
|
||||
assert.equal(world.isPlaying(), true);
|
||||
tick(2000);
|
||||
await flush();
|
||||
assert.equal(radar.shown, time(10));
|
||||
assert.equal(world.now(), Date.parse(time(10)));
|
||||
world.pause();
|
||||
assert.equal(clock.getState().playing, false);
|
||||
clock.play();
|
||||
assert.equal(world.isPlaying(), true);
|
||||
clock.pause();
|
||||
assert.equal(world.isPlaying(), false);
|
||||
clock.play();
|
||||
world.goLive();
|
||||
await flush();
|
||||
assert.equal(clock.getState().playing, false);
|
||||
assert.equal(clock.getState().mode, 'latest');
|
||||
});
|
||||
|
||||
test('a moving world re-applies frames only when a product changes frame', async (t) => {
|
||||
const { clock, world } = harness(t);
|
||||
const radar = product('radar', [0, 10, 20]);
|
||||
clock.register(radar);
|
||||
world.seek(Date.parse(time(0)));
|
||||
await flush();
|
||||
const applied = radar.calls.length;
|
||||
world.seek(Date.parse(time(0)) + 60_000);
|
||||
await flush();
|
||||
assert.equal(radar.calls.length, applied);
|
||||
assert.equal(
|
||||
clock.getState().target,
|
||||
new Date(Date.parse(time(1))).toISOString(),
|
||||
);
|
||||
world.seek(Date.parse(time(11)));
|
||||
await flush();
|
||||
assert.equal(radar.calls.length, applied + 1);
|
||||
assert.equal(radar.shown, time(10));
|
||||
});
|
||||
|
||||
test('a world without frames to step plays on its own', async (t) => {
|
||||
const { clock, world, tick } = harness(t);
|
||||
world.seek(Date.parse(time(0)));
|
||||
world.play();
|
||||
assert.equal(clock.getState().playing, false);
|
||||
tick(1000);
|
||||
assert.equal(world.now(), Date.parse(time(0)) + 1000);
|
||||
});
|
||||
|
||||
function worldAt(at) {
|
||||
return createWorldClock({
|
||||
now: () => at,
|
||||
monotonicNow: () => 0,
|
||||
setInterval: () => 0,
|
||||
clearInterval() {},
|
||||
});
|
||||
}
|
||||
|
||||
test('weather clocks sharing a world hand play over, and the last leaves it live', async () => {
|
||||
const world = worldAt(Date.parse(time(20)));
|
||||
const a = createWeatherClock({ world, now: () => Date.parse(time(20)) });
|
||||
const b = createWeatherClock({ world, now: () => Date.parse(time(20)) });
|
||||
const radarA = product('radar', [0, 10, 20]);
|
||||
const radarB = product('radar', [0, 10, 20]);
|
||||
a.register(radarA);
|
||||
b.register(radarB);
|
||||
await a.play();
|
||||
assert.equal(world.isPlaying(), true);
|
||||
world.pause();
|
||||
assert.equal(a.getState().playing, false);
|
||||
assert.equal(world.isPlaying(), false);
|
||||
await a.play();
|
||||
world.goLive();
|
||||
assert.equal(a.getState().playing, false);
|
||||
b.destroy();
|
||||
assert.equal(world.follows('radar'), true);
|
||||
world.seek(Date.parse(time(0)));
|
||||
world.play();
|
||||
assert.equal(a.getState().playing, true);
|
||||
a.destroy();
|
||||
assert.equal(world.follows('radar'), false);
|
||||
assert.equal(world.isLive(), true);
|
||||
assert.equal(world.isPlaying(), false);
|
||||
});
|
||||
|
||||
test('pausing from inside the first playing notice stops the world too', async (t) => {
|
||||
const { clock, world } = harness(t);
|
||||
clock.register(product('radar', [0, 10, 20]));
|
||||
const off = clock.subscribe((state) => {
|
||||
if (state.playing) {
|
||||
off();
|
||||
clock.pause();
|
||||
}
|
||||
});
|
||||
await clock.play();
|
||||
assert.equal(clock.getState().playing, false);
|
||||
assert.equal(world.isPlaying(), false);
|
||||
});
|
||||
|
||||
test('a step holds a world that was playing on its own', async (t) => {
|
||||
const { clock, world } = harness(t);
|
||||
world.seek(Date.parse(time(0)));
|
||||
world.play();
|
||||
clock.register(product('radar', [0, 10, 20]));
|
||||
await clock.step(1);
|
||||
assert.equal(world.isPlaying(), false);
|
||||
assert.equal(world.now(), Date.parse(time(10)));
|
||||
});
|
||||
|
||||
test('a target superseded from a listener resolves false', async (t) => {
|
||||
const { clock } = harness(t);
|
||||
const radar = product('radar', [0, 10, 20]);
|
||||
clock.register(radar);
|
||||
await flush();
|
||||
let redirected = false;
|
||||
clock.subscribe((state) => {
|
||||
if (state.target === time(0) && !redirected) {
|
||||
redirected = true;
|
||||
void clock.setTarget(time(10));
|
||||
}
|
||||
});
|
||||
assert.equal(await clock.setTarget(time(0)), false);
|
||||
assert.equal(radar.shown, time(10));
|
||||
});
|
||||
|
||||
test('each product’s frames are world clock coverage, and support the timeline while shown', (t) => {
|
||||
const { clock, world } = harness(t);
|
||||
assert.equal(world.coverage(), null);
|
||||
assert.equal(world.getState().supported, false);
|
||||
const radar = product('radar', [0, 10]);
|
||||
const unregister = clock.register(radar);
|
||||
clock.register(product('lightning', [5]));
|
||||
assert.deepEqual(world.coverage(), {
|
||||
start: Date.parse(time(0)),
|
||||
end: Date.parse(time(10)),
|
||||
marks: [0, 5, 10].map((m) => Date.parse(time(m))),
|
||||
});
|
||||
assert.equal(world.getState().supported, true);
|
||||
unregister();
|
||||
assert.deepEqual(world.coverage().marks, [Date.parse(time(5))]);
|
||||
clock.destroy();
|
||||
assert.equal(world.coverage(), null);
|
||||
assert.equal(world.getState().supported, false);
|
||||
});
|
||||
|
||||
test('a suspended product keeps its coverage, so the way back to live stays', (t) => {
|
||||
const { clock, world } = harness(t);
|
||||
const radar = product('radar', [0, 10]);
|
||||
clock.register(radar);
|
||||
radar.suspended = true;
|
||||
void clock.refresh();
|
||||
assert.equal(world.getState().supported, true);
|
||||
assert.deepEqual(
|
||||
world.coverage().marks,
|
||||
[0, 10].map((m) => Date.parse(time(m))),
|
||||
);
|
||||
});
|
||||
|
||||
test('new frames reach the world clock through refresh', (t) => {
|
||||
const { clock, world } = harness(t);
|
||||
const radar = product('radar', []);
|
||||
clock.register(radar);
|
||||
const supported = [];
|
||||
world.subscribe((state) => supported.push(state.supported));
|
||||
assert.equal(world.getState().supported, false);
|
||||
radar.times = [time(0)];
|
||||
void clock.refresh();
|
||||
assert.equal(supported.at(-1), true);
|
||||
});
|
||||
|
||||
test('a clock destroyed while it registers a product lets go of it', (t) => {
|
||||
const { clock, world } = harness(t);
|
||||
const off = world.subscribe(() => {
|
||||
off();
|
||||
clock.destroy();
|
||||
});
|
||||
const unregister = clock.register(product('radar', [0, 10]));
|
||||
unregister();
|
||||
assert.equal(world.follows('radar'), false);
|
||||
assert.equal(world.coverage(), null);
|
||||
assert.equal(world.getState().supported, false);
|
||||
});
|
||||
@@ -52,7 +52,8 @@ const utc = (value) =>
|
||||
: t('Unavailable');
|
||||
|
||||
/** A per-application observation layer, using the existing layer lifecycle and
|
||||
* row controls. History is transient: shared links always open latest imagery. */
|
||||
* row controls. Its time is the world clock's, so a link made away from live
|
||||
* opens its imagery at that time; one made live opens the latest. */
|
||||
export function createWeatherLayer({
|
||||
feed,
|
||||
clock,
|
||||
|
||||
+14
-2
@@ -31,6 +31,7 @@ import {
|
||||
} from '../data/detectionPolicy.js';
|
||||
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
||||
import { VIEW_STYLES } from '../view/index.js';
|
||||
import { isWorldTime } from '../time/worldClock.js';
|
||||
import { RETIRED_STYLES } from '../visualStylePolicy.js';
|
||||
import {
|
||||
GLOBAL_POST_DEFAULTS,
|
||||
@@ -373,6 +374,7 @@ export const SHARE_VIEW_FIELDS = freezeAll([
|
||||
{ number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 },
|
||||
{ number: 26, key: 'scopeTerminusPct', type: 'uint', default: null },
|
||||
{ number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE },
|
||||
{ number: 28, key: 'worldTime', type: 'epoch', default: null },
|
||||
]);
|
||||
const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f]));
|
||||
const fieldNumber = (key) => FIELD_BY_KEY.get(key).number;
|
||||
@@ -732,6 +734,9 @@ export function normalizeShareViewState(input = {}) {
|
||||
: null;
|
||||
state.panelState = normalizePanelState(source.panelState);
|
||||
state.sharedAtMs = epochMsOr(source.sharedAtMs);
|
||||
state.worldTime = isWorldTime(source.worldTime)
|
||||
? Math.floor(source.worldTime / 1000) * 1000
|
||||
: null;
|
||||
let view = normalizeView(source.view);
|
||||
if (state.layerState) view = liftTrackedEntity(state.layerState, view);
|
||||
if (view.kind === 'thing') {
|
||||
@@ -948,8 +953,9 @@ export function encodeShareView(input) {
|
||||
writer.sintField(field.number, signedAngle(state[field.key]));
|
||||
break;
|
||||
case 'sharedAtMs':
|
||||
if (state.sharedAtMs !== null)
|
||||
writer.sintField(field.number, epochSeconds(state.sharedAtMs));
|
||||
case 'worldTime':
|
||||
if (state[field.key] !== null)
|
||||
writer.sintField(field.number, epochSeconds(state[field.key]));
|
||||
break;
|
||||
case 'flags': {
|
||||
let bits = 0;
|
||||
@@ -1347,6 +1353,12 @@ export function decodeShareView(code, { nowMs = Date.now() } = {}) {
|
||||
raw.sharedAtMs =
|
||||
createdAtS >= 1 && Number.isSafeInteger(ms) && ms <= nowMs ? ms : null;
|
||||
}
|
||||
// The world time the link was made at, when it was away from live. It
|
||||
// may lie ahead of the link's creation (a forecast) or of now.
|
||||
if (get('worldTime')) {
|
||||
const ms = (SHARE_VIEW_EPOCH_S + unz(get('worldTime').varint)) * 1000;
|
||||
raw.worldTime = isWorldTime(ms) ? ms : null;
|
||||
}
|
||||
|
||||
if (get('layerState'))
|
||||
raw.layerState = decodeLayers(
|
||||
|
||||
@@ -361,6 +361,7 @@ test('every registered field round-trips at once', () => {
|
||||
})),
|
||||
},
|
||||
sharedAtMs: NOW_MS,
|
||||
worldTime: NOW_MS - 6 * 3_600_000,
|
||||
};
|
||||
// A thing view derives its camera (roll 0), so the camera fields are
|
||||
// checked on the same state as a place view.
|
||||
@@ -501,6 +502,7 @@ test('the field registry is well formed and permanent', () => {
|
||||
[25, 'scopeFeatherPct', 100],
|
||||
[26, 'scopeTerminusPct', null],
|
||||
[27, 'styleParams', BY_STYLE],
|
||||
[28, 'worldTime', null],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
@@ -1338,3 +1340,22 @@ test('a layer option filed under a mirror layer is ignored', () => {
|
||||
{ scope: 'layer-option', code: `${military}.${layerTokenNumber('m')}` },
|
||||
]);
|
||||
});
|
||||
|
||||
test('the world time is written only away from live, and may lie ahead', () => {
|
||||
const at = (worldTime) =>
|
||||
decodeShareView(encodeShareView({ sharedAtMs: NOW_MS, worldTime }), {
|
||||
nowMs: NOW_MS,
|
||||
}).state.worldTime;
|
||||
assert.equal(at(null), null);
|
||||
assert.equal(at(NOW_MS - 3_600_000), NOW_MS - 3_600_000);
|
||||
// A forecast moment ahead of the link's creation is kept.
|
||||
assert.equal(at(NOW_MS + 86_400_000), NOW_MS + 86_400_000);
|
||||
// Before the 2026 epoch too: the field is zigzag.
|
||||
assert.equal(at(Date.UTC(2024, 0, 1)), Date.UTC(2024, 0, 1));
|
||||
// Past what a Date can hold: dropped, so the link opens live.
|
||||
assert.equal(at(8.8e15), null);
|
||||
assert.ok(
|
||||
encodeShareView({ sharedAtMs: NOW_MS, worldTime: NOW_MS - 1000 }).length >
|
||||
encodeShareView({ sharedAtMs: NOW_MS }).length,
|
||||
);
|
||||
});
|
||||
+11
-2
@@ -5,6 +5,7 @@ import {
|
||||
normalizeAllocationStrategy,
|
||||
} from './data/detectionPolicy.js';
|
||||
import { clampScopeTerminusPct } from './scopeMask.js';
|
||||
import { worldClock as pageWorldClock } from './time/worldClock.js';
|
||||
import { clampScopeFeatherPct } from './scopeMaskPolicy.js';
|
||||
import {
|
||||
decodeLayerStateParams,
|
||||
@@ -68,9 +69,11 @@ export class ShareLinkManager {
|
||||
isNavigationCurrent,
|
||||
cancelOwnedNavigation,
|
||||
initialShareCode = null,
|
||||
worldClock = pageWorldClock,
|
||||
} = {},
|
||||
) {
|
||||
this.viewer = viewer;
|
||||
this._worldClock = worldClock;
|
||||
// A host may hand over the share code to open instead of the address.
|
||||
this._initialShareCode =
|
||||
typeof initialShareCode === 'string' ? initialShareCode : null;
|
||||
@@ -163,8 +166,9 @@ export class ShareLinkManager {
|
||||
|
||||
/**
|
||||
* The restore state for a share code, in the long form's parsed shape plus
|
||||
* `shareView` (what the view follows, with its offset and last-known fix),
|
||||
* or null when the code is refused.
|
||||
* `shareView` (what the view follows, with its offset and last-known fix)
|
||||
* and `worldTime` (null when the link was made live), or null when the
|
||||
* code is refused.
|
||||
*/
|
||||
_parseShareCode(code) {
|
||||
const result = decodeShareView(code);
|
||||
@@ -174,6 +178,7 @@ export class ShareLinkManager {
|
||||
}
|
||||
const state = toLegacyShareState(result.state);
|
||||
state.shareView = result.state.view;
|
||||
state.worldTime = result.state.worldTime;
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -592,6 +597,10 @@ export class ShareLinkManager {
|
||||
// that state in the share format.
|
||||
const state = fromLegacyShareState(this._parseShareHash(params.toString()));
|
||||
state.sharedAtMs = Math.floor(nowMs / 1000) * 1000;
|
||||
// Away from live, the link opens at the same world time.
|
||||
state.worldTime = this._worldClock.isLive()
|
||||
? null
|
||||
: Math.floor(this._worldClock.now() / 1000) * 1000;
|
||||
let subject = null;
|
||||
try {
|
||||
subject = this._followSubjectProvider?.({ nowMs }) ?? null;
|
||||
|
||||
@@ -423,3 +423,28 @@ test('the layer coordinator replaces in-flight share layers with the navigated s
|
||||
);
|
||||
coordinator.destroy();
|
||||
});
|
||||
|
||||
test('a link made away from live carries its world time to the page that opens it', (t) => {
|
||||
installTab(t, SHARE_A);
|
||||
const worldTime = Date.UTC(2026, 9, 30, 6, 15, 42);
|
||||
const sharer = new ShareLinkManager(fakeViewer(), {
|
||||
worldClock: { isLive: () => false, now: () => worldTime + 400 },
|
||||
});
|
||||
const link = sharer.buildLink({ nowMs: Date.UTC(2026, 10, 1) });
|
||||
sharer.destroy();
|
||||
const code = new URL(link).pathname.split('/').at(-1);
|
||||
const opener = new ShareLinkManager(fakeViewer(), { initialShareCode: code });
|
||||
assert.equal(opener.parseInitialHash().worldTime, worldTime);
|
||||
opener.destroy();
|
||||
|
||||
const live = new ShareLinkManager(fakeViewer(), {
|
||||
worldClock: { isLive: () => true, now: () => worldTime },
|
||||
});
|
||||
const liveCode = new URL(live.buildLink()).pathname.split('/').at(-1);
|
||||
live.destroy();
|
||||
const liveOpener = new ShareLinkManager(fakeViewer(), {
|
||||
initialShareCode: liveCode,
|
||||
});
|
||||
assert.equal(liveOpener.parseInitialHash().worldTime, null);
|
||||
liveOpener.destroy();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 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. */
|
||||
export const WORLD_CLOCK_TICK_MS = 250;
|
||||
|
||||
/**
|
||||
* @param {{now?: () => number, monotonicNow?: () => number,
|
||||
* setInterval?: typeof globalThis.setInterval,
|
||||
* clearInterval?: typeof globalThis.clearInterval,
|
||||
* follow?: Iterable<string>}} [options] `follow` lists the layers that
|
||||
* move with the cursor from the start.
|
||||
*/
|
||||
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,
|
||||
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;
|
||||
|
||||
const continuous = () => playing && !active;
|
||||
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()) return cursorMs + (monotonicNow() - anchorMono) * rate;
|
||||
return cursorMs;
|
||||
};
|
||||
const getState = () =>
|
||||
Object.freeze({
|
||||
live,
|
||||
timeMs: time(),
|
||||
playing,
|
||||
rate,
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
const syncTicker = () => {
|
||||
if (continuous() && listeners.size) {
|
||||
ticker ??= setInterval(notify, WORLD_CLOCK_TICK_MS);
|
||||
} else if (ticker !== null) {
|
||||
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. */
|
||||
goLive() {
|
||||
if (live && !playing) return;
|
||||
const stepping = active;
|
||||
live = true;
|
||||
cursorMs = null;
|
||||
playing = false;
|
||||
active = null;
|
||||
// Its driven(false) finds nothing left to stop.
|
||||
stepping?.driver.pause();
|
||||
changed();
|
||||
},
|
||||
/** Play from the cursor (from now, when live). */
|
||||
play({ rate: nextRate } = {}) {
|
||||
if (nextRate !== undefined) api.setRate(nextRate);
|
||||
if (playing) return;
|
||||
for (let index = drivers.length - 1; 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();
|
||||
},
|
||||
/** 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);
|
||||
@@ -0,0 +1,307 @@
|
||||
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);
|
||||
});
|
||||
@@ -434,12 +434,12 @@ test('Cyber lower-left telemetry tracks the credit anchor with clearance for its
|
||||
cyberStyles,
|
||||
/#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/,
|
||||
);
|
||||
const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/);
|
||||
const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem \+ var\(--world-timeline-lift, 0px\)\);/);
|
||||
assert.ok(offset, 'telemetry must retain the attribution viewport anchor');
|
||||
const credits = read('./styles/command-dock-trays.css');
|
||||
const creditOffsets = [
|
||||
...credits.matchAll(
|
||||
/#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/g,
|
||||
/#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem \+ var\(--world-timeline-lift, 0px\)\);/g,
|
||||
),
|
||||
];
|
||||
assert.ok(creditOffsets.length > 0);
|
||||
@@ -738,7 +738,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem\);/,
|
||||
/#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem \+ var\(--world-timeline-lift, 0px\)\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
@@ -793,7 +793,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
|
||||
cyberStyles,
|
||||
/#right-context-rail:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-inline-end-color:/,
|
||||
);
|
||||
assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem\);/);
|
||||
assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem \+ var\(--world-timeline-lift, 0px\)\);/);
|
||||
assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/);
|
||||
assert.match(cyberStyles, /data-layer-id='satellites'/);
|
||||
assert.match(cyberStyles, /#search-toggle::before/);
|
||||
|
||||
@@ -586,7 +586,7 @@ test('the phone shell keeps the required attribution visible', () => {
|
||||
};
|
||||
const lift = (block) =>
|
||||
Number(
|
||||
/bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px\)/.exec(block)?.[1],
|
||||
/bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px \+ var\(--world-timeline-lift, 0px\)\)/.exec(block)?.[1],
|
||||
);
|
||||
const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]);
|
||||
const idleCredit = rule(
|
||||
|
||||
@@ -32,6 +32,7 @@ const LEFT_STACK_OBSTACLE_SELECTOR = [
|
||||
'#gev-voice-control',
|
||||
'#pp-toggles',
|
||||
'#param-slider-panel',
|
||||
'#world-timeline',
|
||||
].join(', ');
|
||||
/**
|
||||
* Fixed UI regions that can occupy the right control lane. Runtime rectangle
|
||||
@@ -59,6 +60,7 @@ const RIGHT_STACK_OBSTACLE_SELECTOR = [
|
||||
'#cesium-credits .cesium-credit-textContainer',
|
||||
'#command-dock',
|
||||
'#gev-voice-control',
|
||||
'#world-timeline',
|
||||
].join(', ');
|
||||
export class PanelLayoutController {
|
||||
constructor({
|
||||
|
||||
@@ -4,6 +4,16 @@ import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
|
||||
import { t, msg, formatDate } from '../i18n/index.js';
|
||||
import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js';
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
import { isWorldTime, worldClock } from '../time/worldClock.js';
|
||||
|
||||
// A link made away from live opens at its world time, paused. One without a
|
||||
// world time leaves the clock as it is: a page opens live, and Back or Forward
|
||||
// through long-form hashes (which never carry one) does not stop a replay.
|
||||
function applySharedWorldTime(state) {
|
||||
if (!isWorldTime(state?.worldTime)) return;
|
||||
worldClock.pause();
|
||||
worldClock.seek(state.worldTime);
|
||||
}
|
||||
|
||||
/** How long a shared vessel may take to appear in the vessel feed. */
|
||||
export const SHARED_VESSEL_WAIT_MS = 120_000;
|
||||
@@ -146,6 +156,7 @@ export class ShareRestoration {
|
||||
: Promise.resolve({ status: 'not-requested', share: null, layers: [] });
|
||||
if (savedState) {
|
||||
this._hasShareState = true;
|
||||
applySharedWorldTime(savedState);
|
||||
// Reserve camera authority now; the delayed mesh-friendly flight may
|
||||
// run only if no newer user, voice, or tracking navigation has won.
|
||||
this._initialShareNavigationGeneration =
|
||||
@@ -301,6 +312,7 @@ export class ShareRestoration {
|
||||
layers: [],
|
||||
});
|
||||
this._initialShareState = state;
|
||||
applySharedWorldTime(state);
|
||||
this._initialShareSelectionSuperseded = false;
|
||||
this._hasShareState = true;
|
||||
this._navigatedShareRestoreActive = true;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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 world clock as the timeline shows it: `{live, playing, 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', () => {
|
||||
const spanOf = (state) => {
|
||||
if (!state.supported) return null;
|
||||
const coverage = worldClock.coverage();
|
||||
if (!coverage) return null;
|
||||
const now = worldClock.wallNow();
|
||||
return Object.freeze({
|
||||
start: Math.min(coverage.start, now, state.timeMs),
|
||||
end: Math.max(coverage.end, now, state.timeMs),
|
||||
marks: coverage.marks,
|
||||
});
|
||||
};
|
||||
const read = (state = worldClock.getState()) =>
|
||||
Object.freeze({
|
||||
live: state.live,
|
||||
playing: state.playing,
|
||||
timeMs: state.timeMs,
|
||||
supported: state.supported,
|
||||
span: spanOf(state),
|
||||
});
|
||||
const time = shallowRef(read());
|
||||
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) => {
|
||||
time.value = read(state);
|
||||
syncTicker();
|
||||
});
|
||||
syncTicker();
|
||||
onScopeDispose(() => {
|
||||
unsubscribe();
|
||||
if (ticker !== null) clearInterval(ticker);
|
||||
ticker = null;
|
||||
});
|
||||
|
||||
return {
|
||||
time,
|
||||
togglePlay: () => worldClock.togglePlay(),
|
||||
goLive: () => worldClock.goLive(),
|
||||
/** Scrub to `ms`: playback stops where the hand puts it; at now, live. */
|
||||
scrub(ms) {
|
||||
const span = time.value.span;
|
||||
const edge = span ? (span.end - span.start) * LIVE_EDGE : 0;
|
||||
if (Math.abs(ms - worldClock.wallNow()) <= Math.max(LIVE_EDGE_MS, edge)) {
|
||||
worldClock.goLive();
|
||||
return;
|
||||
}
|
||||
worldClock.pause();
|
||||
worldClock.seek(ms);
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -870,7 +870,7 @@ html:not([lang='en']) .cockpit-roll-token.is-separator {
|
||||
position: fixed;
|
||||
z-index: 146;
|
||||
right: 36px;
|
||||
bottom: calc(2vh + 4.5rem);
|
||||
bottom: calc(2vh + 4.5rem + var(--world-timeline-lift, 0px));
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -2049,7 +2049,7 @@ body.cockpit-mode #intel-hud .hud-right-edge {
|
||||
@media (max-width: 760px) {
|
||||
#view-switcher {
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
bottom: calc(16px + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
body.cockpit-mode #view-switcher {
|
||||
right: auto;
|
||||
|
||||
@@ -377,15 +377,15 @@
|
||||
|
||||
/* Compact dock clearance keeps the reference-like low profile. */
|
||||
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
|
||||
bottom: calc(2vh + 4.7rem);
|
||||
bottom: calc(2vh + 4.7rem + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
||||
bottom: calc(2vh + 5.4rem);
|
||||
bottom: calc(2vh + 5.4rem + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||
bottom: calc(2vh + 8.9rem);
|
||||
bottom: calc(2vh + 8.9rem + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
|
||||
@@ -455,7 +455,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(
|
||||
)
|
||||
#cesium-credits {
|
||||
right: auto;
|
||||
bottom: calc(2vh + 5rem);
|
||||
bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px));
|
||||
left: 1.5rem;
|
||||
width: auto !important;
|
||||
max-width: calc(100vw - 3rem);
|
||||
@@ -464,21 +464,21 @@ body:not(.ui-clean-view):not(.recording-mode):has(
|
||||
|
||||
#intel-hud[data-variant='tactical'] .hud-bottom-left,
|
||||
#intel-hud[data-variant='tactical'] .hud-bottom-right {
|
||||
bottom: calc(2vh + 6.5rem);
|
||||
bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
||||
bottom: calc(2vh + 5.4rem);
|
||||
bottom: calc(2vh + 5.4rem + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-bottom-left {
|
||||
bottom: calc(2vh + 6.5rem);
|
||||
bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||
bottom: calc(2vh + 9rem);
|
||||
bottom: calc(2vh + 9rem + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -487,7 +487,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(
|
||||
#intel-hud[data-variant='minimal'].active
|
||||
)
|
||||
#cesium-credits {
|
||||
bottom: calc(2vh + 5rem);
|
||||
bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px));
|
||||
left: 0.75rem;
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
#command-dock {
|
||||
position: fixed;
|
||||
right: auto;
|
||||
bottom: 2vh;
|
||||
/* Above the world timeline while it shows (world-timeline.css). */
|
||||
bottom: calc(2vh + var(--world-timeline-lift, 0px));
|
||||
left: 50%;
|
||||
z-index: 145;
|
||||
display: grid;
|
||||
@@ -216,7 +217,7 @@
|
||||
|
||||
/* Keep required map attribution clear of the full-width command surface. */
|
||||
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
|
||||
bottom: calc(2vh + 7rem);
|
||||
bottom: calc(2vh + 7rem + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
|
||||
body:not(.ui-clean-view):not(.recording-mode):has(
|
||||
@@ -244,11 +245,11 @@ body.recording-mode #cesium-credits {
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
||||
bottom: calc(2vh + 7.5rem);
|
||||
bottom: calc(2vh + 7.5rem + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||
bottom: calc(2vh + 11.5rem);
|
||||
bottom: calc(2vh + 11.5rem + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
@@ -275,21 +276,21 @@ body.recording-mode #cesium-credits {
|
||||
}
|
||||
|
||||
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
|
||||
bottom: calc(2vh + 9.5rem);
|
||||
bottom: calc(2vh + 9.5rem + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
||||
bottom: calc(2vh + 12rem);
|
||||
bottom: calc(2vh + 12rem + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||
bottom: calc(2vh + 16rem);
|
||||
bottom: calc(2vh + 16rem + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
#command-dock {
|
||||
bottom: 8px;
|
||||
bottom: calc(8px + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
|
||||
#command-dock .location-search-wrap {
|
||||
|
||||
@@ -1585,7 +1585,7 @@
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='cyber'] .hud-bottom-bar {
|
||||
bottom: calc(2vh + 9.8rem);
|
||||
bottom: calc(2vh + 9.8rem + var(--world-timeline-lift, 0px));
|
||||
width: min(48vw, 680px);
|
||||
}
|
||||
|
||||
@@ -1596,13 +1596,13 @@
|
||||
|
||||
#intel-hud[data-variant='cyber'] .hud-bottom-left,
|
||||
#intel-hud[data-variant='cyber'] .hud-bottom-right {
|
||||
bottom: calc(2vh + 6.5rem);
|
||||
bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
|
||||
/* Attribution's logo row extends above its text baseline. Reserve the whole
|
||||
row plus a gap above the dock-relative 2vh + 5rem credit position. */
|
||||
#intel-hud[data-variant='cyber'] .hud-bottom-left {
|
||||
bottom: calc(2vh + 7.75rem);
|
||||
bottom: calc(2vh + 7.75rem + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='cyber'] .hud-corner {
|
||||
@@ -1647,7 +1647,7 @@
|
||||
below its 650px threshold. Keep the left rail inside the same telemetry
|
||||
and attribution-safe floor while its panel bodies take over scrolling. */
|
||||
:root[data-ui-theme='cyber'] #left-panel-stack {
|
||||
bottom: calc(2vh + 13rem);
|
||||
bottom: calc(2vh + 13rem + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1670,7 +1670,7 @@
|
||||
/* The stack holds still, so its rail needs no offset (leftPanelRail.js). */
|
||||
--panel-stack-top-pinned: 1;
|
||||
top: var(--cyber-left-rail-top) !important;
|
||||
bottom: calc(2vh + 13rem);
|
||||
bottom: calc(2vh + 13rem + var(--world-timeline-lift, 0px));
|
||||
inset-inline-start: calc(54px + var(--panel-rail-clearance));
|
||||
width: 300px;
|
||||
padding-inline-start: 10px;
|
||||
|
||||
@@ -200,7 +200,7 @@ body.mobile-shell #command-dock {
|
||||
/* The dock's tray rules size voice by this basis; a column makes it a height. */
|
||||
--dock-voice-width: auto;
|
||||
top: auto;
|
||||
bottom: calc(var(--mobile-tab-space) + 10px);
|
||||
bottom: calc(var(--mobile-tab-space) + 10px + var(--world-timeline-lift, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -380,7 +380,7 @@ body.mobile-shell #command-dock #gev-voice-control {
|
||||
body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view)
|
||||
#cesium-credits {
|
||||
right: auto;
|
||||
bottom: calc(var(--mobile-tab-space) + 106px) !important;
|
||||
bottom: calc(var(--mobile-tab-space) + 106px + var(--world-timeline-lift, 0px)) !important;
|
||||
left: 10px;
|
||||
/* With the base rule's nowrap the credits run off the right edge, and a
|
||||
max-width alone wrapped them into a narrow column: the line needs wrapping
|
||||
@@ -407,7 +407,7 @@ body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not(
|
||||
/* The view switcher clears the tab bar and the voice bar, opposite the credit. */
|
||||
body.mobile-shell:not(.cockpit-mode) #view-switcher {
|
||||
right: 12px;
|
||||
bottom: calc(var(--mobile-tab-space) + 106px);
|
||||
bottom: calc(var(--mobile-tab-space) + 106px + var(--world-timeline-lift, 0px));
|
||||
}
|
||||
|
||||
body.mobile-shell #corner-actions {
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
drawn straight over the notice. Track the credit's own anchor instead
|
||||
and the 12px of clear air holds regardless of height; the rail already
|
||||
scrolls (overflow-y below), so it loses reach, not content. */
|
||||
bottom: calc(2vh + 7.5rem);
|
||||
bottom: calc(2vh + 7.5rem + var(--world-timeline-lift, 0px));
|
||||
left: 16px;
|
||||
width: auto;
|
||||
max-height: none;
|
||||
|
||||
@@ -390,7 +390,7 @@ html:not([lang='en']) #global-loading-detail {
|
||||
/* ── Toast Notification ──────────────────────── */
|
||||
#toast {
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
bottom: calc(100px + var(--world-timeline-lift, 0px));
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
z-index: 200;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
body:has(#world-timeline) {
|
||||
--world-timeline-lift: 50px;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
#world-timeline {
|
||||
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-time {
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#world-timeline .world-timeline-edge {
|
||||
flex: none;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
flex: 1 1 auto;
|
||||
min-width: 80px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
#world-timeline .world-timeline-scrub {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
appearance: 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;
|
||||
}
|
||||
|
||||
#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 on a row of its own beneath play, the time 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;
|
||||
}
|
||||
|
||||
body.mobile-shell #world-timeline .world-timeline-time {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
body.mobile-shell #world-timeline .world-timeline-track {
|
||||
order: 1;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
#world-timeline .world-timeline-edge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#world-timeline .world-timeline-time {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import ScreenAnnotations from './panels/ScreenAnnotations.vue';
|
||||
import AwarenessDirection from './panels/AwarenessDirection.vue';
|
||||
import UiButton from '../kit/UiButton.vue';
|
||||
import GlobeActions from './panels/GlobeActions.vue';
|
||||
import WorldTimeline from './panels/WorldTimeline.vue';
|
||||
|
||||
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
|
||||
const { style } = storeToRefs(useDisplayStore());
|
||||
@@ -117,4 +118,5 @@ const vToastText = {
|
||||
<MissionReplayOverlay />
|
||||
<ScreenAnnotations />
|
||||
<AwarenessDirection />
|
||||
<WorldTimeline />
|
||||
</template>
|
||||
@@ -0,0 +1,150 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { computed } 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';
|
||||
|
||||
// The world timeline: play and pause on the left, the time, a track across
|
||||
// the stretch the layers on can show (each launch or frame a tick) with a
|
||||
// handle to scrub, and the way back to live on the right, lit while live. It
|
||||
// shows while a layer with a time of its own (weather, satellites, launches,
|
||||
// or one a host follows) is on with time to show; with only live feeds such
|
||||
// as flights on, the bar stays hidden.
|
||||
const store = useWorldTimeStore();
|
||||
const { time } = storeToRefs(store);
|
||||
|
||||
const DATE_TIME = Object.freeze({
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
const EDGE_DATE = Object.freeze({
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
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;
|
||||
|
||||
const readout = computed(() =>
|
||||
Number.isFinite(time.value.timeMs)
|
||||
? `${formatDate(time.value.timeMs, DATE_TIME)} UTC`
|
||||
: '',
|
||||
);
|
||||
const span = computed(() => time.value.span);
|
||||
const at = computed(() => {
|
||||
const { start, end } = span.value;
|
||||
return Math.min(end, Math.max(start, time.value.timeMs));
|
||||
});
|
||||
const place = (ms) => {
|
||||
const { start, end } = span.value;
|
||||
return end > start ? ((ms - start) / (end - start)) * 100 : 100;
|
||||
};
|
||||
const ticks = computed(() =>
|
||||
span.value.marks.map((mark) => `${place(mark)}%`),
|
||||
);
|
||||
const scrub = (event) => store.scrub(Number(event.target.value));
|
||||
// Arrows go to the previous or next mark (a launch, a frame), or a step along
|
||||
// the track; Page keys take a tenth of it; Home and End go to its ends.
|
||||
const onKey = (event) => {
|
||||
const { start, end, marks } = span.value;
|
||||
const now = at.value;
|
||||
const length = end - start;
|
||||
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)));
|
||||
};
|
||||
</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
|
||||
><time class="world-timeline-time" :datetime="new Date(time.timeMs).toISOString()" aria-live="off">{{ readout }}</time
|
||||
><span class="world-timeline-edge">{{ formatDate(span.start, EDGE_DATE) }}</span
|
||||
><div class="world-timeline-track"
|
||||
><span v-for="(left, index) in ticks" :key="index" class="world-timeline-tick" :style="{ left }" aria-hidden="true"></span
|
||||
><span class="world-timeline-fill" :style="{ width: `${place(at)}%` }" aria-hidden="true"></span
|
||||
><input
|
||||
class="world-timeline-scrub"
|
||||
type="range"
|
||||
:aria-label="t('World time')"
|
||||
:aria-valuetext="readout"
|
||||
:min="span.start"
|
||||
:max="span.end"
|
||||
step="any"
|
||||
:value="at"
|
||||
@input="scrub"
|
||||
@keydown="onKey"
|
||||
/></div
|
||||
><span class="world-timeline-edge">{{ formatDate(span.end, EDGE_DATE) }}</span
|
||||
><UiButton class="world-timeline-live" :label="time.live ? t('Live') : t('Back to live')" :pressed="time.live" pressed-class="is-live" :disabled="time.live" @click="store.goLive()">{{ t('Live') }}</UiButton
|
||||
></div>
|
||||
</template>
|
||||
@@ -74,9 +74,11 @@ test('bikeshare stations join coordinates from station information', () => {
|
||||
assert.deepEqual(methods.getAnalystRecords(), []);
|
||||
});
|
||||
|
||||
test('launches carry time and hours until launch', () => {
|
||||
const at = Date.now() + 5 * 3_600_000;
|
||||
test('launches carry time and hours until launch at the world time', () => {
|
||||
const now = Date.UTC(2026, 10, 1);
|
||||
const at = now + 5 * 3_600_000;
|
||||
const state = {
|
||||
now: () => now,
|
||||
_enabled: true,
|
||||
_launches: [
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
@import './src/ui/styles/provider-settings.css';
|
||||
@import './src/ui/styles/locale-picker.css';
|
||||
@import './src/ui/styles/weather.css';
|
||||
@import './src/ui/styles/world-timeline.css';
|
||||
@import './src/ui/styles/panel-surfaces.css';
|
||||
@import './src/ui/styles/segmented-control.css';
|
||||
@import './src/ui/styles/kit.css';
|
||||
|
||||
Reference in new issue
Block a user