/** * 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(); }, }; }