feat: make the edge-lit LCD the site's nav bar

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.
This commit is contained in:
2026-10-13 06:48:33 +03:00
parent 2be15980ee
commit a505a31f31
5 changed files with 711 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
/**
* 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;
});
}