"use client"; import { useEffect, useRef } from "react"; import { useReducedMotion } from "motion/react"; type IronFilingsProps = { /** Sheet colour. */ paper?: string; /** Filing colour. */ ink?: string; /** Pixels between filings. Smaller is denser. */ spacing?: number; /** Read out by screen readers. */ label?: string; className?: string; }; const MAX_FILINGS = 9000; const REACH = 0.05; // Field below this cannot turn a filing, so it stays where it lay. const fold = (a: number) => { let d = a % Math.PI; if (d > Math.PI / 2) d -= Math.PI; if (d < -Math.PI / 2) d += Math.PI; return d; }; type Engine = { aim: (x: number, y: number) => void; nudge: (dx: number, dy: number) => void; turn: () => void }; /* IronFilings: a bar magnet under a sheet dusted with thousands of iron filings. Each filing turns along the true field of the two poles and leans toward the nearest one, and stays where it lay when the magnet moves on. */ export function IronFilings({ paper = "#efe6d3", ink = "#2b2622", spacing = 7, label = "Magnet on a sheet of iron filings. Arrow keys move it, Enter turns it.", className = "" }: IronFilingsProps) { const reduce = useReducedMotion(); const box = useRef(null); const canvas = useRef(null); const magnet = useRef(null); const engine = useRef(null); const press = useRef({ x: 0, y: 0, down: false }); useEffect(() => { const host = box.current!; const view = canvas.current!; const bar = magnet.current!; const ctx = view.getContext("2d")!; let w = 0; let h = 0; let span = 80; // Magnet length. let count = 0; let x = new Float32Array(0); let y = new Float32Array(0); let a = new Float32Array(0); let ox = new Float32Array(0); let oy = new Float32Array(0); let len = new Float32Array(0); let mx = 0; let my = 0; let tx = 0; let ty = 0; let rot = -0.35; let turnTo = -0.35; let raf = 0; let ready = false; const placeMagnet = () => { bar.style.fontSize = `${span}px`; bar.style.transform = `translate(${mx - w / 2}px, ${my - h / 2}px) translate(-50%, -50%) rotate(${rot}rad)`; }; /* Moves every filing one frame on. Returns how much changed. */ const settle = (rate: number) => { const ux = Math.cos(rot) * span * 0.36; const uy = Math.sin(rot) * span * 0.36; const nx = mx + ux; const ny = my + uy; const sx = mx - ux; const sy = my - uy; const unit = span * span; let change = 0; for (let i = 0; i < count; i++) { const ax = x[i] - nx; const ay = y[i] - ny; const bx = x[i] - sx; const by = y[i] - sy; const ar = Math.max(1, ax * ax + ay * ay); const br = Math.max(1, bx * bx + by * by); const ad = ar * Math.sqrt(ar); const bd = br * Math.sqrt(br); const fx = ax / ad - bx / bd; const fy = ay / ad - by / bd; const power = Math.hypot(fx, fy) * unit; if (power > REACH) { const k = Math.min(1, rate * Math.min(1, power * 1.6)); const d = fold(Math.atan2(fy, fx) - a[i]) * k; a[i] += d; change += Math.abs(d); } const near = ar < br; const dx = near ? -ax : -bx; const dy = near ? -ay : -by; const dist = Math.sqrt(near ? ar : br); const lean = Math.min(span * 0.09, power * span * 0.05) / dist; const gx = (dx * lean - ox[i]) * Math.min(1, rate * 1.2); const gy = (dy * lean - oy[i]) * Math.min(1, rate * 1.2); ox[i] += gx; oy[i] += gy; change += Math.abs(gx) + Math.abs(gy); } return change; }; const paint = () => { ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); ctx.clearRect(0, 0, w, h); ctx.strokeStyle = ink; ctx.lineCap = "round"; for (let shade = 0; shade < 3; shade++) { ctx.globalAlpha = 0.5 + shade * 0.22; ctx.lineWidth = 0.8 + shade * 0.18; ctx.beginPath(); for (let i = shade; i < count; i += 3) { const c = Math.cos(a[i]) * len[i]; const s = Math.sin(a[i]) * len[i]; const px = x[i] + ox[i]; const py = y[i] + oy[i]; ctx.moveTo(px - c, py - s); ctx.lineTo(px + c, py + s); } ctx.stroke(); } }; const tick = () => { const follow = reduce ? 1 : 0.2; mx += (tx - mx) * follow; my += (ty - my) * follow; rot += (turnTo - rot) * (reduce ? 1 : 0.16); placeMagnet(); const change = settle(reduce ? 1 : 0.16); paint(); const still = Math.abs(tx - mx) + Math.abs(ty - my) + Math.abs(turnTo - rot) * 30 < 0.2; raf = !still || change > count * 0.0015 ? requestAnimationFrame(tick) : 0; }; const wake = () => { if (!raf && ready) raf = requestAnimationFrame(tick); }; const scatter = () => { const rect = host.getBoundingClientRect(); if (!rect.width || !rect.height) return; const oldW = w; const oldH = h; w = rect.width; h = rect.height; view.width = Math.round(w * devicePixelRatio); view.height = Math.round(h * devicePixelRatio); span = Math.max(64, Math.min(170, Math.min(w, h) * 0.44)); if (oldW) { tx *= w / oldW; ty *= h / oldH; mx *= w / oldW; my *= h / oldH; } else { tx = mx = w / 2; ty = my = h / 2; } const gap = Math.max(spacing, Math.sqrt((w * h) / MAX_FILINGS)); const cols = Math.ceil(w / gap); const rows = Math.ceil(h / gap); count = cols * rows; x = new Float32Array(count); y = new Float32Array(count); a = new Float32Array(count); ox = new Float32Array(count); oy = new Float32Array(count); len = new Float32Array(count); for (let i = 0; i < count; i++) { x[i] = ((i % cols) + Math.random()) * gap; y[i] = (Math.floor(i / cols) + Math.random()) * gap; a[i] = Math.random() * Math.PI; len[i] = gap * (0.3 + Math.random() * 0.34); } // Shuffle so the three shades are spread evenly over the sheet. for (let i = count - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [x[i], x[j]] = [x[j], x[i]]; [y[i], y[j]] = [y[j], y[i]]; } for (let pass = 0; pass < 2; pass++) settle(1); placeMagnet(); paint(); ready = true; }; engine.current = { aim(px, py) { tx = Math.max(0, Math.min(w, px)); ty = Math.max(0, Math.min(h, py)); wake(); }, nudge(dx, dy) { this.aim(tx + dx * span * 0.4, ty + dy * span * 0.4); }, turn() { turnTo += Math.PI / 4; wake(); }, }; const watcher = new ResizeObserver(() => { cancelAnimationFrame(raf); raf = 0; scatter(); }); watcher.observe(host); return () => { watcher.disconnect(); cancelAnimationFrame(raf); engine.current = null; }; }, [ink, spacing, reduce]); const local = (e: React.PointerEvent) => { const rect = box.current!.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; }; return (
{ const { x, y } = local(e); engine.current?.aim(x, y); }} onMouseDown={(e) => e.preventDefault()} onPointerDown={(e) => { const { x, y } = local(e); press.current = { x, y, down: true }; engine.current?.aim(x, y); }} onPointerUp={(e) => { const { x, y } = local(e); if (press.current.down && Math.hypot(x - press.current.x, y - press.current.y) < 6) engine.current?.turn(); press.current.down = false; }} onKeyDown={(e) => { const move = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key]; if (move) { e.preventDefault(); engine.current?.nudge(move[0], move[1]); } else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); engine.current?.turn(); } }} >