mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
1 parent
9faec3028e
commit
48093108b1
15 files changed
+434
-70
No files matched your search
@@ -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',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
@@ -586,7 +586,7 @@ test('the phone shell keeps the required attribution visible', () => {
|
|||||||
};
|
};
|
||||||
const lift = (block) =>
|
const lift = (block) =>
|
||||||
Number(
|
Number(
|
||||||
/bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px\)/.exec(block)?.[1],
|
/bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px \+ var\(--world-timeline-lift, 0px\)\)/.exec(block)?.[1],
|
||||||
);
|
);
|
||||||
const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]);
|
const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]);
|
||||||
const idleCredit = rule(
|
const idleCredit = rule(
|
||||||
|
|||||||
@@ -2,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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user