nav-bar.css (after gantry.css) draws the bargraph, caret, indicator, chapter drop and ripple, route glints, brake, self-test and shimmer in LCD pixels under gantry's mask, and masks the wave to the lower bezel. Rules hang off html:root, the same weight the preview attribute had, so they still beat gantry.css. useNavBarSignals mirrors the speedometer, drive controls and chapter onto the bar, writing only on change; the per-frame speed pair goes on the LCD and wave rather than the nav, which keeps the per-frame restyle small. SiteNavigation renders just the parts the bar uses.
209 lines
9.4 KiB
TypeScript
209 lines
9.4 KiB
TypeScript
/**
|
|
* THE NAV BAR'S SIGNALS. The bar's lights (styles/nav-bar.css) and its wave
|
|
* (journey/nav-wave.ts) read the drive from custom properties and
|
|
* attributes on .site-navigation, so a static preview page can fake every
|
|
* one of them. This module only copies data the app already computes; it
|
|
* adds no frame loop:
|
|
*
|
|
* --nav-speed, --nav-throttle 0..1, mirrored from the speedometer's own
|
|
* --speed/--throttle writes (>= 1280 px).
|
|
* These change every frame while driving,
|
|
* so they go on .nav-lcd and .nav-wave,
|
|
* their only readers: on the nav they would
|
|
* restyle everything in it each frame
|
|
* (about 2 ms, against 0.7 ms on the LCD)
|
|
* data-nav-engine off | on | parked (the speedometer's)
|
|
* data-nav-brake, data-nav-jump present while braking / on an explicit drive
|
|
* --nav-here-x, --nav-next-x, --nav-from-x
|
|
* centre of the current, next and previous
|
|
* exit, in nav px
|
|
* data-nav-shift next | prev for SHIFT_MS after a chapter change
|
|
* data-nav-audio present while the soundtrack plays unmuted
|
|
* data-nav-hue 1 | 2 | 3: how fast the wave's hue cycle
|
|
* runs, from --nav-speed with hysteresis (on
|
|
* at 0.80 / 0.88 / 0.95, off below 0.76 /
|
|
* 0.85 / 0.92). It climbs at once and steps
|
|
* down one tier per 0.7 s; then 0 for 4 s
|
|
* while the colour settles back; absent
|
|
* otherwise
|
|
*
|
|
* Once the bar is mounted it also loads the wave (a lazy chunk). Reduced
|
|
* motion and forced colours are the stylesheet's and the wave's to handle:
|
|
* the signals still describe the drive, and the layers that read them
|
|
* either stand still or are not drawn.
|
|
*/
|
|
import { onBeforeUnmount, onMounted, watch, type Ref } from 'vue';
|
|
import type { NavWave } from '@/journey/nav-wave';
|
|
|
|
/** Long enough for the longest one-shot (the drop's ghost, 1.5 s). */
|
|
const SHIFT_MS = 1600;
|
|
const VARS = ['--nav-here-x', '--nav-next-x', '--nav-from-x'];
|
|
const LIVE_VARS = ['--nav-speed', '--nav-throttle'];
|
|
const ATTRS = ['data-nav-engine', 'data-nav-brake', 'data-nav-jump', 'data-nav-shift', 'data-nav-audio', 'data-nav-hue'];
|
|
/** The hue cycle: the speed each tier comes on at, and drops out below. */
|
|
const HUE_ON = [0.8, 0.88, 0.95];
|
|
const HUE_OFF = [0.76, 0.85, 0.92];
|
|
/** The least time on a tier before the next step down, and how long '0'
|
|
* (the colour settling back) lasts. */
|
|
const HUE_STEP_MS = 700;
|
|
const HUE_SETTLE_MS = 4000;
|
|
|
|
/** The hue tier (0..3) for a speed, given the current one: hysteresis, so
|
|
* a needle hovering on a threshold does not flicker the tier. */
|
|
export function hueTier(tier: number, speed: number): number {
|
|
let next = tier;
|
|
while (next < 3 && speed >= HUE_ON[next]!) next += 1;
|
|
while (next > 0 && speed < HUE_OFF[next - 1]!) next -= 1;
|
|
return next;
|
|
}
|
|
|
|
export function useNavBarSignals(anchor: Ref<HTMLElement | null>, activeIndex: Ref<number>): void {
|
|
let nav: HTMLElement | null = null;
|
|
/** Where the per-frame pair goes: the bar's LCD and wave. */
|
|
let live: HTMLElement[] = [];
|
|
let tier = 0;
|
|
let speedNow = 0;
|
|
let tierAt = 0;
|
|
let hueTimer = 0;
|
|
let shiftTimer = 0;
|
|
let shiftFrame = 0;
|
|
let wave: NavWave | null = null;
|
|
let disposed = false;
|
|
/** Last value written per property or attribute (null: absent), so the
|
|
* speedometer's per-frame writes only touch the nav when a value changes. */
|
|
const written = new Map<string, string | null>();
|
|
let speedObserver: MutationObserver | null = null;
|
|
let controlsObserver: MutationObserver | null = null;
|
|
let resizeObserver: ResizeObserver | null = null;
|
|
|
|
const set = (name: string, value: string): void => {
|
|
if (written.get(name) === value) return;
|
|
written.set(name, value);
|
|
nav?.style.setProperty(name, value);
|
|
};
|
|
const setLive = (name: string, value: string): void => {
|
|
if (written.get(name) === value) return;
|
|
written.set(name, value);
|
|
for (const element of live) element.style.setProperty(name, value);
|
|
};
|
|
const attr = (name: string, value: string | null): void => {
|
|
if (written.get(name) === value) return;
|
|
written.set(name, value);
|
|
if (value === null) nav?.removeAttribute(name);
|
|
else nav?.setAttribute(name, value);
|
|
};
|
|
|
|
const measure = (from: number): void => {
|
|
if (!nav) return;
|
|
const box = nav.getBoundingClientRect();
|
|
if (box.width === 0) return;
|
|
// The nav scales at rest (--nav-rest-scale): measure in its own px.
|
|
const scale = nav.offsetWidth / box.width;
|
|
const items = nav.querySelectorAll('li');
|
|
const centre = (index: number): number => {
|
|
const rect = items[Math.max(0, Math.min(items.length - 1, index))]?.getBoundingClientRect();
|
|
return rect === undefined ? 0 : (rect.left - box.left + rect.width / 2) * scale;
|
|
};
|
|
const here = centre(activeIndex.value);
|
|
set('--nav-here-x', `${here.toFixed(1)}px`);
|
|
set('--nav-next-x', `${centre(activeIndex.value + 1).toFixed(1)}px`);
|
|
set('--nav-from-x', `${(from < 0 ? here : centre(from)).toFixed(1)}px`);
|
|
};
|
|
|
|
// The hue tier. Up at once; down one tier per HUE_STEP_MS (a timer
|
|
// carries it on once the needle has settled and stopped writing), then
|
|
// '0' for HUE_SETTLE_MS, then the attribute goes.
|
|
const stepHue = (): void => {
|
|
window.clearTimeout(hueTimer);
|
|
const wanted = hueTier(tier, speedNow);
|
|
if (wanted === tier) return;
|
|
const now = performance.now();
|
|
if (wanted < tier && now - tierAt < HUE_STEP_MS) {
|
|
hueTimer = window.setTimeout(stepHue, tierAt + HUE_STEP_MS - now);
|
|
return;
|
|
}
|
|
tier = wanted > tier ? wanted : tier - 1;
|
|
tierAt = now;
|
|
attr('data-nav-hue', String(tier));
|
|
if (tier > 0) hueTimer = window.setTimeout(stepHue, HUE_STEP_MS);
|
|
else hueTimer = window.setTimeout(() => { if (tier === 0) attr('data-nav-hue', null); }, HUE_SETTLE_MS);
|
|
};
|
|
|
|
const mirrorSpeedo = (): void => {
|
|
const speedo = anchor.value;
|
|
if (!speedo) return;
|
|
const speed = speedo.style.getPropertyValue('--speed') || '0';
|
|
setLive('--nav-speed', speed);
|
|
speedNow = Number.parseFloat(speed) || 0;
|
|
if (hueTier(tier, speedNow) !== tier) stepHue();
|
|
setLive('--nav-throttle', speedo.style.getPropertyValue('--throttle') || '0');
|
|
attr('data-nav-engine', speedo.dataset.engine ?? 'off');
|
|
attr('data-nav-brake', speedo.hasAttribute('data-brake') ? '' : null);
|
|
attr('data-nav-jump', speedo.hasAttribute('data-jump') ? '' : null);
|
|
};
|
|
|
|
const mirrorControls = (): void => {
|
|
const controls = document.querySelector<HTMLElement>('.drive-controls');
|
|
attr('data-nav-audio', controls?.dataset.driveState === 'running' && controls.dataset.muted === 'false' ? '' : null);
|
|
};
|
|
|
|
// A chapter change: drop the one-shot now, then re-measure and restart it
|
|
// in a task after the next frame has rendered. That frame renders without
|
|
// the attribute, so the CSS animations restart even when changes come
|
|
// faster than SHIFT_MS, and the measuring reads a clean layout instead of
|
|
// forcing one inside the journey's frame callback.
|
|
watch(activeIndex, (index, previous) => {
|
|
if (!nav) return;
|
|
window.clearTimeout(shiftTimer);
|
|
window.cancelAnimationFrame(shiftFrame);
|
|
attr('data-nav-shift', null);
|
|
const direction = index > previous ? 'next' : 'prev';
|
|
shiftFrame = window.requestAnimationFrame(() => {
|
|
shiftTimer = window.setTimeout(() => {
|
|
measure(previous);
|
|
attr('data-nav-shift', direction);
|
|
shiftTimer = window.setTimeout(() => attr('data-nav-shift', null), SHIFT_MS);
|
|
}, 0);
|
|
});
|
|
});
|
|
|
|
onMounted(() => {
|
|
nav = anchor.value?.closest<HTMLElement>('.site-navigation') ?? null;
|
|
if (!nav || !anchor.value) return;
|
|
live = [...nav.querySelectorAll<HTMLElement>(':scope > .nav-lcd, :scope > .nav-wave')];
|
|
speedObserver = new MutationObserver(mirrorSpeedo);
|
|
speedObserver.observe(anchor.value, { attributes: true, attributeFilter: ['style', 'data-engine', 'data-brake', 'data-jump'] });
|
|
// The drive controls mount after the nav; watch the body for their state.
|
|
controlsObserver = new MutationObserver(mirrorControls);
|
|
controlsObserver.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['data-drive-state', 'data-muted'] });
|
|
resizeObserver = new ResizeObserver(() => measure(-1));
|
|
resizeObserver.observe(nav);
|
|
mirrorSpeedo();
|
|
mirrorControls();
|
|
measure(-1);
|
|
import('@/journey/nav-wave').then((module) => {
|
|
if (!disposed && nav && !wave) wave = module.mountNavWave(nav);
|
|
}, () => { /* No wave: the band stays dark, the bar works without it. */ });
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
disposed = true;
|
|
speedObserver?.disconnect();
|
|
controlsObserver?.disconnect();
|
|
resizeObserver?.disconnect();
|
|
speedObserver = controlsObserver = null;
|
|
resizeObserver = null;
|
|
window.clearTimeout(shiftTimer);
|
|
window.cancelAnimationFrame(shiftFrame);
|
|
window.clearTimeout(hueTimer);
|
|
wave?.dispose();
|
|
wave = null;
|
|
for (const name of VARS) nav?.style.removeProperty(name);
|
|
for (const element of live) for (const name of LIVE_VARS) element.style.removeProperty(name);
|
|
live = [];
|
|
for (const name of ATTRS) nav?.removeAttribute(name);
|
|
written.clear();
|
|
nav = null;
|
|
});
|
|
}
|