"use client"; import { useRef, useState, type KeyboardEvent, type MouseEvent, type PointerEvent } from "react"; import { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, type AnimationPlaybackControls } from "motion/react"; type Station = { label: string; href: string; }; type RadioDialNavProps = { /** Pages as stations, left to right, 2 to 6. */ stations: Station[]; /** Index of the station tuned at the start. */ start?: number; /** Called when a station link is pressed. Call event.preventDefault() to stay on the page. */ onSelect?: (station: Station, event: MouseEvent) => void; /** Name read out by screen readers. */ label?: string; className?: string; }; const SCALE = [54, 70, 88, 108, 130, 160]; const REACH = 0.05; const TICKS = Array.from({ length: 51 }, (_, i) => `M${i * 2} ${i % 5 ? 5 : 0}V10`).join("") + "M0 10H100"; const NOISE = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence baseFrequency='1.1' numOctaves='2'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E\")"; const spot = (i: number, n: number) => (n < 2 ? 0.5 : 0.1 + (0.8 * i) / (n - 1)); /* RadioDialNav: a valve radio. Turn the knob or press a name: the red needle sweeps the amber glass through static, and the green eye closes on a station. */ export function RadioDialNav({ stations, start = 0, onSelect, label = "Site stations", className = "" }: RadioDialNavProps) { const reduce = useReducedMotion(); const count = stations.length; const pos = useMotionValue(spot(start, count)); const [near, setNear] = useState(start); const run = useRef(null); const grip = useRef(null); const knob = useRef(null); const closest = (p: number) => { let best = 0; stations.forEach((_, i) => { if (Math.abs(spot(i, count) - p) < Math.abs(spot(best, count) - p)) best = i; }); return best; }; useMotionValueEvent(pos, "change", (p) => { const i = closest(p); setNear(Math.abs(spot(i, count) - p) < REACH * 0.7 ? i : -1); }); const signal = useTransform(pos, (p) => { const gap = Math.min(...stations.map((_, i) => Math.abs(spot(i, count) - p))); return Math.max(0, 1 - gap / REACH); }); const glow = useTransform(signal, [0, 1], [0.25, 1]); const needle = useTransform(pos, (p) => `${p * 100}%`); const turn = useTransform(pos, (p) => p * 540 - 270); const eye = useTransform(signal, (s) => { const open = 62 - s * 56; return `conic-gradient(from ${-open}deg, #062414 0 ${open * 2}deg, #63f2a2 ${open * 2}deg 360deg)`; }); const tune = (i: number) => { run.current?.stop(); if (reduce) pos.set(spot(i, count)); else run.current = animate(pos, spot(i, count), { type: "spring", stiffness: 62, damping: 11 }); }; const angle = (e: PointerEvent) => { const box = knob.current!.getBoundingClientRect(); return (Math.atan2(e.clientY - box.top - box.height / 2, e.clientX - box.left - box.width / 2) * 180) / Math.PI; }; const grab = (e: PointerEvent) => { run.current?.stop(); e.currentTarget.setPointerCapture(e.pointerId); grip.current = angle(e); }; const drag = (e: PointerEvent) => { if (grip.current === null) return; const now = angle(e); const moved = ((now - grip.current + 540) % 360) - 180; grip.current = now; pos.set(Math.min(1, Math.max(0, pos.get() + moved / 540))); }; const drop = () => { if (grip.current === null) return; grip.current = null; tune(closest(pos.get())); }; const key = (e: KeyboardEvent) => { const step = e.key === "ArrowRight" || e.key === "ArrowUp" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : 0; if (!step) return; e.preventDefault(); tune(Math.min(count - 1, Math.max(0, closest(pos.get()) + step))); }; const tuned = near >= 0 ? stations[near] : null; return ( ); }