Each character of the input string becomes a circular rigid body dropped inside a triangle. Gravity, wall-edge collisions, and body-body repulsion run for 300 synchronous settle steps before the first paint, so the result is an instantly packed typographic cluster -- no visible simulation wind-up. Adjustable gravity, restitution, triangle size, and rotation; the triangle itself is clipped as a canvas path, so letters never leak past the boundary even when the physics overshoots.
Swarm
8 bodies settled
Source
"use client";
import { useEffect, useRef, useState } from "react";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import { PanelColor, PanelSelect, PanelToggle, PanelText } from "../_kansei/controls";
import { resolveFont, type FontChoice } from "../_kansei/fonts";
type Pt = [number, number];
type Body = { ch: string; x: number; y: number; vx: number; vy: number; angle: number; angularVel: number; radius: number };
function isDark(hex: string) {
const n = parseInt(hex.slice(1), 16);
const lum = (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255;
return lum < 0.5;
}
export default function Swarm() {
const [text, setText] = useState("GIRAGIRA");
const [fontSize, setFontSize] = useState(90);
const [font, setFont] = useState<FontChoice>("sans");
const [gravity, setGravity] = useState(0.8);
const [restitution, setRestitution] = useState(0.2);
const [triangleSize, setTriangleSize] = useState(340);
const [rotation, setRotation] = useState(0);
const [pointUp, setPointUp] = useState(true);
const [uppercase, setUppercase] = useState(true);
const [textColor, setTextColor] = useState("#1a1814");
const [bgColor, setBgColor] = useState("#5c5fa8");
const [triBg, setTriBg] = useState("#88c070");
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let cancelled = false;
function draw() {
if (cancelled || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const PAD = 18;
const W = 420;
const H = triangleSize + PAD * 2;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = `${W}px`;
canvas.style.height = `${H}px`;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
const cx = W / 2;
const ts = triangleSize;
let apex: Pt, baseL: Pt, baseR: Pt;
if (pointUp) {
apex = [cx, PAD]; baseL = [cx - ts / 2, PAD + ts]; baseR = [cx + ts / 2, PAD + ts];
} else {
apex = [cx, PAD + ts]; baseL = [cx - ts / 2, PAD]; baseR = [cx + ts / 2, PAD];
}
const edges: [Pt, Pt][] = [[apex, baseL], [baseL, baseR], [baseR, apex]];
const gravY = pointUp ? gravity * 0.4 : -gravity * 0.4;
const fontFamily = resolveFont(font);
const fontStyle = font === "display" ? "italic " : "";
ctx.font = `${fontStyle}900 ${fontSize}px ${fontFamily}`;
const source = (uppercase ? text.toUpperCase() : text).trim();
const chars = source.split("").filter((c) => c !== " ");
if (chars.length === 0) return;
const inTriangle = (px: number, py: number) => {
const cross = (ax: number, ay: number, bx: number, by: number) => (bx - ax) * (py - ay) - (by - ay) * (px - ax);
const d1 = cross(apex[0], apex[1], baseL[0], baseL[1]);
const d2 = cross(baseL[0], baseL[1], baseR[0], baseR[1]);
const d3 = cross(baseR[0], baseR[1], apex[0], apex[1]);
const hasNeg = d1 < 0 || d2 < 0 || d3 < 0;
const hasPos = d1 > 0 || d2 > 0 || d3 > 0;
return !(hasNeg && hasPos);
};
const triArea = 0.5 * ts * ts;
const gridStep = Math.sqrt(triArea / Math.max(chars.length, 1)) * 0.88;
const topY = Math.min(apex[1], baseL[1]);
const bottomY = Math.max(apex[1], baseL[1]);
const candidates: Pt[] = [];
for (let py = topY + gridStep * 0.6; py < bottomY - gridStep * 0.1; py += gridStep) {
for (let px = cx - ts / 2 + gridStep * 0.3; px <= cx + ts / 2 - gridStep * 0.3; px += gridStep * 0.75) {
if (inTriangle(px, py)) candidates.push([px, py]);
}
}
const shuffled = [...candidates].sort((a, b) => Math.sin(a[0] * 17.3 + a[1] * 31.7) - Math.sin(b[0] * 17.3 + b[1] * 31.7));
const bodies: Body[] = chars.map((ch, i) => {
const pos = shuffled[i % shuffled.length] ?? [cx, (topY + bottomY) / 2];
const w = ctx.measureText(ch).width;
const radius = Math.max(w, fontSize * 0.6) / 2;
return {
ch,
x: pos[0] + Math.sin(i * 41.2) * radius * 0.25,
y: pos[1] + Math.cos(i * 67.3) * radius * 0.25,
vx: 0, vy: 0,
angle: Math.sin(i * 83.1) * ((25 * Math.PI) / 180),
angularVel: 0, radius,
};
});
const wallPad = fontSize * 0.22;
const edgeCollide = (b: Body, A: Pt, B: Pt) => {
const ABx = B[0] - A[0], ABy = B[1] - A[1];
const ABlen2 = ABx * ABx + ABy * ABy;
if (ABlen2 === 0) return;
const t = Math.max(0, Math.min(1, ((b.x - A[0]) * ABx + (b.y - A[1]) * ABy) / ABlen2));
const dx = b.x - (A[0] + t * ABx), dy = b.y - (A[1] + t * ABy);
const dist = Math.sqrt(dx * dx + dy * dy);
const minDist = b.radius + wallPad;
if (dist > 0 && dist < minDist) {
const nx = dx / dist, ny = dy / dist;
b.x += nx * (minDist - dist) * 1.02;
b.y += ny * (minDist - dist) * 1.02;
const vDotN = b.vx * nx + b.vy * ny;
if (vDotN < 0) {
b.vx -= (1 + restitution) * vDotN * nx;
b.vy -= (1 + restitution) * vDotN * ny;
b.angularVel += (b.vy * nx - b.vx * ny) * 0.002;
}
}
};
const charGap = fontSize * 0.12;
const bodyCollide = (a: Body, b: Body) => {
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy);
const minD = a.radius + b.radius + charGap;
if (d > 0 && d < minD) {
const nx = dx / d, ny = dy / d;
const overlap = (minD - d) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap;
b.x += nx * overlap; b.y += ny * overlap;
const vn = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
if (vn > 0) {
const imp = (1 + restitution) * vn * 0.5;
a.vx -= imp * nx; a.vy -= imp * ny;
b.vx += imp * nx; b.vy += imp * ny;
a.angularVel -= vn * 0.0015; b.angularVel += vn * 0.0015;
}
}
};
const centX = (apex[0] + baseL[0] + baseR[0]) / 3;
const centY = (apex[1] + baseL[1] + baseR[1]) / 3;
for (let s = 0; s < 300; s++) {
for (const b of bodies) {
b.vy += gravY * 0.2;
b.x += b.vx; b.y += b.vy; b.angle += b.angularVel;
b.vx *= 0.8; b.vy *= 0.8; b.angularVel *= 0.88;
for (const [A, B] of edges) edgeCollide(b, A, B);
}
for (let pass = 0; pass < 3; pass++)
for (let i = 0; i < bodies.length; i++)
for (let j = i + 1; j < bodies.length; j++) bodyCollide(bodies[i], bodies[j]);
for (const b of bodies) {
if (!inTriangle(b.x, b.y)) {
for (let t = 0.05; t <= 1.0; t += 0.05) {
const nx = b.x + (centX - b.x) * t, ny = b.y + (centY - b.y) * t;
if (inTriangle(nx, ny)) { b.x = nx; b.y = ny; break; }
}
b.vx *= 0.3; b.vy *= 0.3;
}
}
}
ctx.save();
if (rotation !== 0) {
ctx.translate(cx, H / 2);
ctx.rotate((rotation * Math.PI) / 180);
ctx.translate(-cx, -H / 2);
}
ctx.beginPath();
ctx.moveTo(apex[0], apex[1]);
ctx.lineTo(baseL[0], baseL[1]);
ctx.lineTo(baseR[0], baseR[1]);
ctx.closePath();
ctx.fillStyle = triBg;
ctx.fill();
ctx.clip();
ctx.font = `${fontStyle}900 ${fontSize}px ${fontFamily}`;
ctx.fillStyle = textColor;
ctx.textBaseline = "middle";
ctx.textAlign = "center";
for (const b of bodies) {
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(b.angle);
ctx.fillText(b.ch, 0, 0);
ctx.restore();
}
ctx.restore();
}
document.fonts.ready.then(draw);
return () => { cancelled = true; };
}, [text, fontSize, font, gravity, restitution, triangleSize, rotation, pointUp, uppercase, textColor, triBg]);
const dark = isDark(bgColor);
return (
<div className="fixed inset-0 flex items-center justify-center select-none" style={{ background: bgColor }}>
<canvas ref={canvasRef} className="z-[1]" />
<div
className="fixed bottom-8 left-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
>
<p className="text-[12px] tracking-[0.08em] uppercase" style={{ color: dark ? "rgba(255,255,255,0.55)" : "rgba(23,23,23,0.5)" }}>Swarm</p>
<p className="text-[12px] tracking-[0.08em] mt-1" style={{ color: dark ? "rgba(255,255,255,0.3)" : "rgba(23,23,23,0.3)" }}>{text.replace(/ /g, "").length} bodies settled</p>
</div>
<DraggablePanel
isLight={dark}
controls={[
{ label: "Font size", value: fontSize, set: (v) => setFontSize(Math.round(v)), min: 40, max: 160, step: 1 },
{ label: "Triangle", value: triangleSize, set: (v) => setTriangleSize(Math.round(v)), min: 200, max: 460, step: 1 },
{ label: "Gravity", value: gravity, set: setGravity, min: 0, max: 2, step: 0.05 },
{ label: "Restitution", value: restitution, set: setRestitution, min: 0, max: 1, step: 0.05 },
{ label: "Rotation", value: rotation, set: (v) => setRotation(Math.round(v)), min: 0, max: 360, step: 1 },
]}
>
<PanelText label="Text" value={text} set={setText} placeholder="GIRAGIRA" isLight={dark} />
<PanelSelect label="Font" value={font} options={["display", "sans", "noto"] as const} set={setFont} isLight={dark} />
<PanelToggle label="Point up" value={pointUp} set={setPointUp} isLight={dark} />
<PanelToggle label="Uppercase" value={uppercase} set={setUppercase} isLight={dark} />
<PanelColor label="Text" value={textColor} set={setTextColor} isLight={dark} />
<PanelColor label="Triangle" value={triBg} set={setTriBg} isLight={dark} />
<PanelColor label="Background" value={bgColor} set={setBgColor} isLight={dark} />
</DraggablePanel>
<div className="fixed top-8 left-8 z-10">
<ScrambleLink
from="NEMAWASHI LAB"
to="← BACK"
href="/lab"
className={`text-[14px] tracking-[0.08em] uppercase pointer-events-auto font-[family-name:var(--font-flux)] ${dark ? "text-white/60" : "text-foreground/60"}`}
style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
/>
</div>
</div>
);
}