Make the world timeline a scrubber under the dock

The timeline shows whenever a layer with time to show is on, live or not.
Its track runs over the time those layers cover, reaching to now, with a
tick for each launch or frame and a handle to drag; arrows step between the
ticks, and a scrub to now goes live. The bar sits along the bottom of the
screen, and the dock and everything anchored above it rise by its height;
clean view, recording and the cockpit put it away with the dock.
This commit is contained in:
Sameh Khamis committed 2026-11-01 23:37:12 -08:00
1 parent 9faec3028e
commit 48093108b1
15 files changed
+434 -70

No files matched your search

+31 -7
View File
@@ -229,13 +229,30 @@ const GUARDED_PROPS = new Set([
// Custom properties allowed inside a modelled offset. Each is a pinned-tray // Custom properties allowed inside a modelled offset. Each is a pinned-tray
// stack height, proven non-negative below, so evaluating them at 0 is a // stack height, proven non-negative below, so evaluating them at 0 is a
// conservative floor rather than a guess. // conservative floor rather than a guess. The world timeline's lift raises
// the dock, its trays and the credit together, so it moves no gap either.
const VETTED_VARS = new Set([ const VETTED_VARS = new Set([
'--dock-pinned-stack-height', '--dock-pinned-stack-height',
'--dock-lower-pinned-height', '--dock-lower-pinned-height',
'--dock-location-pinned-height', '--dock-location-pinned-height',
'--dock-presets-pinned-height', '--dock-presets-pinned-height',
'--world-timeline-lift',
]); ]);
// Custom properties CSS sets to a fixed length besides their 0 default. The
// clearance checks run at each length CSS gives them, not only at 0.
const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
const LIFTS_PX = [0, 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). */ /** Rules whose final compound targets a modelled element (pseudo-elements aside). */
function ownBoxEntries() { function ownBoxEntries() {
@@ -315,6 +332,7 @@ function toPx(value, viewportHeight, where) {
if (term.startsWith('var(')) { if (term.startsWith('var(')) {
const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim(); const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim();
assert.ok(VETTED_VARS.has(name), `unvetted custom property ${name} in ${where}: "${value}"`); assert.ok(VETTED_VARS.has(name), `unvetted custom property ${name} in ${where}: "${value}"`);
if (name === '--world-timeline-lift') total += liftPx;
continue; // proven non-negative; 0 is the conservative floor continue; // proven non-negative; 0 is the conservative floor
} }
assert.match(term, /^-?[\d.]+(px|rem|vh)$/, `unmodelled length term "${term}" in ${where}: "${value}"`); assert.match(term, /^-?[\d.]+(px|rem|vh)$/, `unmodelled length term "${term}" in ${where}: "${value}"`);
@@ -444,7 +462,8 @@ test('custom properties inside modelled offsets are provably non-negative', () =
assert.ok(declared.length >= VETTED_VARS.size, 'every vetted custom property needs a CSS default'); assert.ok(declared.length >= VETTED_VARS.size, 'every vetted custom property needs a CSS default');
for (const decl of declared) { for (const decl of declared) {
assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`); assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`);
assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`); const shape = FIXED_LENGTH_VARS.has(decl.prop) ? /^(0|\d+px)$/ : /^(0|0px)$/;
assert.match(decl.value, shape, `${decl.prop} default is not a vetted shape: ${decl.value}`);
} }
const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'); const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
const start = layout.indexOf('_updateCommandDockTrayStack() {'); const start = layout.indexOf('_updateCommandDockTrayStack() {');
@@ -517,16 +536,18 @@ test('every open dock tray clears the required credit at every modelled viewport
// number: the dock is anchored at 2vh down to 721px and re-anchors to a flat // number: the dock is anchored at 2vh down to 721px and re-anchors to a flat
// 8px at 720px, while the credit keeps its 2vh base throughout. // 8px at 720px, while the credit keeps its 2vh base throughout.
const failures = []; const failures = [];
eachLift((lift) => {
for (const scenario of TRAY_SCENARIOS) { for (const scenario of TRAY_SCENARIOS) {
for (const width of WIDTHS.filter((w) => w <= 900)) { for (const width of WIDTHS.filter((w) => w <= 900)) {
for (const height of HEIGHTS) { for (const height of HEIGHTS) {
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height); const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) { if (clearance < MIN_CLEARANCE_PX) {
failures.push(`${scenario.name} @ ${width}x${height}: ${clearance.toFixed(1)}px`); failures.push(`${scenario.name} @ ${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
} }
} }
} }
} }
});
assert.deepEqual(failures, [], `a dock tray re-enters the credit band:\n ${failures.join('\n ')}\n`); assert.deepEqual(failures, [], `a dock tray re-enters the credit band:\n ${failures.join('\n ')}\n`);
}); });
@@ -566,21 +587,24 @@ test('the full-width context rail clears the required credit at every modelled v
'none', 'none',
`at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`, `at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`,
); );
eachLift((lift) => {
for (const height of HEIGHTS) { for (const height of HEIGHTS) {
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail'); const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height); const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height}: ${clearance.toFixed(1)}px`); if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
} }
});
} }
assert.deepEqual(failures, [], `context rail re-enters the credit band at ${failures.join(', ')}`); assert.deepEqual(failures, [], `context rail re-enters the credit band at ${failures.join(', ')}`);
}); });
test('the dock anchor changes at 720px — the 2vh cancellation is band-limited', () => { test('the dock anchor changes at 720px — the 2vh cancellation is band-limited', () => {
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, '2vh'); const lift = 'var(--world-timeline-lift, 0px)';
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, '8px'); assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, `calc(2vh + ${lift})`);
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, `calc(8px + ${lift})`);
assert.equal( assert.equal(
resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value, resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value,
'calc(2vh + 5rem)', `calc(2vh + 5rem + ${lift})`,
'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard', 'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard',
); );
}); });
+4 -4
View File
@@ -434,12 +434,12 @@ test('Cyber lower-left telemetry tracks the credit anchor with clearance for its
cyberStyles, cyberStyles,
/#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/, /#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/,
); );
const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/); const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem \+ var\(--world-timeline-lift, 0px\)\);/);
assert.ok(offset, 'telemetry must retain the attribution viewport anchor'); assert.ok(offset, 'telemetry must retain the attribution viewport anchor');
const credits = read('./styles/command-dock-trays.css'); const credits = read('./styles/command-dock-trays.css');
const creditOffsets = [ const creditOffsets = [
...credits.matchAll( ...credits.matchAll(
/#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/g, /#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem \+ var\(--world-timeline-lift, 0px\)\);/g,
), ),
]; ];
assert.ok(creditOffsets.length > 0); assert.ok(creditOffsets.length > 0);
@@ -738,7 +738,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
); );
assert.match( assert.match(
cyberStyles, cyberStyles,
/#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem\);/, /#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem \+ var\(--world-timeline-lift, 0px\)\);/,
); );
assert.match( assert.match(
cyberStyles, cyberStyles,
@@ -793,7 +793,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
cyberStyles, cyberStyles,
/#right-context-rail:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-inline-end-color:/, /#right-context-rail:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-inline-end-color:/,
); );
assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem\);/); assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem \+ var\(--world-timeline-lift, 0px\)\);/);
assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/); assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/);
assert.match(cyberStyles, /data-layer-id='satellites'/); assert.match(cyberStyles, /data-layer-id='satellites'/);
assert.match(cyberStyles, /#search-toggle::before/); assert.match(cyberStyles, /#search-toggle::before/);
+1 -1
View File
@@ -586,7 +586,7 @@ test('the phone shell keeps the required attribution visible', () => {
}; };
const lift = (block) => const lift = (block) =>
Number( Number(
/bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px\)/.exec(block)?.[1], /bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px \+ var\(--world-timeline-lift, 0px\)\)/.exec(block)?.[1],
); );
const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]); const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]);
const idleCredit = rule( const idleCredit = rule(
+59 -8
View File
@@ -2,31 +2,82 @@ import { defineStore } from 'pinia';
import { onScopeDispose, shallowRef } from 'vue'; import { onScopeDispose, shallowRef } from 'vue';
import { worldClock } from '../../time/worldClock.js'; 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, * The world clock as the timeline shows it: `{live, playing, timeMs,
* supported}`, where `supported` says a layer that moves with the clock has * supported, span}`, where `supported` says a layer that moves with the clock
* time to show. It * has time to show and `span` is the stretch the timeline runs over: the
* follows the page's world clock, which tells it of every move, and of the * time those layers cover, reaching to now and to the world time, with their
* time a few times a second while it plays on its own. * 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', () => { 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()) => const read = (state = worldClock.getState()) =>
Object.freeze({ Object.freeze({
live: state.live, live: state.live,
playing: state.playing, playing: state.playing,
timeMs: state.timeMs, timeMs: state.timeMs,
supported: state.supported, supported: state.supported,
span: spanOf(state),
}); });
const time = shallowRef(read()); const time = shallowRef(read());
onScopeDispose( let ticker = null;
worldClock.subscribe((state) => { 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); time.value = read(state);
}), syncTicker();
); });
syncTicker();
onScopeDispose(() => {
unsubscribe();
if (ticker !== null) clearInterval(ticker);
ticker = null;
});
return { return {
time, time,
togglePlay: () => worldClock.togglePlay(), togglePlay: () => worldClock.togglePlay(),
goLive: () => worldClock.goLive(), 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);
},
}; };
}); });
+2 -2
View File
@@ -870,7 +870,7 @@ html:not([lang='en']) .cockpit-roll-token.is-separator {
position: fixed; position: fixed;
z-index: 146; z-index: 146;
right: 36px; right: 36px;
bottom: calc(2vh + 4.5rem); bottom: calc(2vh + 4.5rem + var(--world-timeline-lift, 0px));
display: grid; display: grid;
gap: 6px; gap: 6px;
} }
@@ -2044,7 +2044,7 @@ body.cockpit-mode #intel-hud .hud-right-edge {
@media (max-width: 760px) { @media (max-width: 760px) {
#view-switcher { #view-switcher {
right: 16px; right: 16px;
bottom: 16px; bottom: calc(16px + var(--world-timeline-lift, 0px));
} }
body.cockpit-mode #view-switcher { body.cockpit-mode #view-switcher {
right: auto; right: auto;
+3 -3
View File
@@ -377,15 +377,15 @@
/* Compact dock clearance keeps the reference-like low profile. */ /* Compact dock clearance keeps the reference-like low profile. */
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
bottom: calc(2vh + 4.7rem); bottom: calc(2vh + 4.7rem + var(--world-timeline-lift, 0px));
} }
#intel-hud[data-variant='minimal'] .hud-bottom-left { #intel-hud[data-variant='minimal'] .hud-bottom-left {
bottom: calc(2vh + 5.4rem); bottom: calc(2vh + 5.4rem + var(--world-timeline-lift, 0px));
} }
#intel-hud[data-variant='minimal'] .hud-bottom-bar { #intel-hud[data-variant='minimal'] .hud-bottom-bar {
bottom: calc(2vh + 8.9rem); bottom: calc(2vh + 8.9rem + var(--world-timeline-lift, 0px));
} }
@media (max-width: 900px) { @media (max-width: 900px) {
+6 -6
View File
@@ -455,7 +455,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(
) )
#cesium-credits { #cesium-credits {
right: auto; right: auto;
bottom: calc(2vh + 5rem); bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px));
left: 1.5rem; left: 1.5rem;
width: auto !important; width: auto !important;
max-width: calc(100vw - 3rem); max-width: calc(100vw - 3rem);
@@ -464,21 +464,21 @@ body:not(.ui-clean-view):not(.recording-mode):has(
#intel-hud[data-variant='tactical'] .hud-bottom-left, #intel-hud[data-variant='tactical'] .hud-bottom-left,
#intel-hud[data-variant='tactical'] .hud-bottom-right { #intel-hud[data-variant='tactical'] .hud-bottom-right {
bottom: calc(2vh + 6.5rem); bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px));
} }
#intel-hud[data-variant='minimal'] .hud-bottom-left { #intel-hud[data-variant='minimal'] .hud-bottom-left {
bottom: calc(2vh + 5.4rem); bottom: calc(2vh + 5.4rem + var(--world-timeline-lift, 0px));
} }
:root[data-scope-mask='off'] :root[data-scope-mask='off']
#intel-hud[data-variant='minimal'] #intel-hud[data-variant='minimal']
.hud-bottom-left { .hud-bottom-left {
bottom: calc(2vh + 6.5rem); bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px));
} }
#intel-hud[data-variant='minimal'] .hud-bottom-bar { #intel-hud[data-variant='minimal'] .hud-bottom-bar {
bottom: calc(2vh + 9rem); bottom: calc(2vh + 9rem + var(--world-timeline-lift, 0px));
} }
@media (max-width: 900px) { @media (max-width: 900px) {
@@ -487,7 +487,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(
#intel-hud[data-variant='minimal'].active #intel-hud[data-variant='minimal'].active
) )
#cesium-credits { #cesium-credits {
bottom: calc(2vh + 5rem); bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px));
left: 0.75rem; left: 0.75rem;
max-width: calc(100vw - 1.5rem); max-width: calc(100vw - 1.5rem);
} }
+9 -8
View File
@@ -2,7 +2,8 @@
#command-dock { #command-dock {
position: fixed; position: fixed;
right: auto; right: auto;
bottom: 2vh; /* Above the world timeline while it shows (world-timeline.css). */
bottom: calc(2vh + var(--world-timeline-lift, 0px));
left: 50%; left: 50%;
z-index: 145; z-index: 145;
display: grid; display: grid;
@@ -216,7 +217,7 @@
/* Keep required map attribution clear of the full-width command surface. */ /* Keep required map attribution clear of the full-width command surface. */
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
bottom: calc(2vh + 7rem); bottom: calc(2vh + 7rem + var(--world-timeline-lift, 0px));
} }
body:not(.ui-clean-view):not(.recording-mode):has( body:not(.ui-clean-view):not(.recording-mode):has(
@@ -244,11 +245,11 @@ body.recording-mode #cesium-credits {
} }
#intel-hud[data-variant='minimal'] .hud-bottom-left { #intel-hud[data-variant='minimal'] .hud-bottom-left {
bottom: calc(2vh + 7.5rem); bottom: calc(2vh + 7.5rem + var(--world-timeline-lift, 0px));
} }
#intel-hud[data-variant='minimal'] .hud-bottom-bar { #intel-hud[data-variant='minimal'] .hud-bottom-bar {
bottom: calc(2vh + 11.5rem); bottom: calc(2vh + 11.5rem + var(--world-timeline-lift, 0px));
} }
@media (max-width: 980px) { @media (max-width: 980px) {
@@ -275,21 +276,21 @@ body.recording-mode #cesium-credits {
} }
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
bottom: calc(2vh + 9.5rem); bottom: calc(2vh + 9.5rem + var(--world-timeline-lift, 0px));
} }
#intel-hud[data-variant='minimal'] .hud-bottom-left { #intel-hud[data-variant='minimal'] .hud-bottom-left {
bottom: calc(2vh + 12rem); bottom: calc(2vh + 12rem + var(--world-timeline-lift, 0px));
} }
#intel-hud[data-variant='minimal'] .hud-bottom-bar { #intel-hud[data-variant='minimal'] .hud-bottom-bar {
bottom: calc(2vh + 16rem); bottom: calc(2vh + 16rem + var(--world-timeline-lift, 0px));
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
#command-dock { #command-dock {
bottom: 8px; bottom: calc(8px + var(--world-timeline-lift, 0px));
} }
#command-dock .location-search-wrap { #command-dock .location-search-wrap {
+5 -5
View File
@@ -1572,7 +1572,7 @@
} }
#intel-hud[data-variant='cyber'] .hud-bottom-bar { #intel-hud[data-variant='cyber'] .hud-bottom-bar {
bottom: calc(2vh + 9.8rem); bottom: calc(2vh + 9.8rem + var(--world-timeline-lift, 0px));
width: min(48vw, 680px); width: min(48vw, 680px);
} }
@@ -1583,13 +1583,13 @@
#intel-hud[data-variant='cyber'] .hud-bottom-left, #intel-hud[data-variant='cyber'] .hud-bottom-left,
#intel-hud[data-variant='cyber'] .hud-bottom-right { #intel-hud[data-variant='cyber'] .hud-bottom-right {
bottom: calc(2vh + 6.5rem); bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px));
} }
/* Attribution's logo row extends above its text baseline. Reserve the whole /* Attribution's logo row extends above its text baseline. Reserve the whole
row plus a gap above the dock-relative 2vh + 5rem credit position. */ row plus a gap above the dock-relative 2vh + 5rem credit position. */
#intel-hud[data-variant='cyber'] .hud-bottom-left { #intel-hud[data-variant='cyber'] .hud-bottom-left {
bottom: calc(2vh + 7.75rem); bottom: calc(2vh + 7.75rem + var(--world-timeline-lift, 0px));
} }
#intel-hud[data-variant='cyber'] .hud-corner { #intel-hud[data-variant='cyber'] .hud-corner {
@@ -1634,7 +1634,7 @@
below its 650px threshold. Keep the left rail inside the same telemetry below its 650px threshold. Keep the left rail inside the same telemetry
and attribution-safe floor while its panel bodies take over scrolling. */ and attribution-safe floor while its panel bodies take over scrolling. */
:root[data-ui-theme='cyber'] #left-panel-stack { :root[data-ui-theme='cyber'] #left-panel-stack {
bottom: calc(2vh + 13rem); bottom: calc(2vh + 13rem + var(--world-timeline-lift, 0px));
} }
} }
@@ -1657,7 +1657,7 @@
/* The stack holds still, so its rail needs no offset (leftPanelRail.js). */ /* The stack holds still, so its rail needs no offset (leftPanelRail.js). */
--panel-stack-top-pinned: 1; --panel-stack-top-pinned: 1;
top: var(--cyber-left-rail-top) !important; top: var(--cyber-left-rail-top) !important;
bottom: calc(2vh + 13rem); bottom: calc(2vh + 13rem + var(--world-timeline-lift, 0px));
inset-inline-start: calc(54px + var(--panel-rail-clearance)); inset-inline-start: calc(54px + var(--panel-rail-clearance));
width: 300px; width: 300px;
padding-inline-start: 10px; padding-inline-start: 10px;
+3 -3
View File
@@ -197,7 +197,7 @@ body.mobile-shell #command-dock {
/* The dock's tray rules size voice by this basis; a column makes it a height. */ /* The dock's tray rules size voice by this basis; a column makes it a height. */
--dock-voice-width: auto; --dock-voice-width: auto;
top: auto; top: auto;
bottom: calc(var(--mobile-tab-space) + 10px); bottom: calc(var(--mobile-tab-space) + 10px + var(--world-timeline-lift, 0px));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -377,7 +377,7 @@ body.mobile-shell #command-dock #gev-voice-control {
body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view)
#cesium-credits { #cesium-credits {
right: auto; right: auto;
bottom: calc(var(--mobile-tab-space) + 106px) !important; bottom: calc(var(--mobile-tab-space) + 106px + var(--world-timeline-lift, 0px)) !important;
left: 10px; left: 10px;
max-width: calc(100vw - 20px); max-width: calc(100vw - 20px);
transform-origin: left bottom; transform-origin: left bottom;
@@ -396,7 +396,7 @@ body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not(
/* The view switcher clears the tab bar and the voice bar, opposite the credit. */ /* The view switcher clears the tab bar and the voice bar, opposite the credit. */
body.mobile-shell:not(.cockpit-mode) #view-switcher { body.mobile-shell:not(.cockpit-mode) #view-switcher {
right: 12px; right: 12px;
bottom: calc(var(--mobile-tab-space) + 106px); bottom: calc(var(--mobile-tab-space) + 106px + var(--world-timeline-lift, 0px));
} }
body.mobile-shell #corner-actions { body.mobile-shell #corner-actions {
+1 -1
View File
@@ -177,7 +177,7 @@
drawn straight over the notice. Track the credit's own anchor instead drawn straight over the notice. Track the credit's own anchor instead
and the 12px of clear air holds regardless of height; the rail already and the 12px of clear air holds regardless of height; the rail already
scrolls (overflow-y below), so it loses reach, not content. */ scrolls (overflow-y below), so it loses reach, not content. */
bottom: calc(2vh + 7.5rem); bottom: calc(2vh + 7.5rem + var(--world-timeline-lift, 0px));
left: 16px; left: 16px;
width: auto; width: auto;
max-height: none; max-height: none;
+1 -1
View File
@@ -390,7 +390,7 @@ html:not([lang='en']) #global-loading-detail {
/* ── Toast Notification ──────────────────────── */ /* ── Toast Notification ──────────────────────── */
#toast { #toast {
position: fixed; position: fixed;
bottom: 100px; bottom: calc(100px + var(--world-timeline-lift, 0px));
left: 50%; left: 50%;
transform: translateX(-50%) translateY(20px); transform: translateX(-50%) translateY(20px);
z-index: 200; z-index: 200;
+168 -8
View File
@@ -1,14 +1,37 @@
/* ── World timeline ─────────────────────────── */ /* ── World timeline ─────────────────────────── */
/* Shown while the world time is away from live, centred above the dock. */ /* 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 { #world-timeline {
position: fixed; position: fixed;
bottom: calc(2vh + 7rem); bottom: 2vh;
left: 50%; left: 50%;
z-index: 146; z-index: 146;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
max-width: calc(100vw - 32px); width: min(820px, calc(100vw - 24px));
height: 42px;
box-sizing: border-box; box-sizing: border-box;
padding: 4px 6px; padding: 4px 6px;
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
@@ -20,11 +43,6 @@
transform: translateX(-50%); transform: translateX(-50%);
} }
body.ui-clean-view #world-timeline,
body.recording-mode #world-timeline {
bottom: 2vh;
}
#world-timeline .world-timeline-play, #world-timeline .world-timeline-play,
#world-timeline .world-timeline-live { #world-timeline .world-timeline-live {
display: inline-flex; display: inline-flex;
@@ -46,6 +64,13 @@ body.recording-mode #world-timeline {
background: rgba(0, 212, 255, 0.18); 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 { #world-timeline .world-timeline-live {
font-size: 11px; font-size: 11px;
letter-spacing: 0.08em; letter-spacing: 0.08em;
@@ -60,3 +85,138 @@ body.recording-mode #world-timeline {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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;
}
}
+75 -6
View File
@@ -1,5 +1,5 @@
// The world timeline shows the world time while it is away from live, with // The world timeline shows the world time while a layer with time to show is
// play and pause on the left and the way back to live on the right. // on, with play and pause on the left and the way back to live on the right.
import '../../../testSupport/registerDom.mjs'; import '../../../testSupport/registerDom.mjs';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -7,13 +7,17 @@ import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import { worldClock } from '../../../time/worldClock.js'; import { worldClock } from '../../../time/worldClock.js';
import WorldTimeline from './WorldTimeline.vue'; import WorldTimeline from './WorldTimeline.vue';
import { useWorldTimeStore } from '../../stores/worldTime.js';
function mount(t) { function mount(t) {
document.body.replaceChildren(); document.body.replaceChildren();
const app = createApp(WorldTimeline).use(createPinia()); const pinia = createPinia();
const app = createApp(WorldTimeline).use(pinia);
app.mount(document.body.appendChild(document.createElement('div'))); app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => { t.after(() => {
app.unmount(); app.unmount();
// The store outlives the view; dispose it with its clock subscription.
useWorldTimeStore(pinia).$dispose();
worldClock.goLive(); worldClock.goLive();
}); });
return () => document.getElementById('world-timeline'); return () => document.getElementById('world-timeline');
@@ -36,12 +40,15 @@ function supportingLayer(t, at) {
}; };
} }
test('the timeline shows away from live and plays, pauses and goes back', async (t) => { test('the timeline shows live, and plays, pauses and goes back', async (t) => {
const bar = mount(t); const bar = mount(t);
const at = Date.UTC(2026, 9, 30, 6, 15, 42); const at = Date.UTC(2026, 9, 30, 6, 15, 42);
supportingLayer(t, at); supportingLayer(t, at);
await nextTick(); await nextTick();
assert.equal(bar(), null, 'hidden while live'); 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); worldClock.seek(at);
await nextTick(); await nextTick();
@@ -60,15 +67,19 @@ test('the timeline shows away from live and plays, pauses and goes back', async
await nextTick(); await nextTick();
assert.equal(worldClock.isPlaying(), false); assert.equal(worldClock.isPlaying(), false);
assert.equal(bar().querySelector('.world-timeline-live').disabled, false);
bar().querySelector('.world-timeline-live').click(); bar().querySelector('.world-timeline-live').click();
await nextTick(); await nextTick();
assert.equal(worldClock.isLive(), true); assert.equal(worldClock.isLive(), true);
assert.equal(bar(), null); 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) => { test('the timeline hides while no layer with time to show is on', async (t) => {
const bar = mount(t); const bar = mount(t);
const at = Date.UTC(2026, 9, 30, 6, 15, 42); const at = Date.UTC(2026, 9, 30, 6, 15, 42);
await nextTick();
assert.equal(bar(), null, 'live, nothing to play');
worldClock.seek(at); worldClock.seek(at);
await nextTick(); await nextTick();
assert.equal(bar(), null, 'away from live, but nothing to play'); assert.equal(bar(), null, 'away from live, but nothing to play');
@@ -79,3 +90,61 @@ test('the timeline hides while no layer with time to show is on', async (t) => {
await nextTick(); await nextTick();
assert.equal(bar(), null, 'the layer went off'); 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);
});
+66 -7
View File
@@ -6,11 +6,12 @@ import { useWorldTimeStore } from '../../stores/worldTime.js';
import UiButton from '../../kit/UiButton.vue'; import UiButton from '../../kit/UiButton.vue';
import UiIcon from '../../kit/UiIcon.vue'; import UiIcon from '../../kit/UiIcon.vue';
// The world time while it is away from live: what is playing, or where it // The world timeline: play and pause on the left, the time, a track across
// was paused, with play and pause on the left and the way back to live on // the stretch the layers on can show (each launch or frame a tick) with a
// the right. It shows only while a layer with a time of its own (weather, // handle to scrub, and the way back to live on the right, lit while live. It
// satellites, launches, or one a host follows) is on with time to show; live, // shows while a layer with a time of its own (weather, satellites, launches,
// or with only live feeds such as flights on, the bar stays hidden. // 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 store = useWorldTimeStore();
const { time } = storeToRefs(store); const { time } = storeToRefs(store);
@@ -19,18 +20,76 @@ const DATE_TIME = Object.freeze({
timeStyle: 'medium', timeStyle: 'medium',
timeZone: 'UTC', 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(() => const readout = computed(() =>
Number.isFinite(time.value.timeMs) Number.isFinite(time.value.timeMs)
? `${formatDate(time.value.timeMs, DATE_TIME)} UTC` ? `${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> </script>
<template> <template>
<div v-if="!time.live && time.supported" id="world-timeline" role="group" :aria-label="t('World time')" <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()" ><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 ><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 ><time class="world-timeline-time" :datetime="new Date(time.timeMs).toISOString()" aria-live="off">{{ readout }}</time
><UiButton class="world-timeline-live" :label="t('Back to live')" @click="store.goLive()">{{ t('Live') }}</UiButton ><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> ></div>
</template> </template>