"use client"; import { useRef, useState, type MouseEvent } from "react"; import { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, type AnimationPlaybackControls } from "motion/react"; type Stop = { label: string; href: string; /** Name of a second line crossing here. Draws an interchange. */ transfer?: string; }; type SubwayLineNavProps = { /** Pages as stations along the line, 2 to 7. Short names fit best. */ stops: Stop[]; /** Index of the station you are at first. */ start?: number; /** Name on the board. */ line?: string; /** Line colour. */ color?: string; /** Colour of a crossing line. */ transferColor?: string; /** Minutes per stop shown on the board. */ minutesPerStop?: number; /** Called on press. Call event.preventDefault() to stay on the page. */ onSelect?: (stop: Stop, event: MouseEvent) => void; /** Name read out by screen readers. */ label?: string; className?: string; }; // Bends at 45 degrees only, like a transit map. It falls left to right so the names, which rise, stay clear of it. The ends are spare track for the carriages. const ROUTE: [number, number][] = [ [36, 124], [190, 124], [290, 224], [410, 224], [490, 304], [596, 304], ]; const LEGS = ROUTE.slice(1).map((p, i) => Math.hypot(p[0] - ROUTE[i][0], p[1] - ROUTE[i][1])); const TOTAL = LEGS.reduce((a, b) => a + b, 0); const SPARE = 34; const SPAN = TOTAL - SPARE * 2; const CARRIAGE = 33; const POINTS = ROUTE.map((p) => p.join(",")).join(" "); const place = (distance: number) => { let left = distance; for (let i = 0; i < LEGS.length; i++) { if (left <= LEGS[i] || i === LEGS.length - 1) { const [x0, y0] = ROUTE[i]; const [x1, y1] = ROUTE[i + 1]; const r = left / LEGS[i]; return { x: x0 + (x1 - x0) * r, y: y0 + (y1 - y0) * r, angle: (Math.atan2(y1 - y0, x1 - x0) * 180) / Math.PI }; } left -= LEGS[i]; } return { x: 0, y: 0, angle: 0 }; }; const carriage = (distance: number) => { const p = place(distance); return `translate(${p.x.toFixed(1)} ${p.y.toFixed(1)}) rotate(${p.angle.toFixed(1)}) translate(0 19)`; }; /* SubwayLineNav: your pages as stations on a transit map. A train runs the line to the stop you press, lighting the track behind it, and the board tells how far each stop is. */ export function SubwayLineNav({ stops, start = 0, line = "Green Line", color = "#16895b", transferColor = "#d9722b", minutesPerStop = 2, onSelect, label = "Site map", className = "", }: SubwayLineNavProps) { const reduce = useReducedMotion(); const gap = SPAN / Math.max(stops.length - 1, 1); const stopAt = (i: number) => SPARE + gap * i; const train = useMotionValue(stopAt(start)); const run = useRef(null); const way = useRef(1); const cars = useRef<(SVGGElement | null)[]>([]); const [goal, setGoal] = useState(start); const [reached, setReached] = useState(start); const [next, setNext] = useState(null); const [peek, setPeek] = useState(null); const lit = useTransform(train, (s) => `${s - SPARE} 9999`); useMotionValueEvent(train, "change", (s) => { cars.current.forEach((car, k) => car?.setAttribute("transform", carriage(s + (1 - k) * CARRIAGE * way.current))); const spot = (s - SPARE) / gap; setReached(Math.floor(spot + 0.01)); if (run.current) setNext(way.current > 0 ? Math.floor(spot + 0.01) + 1 : Math.ceil(spot - 0.01) - 1); }); const go = (i: number) => { const from = train.get(); const to = stopAt(i); setGoal(i); run.current?.stop(); if (reduce || from === to) { train.set(to); return; } way.current = Math.sign(to - from); run.current = animate(train, to, { duration: 0.5 + (Math.abs(to - from) / gap) * 0.42, ease: "easeInOut", onComplete: () => { run.current = null; setNext(null); }, }); }; const hops = peek === null ? 0 : Math.abs(peek - goal); const board = next !== null && stops[next] ? { top: "Next stop", main: stops[next].label } : peek !== null && hops ? { top: `${hops} ${hops === 1 ? "stop" : "stops"}, ${hops * minutesPerStop} min`, main: stops[peek].label } : { top: "You are here", main: stops[goal].label }; return ( ); }