diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index 3032a25fe..39fd5b8f2 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -229,13 +229,30 @@ const GUARDED_PROPS = new Set([ // Custom properties allowed inside a modelled offset. Each is a pinned-tray // stack height, proven non-negative below, so evaluating them at 0 is a -// conservative floor rather than a guess. +// conservative floor rather than a guess. The world timeline's lift raises +// the dock, its trays and the credit together, so it moves no gap either. const VETTED_VARS = new Set([ '--dock-pinned-stack-height', '--dock-lower-pinned-height', '--dock-location-pinned-height', '--dock-presets-pinned-height', + '--world-timeline-lift', ]); +// Custom properties CSS sets to a fixed length besides their 0 default. The +// clearance checks run at each length CSS gives them, not only at 0. +const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']); +const LIFTS_PX = [0, 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', ); }); diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index db468d84c..5f69f2e84 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -434,12 +434,12 @@ test('Cyber lower-left telemetry tracks the credit anchor with clearance for its cyberStyles, /#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/, ); - const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/); + const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem \+ var\(--world-timeline-lift, 0px\)\);/); assert.ok(offset, 'telemetry must retain the attribution viewport anchor'); const credits = read('./styles/command-dock-trays.css'); const creditOffsets = [ ...credits.matchAll( - /#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/g, + /#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem \+ var\(--world-timeline-lift, 0px\)\);/g, ), ]; assert.ok(creditOffsets.length > 0); @@ -738,7 +738,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', () ); assert.match( cyberStyles, - /#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem\);/, + /#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem \+ var\(--world-timeline-lift, 0px\)\);/, ); assert.match( cyberStyles, @@ -793,7 +793,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', () cyberStyles, /#right-context-rail:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-inline-end-color:/, ); - assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem\);/); + assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem \+ var\(--world-timeline-lift, 0px\)\);/); assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/); assert.match(cyberStyles, /data-layer-id='satellites'/); assert.match(cyberStyles, /#search-toggle::before/); diff --git a/src/ui/mobileShell.test.mjs b/src/ui/mobileShell.test.mjs index 61d098bf2..d782f7192 100644 --- a/src/ui/mobileShell.test.mjs +++ b/src/ui/mobileShell.test.mjs @@ -586,7 +586,7 @@ test('the phone shell keeps the required attribution visible', () => { }; const lift = (block) => Number( - /bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px\)/.exec(block)?.[1], + /bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px \+ var\(--world-timeline-lift, 0px\)\)/.exec(block)?.[1], ); const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]); const idleCredit = rule( diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index 1c39fbe66..62fb4528c 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -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); + }, }; }); diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index f0bff14d0..aa7d8e469 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -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; diff --git a/src/ui/styles/command-dock-compact.css b/src/ui/styles/command-dock-compact.css index 821877002..c0f3e5f34 100644 --- a/src/ui/styles/command-dock-compact.css +++ b/src/ui/styles/command-dock-compact.css @@ -377,15 +377,15 @@ /* Compact dock clearance keeps the reference-like low profile. */ body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { - bottom: calc(2vh + 4.7rem); + bottom: calc(2vh + 4.7rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 5.4rem); + bottom: calc(2vh + 5.4rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-bar { - bottom: calc(2vh + 8.9rem); + bottom: calc(2vh + 8.9rem + var(--world-timeline-lift, 0px)); } @media (max-width: 900px) { diff --git a/src/ui/styles/command-dock-trays.css b/src/ui/styles/command-dock-trays.css index ecc6bc432..f9035da2c 100644 --- a/src/ui/styles/command-dock-trays.css +++ b/src/ui/styles/command-dock-trays.css @@ -455,7 +455,7 @@ body:not(.ui-clean-view):not(.recording-mode):has( ) #cesium-credits { right: auto; - bottom: calc(2vh + 5rem); + bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px)); left: 1.5rem; width: auto !important; max-width: calc(100vw - 3rem); @@ -464,21 +464,21 @@ body:not(.ui-clean-view):not(.recording-mode):has( #intel-hud[data-variant='tactical'] .hud-bottom-left, #intel-hud[data-variant='tactical'] .hud-bottom-right { - bottom: calc(2vh + 6.5rem); + bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 5.4rem); + bottom: calc(2vh + 5.4rem + var(--world-timeline-lift, 0px)); } :root[data-scope-mask='off'] #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 6.5rem); + bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-bar { - bottom: calc(2vh + 9rem); + bottom: calc(2vh + 9rem + var(--world-timeline-lift, 0px)); } @media (max-width: 900px) { @@ -487,7 +487,7 @@ body:not(.ui-clean-view):not(.recording-mode):has( #intel-hud[data-variant='minimal'].active ) #cesium-credits { - bottom: calc(2vh + 5rem); + bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px)); left: 0.75rem; max-width: calc(100vw - 1.5rem); } diff --git a/src/ui/styles/command-dock.css b/src/ui/styles/command-dock.css index 3c6c8e531..808b5f480 100644 --- a/src/ui/styles/command-dock.css +++ b/src/ui/styles/command-dock.css @@ -2,7 +2,8 @@ #command-dock { position: fixed; right: auto; - bottom: 2vh; + /* Above the world timeline while it shows (world-timeline.css). */ + bottom: calc(2vh + var(--world-timeline-lift, 0px)); left: 50%; z-index: 145; display: grid; @@ -216,7 +217,7 @@ /* Keep required map attribution clear of the full-width command surface. */ body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { - bottom: calc(2vh + 7rem); + bottom: calc(2vh + 7rem + var(--world-timeline-lift, 0px)); } body:not(.ui-clean-view):not(.recording-mode):has( @@ -244,11 +245,11 @@ body.recording-mode #cesium-credits { } #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 7.5rem); + bottom: calc(2vh + 7.5rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-bar { - bottom: calc(2vh + 11.5rem); + bottom: calc(2vh + 11.5rem + var(--world-timeline-lift, 0px)); } @media (max-width: 980px) { @@ -275,21 +276,21 @@ body.recording-mode #cesium-credits { } body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { - bottom: calc(2vh + 9.5rem); + bottom: calc(2vh + 9.5rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 12rem); + bottom: calc(2vh + 12rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-bar { - bottom: calc(2vh + 16rem); + bottom: calc(2vh + 16rem + var(--world-timeline-lift, 0px)); } } @media (max-width: 720px) { #command-dock { - bottom: 8px; + bottom: calc(8px + var(--world-timeline-lift, 0px)); } #command-dock .location-search-wrap { diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 40a3b6cea..1cb522cbd 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -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; diff --git a/src/ui/styles/mobile-shell.css b/src/ui/styles/mobile-shell.css index b8574092f..03076264b 100644 --- a/src/ui/styles/mobile-shell.css +++ b/src/ui/styles/mobile-shell.css @@ -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 { diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index 98aa06ed4..3f7b5fb25 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -177,7 +177,7 @@ drawn straight over the notice. Track the credit's own anchor instead and the 12px of clear air holds regardless of height; the rail already scrolls (overflow-y below), so it loses reach, not content. */ - bottom: calc(2vh + 7.5rem); + bottom: calc(2vh + 7.5rem + var(--world-timeline-lift, 0px)); left: 16px; width: auto; max-height: none; diff --git a/src/ui/styles/status.css b/src/ui/styles/status.css index bba1ef788..404b9cd2c 100644 --- a/src/ui/styles/status.css +++ b/src/ui/styles/status.css @@ -390,7 +390,7 @@ html:not([lang='en']) #global-loading-detail { /* ── Toast Notification ──────────────────────── */ #toast { position: fixed; - bottom: 100px; + bottom: calc(100px + var(--world-timeline-lift, 0px)); left: 50%; transform: translateX(-50%) translateY(20px); z-index: 200; diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css index b791b9520..e7560a467 100644 --- a/src/ui/styles/world-timeline.css +++ b/src/ui/styles/world-timeline.css @@ -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; + } +} diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 2a10d06b3..60a73540b 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -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); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 91b6676db..1c4a8bdc0 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -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))); +};