diff --git a/scripts/lib/bundle-budgets.mjs b/scripts/lib/bundle-budgets.mjs index 9d920fd..c4820fe 100644 --- a/scripts/lib/bundle-budgets.mjs +++ b/scripts/lib/bundle-budgets.mjs @@ -50,6 +50,8 @@ export const classifyArtifact = (path, source = '') => { if (/^assets\/tab-icon-[^/]+\.js$/u.test(path)) return 'deferred-non-video'; // SPEEDOMETER: the gantry dial's model and DOM driver, loaded after mount. if (/^assets\/speedometer-dial-[^/]+\.js$/u.test(path)) return 'deferred-non-video'; + // NAV BAR: the lower-bezel wave canvas, loaded after the bar mounts. + if (/^assets\/nav-wave-[^/]+\.js$/u.test(path)) return 'deferred-non-video'; if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene'; if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts'; if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster'; diff --git a/src/journey/nav-wave.ts b/src/journey/nav-wave.ts new file mode 100644 index 0000000..567d991 --- /dev/null +++ b/src/journey/nav-wave.ts @@ -0,0 +1,397 @@ +/** + * THE NAV BAR'S WAVE (a lazy chunk: composables/useNavBarSignals.ts loads + * it once the bar mounts). The lower bezel's line, drawn on a small canvas + * inside .nav-wave, so the band's LCD pixel mask and the sign's window + * cut-out (styles/nav-bar.css) apply to it. + * + * It reads two signals, so a static preview drives it by setting them: + * --nav-speed 0..1 the wave's energy: inline on .nav-wave (where + * the app writes it each frame) or on the nav + * data-nav-hue 0..3 the hue cycle's pace, on the nav; absent is off + * Its colours come from CSS (--bar-wave, --bar-hue-1..4), re-read when the + * dawn or the chapter's accent changes. + * + * Low speed: a ribbon in the manner of a console's menu waves. Three long + * sines travel at different paces, two one way and one the other, under a + * slow swell, so the line drifts and folds over itself calmly and never + * repeats or scrolls as one piece. Speed quickens the drift at once. + * High speed: a spectrum. The axis sinks to the band's floor and the line + * becomes a smooth envelope through bins that jump up fast and fall back + * slower (attack and release), grouped into travelling swells so its peaks + * read as waves rather than noise; a burst of acceleration kicks them all. + * The two blend continuously in between. The line never leaves its band. + * The hue cycle and its slow return follow data-nav-hue: in over 1.2 s, + * out over 4 s, the pace eased. + * + * One rAF loop while motion is allowed and the canvas is laid out (the + * browser pauses it in a hidden tab). Reduced motion, or data-motion not + * 'true', draws one still frame and redraws only when an input changes. + */ + +/** The spectrum's bins, px apart; the envelope is a smooth curve through them. */ +const BIN = 18; +/** The wave repaints at 30 Hz; the model still steps every frame. */ +const DRAW_INTERVAL = 1 / 30; +const LINE_STEP = 3; // px between line samples; the spectrum's peaks are 18 px apart +/** The hue palette's period along the bar, px. */ +const HUE_PERIOD = 1440; +/** Hue tiers' pace, cycles per second: 1 slow, 2 fast, 3 both; 0 (settling) slow. */ +const HUE_RATE = [1 / 8, 1 / 8, 1 / 3.4, 1 / 8 + 1 / 3.4]; +/** The line's clearance from the band's edges, px. */ +const EDGE = 1.6; +const TAU = Math.PI * 2; +/** The ribbon: [wavelength px, cycles per second (sign is direction), weight, phase]. */ +const RIBBON: ReadonlyArray = [ + [430, 0.09, 0.55, 0.3], + [265, -0.13, 0.4, 1.9], + [150, 0.2, 0.22, 4.1], +]; +/** The ribbon's soft limit: full where the strands meet, never clipped flat. */ +const RIBBON_DRIVE = 1.5; + +const smoothstep = (a: number, b: number, v: number): number => { + const t = Math.min(1, Math.max(0, (v - a) / (b - a))); + return t * t * (3 - 2 * t); +}; +const ease = (dt: number, tau: number): number => 1 - Math.exp(-dt / tau); +const clamp01 = (v: number): number => Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0)); + +export interface WaveFrame { + /** Smoothed speed, 0..1. */ + speed: number; + /** 0 ribbon .. 1 spectrum: the blend, and how far the axis has sunk. */ + spectrum: number; + /** 0 standard colour .. 1 palette, eased. */ + hueMix: number; + /** The palette's offset along the bar, in periods (0..1). */ + hueOffset: number; +} + +export interface WaveModel { + /** Advance by dt seconds toward a raw speed and a hue tier (-1: absent). */ + step(dt: number, speed: number, hueTier: number): WaveFrame; + /** The still frame (reduced motion): the ribbon at rest at this speed's + * amplitude, whatever the speed, in the standard colour. */ + still(speed: number): WaveFrame; + /** Fit the spectrum's bins to a width in px. */ + resize(width: number): void; + /** The axis's y (px from the band's top), for a band of height h. */ + axisY(h: number): number; + /** The line's y (px from the band's top) at x, for a band of height h. */ + lineY(x: number, h: number): number; + /** A bin's level, 0..1. */ + level(index: number): number; + readonly binCount: number; + readonly frame: WaveFrame; +} + +/** A small seeded generator, so the visualizer is the same on every load. */ +function random(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 4294967296; + }; +} + +export function createWaveModel(seed = 17): WaveModel { + const rnd = random(seed); + const frame: WaveFrame = { speed: 0.37, spectrum: 0, hueMix: 0, hueOffset: 0 }; + /** The ribbon's own clock, in seconds of drift: it runs faster with speed. */ + let drift = 0; + /** The spectrum's swells' clock. */ + let swell = 0; + let lastSpeed = 0.37; + let kick = 0; + let hueRate = 0; + let hueLinear = 0; + let levels = new Float32Array(0); + let target = new Float32Array(0); + let timer = new Float32Array(0); + + const ribbon = (x: number): number => { + let v = 0; + for (const [length, rate, weight, offset] of RIBBON) v += weight * Math.sin((x * TAU) / length - TAU * rate * drift + offset); + // A slow swell along the bar, so the ribbon is fuller in places. + const swellAt = 0.82 + 0.18 * Math.sin((x * TAU) / 1900 + TAU * 0.03 * drift); + return (Math.tanh(RIBBON_DRIVE * v) / Math.tanh(RIBBON_DRIVE * 1.2)) * swellAt; + }; + + /** The spectrum at x: Catmull-Rom through the bins, 0..1. */ + const envelope = (x: number): number => { + const n = levels.length; + if (n === 0) return 0; + const at = x / BIN; + const i = Math.floor(at); + const t = at - i; + const p = (k: number): number => levels[Math.min(n - 1, Math.max(0, k))]!; + const p0 = p(i - 1), p1 = p(i), p2 = p(i + 1), p3 = p(i + 2); + const v = p1 + 0.5 * t * (p2 - p0 + t * (2 * p0 - 5 * p1 + 4 * p2 - p3 + t * (3 * (p1 - p2) + p3 - p0))); + return clamp01(v); + }; + + const model: WaveModel = { + frame, + get binCount() { return levels.length; }, + resize(width) { + const count = Math.max(2, Math.ceil(width / BIN) + 1); + if (count === levels.length) return; + levels = new Float32Array(count); + target = new Float32Array(count); + timer = new Float32Array(count).map(() => rnd() * 0.2); + }, + step(dt, speed, hueTier) { + const raw = clamp01(speed); + frame.speed += (raw - frame.speed) * ease(dt, 0.06); + const s = frame.speed; + frame.spectrum = smoothstep(0.5, 0.9, s); + // The ribbon drifts faster with speed, from a calm 1x at rest to 5x flat out. + drift = (drift + dt * (1 + 4 * s * s)) % 10000; + swell = (swell + dt * (0.6 + 1.6 * s)) % 10000; + + // A rise in speed kicks the spectrum; it settles over a quarter second. + kick = Math.min(0.6, kick * (1 - ease(dt, 0.25)) + Math.max(0, raw - lastSpeed) * 1.5); + lastSpeed = raw; + + // Bins re-roll their targets, faster with energy; each rises fast + // (attack) and falls back slower (release). Targets are shaped by two + // travelling swells, so the peaks gather into waves. + const energy = smoothstep(0.3, 0.9, s); + const pace = 1.5 - 0.9 * energy; + for (let i = 0; i < levels.length; i += 1) { + timer[i] = timer[i]! - dt; + if (timer[i]! <= 0) { + const shape = 0.5 + 0.3 * Math.sin((i * TAU) / 11 - swell * 2.1) + 0.2 * Math.sin((i * TAU) / 5.3 + swell * 3.3); + target[i] = Math.min(1, energy * (0.06 + Math.pow(rnd(), 1.5)) * (0.3 + 0.9 * shape) + kick); + timer[i] = (0.05 + 0.13 * rnd()) * pace; + } + const level = levels[i]!; + const want = target[i]!; + levels[i] = level + (want - level) * ease(dt, want > level ? 0.035 : 0.09); + } + + // Hue: in over 1.2 s, out over 4 s; the pace eases between tiers. + const on = hueTier >= 1; + hueLinear = on ? Math.min(1, hueLinear + dt / 1.2) : Math.max(0, hueLinear - dt / 4); + frame.hueMix = hueLinear * hueLinear * (3 - 2 * hueLinear); + const wantRate = hueTier >= 0 ? HUE_RATE[Math.min(3, hueTier)]! : hueLinear > 0 ? HUE_RATE[0]! : 0; + hueRate += (wantRate - hueRate) * ease(dt, 0.8); + frame.hueOffset = (frame.hueOffset + hueRate * dt) % 1; + return frame; + }, + still(speed) { + frame.speed = clamp01(speed); + lastSpeed = frame.speed; + // Always the calm ribbon: a silent spectrum would be a flat line. + frame.spectrum = 0; + drift = 0; + kick = 0; + levels.fill(0); + target.fill(0); + hueLinear = 0; + hueRate = 0; + frame.hueMix = 0; + return frame; + }, + axisY(h) { + // Centred for the ribbon; on the floor for the spectrum, so its peaks + // have the whole band to rise into. + return h / 2 + (h / 2 - EDGE - 0.4) * frame.spectrum; + }, + lineY(x, h) { + const m = frame.spectrum; + const half = h / 2 - EDGE; + const amp = half * (0.74 + 0.22 * smoothstep(0.3, 0.7, frame.speed)); + // Each keeps more of itself through the blend than a straight mix, so + // the middle of the dial is lively rather than the two cancelling out. + const up = Math.pow(1 - m, 0.7) * amp * ribbon(x) + Math.pow(m, 0.8) * (h - 2 * EDGE - 0.4) * envelope(x); + return Math.min(h - EDGE, Math.max(EDGE, model.axisY(h) - up)); + }, + level(index) { + return levels[index] ?? 0; + }, + }; + return model; +} + +type Rgb = [number, number, number]; + +function parseColour(value: string): Rgb | null { + const rgb = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/.exec(value); + if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])]; + const srgb = /color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/.exec(value); + if (srgb) return [Number(srgb[1]) * 255, Number(srgb[2]) * 255, Number(srgb[3]) * 255]; + return null; +} +const css = ([r, g, b]: Rgb): string => `rgb(${Math.round(r)} ${Math.round(g)} ${Math.round(b)})`; +const mix = (a: Rgb, b: Rgb, t: number): Rgb => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; + +export interface NavWave { + dispose(): void; +} + +export function mountNavWave(nav: HTMLElement): NavWave | null { + const slot = nav.querySelector(':scope > .nav-wave'); + if (!slot) return null; + const canvas = document.createElement('canvas'); + canvas.setAttribute('aria-hidden', 'true'); + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + slot.append(canvas); + + const model = createWaveModel(); + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)'); + const forced = window.matchMedia('(forced-colors: active)'); + let width = 0; + let height = 0; + let raf = 0; + let last = 0; + let sinceDraw = 0; + let standard: Rgb = [214, 160, 214]; + let palette: Rgb[] = []; + + // Colours stay in CSS (--bar-wave, --bar-hue-1..4); resolve them through + // the canvas's own color, only on mount and when the dawn or the accent + // (the chapter's --sign-accent, which --bar-wave mixes in) changes. + let accent = ''; + const readColours = (): void => { + const read = (name: string): Rgb | null => { + canvas.style.color = `var(${name})`; + return parseColour(getComputedStyle(canvas).color); + }; + standard = read('--bar-wave') ?? standard; + palette = [1, 2, 3, 4].map((n) => read(`--bar-hue-${n}`)).filter((c): c is Rgb => c !== null); + canvas.style.removeProperty('color'); + accent = nav.style.getPropertyValue('--sign-accent'); + }; + + const inputs = (): [number, number] => { + const speed = Number.parseFloat(slot.style.getPropertyValue('--nav-speed') || nav.style.getPropertyValue('--nav-speed')); + const hue = nav.getAttribute('data-nav-hue'); + return [Number.isFinite(speed) ? speed : 0.37, hue === null ? -1 : Number(hue) || 0]; + }; + + const draw = (): void => { + if (width === 0 || height === 0) return; + const dpr = canvas.width / width; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + const f = model.frame; + let ink: string | CanvasGradient = css(standard); + if (f.hueMix > 0.002 && palette.length === 4) { + // The palette, one period per HUE_PERIOD px, drifting with the offset. + const start = -f.hueOffset * HUE_PERIOD; + const periods = Math.ceil((width - start) / HUE_PERIOD); + const gradient = ctx.createLinearGradient(start, 0, start + periods * HUE_PERIOD, 0); + for (let p = 0; p < periods; p += 1) { + for (let k = 0; k < 4; k += 1) gradient.addColorStop((p + k / 4) / periods, css(mix(standard, palette[k]!, f.hueMix))); + } + gradient.addColorStop(1, css(mix(standard, palette[0]!, f.hueMix))); + ink = gradient; + } + ctx.fillStyle = ink; + ctx.strokeStyle = ink; + const axis = model.axisY(height); + // The axis, faint, so the ribbon reads as a function on a line; at speed + // it is the spectrum's floor. + ctx.globalAlpha = 0.16; + ctx.fillRect(0, axis - 0.5, width, 1); + ctx.beginPath(); + for (let x = 0; x <= width + LINE_STEP; x += LINE_STEP) { + const at = Math.min(x, width); + const y = model.lineY(at, height); + if (x === 0) ctx.moveTo(at, y); + else ctx.lineTo(at, y); + } + ctx.globalAlpha = 0.78; + ctx.lineWidth = 2.6; + ctx.lineJoin = 'round'; + ctx.stroke(); + // A body between the axis and the line: faint for the ribbon, fuller + // for the spectrum, whose silhouette it fills in. + ctx.lineTo(width, axis); + ctx.lineTo(0, axis); + ctx.closePath(); + ctx.globalAlpha = 0.16 + 0.22 * f.spectrum; + ctx.fill(); + ctx.globalAlpha = 1; + }; + + const moving = (): boolean => nav.dataset.motion === 'true' && !reduce.matches && !forced.matches && width > 0; + + const tick = (now: number): void => { + raf = 0; + if (!moving()) return; + const dt = last === 0 ? 1 / 60 : Math.min(0.05, Math.max(0, (now - last) / 1000)); + last = now; + const [speed, hue] = inputs(); + model.step(dt, speed, hue); + // Repaint at most 30 times a second: each repaint costs the page a + // canvas upload, and the eye reads the wave as smooth at that rate. + sinceDraw += dt; + if (sinceDraw >= DRAW_INTERVAL - 0.004) { + sinceDraw = 0; + draw(); + } + raf = window.requestAnimationFrame(tick); + }; + + // A still frame: the wave at its crest, the visualizer at rest. + const still = (): void => { + model.still(inputs()[0]); + draw(); + }; + + const update = (): void => { + if (moving()) { + if (raf === 0) { last = 0; raf = window.requestAnimationFrame(tick); } + } else { + if (raf !== 0) { window.cancelAnimationFrame(raf); raf = 0; } + still(); + } + }; + + const resize = new ResizeObserver(([entry]) => { + if (!entry) return; + width = entry.contentRect.width; + height = entry.contentRect.height; + const dpr = Math.min(2, window.devicePixelRatio || 1); + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + model.resize(width); + // Resizing the canvas clears it: repaint now rather than on the loop's + // next 30 Hz tick, so a resize never shows an empty band. + if (raf === 0) update(); + else draw(); + }); + resize.observe(canvas); + + // Without motion the still frame follows the inputs; with motion the + // loop reads them itself and this only notices data-motion changing, and + // a new chapter's accent (an inline style read, so no style recalc unless + // it changed). + const navObserver = new MutationObserver(() => { + if (nav.style.getPropertyValue('--sign-accent') !== accent) readColours(); + if (raf === 0 || !moving()) update(); + }); + navObserver.observe(nav, { attributes: true, attributeFilter: ['style', 'data-nav-hue', 'data-motion'] }); + navObserver.observe(slot, { attributes: true, attributeFilter: ['style'] }); + const dawnObserver = new MutationObserver(() => { readColours(); if (raf === 0) still(); }); + dawnObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-dawn'] }); + reduce.addEventListener('change', update); + forced.addEventListener('change', update); + + readColours(); + return { + dispose() { + if (raf !== 0) window.cancelAnimationFrame(raf); + raf = 0; + resize.disconnect(); + navObserver.disconnect(); + dawnObserver.disconnect(); + reduce.removeEventListener('change', update); + forced.removeEventListener('change', update); + canvas.remove(); + }, + }; +} diff --git a/tests/performance/budgets.test.ts b/tests/performance/budgets.test.ts index e12addd..b10e6e0 100644 --- a/tests/performance/budgets.test.ts +++ b/tests/performance/budgets.test.ts @@ -74,6 +74,7 @@ describe('release budgets', () => { expect(classifyArtifact('assets/DashcamFrame-c.css', '.dashcam-frame{}')).toBe('deferred-non-video'); expect(classifyArtifact('assets/tab-icon-c.js', 'renderTabIcon')).toBe('deferred-non-video'); expect(classifyArtifact('assets/speedometer-dial-c.js', 'createSpeedometerDial')).toBe('deferred-non-video'); + expect(classifyArtifact('assets/nav-wave-c.js', 'mountNavWave')).toBe('deferred-non-video'); expect(() => classifyArtifact('assets/mystery.bin', '')).toThrow(/unclassified/u); }); diff --git a/tests/unit/nav-wave.test.ts b/tests/unit/nav-wave.test.ts new file mode 100644 index 0000000..bcf0aae --- /dev/null +++ b/tests/unit/nav-wave.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { createWaveModel } from '@/journey/nav-wave'; + +const run = (model: ReturnType, seconds: number, speed: number, hue = -1): void => { + for (let t = 0; t < seconds; t += 1 / 60) model.step(1 / 60, speed, hue); +}; +const line = (model: ReturnType, h: number, width = 1440, step = 3): number[] => { + const ys: number[] = []; + for (let x = 0; x <= width; x += step) ys.push(model.lineY(x, h)); + return ys; +}; + +describe('nav bar wave', () => { + it('never leaves its band, at any speed or band height', () => { + // One expectation per band rather than per sample: half a million + // expect() calls ran past the timeout on a loaded machine. + for (const h of [18, 14]) { + const model = createWaveModel(); + model.resize(1440); + let low = Infinity; + let high = -Infinity; + for (const speed of [0, 0.37, 0.7, 1, 0.4, 1]) { + for (let k = 0; k < 90; k += 1) { + model.step(1 / 60, speed, -1); + for (const y of line(model, h, 1440, 6)) { + low = Math.min(low, y); + high = Math.max(high, y); + } + } + } + expect(low).toBeGreaterThanOrEqual(1.6); + expect(high).toBeLessThanOrEqual(h - 1.6); + } + }); + + it('is a smooth ribbon at cruise that drifts rather than standing still', () => { + const model = createWaveModel(); + model.resize(1440); + run(model, 2, 0.37); + expect(model.frame.spectrum).toBe(0); + const before = line(model, 18); + // Smooth: samples 3 px apart move by well under a pixel. + for (let i = 1; i < before.length; i += 1) expect(Math.abs(before[i]! - before[i - 1]!)).toBeLessThan(0.6); + // It travels: the highest crest moves along the bar within a second, + // gently (no more than a third of the bar). + const crest = (ys: number[]): number => ys.indexOf(Math.min(...ys)) * 3; + const a = crest(before); + run(model, 1, 0.37); + const moved = Math.abs(crest(line(model, 18)) - a); + expect(moved).toBeGreaterThan(6); + expect(moved).toBeLessThan(480); + }); + + it('is a spectrum flat out: peaks rising from the floor into the band', () => { + const model = createWaveModel(); + model.resize(1440); + run(model, 2, 1); + expect(model.frame.spectrum).toBeGreaterThan(0.99); + const h = 18; + expect(model.axisY(h)).toBeGreaterThan(h - 2.5); + const ys = line(model, h); + const tall = ys.filter((y) => y < h / 2).length; + expect(tall).toBeGreaterThan(ys.length / 5); + // Well-defined peaks: many local maxima, not one blob. + let peaks = 0; + for (let i = 1; i < ys.length - 1; i += 1) if (ys[i]! < ys[i - 1]! && ys[i]! <= ys[i + 1]! && ys[i]! < h - 6) peaks += 1; + expect(peaks).toBeGreaterThan(20); + // Smooth, not blocky: no vertical cliffs between samples 3 px apart. + for (let i = 1; i < ys.length; i += 1) expect(Math.abs(ys[i]! - ys[i - 1]!)).toBeLessThan(6); + }); + + it('follows a change of speed within a fraction of a second', () => { + const model = createWaveModel(); + model.resize(800); + run(model, 1, 0.37); + run(model, 0.2, 1); + expect(model.frame.speed).toBeGreaterThan(0.9); + run(model, 0.2, 0.37); + expect(model.frame.speed).toBeLessThan(0.45); + }); + + it('brings the hue in over 1.2 s and lets it go over 4 s', () => { + const model = createWaveModel(); + model.resize(800); + run(model, 1.25, 1, 3); + expect(model.frame.hueMix).toBe(1); + run(model, 2, 0.4, 0); + expect(model.frame.hueMix).toBeGreaterThan(0.1); + expect(model.frame.hueMix).toBeLessThan(0.9); + run(model, 2.1, 0.4, -1); + expect(model.frame.hueMix).toBe(0); + }); + + it('has a still frame for reduced motion', () => { + const model = createWaveModel(); + model.resize(800); + run(model, 1, 1, 3); + const frame = model.still(1); + expect(frame.hueMix).toBe(0); + expect(frame.spectrum).toBe(0); + expect(model.level(10)).toBe(0); + }); +});