"use client"; import { useEffect, useId, useState } from "react"; import { useReducedMotion } from "motion/react"; type ThreadSpoolProps = { /** Thread colours, one per wind. */ colors?: string[]; /** Layers of thread laid before it pays back. */ layers?: number; /** 1 is the default pace; 2 is twice as fast. */ speed?: number; /** Read out by screen readers. */ label?: string; className?: string; }; const clamp = (v: number) => Math.min(1, Math.max(0, v)); const span = (p: number, a: number, b: number) => clamp((p - a) / (b - a)); const ease = (t: number) => t * t * (3 - 2 * t); const lerp = (a: number, b: number, t: number) => a + (b - a) * t; const CX = 92; // Centre of the bobbin. const TOP = 38; // Barrel top and bottom. const BOTTOM = 178; const BARREL = 18; const STEP = 36; // Total thread depth on one side. const GUIDE = 186; // The rod the eyelet rides on. const CONE = 252; const WIND = 0.78; const REST = 0.5; /* ThreadSpool: a small winding bench. The thread leaves a cone, runs through an eyelet that rides up and down a rod, and lays real layers on a wooden bobbin; when full it pays back, and the next colour starts. */ export function ThreadSpool({ colors = ["#d4365f", "#1f9d8b", "#e6a02f"], layers = 7, speed = 1, label = "Loading", className = "" }: ThreadSpoolProps) { const reduce = useReducedMotion(); const id = useId().replace(/:/g, ""); const [clock, setClock] = useState(REST); useEffect(() => { if (reduce) return; const seconds = 8 / speed; const start = performance.now() - REST * seconds * 1000; let raf = 0; const tick = (now: number) => { setClock((now - start) / (seconds * 1000)); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [reduce, speed]); const shown = reduce ? REST : clock; const p = shown % 1; const color = colors[Math.floor(shown) % colors.length]; // Wound share: climbs through the layers, then pays back faster. const wound = lerp(span(p, 0, WIND), 0, ease(span(p, WIND, 1))); const q = Math.min(wound * layers, layers - 0.0001); const done = Math.floor(q); const pass = q - done; const depth = STEP / layers; // Odd layers run up, even ones run down, so the eyelet goes to and fro. const up = done % 2 === 0; const at = up ? lerp(BOTTOM, TOP, pass) : lerp(TOP, BOTTOM, pass); const body = BARREL + depth * done; const lay = body + depth; // One outline for the wound thread: the finished layers plus the step of the running one. const l = CX - lay; const r = CX + lay; const wrap = done === 0 ? `M${l} ${up ? BOTTOM : TOP} H${r} V${at} H${l} Z` : up ? `M${CX - body} ${TOP} H${CX + body} V${at} H${r} V${BOTTOM} H${l} V${at} H${CX - body} Z` : `M${l} ${TOP} H${r} V${at} H${CX + body} V${BOTTOM} H${CX - body} V${at} H${l} Z`; const spin = wound * 1400; const left = 1 - wound * 0.72; // Thread left on the cone. const mark = (spin / 18) % (Math.PI * 2); return (
{/* Bobbin: barrel, thread layers, then the two flanges over the ends. */} {/* The rod and the cone behind the thread. */} {/* The running thread: cone top, overhead eye, down to the eyelet, across to the bobbin. */}
); }