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.
Char Bitmap
GR · 6×8
Source
"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>
);
}
// 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("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
/** 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>`;
}
// 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": [".####.", "#....#", "#....#", ".#####", ".....#", ".....#", ".....#", ".####."],
"?": [".####.", "#....#", "...#..", "..#...", "..#...", ".....#", "......", "..#..."],
"!": ["..#...", "..#...", "..#...", "..#...", "..#...", "......", "......", "..#..."],
" ": ["......", "......", "......", "......", "......", "......", "......", "......"],
};
// 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);