"use client"; import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "motion/react"; type RopeDragProps = { /** Called on every ring, with the running count. */ onRing?: (count: number) => void; /** Sound the bell. Browsers stay silent until the first press or key. */ sound?: boolean; /** Read out by screen readers. */ label?: string; className?: string; }; const W = 360; const H = 336; const PX = 180; // Bell pivot. const PY = 36; const DROP = 82; // Pivot to the eye the rope hangs from. const NODES = 17; const LINK = 10; const STRETCH = 1.14; // The rope gives this much before it stops. const RING_AT = 1.08; const RE_ARM = 1.02; /* Smooth line through the nodes, plus the slanted lay of the strands along it. */ function rope(xs: ArrayLike, ys: ArrayLike) { let line = `M${xs[0].toFixed(1)} ${ys[0].toFixed(1)}`; for (let i = 1; i < NODES - 1; i++) { const mx = (xs[i] + xs[i + 1]) / 2; const my = (ys[i] + ys[i + 1]) / 2; line += `Q${xs[i].toFixed(1)} ${ys[i].toFixed(1)} ${mx.toFixed(1)} ${my.toFixed(1)}`; } line += `L${xs[NODES - 1].toFixed(1)} ${ys[NODES - 1].toFixed(1)}`; let lay = ""; for (let i = 0; i < NODES - 1; i++) { const dx = xs[i + 1] - xs[i]; const dy = ys[i + 1] - ys[i]; const d = Math.hypot(dx, dy) || 1; const tx = dx / d; const ty = dy / d; for (let s = 0.17; s < 1; s += 0.33) { const x = xs[i] + dx * s; const y = ys[i] + dy * s; lay += `M${(x - ty * 4.6 - tx * 2).toFixed(1)} ${(y + tx * 4.6 - ty * 2).toFixed(1)}L${(x + ty * 4.6 + tx * 2).toFixed(1)} ${(y - tx * 4.6 + ty * 2).toFixed(1)}`; } } return { line, lay }; } const REST_X = Array.from({ length: NODES }, () => PX); const REST_Y = Array.from({ length: NODES }, (_, i) => PY + DROP + i * LINK); const REST = rope(REST_X, REST_Y); function bellTone(audio: AudioContext, loud: number) { const now = audio.currentTime; [ [1, 0.5, 2.6], [2, 0.28, 1.9], [2.41, 0.2, 1.5], [3.01, 0.14, 1], [4.17, 0.08, 0.6], ].forEach(([ratio, level, fade]) => { const osc = audio.createOscillator(); const gain = audio.createGain(); osc.frequency.value = 410 * ratio; gain.gain.setValueAtTime(0.0001, now); gain.gain.exponentialRampToValueAtTime(level * loud, now + 0.006); gain.gain.exponentialRampToValueAtTime(0.0001, now + fade); osc.connect(gain).connect(audio.destination); osc.start(now); osc.stop(now + fade + 0.02); }); } type Engine = { grab: (x: number, y: number) => void; drag: (x: number, y: number) => void; drop: () => void; brush: (x: number, y: number) => void; tug: () => void; sway: (dir: number) => void; unlock: () => void; }; /* RopeDrag: a brass bell on a beam with a jute pull rope and a wooden toggle. The rope has real weight: pull it and it swings, tug it hard and the bell rings, brush past and it stirs. */ export function RopeDrag({ onRing, sound = true, label = "Bell rope", className = "" }: RopeDragProps) { const reduce = useReducedMotion(); const svg = useRef(null); const base = useRef(null); const light = useRef(null); const lay = useRef(null); const hit = useRef(null); const toggle = useRef(null); const bell = useRef(null); const arcs = useRef(null); const engine = useRef(null); const callback = useRef(onRing); const dragging = useRef(false); const [count, setCount] = useState(0); useEffect(() => { callback.current = onRing; }, [onRing]); useEffect(() => { const x = Float32Array.from(REST_X); const y = Float32Array.from(REST_Y); const px = x.slice(); const py = y.slice(); const damp = reduce ? 0.55 : 0.992; let angle = 0; let spin = 0; let held = -1; let aimX = 0; let aimY = 0; let armed = true; let side = 1; let rings = 0; let raf = 0; let then = 0; let store = 0; let audio: AudioContext | undefined; const ring = (dir: number) => { rings += 1; if (!reduce) spin += dir * -2.3; if (audio) bellTone(audio, 0.34); arcs.current?.animate( [ { opacity: 0.9, transform: "scale(0.92)" }, { opacity: 0, transform: "scale(1.28)" }, ], { duration: 800, easing: "ease-out" }, ); setCount(rings); callback.current?.(rings); }; const step = () => { const topX = PX - Math.sin(angle) * DROP; const topY = PY + Math.cos(angle) * DROP; let pull = 0; let lean = 0; if (held >= 0) { const dx = aimX - topX; const dy = aimY - topY; const dist = Math.hypot(dx, dy) || 1; const reach = held * LINK; pull = dist / reach; lean = dx / dist; const limit = Math.min(1, (reach * STRETCH) / dist); const hx = topX + dx * limit; const hy = topY + dy * limit; x[held] += (hx - x[held]) * 0.5; y[held] += (hy - y[held]) * 0.5; if (pull >= RING_AT && armed) { armed = false; side = Math.abs(dx) > 6 ? Math.sign(dx) : -side; ring(side); } if (pull < RE_ARM) armed = true; } if (!reduce) { const force = -38 * angle - 2.6 * spin - (pull > 1 ? lean * 26 * Math.min(1, (pull - 1) / 0.12) : 0); spin += force / 60; angle += spin / 60; } for (let i = 1; i < NODES; i++) { if (i === held) { px[i] = x[i]; py[i] = y[i]; continue; } const vx = (x[i] - px[i]) * damp; const vy = (y[i] - py[i]) * damp; px[i] = x[i]; py[i] = y[i]; x[i] += vx; y[i] += vy + 0.34; } for (let pass = 0; pass < 22; pass++) { x[0] = topX; y[0] = topY; for (let i = 0; i < NODES - 1; i++) { const j = i + 1; const dx = x[j] - x[i]; const dy = y[j] - y[i]; const d = Math.hypot(dx, dy) || 0.001; const push = (d - LINK) / d; const a = i === 0 || i === held ? 0 : j === held ? 1 : 0.5; const b = j === held ? 0 : i === 0 || i === held ? 1 : 0.5; x[i] += dx * push * a; y[i] += dy * push * a; x[j] -= dx * push * b; y[j] -= dy * push * b; } } }; const paint = () => { const { line, lay: strands } = rope(x, y); base.current?.setAttribute("d", line); light.current?.setAttribute("d", line); hit.current?.setAttribute("d", line); lay.current?.setAttribute("d", strands); const e = NODES - 1; const turn = (Math.atan2(y[e] - y[e - 2], x[e] - x[e - 2]) * 180) / Math.PI - 90; toggle.current?.setAttribute("transform", `translate(${x[e].toFixed(1)} ${y[e].toFixed(1)}) rotate(${turn.toFixed(1)})`); bell.current?.setAttribute("transform", `rotate(${((angle * 180) / Math.PI).toFixed(2)} ${PX} ${PY})`); }; const tick = (now: number) => { store += Math.min(50, now - then); then = now; while (store >= 1000 / 60) { step(); store -= 1000 / 60; } paint(); let motion = Math.abs(spin) * DROP + Math.abs(angle) * DROP; for (let i = 1; i < NODES; i++) motion += Math.abs(x[i] - px[i]) + Math.abs(y[i] - py[i]) * 0.2 + Math.abs(x[i] - x[0]) * 0.03; raf = held >= 0 || motion > 0.45 ? requestAnimationFrame(tick) : 0; }; const wake = () => { if (raf) return; then = performance.now(); raf = requestAnimationFrame(tick); }; engine.current = { grab(gx, gy) { let best = 3; let far = Infinity; for (let i = 3; i < NODES; i++) { const d = Math.hypot(x[i] - gx, y[i] - gy); if (d < far) { far = d; best = i; } } held = best; aimX = gx; aimY = gy; wake(); }, drag(gx, gy) { aimX = gx; aimY = gy; }, drop() { held = -1; armed = true; wake(); }, brush(gx, gy) { let touched = false; for (let i = 2; i < NODES; i++) { const d = gx - x[i]; if (Math.abs(d) < 15 && Math.abs(gy - y[i]) < 15) { x[i] -= Math.sign(d || 1) * 0.9; touched = true; } } if (touched) wake(); }, tug() { for (let i = 8; i < NODES; i++) y[i] += ((i - 7) / (NODES - 8)) * 16; side = -side; ring(side); wake(); }, sway(dir) { for (let i = 4; i < NODES; i++) x[i] += dir * ((i - 3) / (NODES - 4)) * 10; wake(); }, unlock() { if (!sound) return; audio ??= new AudioContext(); void audio.resume(); }, }; return () => { cancelAnimationFrame(raf); engine.current = null; void audio?.close(); }; }, [reduce, sound]); const local = (e: { clientX: number; clientY: number }) => { const box = svg.current!.getBoundingClientRect(); return { x: ((e.clientX - box.left) / box.width) * W, y: ((e.clientY - box.top) / box.height) * H }; }; /* Window listeners, so the rope stays in hand past the edge of the box. */ const onDown = (e: React.PointerEvent) => { const { x, y } = local(e); engine.current?.unlock(); engine.current?.grab(x, y); dragging.current = true; const move = (ev: PointerEvent) => { const at = local(ev); engine.current?.drag(at.x, at.y); }; const up = () => { dragging.current = false; engine.current?.drop(); window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", up); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); window.addEventListener("pointercancel", up); }; const onKey = (e: React.KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); engine.current?.unlock(); engine.current?.tug(); } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") { e.preventDefault(); engine.current?.sway(e.key === "ArrowRight" ? 1 : -1); } }; return ( { if (dragging.current) return; const { x, y } = local(e); engine.current?.brush(x, y); }} > {/* Ring lines, drawn behind the bell. */} {/* The rope hangs from the bell's eye, so it rides with the swing. */} e.preventDefault()} onKeyDown={onKey} /> {/* A wide, unseen stroke to take the grab. */} {/* Bell swings on its pivot. */} {/* Beam and bracket stay still in front. */} {count ? `Bell rung ${count} ${count === 1 ? "time" : "times"}` : ""} ); }