import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { Pause, Play, RotateCcw, SkipForward } from "lucide-react"; export type LoopStop = { x: number; y: number; ids: string[]; inspect: string; title: string; note: string; }; type Props = { stops: LoopStop[]; loopMs?: number; viewBox: string; ariaLabel: string; onSelect: (id: string) => void; children: (api: { on: (id: string) => boolean; markerId: string; token: { x: number; y: number }; step: number; }) => ReactNode; }; const HOLD = 0.38; export function LoopFigure({ stops, loopMs = 16000, viewBox, ariaLabel, onSelect, children, }: Props) { const [playing, setPlaying] = useState(true); const [progress, setProgress] = useState(0); const started = useRef(performance.now()); const pausedAt = useRef(0); const rawId = useId().replace(/:/g, ""); const markerId = `arr-${rawId}`; useEffect(() => { if (!playing) return; let frame = 0; const tick = (now: number) => { setProgress(((now - started.current) % loopMs) / loopMs); frame = requestAnimationFrame(tick); }; frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame); }, [playing, loopMs]); const motion = useMemo(() => alongPath(stops, progress), [stops, progress]); const current = stops[motion.step]; const lit = useMemo(() => new Set(current.ids), [current]); useEffect(() => { onSelect(current.inspect); }, [current.inspect, onSelect]); const jump = (next: number) => { const idx = ((next % stops.length) + stops.length) % stops.length; started.current = performance.now() - (idx / stops.length) * loopMs; setProgress(idx / stops.length); }; return (

Loop ยท {motion.step + 1} / {stops.length}

{current.title}

{children({ on: (id) => lit.has(id), markerId, token: { x: motion.x, y: motion.y }, step: motion.step, })}

{current.note}

); } function alongPath(stops: LoopStop[], t: number) { const n = stops.length; const x = ((((t % 1) + 1) % 1) * n); const step = Math.min(n - 1, Math.floor(x)); const f = x - step; const a = stops[step]; const b = stops[(step + 1) % n]; if (f < HOLD) return { x: a.x, y: a.y, step }; const u = (f - HOLD) / (1 - HOLD); const ease = u * u * (3 - 2 * u); return { x: a.x + (b.x - a.x) * ease, y: a.y + (b.y - a.y) * ease, step, }; } export const FILL = { orange: "#e39b4a", yellow: "#e6cf4e", cyan: "#78c3c4", pink: "#efb0c2", green: "#86c86f", purple: "#9b8ec8", white: "#f4f4f4", blue: "#7eb6d9", }; export function FigBox({ x, y, w, h, fill, label, lit, onClick, }: { x: number; y: number; w: number; h: number; fill: string; label: string; lit: boolean; onClick: () => void; }) { return ( {lit ? ( ) : null} 18 ? 11 : 13} fill="#1a1a1a" fontWeight={lit ? 700 : 600} > {label} ); } export function Arrow({ x1, y1, x2, y2, active, markerId, }: { x1: number; y1: number; x2: number; y2: number; active: boolean; markerId: string; }) { return ( ); }