"use client";
import { memo, useEffect, useRef } from "react";
import { useAnimationLoop, type Metrics } from "@/hooks/use-animation-loop";
export type WeavePin = "top-row" | "corners" | "top-and-bottom" | "free";
export type WeaveShading = "strain" | "wireframe" | "solid";
interface WeaveProps {
cols?: number;
rows?: number;
iterations?: number;
gravity?: number;
wind?: number;
stiffness?: number;
damping?: number;
tearThreshold?: number;
pinMode?: WeavePin;
shading?: WeaveShading;
tint?: string;
strainColor?: string;
paused?: boolean;
reducedMotion?: boolean;
className?: string;
}
const DT = 1 / 120;
const MAX_STEPS = 8;
const SETTLE_STEPS = 260;
const PX_PER_M = 46;
const MAX_GRAB_V = 14;
interface Particle {
x: number;
y: number;
px: number;
py: number;
pinned: boolean;
}
interface Link {
a: number;
b: number;
rest: number;
dead: boolean;
strain: number;
}
const Weave = memo(
({
cols = 26,
rows = 18,
iterations = 6,
gravity = 9.8,
wind = 4.2,
stiffness = 0.9,
damping = 0.02,
tearThreshold = 1.9,
pinMode = "corners",
shading = "strain",
tint = "#a855f7",
strainColor = "#f87171",
paused = false,
reducedMotion = false,
className,
}: WeaveProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawRef = useRef<((dt: number) => void | false) | null>(null);
const measureRef = useRef<((m: Metrics) => void) | null>(null);
const live = useRef({
cols, rows, iterations, gravity, wind, stiffness, damping,
tearThreshold, pinMode, shading, tint, strainColor, reducedMotion,
});
live.current = {
cols, rows, iterations, gravity, wind, stiffness, damping,
tearThreshold, pinMode, shading, tint, strainColor, reducedMotion,
};
const loop = useAnimationLoop({
target: containerRef,
halted: paused || reducedMotion,
dpr: "auto",
onResize: (metrics) => measureRef.current?.(metrics),
onFrame: ({ dt }) => (drawRef.current ? drawRef.current(dt) : false),
});
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let particles: Particle[] = [];
let links: Link[] = [];
let gridCols = 0;
let gridRows = 0;
let width = 0;
let height = 0;
let clock = 0;
const grab = { index: -1, x: 0, y: 0, lx: 0, ly: 0, active: false };
const isPinned = (i: number, j: number, mode: string): boolean => {
const last = gridCols - 1;
if (mode === "top-row") return j === 0;
if (mode === "corners") return j === 0 && (i === 0 || i === last);
if (mode === "top-and-bottom") return j === 0 || j === gridRows - 1;
return false;
};
const build = () => {
const l = live.current;
gridCols = Math.max(4, Math.round(l.cols));
gridRows = Math.max(3, Math.round(l.rows));
const spanX = width * 0.68;
const spanY = height * 0.6;
const step = Math.min(
spanX / (gridCols - 1),
spanY / (gridRows - 1),
);
const originX = (width - step * (gridCols - 1)) / 2;
const originY = height * 0.14;
particles = [];
for (let j = 0; j < gridRows; j++) {
for (let i = 0; i < gridCols; i++) {
const x = originX + i * step;
const y = originY + j * step;
particles.push({
x,
y,
px: x,
py: y,
pinned: isPinned(i, j, l.pinMode),
});
}
}
links = [];
const push = (a: number, b: number) => {
const dx = particles[b].x - particles[a].x;
const dy = particles[b].y - particles[a].y;
links.push({ a, b, rest: Math.hypot(dx, dy), dead: false, strain: 0 });
};
for (let j = 0; j < gridRows; j++) {
for (let i = 0; i < gridCols; i++) {
const at = j * gridCols + i;
if (i < gridCols - 1) push(at, at + 1);
if (j < gridRows - 1) push(at, at + gridCols);
}
}
grab.index = -1;
grab.active = false;
};
const step = () => {
const l = live.current;
clock += DT;
const g = l.gravity * PX_PER_M * DT * DT;
const windScale = l.wind * PX_PER_M * DT * DT;
const retain = 1 - Math.min(0.9, Math.max(0, l.damping));
for (let k = 0; k < particles.length; k++) {
const p = particles[k];
if (p.pinned) {
p.px = p.x;
p.py = p.y;
continue;
}
const j = Math.floor(k / gridCols);
const gust =
windScale *
(Math.sin(clock * 1.7 + j * 0.35) * 0.6 +
Math.sin(clock * 0.63 + p.x * 0.012) * 0.4);
const vx = (p.x - p.px) * retain;
const vy = (p.y - p.py) * retain;
p.px = p.x;
p.py = p.y;
p.x += vx + gust;
p.y += vy + g;
}
if (grab.active && grab.index >= 0) {
const p = particles[grab.index];
const dx = Math.max(-MAX_GRAB_V, Math.min(MAX_GRAB_V, grab.x - grab.lx));
const dy = Math.max(-MAX_GRAB_V, Math.min(MAX_GRAB_V, grab.y - grab.ly));
p.x = grab.x;
p.y = grab.y;
p.px = grab.x - dx;
p.py = grab.y - dy;
}
const k = Math.max(0.05, Math.min(1, l.stiffness));
const passes = Math.max(1, Math.round(l.iterations));
for (let pass = 0; pass < passes; pass++) {
for (let c = 0; c < links.length; c++) {
const link = links[c];
if (link.dead) continue;
const a = particles[link.a];
const b = particles[link.b];
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.hypot(dx, dy) || 1e-6;
const diff = ((dist - link.rest) / dist) * 0.5 * k;
const ox = dx * diff;
const oy = dy * diff;
if (!a.pinned) {
a.x += ox;
a.y += oy;
}
if (!b.pinned) {
b.x -= ox;
b.y -= oy;
}
}
}
const limit = Math.max(1.02, l.tearThreshold);
for (let c = 0; c < links.length; c++) {
const link = links[c];
if (link.dead) continue;
const a = particles[link.a];
const b = particles[link.b];
const dist = Math.hypot(b.x - a.x, b.y - a.y);
const ratio = dist / link.rest;
link.strain = Math.max(0, Math.min(1, (ratio - 1) / (limit - 1)));
if (ratio > limit) link.dead = true;
}
};
let hIndex: Int32Array = new Int32Array(0);
let vIndex: Int32Array = new Int32Array(0);
const indexLinks = () => {
hIndex = new Int32Array(gridCols * gridRows).fill(-1);
vIndex = new Int32Array(gridCols * gridRows).fill(-1);
let n = 0;
for (let j = 0; j < gridRows; j++) {
for (let i = 0; i < gridCols; i++) {
if (i < gridCols - 1) hIndex[j * gridCols + i] = n++;
if (j < gridRows - 1) vIndex[j * gridCols + i] = n++;
}
}
};
const alive = (idx: number): boolean => idx >= 0 && !links[idx].dead;
const draw = () => {
const l = live.current;
ctx.clearRect(0, 0, width, height);
if (!particles.length) return;
const [tr, tg, tb] = hexToRgb(l.tint);
const [sr, sg, sb] = hexToRgb(l.strainColor);
if (l.shading === "wireframe") {
ctx.lineWidth = 1;
for (let c = 0; c < links.length; c++) {
const link = links[c];
if (link.dead) continue;
const a = particles[link.a];
const b = particles[link.b];
const t = link.strain;
ctx.strokeStyle = `rgba(${Math.round(tr + (sr - tr) * t)}, ${Math.round(tg + (sg - tg) * t)}, ${Math.round(tb + (sb - tb) * t)}, ${0.35 + t * 0.6})`;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
} else {
for (let j = 0; j < gridRows - 1; j++) {
for (let i = 0; i < gridCols - 1; i++) {
const top = hIndex[j * gridCols + i];
const bottom = hIndex[(j + 1) * gridCols + i];
const left = vIndex[j * gridCols + i];
const right = vIndex[j * gridCols + i + 1];
if (!alive(top) || !alive(bottom) || !alive(left) || !alive(right)) {
continue;
}
const p0 = particles[j * gridCols + i];
const p1 = particles[j * gridCols + i + 1];
const p2 = particles[(j + 1) * gridCols + i + 1];
const p3 = particles[(j + 1) * gridCols + i];
let t = 0;
if (l.shading === "strain") {
t =
(links[top].strain +
links[bottom].strain +
links[left].strain +
links[right].strain) *
0.25;
}
ctx.fillStyle = `rgba(${Math.round(tr + (sr - tr) * t)}, ${Math.round(tg + (sg - tg) * t)}, ${Math.round(tb + (sb - tb) * t)}, ${0.2 + t * 0.68})`;
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.lineTo(p3.x, p3.y);
ctx.closePath();
ctx.fill();
}
}
ctx.lineWidth = 1;
for (let c = 0; c < links.length; c++) {
const link = links[c];
if (link.dead) continue;
const a = particles[link.a];
const b = particles[link.b];
const t = link.strain;
ctx.strokeStyle = `rgba(${Math.round(tr + (sr - tr) * t)}, ${Math.round(tg + (sg - tg) * t)}, ${Math.round(tb + (sb - tb) * t)}, ${0.22 + t * 0.7})`;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
ctx.fillStyle = `rgba(${tr}, ${tg}, ${tb}, 0.9)`;
for (let k = 0; k < particles.length; k++) {
if (!particles[k].pinned) continue;
ctx.beginPath();
ctx.arc(particles[k].x, particles[k].y, 2.5, 0, Math.PI * 2);
ctx.fill();
}
if (grab.active && grab.index >= 0) {
const p = particles[grab.index];
ctx.strokeStyle = `rgba(${tr}, ${tg}, ${tb}, 0.85)`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(p.x, p.y, 7, 0, Math.PI * 2);
ctx.stroke();
}
};
let accumulator = 0;
drawRef.current = (dt) => {
accumulator += Math.min(dt, 0.25);
let steps = 0;
while (accumulator >= DT && steps < MAX_STEPS) {
step();
accumulator -= DT;
steps++;
}
if (steps === MAX_STEPS) accumulator = 0;
draw();
};
measureRef.current = ({ width: w, height: h, dpr, bufferWidth, bufferHeight }) => {
width = w;
height = h;
canvas.width = bufferWidth;
canvas.height = bufferHeight;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
build();
indexLinks();
for (let s = 0; s < SETTLE_STEPS; s++) step();
draw();
};
const at = (event: PointerEvent): { x: number; y: number } => {
const box = canvas.getBoundingClientRect();
return { x: event.clientX - box.left, y: event.clientY - box.top };
};
const onDown = (event: PointerEvent) => {
if (live.current.reducedMotion) return;
const { x, y } = at(event);
let best = -1;
let bestD = 30 * 30;
for (let k = 0; k < particles.length; k++) {
const p = particles[k];
const d = (p.x - x) * (p.x - x) + (p.y - y) * (p.y - y);
if (d < bestD) {
bestD = d;
best = k;
}
}
if (best < 0) return;
grab.index = best;
grab.x = x;
grab.y = y;
grab.lx = x;
grab.ly = y;
grab.active = true;
canvas.setPointerCapture(event.pointerId);
loop.start();
};
const onMove = (event: PointerEvent) => {
if (!grab.active) return;
const { x, y } = at(event);
grab.lx = grab.x;
grab.ly = grab.y;
grab.x = x;
grab.y = y;
};
const onUp = (event: PointerEvent) => {
grab.active = false;
grab.index = -1;
if (canvas.hasPointerCapture(event.pointerId)) {
canvas.releasePointerCapture(event.pointerId);
}
};
canvas.addEventListener("pointerdown", onDown);
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointercancel", onUp);
loop.resize();
loop.start();
return () => {
drawRef.current = null;
measureRef.current = null;
canvas.removeEventListener("pointerdown", onDown);
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerup", onUp);
canvas.removeEventListener("pointercancel", onUp);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cols, rows, pinMode]);
useEffect(() => {
loop.resize();
}, [cols, rows, pinMode, loop]);
return (
<div ref={containerRef} className={className ?? "relative h-full w-full"}>
<canvas
ref={canvasRef}
className="absolute top-0 left-0 block touch-none select-none"
/>
</div>
);
},
);
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace("#", "");
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const n = parseInt(full, 16);
if (Number.isNaN(n) || full.length !== 6) return [255, 255, 255];
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
Weave.displayName = "Weave";
export default Weave;