NEMAWASHI LAB

Spires

OTF

Column-runs in a double-resolution bitmap font are collapsed into vertical bars whose profiles taper to pointed spire tips via four arc segments, giving each bar a gothic cathedral-window silhouette. Horizontal rule lines and vertical string lines form a visible grid behind the text. Transitions between compositions are hand-animated at 800 ms with easeInOutCubic: the old bars fade out over the first 40% of the duration, the new bars fade in over the remaining 60%, and background colour and string colour lerp continuously through the full swing.

Source

SpiresCanvas.tsx630 lines
"use client";

import { useRef, useMemo, useEffect, useState, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import exportSpiresFont from "./exportSpiresFont";
import { FONT_2X, ROWS, SPACE_COLS_2X, LETTER_GAP_COLS_2X } from "../alphabet";

// ---------------------------------------------------------------------------
// Two static viewBox widths — portrait is tighter so glyphs fill more screen
// ---------------------------------------------------------------------------

const VB_WIDE = 1000;
const VB_TALL = 700;

// ---------------------------------------------------------------------------
// Shared grid — constant across all compositions so transitions stay on-grid
// ---------------------------------------------------------------------------

const GRID = {
  cellW: 18,
  cellH: 38,
  barW: 16,
  spireH: 8,
  spireR: 12,
  stringWidth: 1.4,
} as const;

// ---------------------------------------------------------------------------
// Color utilities
// ---------------------------------------------------------------------------

function hexToRgb(hex: string): [number, number, number] {
  const h = hex.replace("#", "");
  return [
    parseInt(h.substring(0, 2), 16),
    parseInt(h.substring(2, 4), 16),
    parseInt(h.substring(4, 6), 16),
  ];
}

function rgbToHex(r: number, g: number, b: number): string {
  return (
    "#" +
    [r, g, b]
      .map((v) =>
        Math.round(Math.min(255, Math.max(0, v)))
          .toString(16)
          .padStart(2, "0"),
      )
      .join("")
  );
}

function lerpColor(a: string, b: string, t: number): string {
  const [r1, g1, b1] = hexToRgb(a);
  const [r2, g2, b2] = hexToRgb(b);
  return rgbToHex(
    r1 + (r2 - r1) * t,
    g1 + (g2 - g1) * t,
    b1 + (b2 - b1) * t,
  );
}

function lerpRgba(a: string, b: string, t: number): string {
  const parseRgba = (s: string) => {
    const m = s.match(/[\d.]+/g)!;
    return m.map(Number);
  };
  const ca = parseRgba(a);
  const cb = parseRgba(b);
  const r = ca[0] + (cb[0] - ca[0]) * t;
  const g = ca[1] + (cb[1] - ca[1]) * t;
  const bl = ca[2] + (cb[2] - ca[2]) * t;
  const al = ca[3] + (cb[3] - ca[3]) * t;
  return `rgba(${Math.round(r)},${Math.round(g)},${Math.round(bl)},${al.toFixed(3)})`;
}

function easeInOutCubic(t: number): number {
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}

// ---------------------------------------------------------------------------
// Compositions — wide/tall text variants + colours
// ---------------------------------------------------------------------------

interface Composition {
  name: string;
  text: string;
  textTall?: string;
  bg: string;
  spireColor: string;
  stringColor: string;
  uiColor: "light" | "dark";
}

const compositions: Composition[] = [
  {
    name: "GIRAGIRA",
    text: "GIRAGIRA",
    bg: "#f0eeeb",
    spireColor: "#171717",
    stringColor: "rgba(23,23,23,0.20)",
    uiColor: "dark",
  },
  {
    name: "SPIRES",
    text: "SPIRES",
    bg: "#e8e2f0",
    spireColor: "#1a1240",
    stringColor: "rgba(26,18,64,0.20)",
    uiColor: "dark",
  },
  {
    name: "ARTISTIC",
    text: "ARTISTIC",
    bg: "#f5ede0",
    spireColor: "#3a1a10",
    stringColor: "rgba(58,26,16,0.20)",
    uiColor: "dark",
  },
  {
    name: "GIRAGIRA / NIGHT",
    text: "GIRAGIRA",
    bg: "#0e0e10",
    spireColor: "#f0eeeb",
    stringColor: "rgba(240,238,235,0.16)",
    uiColor: "light",
  },
  {
    name: "A — R",
    text: "ABCDEFGHI\nJKLMNOPQR",
    textTall: "ABCDEFG\nHIJKLMN\nOPQR",
    bg: "#f0eeeb",
    spireColor: "#171717",
    stringColor: "rgba(23,23,23,0.18)",
    uiColor: "dark",
  },
  {
    name: "S — Z",
    text: "STUVWXYZ",
    bg: "#f0eeeb",
    spireColor: "#171717",
    stringColor: "rgba(23,23,23,0.18)",
    uiColor: "dark",
  },
  {
    name: "a — r",
    text: "abcdefghi\njklmnopqr",
    textTall: "abcdefg\nhijklmn\nopqr",
    bg: "#f0eeeb",
    spireColor: "#171717",
    stringColor: "rgba(23,23,23,0.18)",
    uiColor: "dark",
  },
  {
    name: "s — z",
    text: "stuvwxyz",
    bg: "#f0eeeb",
    spireColor: "#171717",
    stringColor: "rgba(23,23,23,0.18)",
    uiColor: "dark",
  },
  {
    name: "0123456789",
    text: "0123456789\n.,!?-':;",
    bg: "#f0eeeb",
    spireColor: "#171717",
    stringColor: "rgba(23,23,23,0.18)",
    uiColor: "dark",
  },
];

// ---------------------------------------------------------------------------
// Layout — turn text + FONT into bars (column-runs), string x's, h-lines
// ---------------------------------------------------------------------------

interface Bar {
  x: number;
  yTop: number;
  yBot: number;
}

interface HLine {
  y: number;
  xLeft: number;
  xRight: number;
}

interface LayoutData {
  bars: Bar[];
  strings: number[];
  hLines: HLine[];
}

function layoutLine(lineText: string) {
  let totalCols = 0;
  for (let i = 0; i < lineText.length; i++) {
    const ch = lineText[i];
    if (ch === " ") {
      totalCols += SPACE_COLS_2X;
    } else {
      const glyph = FONT_2X[ch];
      if (glyph) totalCols += glyph[0].length;
    }
    if (i < lineText.length - 1) totalCols += LETTER_GAP_COLS_2X;
  }
  return totalCols;
}

function layout(text: string, vbW: number, vbH: number): LayoutData {
  const halfW = GRID.cellW / 2;
  const lines = text.split("\n");

  const lineH = ROWS * GRID.cellH;
  const lineGap = GRID.cellH;
  const totalH = lines.length * lineH + (lines.length - 1) * lineGap;
  const baseOy = (vbH - totalH) / 2;

  const bars: Bar[] = [];
  const strings: number[] = [];
  const hLines: HLine[] = [];

  for (let li = 0; li < lines.length; li++) {
    const lineText = lines[li];
    const totalCols = layoutLine(lineText);
    const totalW = totalCols * halfW;
    const ox = (vbW - totalW) / 2 + halfW / 2;
    const oy = baseOy + li * (lineH + lineGap);

    for (let r = 0; r <= ROWS; r++) {
      hLines.push({ y: oy + r * GRID.cellH, xLeft: 0, xRight: vbW });
    }

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

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

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

      const gW = glyph[0].length;
      for (let c = 0; c < gW; c++) {
        const x = ox + (colCursor + c) * halfW;

        if (c % 2 === 0) {
          strings.push(x);
        }

        let r = 0;
        while (r < ROWS) {
          if (glyph[r][c] === "#") {
            const runStart = r;
            while (r < ROWS && glyph[r][c] === "#") r++;
            const runEnd = r - 1;
            const yTop = oy + runStart * GRID.cellH;
            const yBot = oy + (runEnd + 1) * GRID.cellH;
            bars.push({ x, yTop, yBot });
          } else {
            r++;
          }
        }
      }
      colCursor += gW;
      if (i < lineText.length - 1) colCursor += LETTER_GAP_COLS_2X;
    }
  }

  return { bars, strings, hLines };
}

// ---------------------------------------------------------------------------
// Bar path
// ---------------------------------------------------------------------------

function barPath(bar: Bar, bw: number = GRID.barW, sh: number = GRID.spireH, sr: number = GRID.spireR): string {
  const halfW = bw / 2;
  const xL = bar.x - halfW;
  const xR = bar.x + halfW;
  const ySTop = bar.yTop + sh;
  const ySBot = bar.yBot - sh;
  const R = sr;
  return [
    `M ${bar.x} ${bar.yTop}`,
    `A ${R} ${R} 0 0 0 ${xR} ${ySTop}`,
    `L ${xR} ${ySBot}`,
    `A ${R} ${R} 0 0 0 ${bar.x} ${bar.yBot}`,
    `A ${R} ${R} 0 0 0 ${xL} ${ySBot}`,
    `L ${xL} ${ySTop}`,
    `A ${R} ${R} 0 0 0 ${bar.x} ${bar.yTop}`,
    "Z",
  ].join(" ");
}

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

export default function SpiresCanvas() {
  const searchParams = useSearchParams();
  const urlIndex =
    Math.abs(Number(searchParams.get("v")) || 0) % compositions.length;

  const [index, setIndex] = useState(urlIndex);
  const nextIndex = (index + 1) % compositions.length;

  useEffect(() => {
    setIndex(urlIndex);
  }, [urlIndex]);

  const [vbW, setVbW] = useState(VB_WIDE);
  const [vbH, setVbH] = useState(VB_WIDE);
  const svgRef = useRef<SVGSVGElement>(null);

  const animatingRef = useRef(false);
  const rafRef = useRef<number>(0);
  const containerRef = useRef<HTMLDivElement>(null);
  const stringsRef = useRef<SVGGElement>(null);
  const barsOldRef = useRef<SVGGElement>(null);
  const barsNewRef = useRef<SVGGElement>(null);

  // Size against the stage container (the viewport standalone, the window
  // when embedded in the GIRAGIRA HQ desktop).
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const update = () => {
      const aspect = el.clientHeight / Math.max(1, el.clientWidth);
      const isPortrait = aspect > 1;
      const w = isPortrait ? VB_TALL : VB_WIDE;
      setVbW(w);
      setVbH(Math.round(w * aspect));
    };
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const comp = compositions[index];
  const isLight = comp.uiColor === "light";
  const isPortrait = vbH > vbW;

  const [barW, setBarW] = useState<number>(GRID.barW);
  const [spireH, setSpireH] = useState<number>(GRID.spireH);
  const [spireR, setSpireR] = useState<number>(GRID.spireR);
  const [previewFamily, setPreviewFamily] = useState<string | null>(null);
  const [customText, setCustomText] = useState("");
  const text = customText || ((isPortrait && comp.textTall) ? comp.textTall : comp.text);
  const { bars, strings, hLines } = useMemo(
    () => layout(text, vbW, vbH),
    [text, vbW, vbH],
  );

  const handleCycle = useCallback(() => {
    if (animatingRef.current) return;
    animatingRef.current = true;

    const fromIndex = index;
    const toIndex = (fromIndex + 1) % compositions.length;
    const fromComp = compositions[fromIndex];
    const toComp = compositions[toIndex];
    const toText = (isPortrait && toComp.textTall) ? toComp.textTall : toComp.text;

    const toLayout = layout(toText, vbW, vbH);

    if (barsNewRef.current) {
      barsNewRef.current.innerHTML = "";
      for (const bar of toLayout.bars) {
        const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
        path.setAttribute("d", barPath(bar));
        barsNewRef.current.appendChild(path);
      }
      barsNewRef.current.setAttribute("fill", toComp.spireColor);
      barsNewRef.current.setAttribute("opacity", "0");
    }

    const toStringsMarkup = toLayout.strings.map((x) => {
      const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
      line.setAttribute("x1", String(x));
      line.setAttribute("y1", "0");
      line.setAttribute("x2", String(x));
      line.setAttribute("y2", String(vbH));
      return line;
    });

    const toHLinesMarkup = toLayout.hLines.map((h) => {
      const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
      line.setAttribute("x1", String(h.xLeft));
      line.setAttribute("y1", String(h.y));
      line.setAttribute("x2", String(h.xRight));
      line.setAttribute("y2", String(h.y));
      return line;
    });

    const duration = 800;
    const start = performance.now();
    let stringsSwapped = false;

    function step(now: number) {
      const elapsed = now - start;
      const rawT = Math.min(elapsed / duration, 1);
      const t = easeInOutCubic(rawT);

      if (containerRef.current) {
        containerRef.current.style.backgroundColor = lerpColor(fromComp.bg, toComp.bg, t);
      }

      if (stringsRef.current) {
        stringsRef.current.setAttribute(
          "stroke",
          lerpRgba(fromComp.stringColor, toComp.stringColor, t),
        );
      }

      let oldOpacity: number;
      let newOpacity: number;
      if (rawT < 0.4) {
        oldOpacity = 1 - rawT / 0.4;
        newOpacity = 0;
      } else {
        oldOpacity = 0;
        newOpacity = (rawT - 0.4) / 0.6;
      }

      barsOldRef.current?.setAttribute("opacity", String(oldOpacity));
      barsOldRef.current?.setAttribute(
        "fill",
        lerpColor(fromComp.spireColor, toComp.spireColor, t),
      );
      barsNewRef.current?.setAttribute("opacity", String(newOpacity));

      if (!stringsSwapped && rawT >= 0.4 && stringsRef.current) {
        stringsSwapped = true;
        stringsRef.current.innerHTML = "";
        for (const line of toStringsMarkup) {
          stringsRef.current.appendChild(line);
        }
        for (const line of toHLinesMarkup) {
          stringsRef.current.appendChild(line);
        }
      }

      if (rawT < 1) {
        rafRef.current = requestAnimationFrame(step);
      } else {
        setIndex(toIndex);
        window.history.replaceState(null, "", `/lab/spires?v=${toIndex}`);
        animatingRef.current = false;
      }
    }

    rafRef.current = requestAnimationFrame(step);
  }, [index, vbW, vbH, isPortrait]);

  useEffect(() => {
    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, []);

  return (
    <div
      ref={containerRef}
      className="fixed inset-0 select-none cursor-pointer"
      style={{ backgroundColor: comp.bg }}
      onClick={handleCycle}
    >
      <svg
        ref={svgRef}
        className="absolute inset-0 w-full h-full z-[1]"
        viewBox={`0 0 ${vbW} ${vbH}`}
        preserveAspectRatio="xMidYMid meet"
        overflow="hidden"
      >
        <g key={`str-${index}`} ref={stringsRef} stroke={comp.stringColor} strokeWidth={GRID.stringWidth}>
          {strings.map((x, i) => (
            <line key={i} x1={x} y1={0} x2={x} y2={vbH} />
          ))}
          {hLines.map((h, i) => (
            <line key={`h${i}`} x1={h.xLeft} y1={h.y} x2={h.xRight} y2={h.y} />
          ))}
        </g>

        <g key={`bars-${index}`} ref={barsOldRef} fill={comp.spireColor}>
          {bars.map((bar, i) => (
            <path key={i} d={barPath(bar, barW, spireH, spireR)} />
          ))}
        </g>

        <g ref={barsNewRef} fill={comp.spireColor} opacity={0} />
      </svg>

      <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>

      <DraggablePanel
        isLight={isLight}
        controls={[
          { label: "Bar width", value: barW, set: setBarW, min: 6, max: 24, step: 1 },
          { label: "Spire height", value: spireH, set: setSpireH, min: 2, max: 16, step: 1 },
          { label: "Spire radius", value: spireR, set: setSpireR, min: 4, max: 20, 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(exportSpiresFont(barW, spireH, spireR))}
          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 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 transition-colors duration-700"
          style={{
            color: isLight
              ? "rgba(255,255,255,0.5)"
              : "rgba(23,23,23,0.5)",
          }}
        >
          {comp.name}
        </p>
        <p
          className="text-[12px] tracking-[0.08em] mt-1 transition-colors duration-700"
          style={{
            color: isLight
              ? "rgba(255,255,255,0.3)"
              : "rgba(23,23,23,0.3)",
          }}
        >
          {index + 1} / {compositions.length}
        </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>
  );
}
exportSpiresFont.ts83 lines
import { Glyph, Path } from "opentype.js";
import { UPM, createFont, downloadFont, previewFont, svgArcToBeziers } from "../fontExport";
import { FONT_2X, ROWS, SPACE_COLS_2X } from "../alphabet";

const CELL_H = UPM / ROWS;
const HALF_W = Math.round((CELL_H * (18 / 38)) / 2);

function barToGlyphPath(
  path: Path,
  x: number, yTop: number, yBot: number,
  barW: number, spireH: number, spireR: number,
) {
  const hw = barW / 2;
  const bodyTop = yTop - spireH;
  const bodyBot = yBot + spireH;

  path.moveTo(x, yTop);
  svgArcToBeziers(path, x, yTop, spireR, false, true, x + hw, bodyTop);
  path.lineTo(x + hw, bodyBot);
  svgArcToBeziers(path, x + hw, bodyBot, spireR, false, true, x, yBot);
  svgArcToBeziers(path, x, yBot, spireR, false, true, x - hw, bodyBot);
  path.lineTo(x - hw, bodyTop);
  svgArcToBeziers(path, x - hw, bodyTop, spireR, false, true, x, yTop);
  path.closePath();
}

export default function exportSpiresFont(
  barW: number,
  spireH: number,
  spireR: number,
) {
  const bwScaled = barW * (CELL_H / 38);
  const shScaled = spireH * (CELL_H / 38);
  const srScaled = spireR * (CELL_H / 38);

  const otGlyphs: Glyph[] = [];

  for (const [char, rows] of Object.entries(FONT_2X)) {
    const code = char.charCodeAt(0);
    const glyphW = rows[0].length;
    const advanceWidth = glyphW * HALF_W + HALF_W * 0.3;
    const path = new Path();

    for (let c = 0; c < glyphW; c++) {
      let r = 0;
      while (r < ROWS) {
        if (rows[r][c] === "#") {
          const runStart = r;
          while (r < ROWS && rows[r][c] === "#") r++;
          const runEnd = r;

          const x = c * HALF_W + HALF_W / 2;
          const yTop = UPM - runStart * CELL_H;
          const yBot = UPM - runEnd * CELL_H;

          barToGlyphPath(path, x, yTop, yBot, bwScaled, shScaled, srScaled);
        } else {
          r++;
        }
      }
    }

    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_2X * HALF_W,
    path: new Path(),
  }));

  const filename = `GRGR_Spires_w${barW}_h${spireH}_r${spireR}.otf`;
  const font = createFont("GRGR Spires", otGlyphs, UPM, 0);
  downloadFont(font, filename);
  return previewFont(font, "GRGR-Spires-Preview");
}

NEMAWASHI — Kotaro Abe