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
"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>
);
}
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");
}