NEMAWASHI LAB

Stick

OTF

Every letter is decomposed into raw line segments -- A is three strokes, Z is three, a full-stop is one. A seeded PRNG jitters each segment's midpoint and rotates it around its centre by up to looseness * 24 degrees, so the skeleton stays legible while the strokes drift like scattered matchsticks. Per-character colour is picked deterministically from a four-swatch palette, and the gap control shortens each segment symmetrically from both ends so adjacent strokes never quite touch. The whole set exports as an OTF via opentype.js, making the tuned looseness permanent type.

GIRAGIRA

1 / 10

Click anywhere

Controls
Weight8
Looseness0.50
Gap8
Preview text
Colors

Source

StickCanvas.tsx667 lines
"use client";

import { useState, useCallback, useMemo, useRef } from "react";
import { motion } from "framer-motion";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import exportStickFont from "./exportStickFont";

// ---------------------------------------------------------------------------
// Glyph data
// ---------------------------------------------------------------------------

type StrokeSeg = [number, number, number, number];

const STICK_GLYPHS: Record<string, StrokeSeg[]> = {
  " ": [],
  A: [[4, 85, 26, 8], [26, 8, 46, 85], [12, 52, 40, 52]],
  B: [[10, 8, 10, 85], [10, 8, 38, 8], [38, 8, 42, 42], [10, 42, 38, 42], [38, 42, 42, 82], [10, 85, 38, 85]],
  C: [[42, 12, 8, 16], [8, 16, 8, 74], [8, 74, 42, 78]],
  D: [[10, 8, 10, 85], [10, 8, 38, 8], [42, 14, 42, 76], [10, 82, 38, 82]],
  E: [[10, 8, 10, 85], [10, 8, 44, 8], [10, 46, 38, 46], [10, 85, 44, 85]],
  F: [[10, 8, 10, 85], [10, 8, 44, 8], [10, 46, 38, 46]],
  G: [[42, 12, 8, 16], [8, 16, 8, 74], [8, 74, 42, 78], [42, 78, 42, 48], [42, 48, 24, 48]],
  H: [[8, 8, 8, 85], [42, 8, 42, 85], [8, 46, 42, 46]],
  I: [[25, 8, 25, 85], [8, 8, 42, 8], [8, 85, 42, 85]],
  J: [[8, 8, 44, 8], [38, 8, 38, 72], [38, 72, 14, 82], [14, 82, 8, 68]],
  K: [[8, 8, 8, 85], [8, 48, 42, 8], [8, 48, 42, 85]],
  L: [[10, 8, 10, 85], [10, 85, 46, 85]],
  M: [[5, 85, 5, 8], [5, 8, 28, 46], [28, 46, 50, 8], [50, 8, 50, 85]],
  N: [[5, 85, 5, 8], [5, 8, 46, 85], [46, 85, 46, 8]],
  O: [[14, 8, 36, 8], [40, 14, 40, 76], [14, 82, 36, 82], [10, 14, 10, 76]],
  P: [[10, 8, 10, 85], [10, 8, 36, 8], [36, 8, 42, 44], [10, 44, 42, 44]],
  Q: [[14, 8, 36, 8], [40, 14, 40, 76], [14, 82, 36, 82], [10, 14, 10, 76], [32, 70, 46, 88]],
  R: [[10, 8, 10, 85], [10, 8, 36, 8], [36, 8, 44, 26], [10, 46, 44, 26], [10, 46, 44, 85]],
  S: [[40, 10, 14, 10], [14, 10, 5, 26], [5, 26, 42, 50], [42, 50, 44, 66], [44, 66, 14, 82], [14, 82, 5, 70]],
  T: [[4, 8, 46, 8], [25, 8, 25, 85]],
  U: [[5, 8, 5, 64], [5, 64, 12, 80], [12, 80, 38, 80], [38, 80, 46, 64], [46, 64, 46, 8]],
  V: [[4, 8, 25, 85], [25, 85, 46, 8]],
  W: [[4, 8, 16, 85], [16, 85, 27, 46], [27, 46, 38, 85], [38, 85, 50, 8]],
  X: [[5, 8, 46, 85], [46, 8, 5, 85]],
  Y: [[5, 8, 26, 46], [46, 8, 26, 46], [26, 46, 26, 85]],
  Z: [[5, 8, 46, 8], [46, 8, 5, 85], [5, 85, 46, 85]],
  a: [[14, 22, 36, 22], [8, 26, 8, 60], [14, 64, 38, 64], [38, 22, 38, 85]],
  b: [[8, 2, 8, 85], [8, 44, 38, 44], [42, 48, 42, 76], [8, 80, 38, 80]],
  c: [[40, 26, 8, 28], [8, 28, 8, 72], [8, 72, 40, 74]],
  d: [[44, 2, 44, 85], [44, 20, 14, 20], [10, 24, 10, 76], [44, 80, 14, 80]],
  e: [[8, 24, 38, 24], [8, 24, 8, 78], [8, 78, 38, 78], [8, 50, 44, 50]],
  f: [[28, 5, 28, 85], [12, 24, 42, 24], [28, 5, 36, 8]],
  g: [[14, 20, 36, 20], [10, 24, 10, 74], [14, 80, 36, 80], [40, 24, 40, 108], [40, 108, 10, 108]],
  h: [[8, 2, 8, 85], [8, 44, 40, 44], [40, 44, 40, 85]],
  i: [[22, 30, 22, 85], [18, 10, 26, 10]],
  j: [[18, 10, 26, 10], [22, 30, 22, 80], [22, 80, 8, 88], [8, 88, 4, 76]],
  k: [[8, 2, 8, 85], [8, 52, 38, 24], [8, 52, 38, 85]],
  l: [[22, 2, 22, 85]],
  m: [[5, 24, 5, 85], [5, 24, 25, 44], [25, 44, 44, 24], [44, 24, 44, 85]],
  n: [[5, 24, 5, 85], [5, 24, 38, 24], [38, 24, 38, 85]],
  o: [[14, 20, 36, 20], [40, 26, 40, 74], [14, 80, 36, 80], [10, 26, 10, 74]],
  p: [[10, 10, 10, 112], [10, 10, 38, 10], [38, 10, 44, 44], [10, 44, 44, 44]],
  q: [[14, 20, 36, 20], [10, 26, 10, 74], [14, 80, 36, 80], [40, 24, 40, 108]],
  r: [[8, 24, 8, 85], [8, 24, 34, 24], [34, 24, 42, 40]],
  s: [[38, 22, 14, 22], [14, 22, 5, 35], [5, 35, 40, 50], [40, 50, 44, 64], [44, 64, 14, 78]],
  t: [[22, 5, 22, 85], [6, 24, 40, 24]],
  u: [[5, 24, 5, 68], [5, 68, 12, 80], [12, 80, 38, 80], [38, 80, 46, 68], [46, 68, 46, 24]],
  v: [[4, 24, 25, 85], [25, 85, 46, 24]],
  w: [[4, 24, 14, 85], [14, 85, 26, 52], [26, 52, 38, 85], [38, 85, 48, 24]],
  x: [[5, 24, 44, 85], [44, 24, 5, 85]],
  y: [[5, 24, 26, 62], [46, 24, 26, 62], [26, 62, 14, 112]],
  z: [[5, 24, 44, 24], [44, 24, 5, 85], [5, 85, 44, 85]],
  "0": [[14, 8, 36, 8], [40, 14, 40, 76], [14, 82, 36, 82], [10, 14, 10, 76]],
  "1": [[8, 18, 25, 8], [25, 8, 25, 85], [12, 85, 38, 85]],
  "2": [[14, 8, 36, 8], [36, 8, 42, 36], [42, 36, 8, 82], [8, 82, 44, 82]],
  "3": [[8, 12, 36, 8], [36, 8, 42, 24], [42, 24, 14, 42], [14, 42, 42, 62], [42, 62, 36, 82], [8, 78, 36, 82]],
  "4": [[8, 8, 8, 46], [8, 46, 44, 46], [36, 8, 36, 85]],
  "5": [[8, 8, 44, 8], [8, 8, 8, 42], [8, 42, 36, 42], [36, 42, 44, 62], [44, 62, 36, 82], [8, 82, 36, 82]],
  "6": [[36, 8, 14, 14], [10, 14, 10, 76], [14, 82, 36, 82], [40, 48, 40, 76], [14, 42, 36, 42]],
  "7": [[4, 8, 46, 8], [46, 8, 18, 85]],
  "8": [[14, 8, 36, 8], [10, 14, 10, 36], [14, 42, 36, 42], [10, 48, 10, 76], [14, 82, 36, 82], [40, 14, 40, 36], [40, 48, 40, 76]],
  "9": [[14, 8, 36, 8], [10, 14, 10, 42], [14, 42, 36, 42], [40, 14, 40, 76], [14, 82, 36, 82]],
  ".": [[22, 76, 22, 85]],
  ",": [[22, 76, 22, 85], [22, 85, 16, 98]],
  "!": [[22, 8, 22, 62], [22, 76, 22, 85]],
  "?": [[14, 8, 36, 8], [36, 8, 42, 22], [42, 22, 22, 46], [22, 76, 22, 85]],
  "-": [[8, 46, 40, 46]],
  "'": [[22, 4, 22, 22]],
  ":": [[22, 26, 22, 34], [22, 60, 22, 68]],
  ";": [[22, 26, 22, 34], [22, 60, 22, 68], [22, 68, 16, 80]],
  "♥": [
    [10, 28, 8, 18], [8, 18, 16, 10], [16, 10, 24, 20],
    [24, 20, 32, 10], [32, 10, 40, 18], [40, 18, 38, 28],
    [10, 28, 24, 82], [38, 28, 24, 82],
  ],
  "♠": [
    [24, 10, 8, 42], [24, 10, 40, 42],
    [8, 42, 12, 56], [40, 42, 36, 56],
    [12, 56, 24, 48], [36, 56, 24, 48],
    [24, 50, 24, 80], [14, 80, 34, 80],
  ],
  "♦": [
    [24, 8, 6, 46], [6, 46, 24, 84],
    [24, 8, 42, 46], [42, 46, 24, 84],
  ],
  "♣": [
    [16, 30, 24, 12], [24, 12, 32, 30], [32, 30, 16, 30],
    [2, 52, 2, 34], [2, 34, 18, 34], [18, 52, 2, 52],
    [30, 34, 46, 34], [46, 34, 46, 52], [46, 52, 30, 52],
    [24, 50, 24, 80], [14, 80, 34, 80],
  ],
};

const STICK_WIDTHS: Record<string, number> = {
  " ": 26, A: 52, B: 52, C: 50, D: 54, E: 48, F: 46, G: 54, H: 52, I: 34,
  J: 40, K: 50, L: 46, M: 58, N: 52, O: 58, P: 50, Q: 60, R: 52, S: 50,
  T: 48, U: 52, V: 52, W: 62, X: 50, Y: 50, Z: 50,
  a: 48, b: 52, c: 48, d: 52, e: 50, f: 38, g: 52, h: 50, i: 30, j: 32,
  k: 48, l: 28, m: 58, n: 48, o: 52, p: 54, q: 52, r: 44, s: 48, t: 38,
  u: 50, v: 50, w: 58, x: 48, y: 50, z: 48,
  "0": 52, "1": 40, "2": 52, "3": 52, "4": 48, "5": 52,
  "6": 50, "7": 48, "8": 52, "9": 50,
  ".": 26, ",": 26, "!": 26, "?": 48, "-": 44, "'": 26, ":": 26, ";": 26,
  "♥": 48, "♠": 48, "♦": 48, "♣": 48,
};

function seededRand(seed: number): number {
  const s = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
  return s - Math.floor(s);
}

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

interface StickComposition {
  name: string;
  text: string;
  bg: string;
  colors: string[];
  charColors?: Record<string, string[]>;
  uiColor: "light" | "dark";
  scale: number;
  stickWidth: number;
  letterGap: number;
  looseness: number;
  stickGap: number;
}

const compositions: StickComposition[] = [
  {
    name: "GIRAGIRA",
    text: "GIRAGIRA",
    bg: "#f0eeeb",
    colors: ["#3a3a6a", "#9a9ec8", "#5a4aad", "#2a6a8a"],
    uiColor: "dark",
    scale: 1.8,
    stickWidth: 8,
    letterGap: 12,
    looseness: 0.5,
    stickGap: 8,
  },
  {
    name: "Glare",
    text: "Glare",
    bg: "#1a1a1a",
    colors: ["#e8e4e0", "#9a9ec8", "#c89018", "#e06820"],
    uiColor: "light",
    scale: 2.2,
    stickWidth: 10,
    letterGap: 14,
    looseness: 0.4,
    stickGap: 10,
  },
  {
    name: "giragira",
    text: "giragira",
    bg: "#e6e0f0",
    colors: ["#5a4aad", "#c86040", "#2a6a5a", "#3a3a6a"],
    uiColor: "dark",
    scale: 1.6,
    stickWidth: 7,
    letterGap: 10,
    looseness: 0.6,
    stickGap: 6,
  },
  {
    name: "STICKS",
    text: "STICKS",
    bg: "#f5ede0",
    colors: ["#c86040", "#2a6a8a", "#c89018", "#6a2020"],
    uiColor: "dark",
    scale: 2.0,
    stickWidth: 9,
    letterGap: 16,
    looseness: 0.3,
    stickGap: 12,
  },
  {
    name: "LAB",
    text: "LAB",
    bg: "#e0ecf4",
    colors: ["#2a4a6a", "#c86040", "#e6c820", "#2a6a5a"],
    uiColor: "dark",
    scale: 2.4,
    stickWidth: 12,
    letterGap: 18,
    looseness: 0.55,
    stickGap: 10,
  },
  {
    name: "Suits",
    text: "♠ ♥ ♦ ♣",
    bg: "#0a1a0a",
    colors: ["#e8e4e0"],
    charColors: {
      "♥": ["#e83838", "#cc2020"],
      "♦": ["#e86830", "#d04820"],
      "♠": ["#e8e4e0", "#c8c4c0"],
      "♣": ["#e8e4e0", "#c8c4c0"],
    },
    uiColor: "light",
    scale: 2.4,
    stickWidth: 8,
    letterGap: 14,
    looseness: 0.15,
    stickGap: 4,
  },
  {
    name: "A♠ K♥ Q♦ J♣",
    text: "A♠ K♥ Q♦ J♣",
    bg: "#f5f0e8",
    colors: ["#1a1a2e"],
    charColors: {
      "♥": ["#cc0000", "#b01020"],
      "♦": ["#cc0000", "#c83020"],
      "♠": ["#1a1a2e", "#2a2a3a"],
      "♣": ["#1a1a2e", "#2a2a3a"],
      "A": ["#1a1a2e", "#2a2a3a"],
      "K": ["#cc0000", "#b01020"],
      "Q": ["#cc0000", "#c83020"],
      "J": ["#1a1a2e", "#2a2a3a"],
    },
    uiColor: "dark",
    scale: 1.4,
    stickWidth: 7,
    letterGap: 10,
    looseness: 0.3,
    stickGap: 6,
  },
  {
    name: "Uppercase",
    text: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
    bg: "#f0eeeb",
    colors: ["#3a3a6a", "#9a9ec8", "#5a4aad", "#2a6a8a"],
    uiColor: "dark",
    scale: 1.0,
    stickWidth: 5,
    letterGap: 6,
    looseness: 0.3,
    stickGap: 5,
  },
  {
    name: "lowercase",
    text: "abcdefghijklmnopqrstuvwxyz",
    bg: "#f0eeeb",
    colors: ["#3a3a6a", "#9a9ec8", "#5a4aad", "#2a6a8a"],
    uiColor: "dark",
    scale: 1.0,
    stickWidth: 5,
    letterGap: 6,
    looseness: 0.3,
    stickGap: 5,
  },
  {
    name: "0123456789",
    text: "0123456789 .,!?-':;",
    bg: "#f0eeeb",
    colors: ["#3a3a6a", "#9a9ec8", "#5a4aad", "#2a6a8a"],
    uiColor: "dark",
    scale: 1.2,
    stickWidth: 6,
    letterGap: 8,
    looseness: 0.3,
    stickGap: 6,
  },
];

const COLOR_PRESETS = [
  "#3a3a6a", "#5a4aad", "#9a9ec8", "#2a4a6a", "#2a6a8a", "#2a6a5a",
  "#6a2020", "#c86040", "#e06820", "#c89018", "#e6c820", "#e8e4e0",
];

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

export default function StickCanvas() {
  const [index, setIndex] = useState(0);
  const comp = compositions[index];
  const isLight = comp.uiColor === "light";

  const [stickWidth, setStickWidth] = useState(comp.stickWidth);
  const [looseness, setLooseness] = useState(comp.looseness);
  const [stickGap, setStickGap] = useState(comp.stickGap);
  const [previewFamily, setPreviewFamily] = useState<string | null>(null);
  const [customText, setCustomText] = useState("");
  const [colors, setColors] = useState(["#2a6a5a", "#9a9ec8", "#c89018", "#e06820"]);
  const [selectedColorIdx, setSelectedColorIdx] = useState(0);
  const svgRef = useRef<SVGSVGElement>(null);

  const exportSVG = useCallback(() => {
    const svg = svgRef.current;
    if (!svg) return;
    const clone = svg.cloneNode(true) as SVGSVGElement;
    clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
    const vb = clone.getAttribute("viewBox")!.split(" ").map(Number);
    const bg = document.createElement("rect");
    bg.setAttribute("x", String(vb[0]));
    bg.setAttribute("y", String(vb[1]));
    bg.setAttribute("width", String(vb[2]));
    bg.setAttribute("height", String(vb[3]));
    bg.setAttribute("fill", comp.bg);
    clone.insertBefore(bg, clone.firstChild);
    const blob = new Blob(
      ['<?xml version="1.0" encoding="UTF-8"?>\n' + clone.outerHTML],
      { type: "image/svg+xml" },
    );
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `stick_${comp.text.replace(/\s+/g, "_")}.svg`;
    a.click();
    URL.revokeObjectURL(url);
  }, [comp]);

  const updateColor = useCallback((idx: number, hex: string) => {
    setColors(prev => {
      const next = [...prev];
      next[idx] = hex;
      return next;
    });
  }, []);

  const handleClick = useCallback(() => {
    const next = (index + 1) % compositions.length;
    setIndex(next);
  }, [index]);

  // Build stick SVG elements
  const { elements, vbW, vbH } = useMemo(() => {
    const chars = (customText || comp.text).split("");
    const sc = comp.scale;
    const gap = comp.letterGap;

    let totalW = 0;
    chars.forEach((c) => { totalW += (STICK_WIDTHS[c] ?? 50) + gap; });
    if (chars.length > 0) totalW -= gap;

    const w = Math.max(totalW * sc, 1);
    const h = 130 * sc;
    let xCursor = 0;
    const els: React.ReactNode[] = [];

    chars.forEach((c, ci) => {
      const segs = STICK_GLYPHS[c] ?? [];
      const gw = STICK_WIDTHS[c] ?? 50;
      const maxRotDeg = looseness * 24;
      const maxJitter = looseness * 6;

      segs.forEach(([x1, y1, x2, y2], si) => {
        const seed = ci * 200 + si;
        const rotDeg = (seededRand(seed) - 0.5) * 2 * maxRotDeg;
        const jx = (seededRand(seed + 1) - 0.5) * 2 * maxJitter;
        const jy = (seededRand(seed + 2) - 0.5) * 2 * maxJitter;

        const cx = (x1 + x2) / 2 + xCursor + jx;
        const cy = (y1 + y2) / 2 + jy;
        const hdx = (x2 - x1) / 2;
        const hdy = (y2 - y1) / 2;
        const halfLen = Math.sqrt(hdx * hdx + hdy * hdy);
        const shrink = halfLen > 0 ? Math.min(stickGap / 2, halfLen * 0.42) : 0;
        const gapFactor = halfLen > 0 ? (halfLen - shrink) / halfLen : 1;
        const ghdx = hdx * gapFactor;
        const ghdy = hdy * gapFactor;
        const rad = (rotDeg * Math.PI) / 180;
        const cos = Math.cos(rad);
        const sin = Math.sin(rad);
        const rhdx = ghdx * cos - ghdy * sin;
        const rhdy = ghdx * sin + ghdy * cos;

        const charPalette = comp.charColors?.[c];
        const strokeCol = charPalette
          ? charPalette[Math.floor(seededRand(ci * 53 + si * 17 + 7) * charPalette.length)]
          : colors[Math.floor(seededRand(ci * 53 + si * 17 + 7) * colors.length)];

        // Round to 2dp — raw trig floats drift between server and client at
        // the ~10th decimal and trigger SSR hydration mismatches.
        const r2 = (n: number) => Math.round(n * 100) / 100;
        els.push(
          <line
            key={`${ci}-${si}`}
            x1={r2((cx - rhdx) * sc)}
            y1={r2((cy - rhdy) * sc)}
            x2={r2((cx + rhdx) * sc)}
            y2={r2((cy + rhdy) * sc)}
            stroke={strokeCol}
            strokeWidth={stickWidth}
            strokeLinecap="round"
          />
        );
      });

      xCursor += gw + gap;
    });

    return { elements: els, vbW: w, vbH: h };
  }, [comp, customText, stickWidth, looseness, stickGap, colors]);

  const pad = stickWidth * 2;

  return (
    <div
      className="fixed inset-0 cursor-pointer select-none transition-colors duration-700 flex items-center justify-center"
      style={{ backgroundColor: comp.bg }}
      onClick={handleClick}
    >
      <svg
        ref={svgRef}
        viewBox={`${-pad} ${-pad} ${vbW + pad * 2} ${vbH + pad * 2}`}
        className="max-w-[85%] max-h-[60%]"
        overflow="visible"
      >
        {elements}
      </svg>

      {/* 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>

      {/* Hint */}
      <div
        className="fixed bottom-8 right-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
        style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
      >
        <motion.p
          className="text-[12px] tracking-[0.08em] uppercase"
          animate={{
            color: isLight
              ? "rgba(255,255,255,0.3)"
              : "rgba(23,23,23,0.3)",
          }}
          transition={{ duration: 0.6 }}
        >
          Click anywhere
        </motion.p>
      </div>

      <DraggablePanel
        isLight={isLight}
        controls={[
          { label: "Weight", value: stickWidth, set: setStickWidth, min: 3, max: 16, step: 1 },
          { label: "Looseness", value: looseness, set: setLooseness, min: 0, max: 1, step: 0.05 },
          { label: "Gap", value: stickGap, set: setStickGap, min: 0, max: 16, 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>
        <div style={{ marginBottom: 12 }}>
          <div style={{
            display: "flex", justifyContent: "space-between",
            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: 6,
          }}>
            <span>Colors</span>
          </div>
          <div style={{ display: "flex", gap: 6, justifyContent: "center", marginBottom: 8 }}>
            {colors.map((c, i) => (
              <div
                key={i}
                onClick={() => setSelectedColorIdx(i)}
                style={{
                  width: 24, height: 24, borderRadius: "50%",
                  backgroundColor: c,
                  outline: selectedColorIdx === i
                    ? `2px solid ${isLight ? "#fff" : "#1a1a2e"}`
                    : "none",
                  outlineOffset: 2,
                  cursor: "pointer",
                  border: `1px solid ${isLight ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.1)"}`,
                }}
              />
            ))}
          </div>
          <div style={{ display: "flex", flexWrap: "wrap", gap: 3, marginBottom: 8 }}>
            {COLOR_PRESETS.map((c) => (
              <div
                key={c}
                onClick={() => updateColor(selectedColorIdx, c)}
                style={{
                  width: 14, height: 14, borderRadius: 3,
                  backgroundColor: c,
                  cursor: "pointer",
                  border: `1px solid ${isLight ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.08)"}`,
                }}
              />
            ))}
          </div>
          <div style={{ display: "flex", gap: 4, alignItems: "center" }}>
            <input
              type="text"
              value={colors[selectedColorIdx]}
              onChange={(e) => updateColor(selectedColorIdx, e.target.value)}
              style={{
                flex: 1,
                padding: "4px 8px",
                background: "transparent",
                border: `1px solid ${isLight ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.08)"}`,
                borderRadius: 6,
                color: isLight ? "#fff" : "#1a1a2e",
                fontSize: 11,
                fontFamily: "monospace",
                boxSizing: "border-box",
                minWidth: 0,
              }}
            />
            <label style={{ position: "relative", cursor: "pointer", flexShrink: 0 }}>
              <div style={{
                width: 24, height: 24, borderRadius: 4,
                backgroundColor: colors[selectedColorIdx],
                border: `1px solid ${isLight ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.1)"}`,
              }} />
              <input
                type="color"
                value={/^#[0-9a-fA-F]{6}$/.test(colors[selectedColorIdx]) ? colors[selectedColorIdx] : "#000000"}
                onChange={(e) => updateColor(selectedColorIdx, e.target.value)}
                style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", opacity: 0, cursor: "pointer" }}
              />
            </label>
          </div>
        </div>
        <div style={{ display: "flex", gap: 6, marginTop: 4 }}>
          <button
            onClick={() => setPreviewFamily(exportStickFont(STICK_GLYPHS, STICK_WIDTHS, stickWidth, looseness, stickGap))}
            style={{
              flex: 1, padding: "8px 0", 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>
          <button
            onClick={exportSVG}
            style={{
              flex: 1, padding: "8px 0", 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 SVG
          </button>
        </div>
      </DraggablePanel>

      {/* Back */}
      <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>
  );
}
exportStickFont.ts86 lines
import { Glyph, Path } from "opentype.js";
import { UPM, ASCENDER, createFont, downloadFont, previewFont, roundedLineToPath } from "../fontExport";

type StrokeSeg = [number, number, number, number];

function seededRand(seed: number): number {
  const s = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
  return s - Math.floor(s);
}

const GLYPH_HEIGHT = 130;
const SCALE = UPM / GLYPH_HEIGHT;

export default function exportStickFont(
  glyphs: Record<string, StrokeSeg[]>,
  widths: Record<string, number>,
  stickWidth: number,
  looseness: number,
  stickGap: number,
) {
  const otGlyphs: Glyph[] = [];

  for (const [char, segs] of Object.entries(glyphs)) {
    if (char === " ") continue;
    const code = char.charCodeAt(0);
    const gw = (widths[char] ?? 50) * SCALE;
    const path = new Path();
    const maxRotDeg = looseness * 24;
    const maxJitter = looseness * 6;
    const sw = stickWidth * SCALE * 0.6;

    segs.forEach(([x1, y1, x2, y2], si) => {
      const seed = 0 * 200 + si;
      const rotDeg = (seededRand(seed) - 0.5) * 2 * maxRotDeg;
      const jx = (seededRand(seed + 1) - 0.5) * 2 * maxJitter;
      const jy = (seededRand(seed + 2) - 0.5) * 2 * maxJitter;

      const cx = (x1 + x2) / 2 + jx;
      const cy = (y1 + y2) / 2 + jy;
      const hdx = (x2 - x1) / 2;
      const hdy = (y2 - y1) / 2;
      const halfLen = Math.sqrt(hdx * hdx + hdy * hdy);
      const shrink = halfLen > 0 ? Math.min(stickGap / 2, halfLen * 0.42) : 0;
      const gapFactor = halfLen > 0 ? (halfLen - shrink) / halfLen : 1;
      const ghdx = hdx * gapFactor;
      const ghdy = hdy * gapFactor;
      const rad = (rotDeg * Math.PI) / 180;
      const cos = Math.cos(rad);
      const sin = Math.sin(rad);
      const rhdx = ghdx * cos - ghdy * sin;
      const rhdy = ghdx * sin + ghdy * cos;

      const lx1 = (cx - rhdx) * SCALE;
      const ly1 = ASCENDER - (cy - rhdy) * SCALE;
      const lx2 = (cx + rhdx) * SCALE;
      const ly2 = ASCENDER - (cy + rhdy) * SCALE;

      roundedLineToPath(path, lx1, ly1, lx2, ly2, sw);
    });

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

  const spaceCode = " ".charCodeAt(0);
  otGlyphs.push(new Glyph({
    name: "space",
    unicode: spaceCode,
    advanceWidth: (widths[" "] ?? 26) * SCALE,
    path: new Path(),
  }));

  const w = Math.round(stickWidth);
  const l = Math.round(looseness * 100).toString().padStart(3, "0");
  const g = Math.round(stickGap);
  const filename = `GRGR_Stick_w${w}_l${l}_g${g}.otf`;

  const font = createFont("GRGR Stick", otGlyphs);
  downloadFont(font, filename);
  return previewFont(font, "GRGR-Stick-Preview");
}

NEMAWASHI — Kotaro Abe