NEMAWASHI LAB

Beads

OTF

Letters are drawn on a vertical-string abacus: thin lines run full-height, and bitmap glyph data from a shared 6-row proportional alphabet decides where to place beads. Each bead is an SVG symbol -- a narrow rect body capped by two discs whose radius, width, and height are independently adjustable. The layout maps the grid-font's hash/dot matrix into viewBox coordinates, centres multi-line text, and leaves empty strings visible so the curtain reads even where no bead sits. Click cycles compositions; the result can be exported as an OTF.

Source

BeadsCanvas.tsx464 lines
"use client";

import { useRef, useMemo, useEffect, useState, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { motion } from "framer-motion";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import exportBeadsFont from "./exportBeadsFont";
import { FONT, ROWS, SPACE_COLS, LETTER_GAP_COLS } from "../alphabet";

const VB = 1000;

// ---------------------------------------------------------------------------
// Compositions
// ---------------------------------------------------------------------------

interface Composition {
  name: string;
  text: string;
  bg: string;
  beadColor: string;
  stringColor: string;
  uiColor: "light" | "dark";
  // Cell geometry (one cell = one string × one slot)
  cellW: number;          // horizontal cell pitch (string spacing)
  cellH: number;          // vertical cell pitch (slot spacing)
  // Bead geometry
  bodyW: number;          // narrow waist width
  bodyH: number;          // central rectangle height
  headR: number;          // head disc radius (heads protrude beyond bodyW)
  stringWidth: number;    // vertical string stroke width
}

const compositions: Composition[] = [
  {
    name: "GIRAGIRA",
    text: "GIRAGIRA",
    bg: "#f0eeeb",
    beadColor: "#171717",
    stringColor: "rgba(23,23,23,0.22)",
    uiColor: "dark",
    cellW: 22,
    cellH: 60,
    bodyW: 6,
    bodyH: 30,
    headR: 9,
    stringWidth: 1.2,
  },
  {
    name: "BEADS",
    text: "BEADS",
    bg: "#e8e2f0",
    beadColor: "#1a1240",
    stringColor: "rgba(26,18,64,0.22)",
    uiColor: "dark",
    cellW: 30,
    cellH: 84,
    bodyW: 9,
    bodyH: 42,
    headR: 13,
    stringWidth: 1.6,
  },
  {
    name: "GIRAGIRA LAB",
    text: "GIRAGIRA LAB",
    bg: "#f5ede0",
    beadColor: "#3a1a10",
    stringColor: "rgba(58,26,16,0.22)",
    uiColor: "dark",
    cellW: 16,
    cellH: 42,
    bodyW: 4,
    bodyH: 22,
    headR: 6.5,
    stringWidth: 1,
  },
  {
    name: "GIRAGIRA / NIGHT",
    text: "GIRAGIRA",
    bg: "#0e0e10",
    beadColor: "#f0eeeb",
    stringColor: "rgba(240,238,235,0.18)",
    uiColor: "light",
    cellW: 22,
    cellH: 60,
    bodyW: 6,
    bodyH: 30,
    headR: 9,
    stringWidth: 1.2,
  },
  {
    name: "A — R",
    text: "ABCDEFGHI\nJKLMNOPQR",
    bg: "#f0eeeb",
    beadColor: "#171717",
    stringColor: "rgba(23,23,23,0.22)",
    uiColor: "dark",
    cellW: 16,
    cellH: 36,
    bodyW: 4,
    bodyH: 18,
    headR: 6,
    stringWidth: 0.8,
  },
  {
    name: "S — Z",
    text: "STUVWXYZ",
    bg: "#f0eeeb",
    beadColor: "#171717",
    stringColor: "rgba(23,23,23,0.22)",
    uiColor: "dark",
    cellW: 16,
    cellH: 36,
    bodyW: 4,
    bodyH: 18,
    headR: 6,
    stringWidth: 0.8,
  },
  {
    name: "a — r",
    text: "abcdefghi\njklmnopqr",
    bg: "#f0eeeb",
    beadColor: "#171717",
    stringColor: "rgba(23,23,23,0.22)",
    uiColor: "dark",
    cellW: 16,
    cellH: 36,
    bodyW: 4,
    bodyH: 18,
    headR: 6,
    stringWidth: 0.8,
  },
  {
    name: "s — z",
    text: "stuvwxyz",
    bg: "#f0eeeb",
    beadColor: "#171717",
    stringColor: "rgba(23,23,23,0.22)",
    uiColor: "dark",
    cellW: 16,
    cellH: 36,
    bodyW: 4,
    bodyH: 18,
    headR: 6,
    stringWidth: 0.8,
  },
  {
    name: "0123456789",
    text: "0123456789\n.,!?-':;",
    bg: "#f0eeeb",
    beadColor: "#171717",
    stringColor: "rgba(23,23,23,0.22)",
    uiColor: "dark",
    cellW: 16,
    cellH: 36,
    bodyW: 4,
    bodyH: 18,
    headR: 6,
    stringWidth: 0.8,
  },
];

// ---------------------------------------------------------------------------
// Layout — turn text + FONT into bead positions and string x's (viewBox coords)
// ---------------------------------------------------------------------------

interface LayoutData {
  beads: { x: number; y: number }[];
  strings: number[];
}

function lineCols(lineText: string): number {
  let cols = 0;
  for (let i = 0; i < lineText.length; i++) {
    const ch = lineText[i];
    if (ch === " ") cols += SPACE_COLS;
    else {
      const glyph = FONT[ch];
      if (glyph) cols += glyph[0].length;
    }
    if (i < lineText.length - 1) cols += LETTER_GAP_COLS;
  }
  return cols;
}

function layout(comp: Composition, vbH: number): LayoutData {
  const lines = comp.text.split("\n");
  const lineH = ROWS * comp.cellH;
  const lineGap = comp.cellH;
  const totalH = lines.length * lineH + (lines.length - 1) * lineGap;
  const baseOy = (vbH - totalH) / 2 + comp.cellH / 2;

  const beads: { x: number; y: number }[] = [];
  const strings: number[] = [];

  for (let li = 0; li < lines.length; li++) {
    const lineText = lines[li];
    const totalW = lineCols(lineText) * comp.cellW;
    const ox = (VB - totalW) / 2 + comp.cellW / 2;
    const oy = baseOy + li * (lineH + lineGap);

    let colCursor = 0;
    for (let i = 0; i < lineText.length; i++) {
      const ch = lineText[i];

      if (ch === " ") {
        colCursor += SPACE_COLS;
        if (i < lineText.length - 1) colCursor += LETTER_GAP_COLS;
        continue;
      }

      const glyph = FONT[ch];
      if (!glyph) {
        colCursor += LETTER_GAP_COLS;
        continue;
      }

      const gW = glyph[0].length;
      for (let c = 0; c < gW; c++) {
        const x = ox + (colCursor + c) * comp.cellW;
        strings.push(x);
        for (let r = 0; r < ROWS; r++) {
          if (glyph[r][c] === "#") {
            beads.push({ x, y: oy + r * comp.cellH });
          }
        }
      }
      colCursor += gW;
      if (i < lineText.length - 1) colCursor += LETTER_GAP_COLS;
    }
  }

  return { beads, strings };
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export default function BeadsCanvas() {
  const searchParams = useSearchParams();
  const initialIndex = Math.abs(Number(searchParams.get("v")) || 0) % compositions.length;
  const [index, setIndex] = useState(initialIndex);
  const nextIndex = (index + 1) % compositions.length;

  const handleClick = useCallback(() => {
    setIndex(nextIndex);
    window.history.replaceState(null, "", `/lab/beads?v=${nextIndex}`);
  }, [nextIndex]);

  const [vbH, setVbH] = useState(VB);
  const stageRef = useRef<HTMLDivElement>(null);
  const svgRef = useRef<SVGSVGElement>(null);

  useEffect(() => {
    const el = stageRef.current;
    if (!el) return;
    const update = () => {
      const aspect = el.clientHeight / Math.max(1, el.clientWidth);
      setVbH(Math.round(VB * aspect));
    };
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const comp = compositions[index];
  const isLight = comp.uiColor === "light";

  const [headR, setHeadR] = useState(comp.headR);
  const [bodyW, setBodyW] = useState(comp.bodyW);
  const [bodyH, setBodyH] = useState(comp.bodyH);
  const [previewFamily, setPreviewFamily] = useState<string | null>(null);
  const [customText, setCustomText] = useState("");

  const activeComp = useMemo(
    () => (customText ? { ...comp, text: customText } : comp),
    [comp, customText],
  );
  const { beads, strings } = useMemo(() => layout(activeComp, vbH), [activeComp, vbH]);

  const beadId = `bead-${index}-${headR}-${bodyW}-${bodyH}`;

  return (
    <div
      ref={stageRef}
      className="fixed inset-0 select-none transition-colors duration-700 cursor-pointer"
      style={{ backgroundColor: comp.bg }}
      onClick={handleClick}
    >
      <svg
        ref={svgRef}
        className="absolute inset-0 w-full h-full z-[1]"
        viewBox={`0 0 ${VB} ${vbH}`}
        preserveAspectRatio="xMidYMid meet"
        overflow="hidden"
      >
        <defs>
          <symbol id={beadId} overflow="visible">
            <rect
              x={-bodyW / 2}
              y={-bodyH / 2}
              width={bodyW}
              height={bodyH}
            />
            <circle cx={0} cy={-bodyH / 2} r={headR} />
            <circle cx={0} cy={bodyH / 2} r={headR} />
          </symbol>
        </defs>

        {/* Vertical strings — only on letter columns, full canvas height */}
        <g stroke={comp.stringColor} strokeWidth={comp.stringWidth}>
          {strings.map((x, i) => (
            <line key={i} x1={x} y1={0} x2={x} y2={vbH} />
          ))}
        </g>

        {/* Beads */}
        <g fill={comp.beadColor}>
          {beads.map((b, i) => (
            <use key={i} href={`#${beadId}`} x={b.x} y={b.y} />
          ))}
        </g>

      </svg>

      {/* Composition label */}
      <div
        className="fixed bottom-8 left-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
        style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
      >
        <motion.div
          key={comp.name}
          initial={{ opacity: 0, y: 10 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.4, delay: 0.2 }}
        >
          <motion.p
            className="text-[12px] tracking-[0.08em] uppercase"
            animate={{
              color: isLight
                ? "rgba(255,255,255,0.5)"
                : "rgba(23,23,23,0.5)",
            }}
            transition={{ duration: 0.6 }}
          >
            {comp.name}
          </motion.p>
          <motion.p
            className="text-[12px] tracking-[0.08em] mt-1"
            animate={{
              color: isLight
                ? "rgba(255,255,255,0.3)"
                : "rgba(23,23,23,0.3)",
            }}
            transition={{ duration: 0.6 }}
          >
            {index + 1} / {compositions.length}
          </motion.p>
        </motion.div>
      </div>

      <DraggablePanel
        isLight={isLight}
        controls={[
          { label: "Head radius", value: headR, set: setHeadR, min: 3, max: 16, step: 0.5 },
          { label: "Body width", value: bodyW, set: setBodyW, min: 1, max: 12, step: 0.5 },
          { label: "Body height", value: bodyH, set: setBodyH, min: 8, max: 50, step: 1 },
        ]}
      >
        <div style={{ marginBottom: 8 }}>
          <div style={{
            fontSize: 10, letterSpacing: "0.06em", textTransform: "uppercase",
            color: isLight ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)",
            fontVariationSettings: "'wght' 300, 'SRIF' 100", marginBottom: 4,
          }}>
            Preview text
          </div>
          <textarea
            value={customText}
            onChange={(e) => setCustomText(e.target.value)}
            placeholder={comp.text}
            rows={2}
            style={{
              width: "100%",
              padding: "6px 8px",
              background: "transparent",
              border: isLight
                ? "1px solid rgba(255,255,255,0.15)"
                : "1px solid rgba(0,0,0,0.1)",
              borderRadius: 6,
              color: isLight ? "#fff" : "#1a1a2e",
              fontSize: 12,
              fontFamily: "var(--font-flux), sans-serif",
              boxSizing: "border-box",
              resize: "none",
            }}
          />
        </div>
        <button
          onClick={() => setPreviewFamily(exportBeadsFont(headR, bodyW, bodyH))}
          style={{
            width: "100%", padding: "8px 0", marginTop: 4, borderRadius: 8,
            border: isLight ? "1px solid rgba(255,255,255,0.2)" : "1px solid rgba(0,0,0,0.1)",
            background: isLight ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.04)",
            color: isLight ? "#fff" : "#1a1a2e",
            fontFamily: "var(--font-flux), sans-serif",
            fontVariationSettings: "'wght' 500, 'SRIF' 100",
            fontSize: 11, letterSpacing: "0.08em", textTransform: "uppercase",
            cursor: "pointer",
          }}
        >
          Export OTF
        </button>
      </DraggablePanel>

      <div
        className="fixed bottom-8 right-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: isLight ? "rgba(255,255,255,0.3)" : "rgba(23,23,23,0.3)" }}
        >
          Click anywhere
        </p>
      </div>

      <div className="fixed top-8 left-8 z-10" onClick={(e) => e.stopPropagation()}>
        <ScrambleLink
          from="NEMAWASHI LAB"
          to="← BACK"
          href="/lab"
          className={`text-[14px] tracking-[0.08em] uppercase pointer-events-auto font-[family-name:var(--font-flux)] ${
            isLight ? "text-white/60" : "text-foreground/60"
          }`}
          style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
        />
      </div>

      {previewFamily && (
        <div
          className="fixed bottom-0 left-0 right-0 z-30 pointer-events-none"
          style={{
            background: isLight ? "rgba(0,0,0,0.85)" : "rgba(255,255,255,0.95)",
            padding: "16px 32px",
            textAlign: "center",
          }}
        >
          <p style={{
            fontFamily: `"${previewFamily}", sans-serif`,
            fontSize: 32,
            color: isLight ? "#fff" : "#1a1a2e",
            letterSpacing: "0.05em",
          }}>
            AaBbCcDd 0123456789 .,!?
          </p>
        </div>
      )}
    </div>
  );
}
exportBeadsFont.ts102 lines
import { Glyph, Path } from "opentype.js";
import { UPM, createFont, downloadFont, previewFont } from "../fontExport";
import { FONT, ROWS, SPACE_COLS } from "../alphabet";

const CELL_H = UPM / ROWS;
const CELL_W = Math.round(CELL_H * (22 / 60));
const SCALE = CELL_H / 60;
const K = 0.5522847498;

function ccwCircle(path: Path, cx: number, cy: number, r: number) {
  const k = K * r;
  // CCW from (cx+r, cy): right→bottom→left→top→right
  path.curveTo(cx + r, cy - k, cx + k, cy - r, cx, cy - r);
  path.curveTo(cx - k, cy - r, cx - r, cy - k, cx - r, cy);
  path.curveTo(cx - r, cy + k, cx - k, cy + r, cx, cy + r);
  path.curveTo(cx + k, cy + r, cx + r, cy + k, cx + r, cy);
}

function bead(
  path: Path,
  cx: number, cy: number,
  hr: number, bw: number, bh: number,
) {
  const hw = bw / 2;
  const hh = bh / 2;
  const topCy = cy + hh;
  const botCy = cy - hh;

  // Single contour per bead: body + full circles connected by zero-width bridges.
  // Bridge = line out to circle edge + full circle + line back. No overlapping contours.

  path.moveTo(cx + hw, topCy);

  // Bridge to top circle, trace full circle CCW, bridge back
  path.lineTo(cx + hr, topCy);
  ccwCircle(path, cx, topCy, hr);
  path.lineTo(cx + hw, topCy);

  // Down right body edge
  path.lineTo(cx + hw, botCy);

  // Bridge to bottom circle, trace full circle CCW, bridge back
  path.lineTo(cx + hr, botCy);
  ccwCircle(path, cx, botCy, hr);
  path.lineTo(cx + hw, botCy);

  // Left body edge back up
  path.lineTo(cx - hw, botCy);
  path.lineTo(cx - hw, topCy);

  path.closePath();
}

export default function exportBeadsFont(
  headR: number,
  bodyW: number,
  bodyH: number,
) {
  const hr = headR * SCALE;
  const bw = bodyW * SCALE;
  const bh = bodyH * SCALE;

  const otGlyphs: Glyph[] = [];

  for (const [char, rows] of Object.entries(FONT)) {
    const code = char.charCodeAt(0);
    const glyphW = rows[0].length;
    const leftBearing = hr;
    const advanceWidth = glyphW * CELL_W + leftBearing * 2;
    const path = new Path();

    for (let c = 0; c < glyphW; c++) {
      for (let r = 0; r < ROWS; r++) {
        if (rows[r][c] === "#") {
          const bx = c * CELL_W + CELL_W / 2 + leftBearing;
          const by = UPM - (r * CELL_H + CELL_H / 2);
          bead(path, bx, by, hr, bw, bh);
        }
      }
    }

    otGlyphs.push(new Glyph({
      name: char.length === 1 ? char : `uni${code.toString(16).toUpperCase().padStart(4, "0")}`,
      unicode: code,
      advanceWidth,
      path,
    }));
  }

  otGlyphs.push(new Glyph({
    name: "space",
    unicode: 32,
    advanceWidth: SPACE_COLS * CELL_W,
    path: new Path(),
  }));

  const filename = `GRGR_Beads_r${headR}_w${bodyW}_h${bodyH}.otf`;
  const font = createFont("GRGR Beads", otGlyphs);
  downloadFont(font, filename);
  return previewFont(font, "GRGR-Beads-Preview");
}

NEMAWASHI — Kotaro Abe