"use client"; import { useEffect, useId, useRef, useState, type MouseEvent, type PointerEvent } from "react"; import { useAnimationFrame, useInView, useMotionValue, useReducedMotion, useSpring } from "motion/react"; type Heading = { label: string; href: string; }; type CompassNavProps = { /** Pages round the card, clockwise from the top, 2 to 8. Short names fit best. */ items: Heading[]; /** Index of the page the needle points at first. */ start?: number; /** Called on press. Call event.preventDefault() to stay on the page. */ onSelect?: (item: Heading, event: MouseEvent) => void; /** Name read out by screen readers. */ label?: string; className?: string; }; const MID = 200; const polar = (deg: number, r: number) => { const a = ((deg - 90) * Math.PI) / 180; return { x: MID + r * Math.cos(a), y: MID + r * Math.sin(a) }; }; const star = (deg: number, long: number, wide: number) => { const tip = polar(deg, long); const left = polar(deg - 45, wide); const right = polar(deg + 45, wide); return { dark: `${MID},${MID} ${tip.x},${tip.y} ${left.x},${left.y}`, light: `${MID},${MID} ${tip.x},${tip.y} ${right.x},${right.y}`, }; }; const ROSE = [45, 135, 225, 315, 0, 90, 180, 270].map((deg, i) => star(deg, i < 4 ? 76 : 112, i < 4 ? 20 : 26)); const TICKS = Array.from({ length: 36 }, (_, i) => i * 10); /* CompassNav: a brass ship's compass. Pages sit round the card; the needle follows your pointer like a magnet, and swings to rest on the one you press. */ export function CompassNav({ items, start = 0, onSelect, label = "Site compass", className = "" }: CompassNavProps) { const reduce = useReducedMotion(); const uid = useId().replace(/[^a-z0-9]/gi, ""); const face = useRef(null); const needle = useRef(null); const degrees = useRef(null); const seen = useInView(face); const [active, setActive] = useState(start); const bearing = (i: number) => (360 * i) / items.length; const aim = useMotionValue(bearing(start)); const swing = useSpring(aim, { stiffness: 58, damping: 5.5 }); const point = (deg: number) => { const now = aim.get(); const next = now + ((deg - now + 540) % 360) - 180; aim.set(next); if (reduce) swing.jump(next); }; // Reduced motion has no animation frames to lean on, so set the needle here. useEffect(() => { if (reduce) needle.current?.setAttribute("transform", `rotate(${bearing(active)} ${MID} ${MID})`); }); useAnimationFrame((t) => { if (!seen || reduce) return; const deg = swing.get() + Math.sin(t / 830) * 0.8; needle.current?.setAttribute("transform", `rotate(${deg} ${MID} ${MID})`); if (degrees.current) degrees.current.textContent = `${String(Math.round(((deg % 360) + 360) % 360) % 360).padStart(3, "0")}°`; }); const follow = (e: PointerEvent) => { const box = e.currentTarget.getBoundingClientRect(); const x = e.clientX - box.left - box.width / 2; const y = e.clientY - box.top - box.height / 2; if (Math.hypot(x, y) > box.width * 0.07) point((Math.atan2(y, x) * 180) / Math.PI + 90); }; return ( ); }