/** * 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, activeIndex: Ref): 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(); 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('.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('.site-navigation') ?? null; if (!nav || !anchor.value) return; live = [...nav.querySelectorAll(':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; }); }