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