NEMAWASHI LAB

Char Bitmap

SVG

Large-scale text is spelled out on a 6x8 thin bitmap font where every lit cell is replaced by a two-character token pair, drawn as monospace SVG <text> rows whose textLength/lengthAdjust holds the character grid rigid. Six presets are the starting points -- each one a token pair, weight, palette, and letter spacing -- and from there the text, pair, size, weight, gap, alignment, and both colours are live controls; multi-line input aligns per block (left/center/right). Export writes the current state, not the preset it started from, as an SVG scaled into a 1000x1000 viewBox with centred placement.

GRGRGRGR GRGRGRGRGR GRGRGRGRGR GRGRGRGR GRGRGRGR GRGRGRGRGR GRGRGRGRGR GRGRGRGR GR GR GR GR GR GR GR GR GR GR GR GRGR GR GR GR GR GR GR GR GR GR GR GRGR GRGRGR GR GRGRGRGRGR GRGRGRGRGRGR GR GRGRGR GR GRGRGRGRGR GRGRGRGRGRGRGR GR GR GR GR GR GR GR GR GR GR GR GR GRGR GR GR GR GR GR GR GR GR GR GR GR GR GRGR GR GR GR GR GR GR GR GR GR GR GR GR GR GRGRGRGR GRGRGRGRGR GR GR GR GR GRGRGRGR GRGRGRGRGR GR GR GR GR

Char Bitmap

GR · 6×8

Controls
Font size14
Weight400
Letter gap1
Preset
Text
Char A
Char B
Align
Colour
Background

Source

CharBitmap.tsx154 lines
"use client";

import { useState } from "react";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import { PanelColor, PanelSelect, PanelText, PanelTextArea } from "../_kansei/controls";
import { THIN_GLYPH_W, THIN_GLYPH_H } from "./thinfont";
import { PRESETS, PRESET_NAMES, type Align } from "./presets";
import { createBitmapSvg, downloadSvg, getBitmapMetrics, getExportFileName } from "./export";

// Geist Mono by name, not by CSS variable: the exported file is read outside
// this page, where the variable means nothing.
const EXPORT_FONT = "'Geist Mono', monospace";

const FIRST = PRESETS[0];

export default function CharBitmap() {
  const [preset, setPreset] = useState(FIRST.name);
  const [text, setText] = useState(FIRST.text);
  const [charA, setCharA] = useState(FIRST.charA);
  const [charB, setCharB] = useState(FIRST.charB);
  const [fontSize, setFontSize] = useState(FIRST.fontSize);
  const [fontWeight, setFontWeight] = useState(FIRST.fontWeight);
  const [letterGap, setLetterGap] = useState(FIRST.letterGap);
  const [align, setAlign] = useState<Align>(FIRST.align);
  const [color, setColor] = useState(FIRST.color);
  const [bgColor, setBgColor] = useState(FIRST.bg);
  /* the preset says whether its scene is dark; the controls and corner labels
     follow it, and the pickers can take the colours anywhere afterwards */
  const [isLight, setIsLight] = useState(FIRST.uiColor === "light");

  const pair = charA + charB;

  // A preset is a starting point: it fills the controls, it does not lock them.
  const applyPreset = (name: string) => {
    const next = PRESETS.find((p) => p.name === name);
    if (!next) return;
    setPreset(next.name);
    setText(next.text);
    setCharA(next.charA);
    setCharB(next.charB);
    setFontSize(next.fontSize);
    setFontWeight(next.fontWeight);
    setLetterGap(next.letterGap);
    setAlign(next.align);
    setColor(next.color);
    setBgColor(next.bg);
    setIsLight(next.uiColor === "light");
  };

  const scene = {
    text,
    charA,
    charB,
    fontSize,
    fontWeight,
    letterGap,
    align,
    color,
    bg: bgColor,
  };

  const { lineBlocks, charW, lineH, blockGap, svgW, svgH } = getBitmapMetrics(scene);

  let yOffset = 0;

  return (
    <div className="fixed inset-0 flex items-center justify-center select-none" style={{ background: bgColor }}>
      <svg
        viewBox={`0 0 ${svgW} ${svgH}`}
        className="w-[88vw] max-w-[1000px] h-auto z-[1]"
        xmlns="http://www.w3.org/2000/svg"
        style={{ whiteSpace: "pre" }}
      >
        {lineBlocks.map((block, bi) => {
          const blockY = yOffset;
          yOffset += block.length * lineH + blockGap;
          const blockMaxLen = Math.max(...block.map((r) => r.length), 1);
          const blockW = blockMaxLen * charW;
          const xOff = align === "center" ? (svgW - blockW) / 2 : align === "right" ? svgW - blockW : 0;
          return block.map((row, ri) => (
            <text
              key={`${bi}-${ri}`}
              x={xOff}
              y={blockY + ri * lineH + fontSize}
              fill={color}
              fontSize={fontSize}
              fontWeight={fontWeight}
              dominantBaseline="alphabetic"
              xmlSpace="preserve"
              textLength={row.length * charW}
              lengthAdjust="spacing"
              style={{ fontFamily: "var(--font-geist-mono), monospace" }}
            >
              {row}
            </text>
          ));
        })}
      </svg>

      {/* Export — the live state, not the preset it started from */}
      <div className="fixed top-8 right-8 z-10 font-[family-name:var(--font-flux)]">
        <button
          onClick={() => downloadSvg(createBitmapSvg(scene, EXPORT_FONT), getExportFileName(scene))}
          className="pointer-events-auto text-[12px] tracking-[0.08em] uppercase transition-opacity hover:opacity-100"
          style={{
            fontVariationSettings: "'wght' 300, 'SRIF' 100",
            color: isLight ? "rgba(255,255,255,0.4)" : "rgba(23,23,23,0.4)",
          }}
        >
          Export SVG
        </button>
      </div>

      <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 ${isLight ? "text-white/50" : "text-foreground/50"}`}>Char Bitmap</p>
        <p className={`text-[12px] tracking-[0.08em] mt-1 ${isLight ? "text-white/30" : "text-foreground/30"}`}>{pair} · {THIN_GLYPH_W}×{THIN_GLYPH_H}</p>
      </div>

      <DraggablePanel
        isLight={isLight}
        controls={[
          { label: "Font size", value: fontSize, set: (v) => setFontSize(Math.round(v)), min: 6, max: 32, step: 1 },
          { label: "Weight", value: fontWeight, set: (v) => setFontWeight(Math.round(v / 100) * 100), min: 300, max: 800, step: 100 },
          { label: "Letter gap", value: letterGap, set: (v) => setLetterGap(Math.round(v)), min: 0, max: 4, step: 1 },
        ]}
      >
        <PanelSelect label="Preset" value={preset} options={PRESET_NAMES} set={applyPreset} isLight={isLight} wrap />
        <PanelTextArea label="Text" value={text} set={setText} placeholder="GIRAGIRA" isLight={isLight} />
        <PanelText label="Char A" value={charA} set={(v) => setCharA(v.slice(0, 1))} isLight={isLight} />
        <PanelText label="Char B" value={charB} set={(v) => setCharB(v.slice(0, 1))} isLight={isLight} />
        <PanelSelect label="Align" value={align} options={["left", "center", "right"] as const} set={setAlign} isLight={isLight} />
        <PanelColor label="Colour" value={color} set={setColor} isLight={isLight} />
        <PanelColor label="Background" value={bgColor} set={setBgColor} isLight={isLight} />
      </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)] ${
            isLight ? "text-white/60" : "text-foreground/60"
          }`}
          style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
        />
      </div>
    </div>
  );
}
export.ts160 lines
// The bitmap maths, kept out of the component: rows of token pairs, the metrics
// the live SVG lays itself out with, and the square SVG the Export button
// writes. Pure functions — no React, and nothing random or time-dependent, so
// the server and the first client render agree.

import { THIN_FONT, THIN_GLYPH_W, THIN_GLYPH_H } from "./thinfont";
import type { Align } from "./presets";

/** Export artboard — the composition is scaled and centred into VB × VB. */
export const VB = 1000;

/** Everything a drawing needs; the live state and any preset both satisfy it. */
export interface BitmapScene {
  text: string;
  charA: string;
  charB: string;
  fontSize: number;
  fontWeight: number;
  letterGap: number;
  align: Align;
  color: string;
  bg: string;
}

/** One string per pixel row, each lit cell swapped for the two-char token. */
export function buildBitmapRows(scene: BitmapScene): string[][] {
  const pair = scene.charA + scene.charB;
  const space = "  ";
  const lineBlocks: string[][] = [];

  for (const textLine of scene.text.split("\n")) {
    const letters = textLine.toUpperCase().split("");
    const block: string[] = [];
    for (let r = 0; r < THIN_GLYPH_H; r++) {
      let line = "";
      for (let li = 0; li < letters.length; li++) {
        if (li > 0) line += space.repeat(scene.letterGap);
        const glyph = THIN_FONT[letters[li]];
        if (!glyph) {
          line += space.repeat(THIN_GLYPH_W);
          continue;
        }
        const row = glyph[r] || "......";
        for (let c = 0; c < row.length; c++) line += row[c] === "#" ? pair : space;
      }
      block.push(line);
    }
    lineBlocks.push(block);
  }

  return lineBlocks;
}

export function getBitmapMetrics(scene: BitmapScene) {
  const lineBlocks = buildBitmapRows(scene);
  const charW = scene.fontSize * 0.602;
  const lineH = scene.fontSize * 1.2;
  const blockGap = lineH * 1.5;
  const maxLen = Math.max(...lineBlocks.flatMap((b) => b.map((r) => r.length)), 1);
  const svgW = maxLen * charW;
  const totalRows = lineBlocks.reduce((sum, b) => sum + b.length, 0);
  const svgH = totalRows * lineH + (lineBlocks.length - 1) * blockGap;

  return { lineBlocks, charW, lineH, blockGap, svgW, svgH };
}

/** Fit the content into the square artboard at 85%, centred. */
export function getBitmapPlacement(svgW: number, svgH: number) {
  const scale = Math.min(VB / svgW, VB / svgH) * 0.85;
  const scaledW = svgW * scale;
  const scaledH = svgH * scale;
  const offsetX = (VB - scaledW) / 2;
  const offsetY = (VB - scaledH) / 2;

  return { scale, offsetX, offsetY };
}

export function escapeXml(value: string) {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;");
}

/** charbits-<first line of the text>.svg */
export function getExportFileName(scene: BitmapScene) {
  const slug = scene.text
    .split("\n")[0]
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "");

  return `charbits-${slug || "untitled"}.svg`;
}

export function downloadSvg(svg: string, filename: string) {
  const blob = new Blob([svg], { type: "image/svg+xml" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

/* Each token is its own <text> in the export — a whole row as one string would
   rely on the reader honouring xml:space, and most of them do not. */
export function createBitmapSvg(scene: BitmapScene, fontFamily: string) {
  const { lineBlocks, charW, lineH, blockGap, svgW, svgH } = getBitmapMetrics(scene);
  const { scale, offsetX, offsetY } = getBitmapPlacement(svgW, svgH);
  let yOffset = 0;

  const tokenElements = lineBlocks
    .flatMap((block) => {
      const blockY = yOffset;
      yOffset += block.length * lineH + blockGap;
      const blockMaxLen = Math.max(...block.map((row) => row.length), 1);
      const blockW = blockMaxLen * charW;
      const xOff =
        scene.align === "center"
          ? (svgW - blockW) / 2
          : scene.align === "right"
            ? svgW - blockW
            : 0;

      return block.flatMap((row, rowIndex) => {
        const y = blockY + rowIndex * lineH + scene.fontSize;
        const tokens: string[] = [];

        for (let xIndex = 0; xIndex < row.length; xIndex += 2) {
          const token = row.slice(xIndex, xIndex + 2);

          if (token.trim().length === 0) {
            continue;
          }

          tokens.push(
            `    <text x="${xOff + xIndex * charW}" y="${y}" fill="${scene.color}" font-size="${
              scene.fontSize
            }" font-family="${escapeXml(fontFamily)}" font-weight="${
              scene.fontWeight
            }" dominant-baseline="alphabetic">${escapeXml(token)}</text>`
          );
        }

        return tokens;
      });
    })
    .join("\n");

  return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${VB} ${VB}" width="${VB}" height="${VB}">
  <rect width="${VB}" height="${VB}" fill="${scene.bg}"/>
  <g transform="translate(${offsetX}, ${offsetY}) scale(${scale})">
${tokenElements}
  </g>
</svg>`;
}
thinfont.ts47 lines
// Thin 1px-stroke bitmap font (6 wide × 8 tall) for the Char Bitmap effect.
// Ported from kansei (THIN_FONT).
export const THIN_GLYPH_W = 6;
export const THIN_GLYPH_H = 8;

export const THIN_FONT: Record<string, string[]> = {
  A: [".####.", "#....#", "#....#", "######", "#....#", "#....#", "#....#", "#....#"],
  B: ["#####.", "#....#", "#....#", "#####.", "#....#", "#....#", "#....#", "#####."],
  C: [".#####", "#.....", "#.....", "#.....", "#.....", "#.....", "#.....", ".#####"],
  D: ["####..", "#...#.", "#....#", "#....#", "#....#", "#....#", "#...#.", "####.."],
  E: ["######", "#.....", "#.....", "####..", "#.....", "#.....", "#.....", "######"],
  F: ["######", "#.....", "#.....", "####..", "#.....", "#.....", "#.....", "#....."],
  G: [".####.", "#.....", "#.....", "#..###", "#....#", "#....#", "#....#", ".####."],
  H: ["#....#", "#....#", "#....#", "######", "#....#", "#....#", "#....#", "#....#"],
  I: ["#####.", "..#...", "..#...", "..#...", "..#...", "..#...", "..#...", "#####."],
  J: [".#####", "....#.", "....#.", "....#.", "....#.", "....#.", "#...#.", ".###.."],
  K: ["#...#.", "#..#..", "#.#...", "##....", "#.#...", "#..#..", "#...#.", "#....#"],
  L: ["#.....", "#.....", "#.....", "#.....", "#.....", "#.....", "#.....", "######"],
  M: ["#....#", "##..##", "#.##.#", "#..#.#", "#....#", "#....#", "#....#", "#....#"],
  N: ["#....#", "##...#", "#.#..#", "#..#.#", "#...##", "#....#", "#....#", "#....#"],
  O: [".####.", "#....#", "#....#", "#....#", "#....#", "#....#", "#....#", ".####."],
  P: ["#####.", "#....#", "#....#", "#####.", "#.....", "#.....", "#.....", "#....."],
  Q: [".####.", "#....#", "#....#", "#....#", "#....#", "#..#.#", "#...#.", ".###.#"],
  R: ["#####.", "#....#", "#....#", "#####.", "#.#...", "#..#..", "#...#.", "#....#"],
  S: [".#####", "#.....", "#.....", ".####.", ".....#", ".....#", ".....#", "#####."],
  T: ["#####.", "..#...", "..#...", "..#...", "..#...", "..#...", "..#...", "..#..."],
  U: ["#....#", "#....#", "#....#", "#....#", "#....#", "#....#", "#....#", ".####."],
  V: ["#....#", "#....#", "#....#", "#....#", "#....#", ".#..#.", "..##..", "..#..."],
  W: ["#....#", "#....#", "#....#", "#....#", "#.##.#", "#.##.#", "##..##", "#....#"],
  X: ["#....#", ".#..#.", "..##..", "..#...", "..##..", ".#..#.", "#....#", "#....#"],
  Y: ["#....#", ".#..#.", "..##..", "..#...", "..#...", "..#...", "..#...", "..#..."],
  Z: ["######", ".....#", "....#.", "..##..", ".#....", "#.....", "#.....", "######"],
  "0": [".####.", "#....#", "#...##", "#..#.#", "#.#..#", "##...#", "#....#", ".####."],
  "1": ["..#...", "..#...", "..#...", "..#...", "..#...", "..#...", "..#...", "..#..."],
  "2": [".####.", "#....#", ".....#", "...#..", "..#...", "#.....", "#.....", "######"],
  "3": ["#####.", ".....#", ".....#", ".####.", ".....#", ".....#", ".....#", "#####."],
  "4": ["#....#", "#....#", "#....#", "######", ".....#", ".....#", ".....#", ".....#"],
  "5": ["######", "#.....", "#.....", "#####.", ".....#", ".....#", ".....#", "#####."],
  "6": [".####.", "#.....", "#.....", "#####.", "#....#", "#....#", "#....#", ".####."],
  "7": ["######", ".....#", "....#.", "...#..", "..#...", ".#....", ".#....", ".#...."],
  "8": [".####.", "#....#", "#....#", ".####.", "#....#", "#....#", "#....#", ".####."],
  "9": [".####.", "#....#", "#....#", ".#####", ".....#", ".....#", ".....#", ".####."],
  "?": [".####.", "#....#", "...#..", "..#...", "..#...", ".....#", "......", "..#..."],
  "!": ["..#...", "..#...", "..#...", "..#...", "..#...", "......", "......", "..#..."],
  " ": ["......", "......", "......", "......", "......", "......", "......", "......"],
};
presets.ts105 lines
// Starting points for the playground, ported from the retired /lab/bitmap
// showcase. Each one is a whole scene — token pair, type, palette, spacing —
// that the panel loads straight into the live controls.

export type Align = "left" | "center" | "right";

export interface CharBitsPreset {
  /** single-line, so it reads as a button in the Preset row */
  name: string;
  text: string;
  charA: string;
  charB: string;
  fontSize: number;
  fontWeight: number;
  letterGap: number;
  bg: string;
  color: string;
  /** "light" = dark scene, so panel and corner labels flip to white */
  uiColor: "light" | "dark";
  align: Align;
}

export const PRESETS: CharBitsPreset[] = [
  {
    name: "GIRAGIRA",
    text: "GIRAGIRA",
    charA: "G",
    charB: "R",
    fontSize: 14,
    fontWeight: 400,
    letterGap: 1,
    bg: "#f0eeeb",
    color: "#1a1a1a",
    uiColor: "dark",
    align: "center",
  },
  {
    name: "GLARE",
    text: "GLARE",
    charA: "0",
    charB: "1",
    fontSize: 20,
    fontWeight: 300,
    letterGap: 2,
    bg: "#1a1a1a",
    color: "#e6c820",
    uiColor: "light",
    align: "center",
  },
  {
    name: "2026",
    text: "2026",
    charA: "2",
    charB: "6",
    fontSize: 24,
    fontWeight: 600,
    letterGap: 1,
    bg: "#e6e0f0",
    color: "#3a3a6a",
    uiColor: "dark",
    align: "center",
  },
  {
    name: "HELLO WORLD",
    text: "HELLO\nWORLD",
    charA: "H",
    charB: "W",
    fontSize: 16,
    fontWeight: 400,
    letterGap: 1,
    bg: "#e0ecf4",
    color: "#2a4a6a",
    uiColor: "dark",
    align: "center",
  },
  {
    name: "TYPE",
    text: "TYPE",
    charA: "T",
    charB: "Y",
    fontSize: 22,
    fontWeight: 700,
    letterGap: 2,
    bg: "#f0e4e0",
    color: "#6a2020",
    uiColor: "dark",
    align: "center",
  },
  {
    name: "LAB",
    text: "LAB",
    charA: "!",
    charB: "?",
    fontSize: 24,
    fontWeight: 500,
    letterGap: 1,
    bg: "#e2e8e0",
    color: "#2a4a2a",
    uiColor: "dark",
    align: "center",
  },
];

export const PRESET_NAMES = PRESETS.map((p) => p.name);

NEMAWASHI — Kotaro Abe