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
+445 -81

No files matched your search

+41 -17
View File
@@ -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',
);
});
+4 -4
View File
@@ -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/);
+1 -1
View File
@@ -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(
+60 -9
View File
@@ -2,31 +2,82 @@ 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}`, where `supported` says a layer that moves with the clock has
* time to show. 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.
* 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());
onScopeDispose(
worldClock.subscribe((state) => {
time.value = read(state);
}),
);
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);
},
};
});
+2 -2
View File
@@ -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;
}
@@ -2044,7 +2044,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;
+3 -3
View File
@@ -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) {
+6 -6
View File
@@ -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);
}
+9 -8
View File
@@ -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 {
+5 -5
View File
@@ -1572,7 +1572,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);
}
@@ -1583,13 +1583,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 {
@@ -1634,7 +1634,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));
}
}
@@ -1657,7 +1657,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;
+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. */
--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;
@@ -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)
#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;
max-width: calc(100vw - 20px);
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. */
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 {
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+168 -8
View File
@@ -1,14 +1,37 @@
/* ── 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 {
position: fixed;
bottom: calc(2vh + 7rem);
bottom: 2vh;
left: 50%;
z-index: 146;
display: flex;
align-items: center;
gap: 10px;
max-width: calc(100vw - 32px);
width: min(820px, calc(100vw - 24px));
height: 42px;
box-sizing: border-box;
padding: 4px 6px;
border: 1px solid var(--glass-border);
@@ -20,11 +43,6 @@
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-live {
display: inline-flex;
@@ -46,6 +64,13 @@ body.recording-mode #world-timeline {
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;
@@ -60,3 +85,138 @@ body.recording-mode #world-timeline {
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;
}
}
+75 -6
View File
@@ -1,5 +1,5 @@
// The world timeline shows the world time while it is away from live, with
// play and pause on the left and the way back to live on the right.
// 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';
@@ -7,13 +7,17 @@ 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 app = createApp(WorldTimeline).use(createPinia());
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');
@@ -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 at = Date.UTC(2026, 9, 30, 6, 15, 42);
supportingLayer(t, at);
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);
await nextTick();
@@ -60,15 +67,19 @@ test('the timeline shows away from live and plays, pauses and goes back', async
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.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) => {
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');
@@ -79,3 +90,61 @@ test('the timeline hides while no layer with time to show is on', async (t) => {
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);
});
+66 -7
View File
@@ -6,11 +6,12 @@ import { useWorldTimeStore } from '../../stores/worldTime.js';
import UiButton from '../../kit/UiButton.vue';
import UiIcon from '../../kit/UiIcon.vue';
// The world time while it is away from live: what is playing, or where it
// was paused, with play and pause on the left and the way back to live on
// the right. It shows only while a layer with a time of its own (weather,
// satellites, launches, or one a host follows) is on with time to show; live,
// or with only live feeds such as flights on, the bar stays hidden.
// 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);
@@ -19,18 +20,76 @@ const DATE_TIME = Object.freeze({
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.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()"
><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
><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>
</template>