NEMAWASHI LAB

Swarm

Canvas

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

Controls
Font size90
Triangle340
Gravity0.80
Restitution0.20
Rotation0
Text
Font
Text
Triangle
Background

Source

Swarm.tsx251 lines
"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>
  );
}

NEMAWASHI — Kotaro Abe