Every letter is decomposed into raw line segments -- A is three strokes, Z is three, a full-stop is one. A seeded PRNG jitters each segment's midpoint and rotates it around its centre by up to looseness * 24 degrees, so the skeleton stays legible while the strokes drift like scattered matchsticks. Per-character colour is picked deterministically from a four-swatch palette, and the gap control shortens each segment symmetrically from both ends so adjacent strokes never quite touch. The whole set exports as an OTF via opentype.js, making the tuned looseness permanent type.
Source
"use client";
import { useState, useCallback, useMemo, useRef } from "react";
import { motion } from "framer-motion";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import exportStickFont from "./exportStickFont";
// ---------------------------------------------------------------------------
// Glyph data
// ---------------------------------------------------------------------------
type StrokeSeg = [number, number, number, number];
const STICK_GLYPHS: Record<string, StrokeSeg[]> = {
" ": [],
A: [[4, 85, 26, 8], [26, 8, 46, 85], [12, 52, 40, 52]],
B: [[10, 8, 10, 85], [10, 8, 38, 8], [38, 8, 42, 42], [10, 42, 38, 42], [38, 42, 42, 82], [10, 85, 38, 85]],
C: [[42, 12, 8, 16], [8, 16, 8, 74], [8, 74, 42, 78]],
D: [[10, 8, 10, 85], [10, 8, 38, 8], [42, 14, 42, 76], [10, 82, 38, 82]],
E: [[10, 8, 10, 85], [10, 8, 44, 8], [10, 46, 38, 46], [10, 85, 44, 85]],
F: [[10, 8, 10, 85], [10, 8, 44, 8], [10, 46, 38, 46]],
G: [[42, 12, 8, 16], [8, 16, 8, 74], [8, 74, 42, 78], [42, 78, 42, 48], [42, 48, 24, 48]],
H: [[8, 8, 8, 85], [42, 8, 42, 85], [8, 46, 42, 46]],
I: [[25, 8, 25, 85], [8, 8, 42, 8], [8, 85, 42, 85]],
J: [[8, 8, 44, 8], [38, 8, 38, 72], [38, 72, 14, 82], [14, 82, 8, 68]],
K: [[8, 8, 8, 85], [8, 48, 42, 8], [8, 48, 42, 85]],
L: [[10, 8, 10, 85], [10, 85, 46, 85]],
M: [[5, 85, 5, 8], [5, 8, 28, 46], [28, 46, 50, 8], [50, 8, 50, 85]],
N: [[5, 85, 5, 8], [5, 8, 46, 85], [46, 85, 46, 8]],
O: [[14, 8, 36, 8], [40, 14, 40, 76], [14, 82, 36, 82], [10, 14, 10, 76]],
P: [[10, 8, 10, 85], [10, 8, 36, 8], [36, 8, 42, 44], [10, 44, 42, 44]],
Q: [[14, 8, 36, 8], [40, 14, 40, 76], [14, 82, 36, 82], [10, 14, 10, 76], [32, 70, 46, 88]],
R: [[10, 8, 10, 85], [10, 8, 36, 8], [36, 8, 44, 26], [10, 46, 44, 26], [10, 46, 44, 85]],
S: [[40, 10, 14, 10], [14, 10, 5, 26], [5, 26, 42, 50], [42, 50, 44, 66], [44, 66, 14, 82], [14, 82, 5, 70]],
T: [[4, 8, 46, 8], [25, 8, 25, 85]],
U: [[5, 8, 5, 64], [5, 64, 12, 80], [12, 80, 38, 80], [38, 80, 46, 64], [46, 64, 46, 8]],
V: [[4, 8, 25, 85], [25, 85, 46, 8]],
W: [[4, 8, 16, 85], [16, 85, 27, 46], [27, 46, 38, 85], [38, 85, 50, 8]],
X: [[5, 8, 46, 85], [46, 8, 5, 85]],
Y: [[5, 8, 26, 46], [46, 8, 26, 46], [26, 46, 26, 85]],
Z: [[5, 8, 46, 8], [46, 8, 5, 85], [5, 85, 46, 85]],
a: [[14, 22, 36, 22], [8, 26, 8, 60], [14, 64, 38, 64], [38, 22, 38, 85]],
b: [[8, 2, 8, 85], [8, 44, 38, 44], [42, 48, 42, 76], [8, 80, 38, 80]],
c: [[40, 26, 8, 28], [8, 28, 8, 72], [8, 72, 40, 74]],
d: [[44, 2, 44, 85], [44, 20, 14, 20], [10, 24, 10, 76], [44, 80, 14, 80]],
e: [[8, 24, 38, 24], [8, 24, 8, 78], [8, 78, 38, 78], [8, 50, 44, 50]],
f: [[28, 5, 28, 85], [12, 24, 42, 24], [28, 5, 36, 8]],
g: [[14, 20, 36, 20], [10, 24, 10, 74], [14, 80, 36, 80], [40, 24, 40, 108], [40, 108, 10, 108]],
h: [[8, 2, 8, 85], [8, 44, 40, 44], [40, 44, 40, 85]],
i: [[22, 30, 22, 85], [18, 10, 26, 10]],
j: [[18, 10, 26, 10], [22, 30, 22, 80], [22, 80, 8, 88], [8, 88, 4, 76]],
k: [[8, 2, 8, 85], [8, 52, 38, 24], [8, 52, 38, 85]],
l: [[22, 2, 22, 85]],
m: [[5, 24, 5, 85], [5, 24, 25, 44], [25, 44, 44, 24], [44, 24, 44, 85]],
n: [[5, 24, 5, 85], [5, 24, 38, 24], [38, 24, 38, 85]],
o: [[14, 20, 36, 20], [40, 26, 40, 74], [14, 80, 36, 80], [10, 26, 10, 74]],
p: [[10, 10, 10, 112], [10, 10, 38, 10], [38, 10, 44, 44], [10, 44, 44, 44]],
q: [[14, 20, 36, 20], [10, 26, 10, 74], [14, 80, 36, 80], [40, 24, 40, 108]],
r: [[8, 24, 8, 85], [8, 24, 34, 24], [34, 24, 42, 40]],
s: [[38, 22, 14, 22], [14, 22, 5, 35], [5, 35, 40, 50], [40, 50, 44, 64], [44, 64, 14, 78]],
t: [[22, 5, 22, 85], [6, 24, 40, 24]],
u: [[5, 24, 5, 68], [5, 68, 12, 80], [12, 80, 38, 80], [38, 80, 46, 68], [46, 68, 46, 24]],
v: [[4, 24, 25, 85], [25, 85, 46, 24]],
w: [[4, 24, 14, 85], [14, 85, 26, 52], [26, 52, 38, 85], [38, 85, 48, 24]],
x: [[5, 24, 44, 85], [44, 24, 5, 85]],
y: [[5, 24, 26, 62], [46, 24, 26, 62], [26, 62, 14, 112]],
z: [[5, 24, 44, 24], [44, 24, 5, 85], [5, 85, 44, 85]],
"0": [[14, 8, 36, 8], [40, 14, 40, 76], [14, 82, 36, 82], [10, 14, 10, 76]],
"1": [[8, 18, 25, 8], [25, 8, 25, 85], [12, 85, 38, 85]],
"2": [[14, 8, 36, 8], [36, 8, 42, 36], [42, 36, 8, 82], [8, 82, 44, 82]],
"3": [[8, 12, 36, 8], [36, 8, 42, 24], [42, 24, 14, 42], [14, 42, 42, 62], [42, 62, 36, 82], [8, 78, 36, 82]],
"4": [[8, 8, 8, 46], [8, 46, 44, 46], [36, 8, 36, 85]],
"5": [[8, 8, 44, 8], [8, 8, 8, 42], [8, 42, 36, 42], [36, 42, 44, 62], [44, 62, 36, 82], [8, 82, 36, 82]],
"6": [[36, 8, 14, 14], [10, 14, 10, 76], [14, 82, 36, 82], [40, 48, 40, 76], [14, 42, 36, 42]],
"7": [[4, 8, 46, 8], [46, 8, 18, 85]],
"8": [[14, 8, 36, 8], [10, 14, 10, 36], [14, 42, 36, 42], [10, 48, 10, 76], [14, 82, 36, 82], [40, 14, 40, 36], [40, 48, 40, 76]],
"9": [[14, 8, 36, 8], [10, 14, 10, 42], [14, 42, 36, 42], [40, 14, 40, 76], [14, 82, 36, 82]],
".": [[22, 76, 22, 85]],
",": [[22, 76, 22, 85], [22, 85, 16, 98]],
"!": [[22, 8, 22, 62], [22, 76, 22, 85]],
"?": [[14, 8, 36, 8], [36, 8, 42, 22], [42, 22, 22, 46], [22, 76, 22, 85]],
"-": [[8, 46, 40, 46]],
"'": [[22, 4, 22, 22]],
":": [[22, 26, 22, 34], [22, 60, 22, 68]],
";": [[22, 26, 22, 34], [22, 60, 22, 68], [22, 68, 16, 80]],
"♥": [
[10, 28, 8, 18], [8, 18, 16, 10], [16, 10, 24, 20],
[24, 20, 32, 10], [32, 10, 40, 18], [40, 18, 38, 28],
[10, 28, 24, 82], [38, 28, 24, 82],
],
"♠": [
[24, 10, 8, 42], [24, 10, 40, 42],
[8, 42, 12, 56], [40, 42, 36, 56],
[12, 56, 24, 48], [36, 56, 24, 48],
[24, 50, 24, 80], [14, 80, 34, 80],
],
"♦": [
[24, 8, 6, 46], [6, 46, 24, 84],
[24, 8, 42, 46], [42, 46, 24, 84],
],
"♣": [
[16, 30, 24, 12], [24, 12, 32, 30], [32, 30, 16, 30],
[2, 52, 2, 34], [2, 34, 18, 34], [18, 52, 2, 52],
[30, 34, 46, 34], [46, 34, 46, 52], [46, 52, 30, 52],
[24, 50, 24, 80], [14, 80, 34, 80],
],
};
const STICK_WIDTHS: Record<string, number> = {
" ": 26, A: 52, B: 52, C: 50, D: 54, E: 48, F: 46, G: 54, H: 52, I: 34,
J: 40, K: 50, L: 46, M: 58, N: 52, O: 58, P: 50, Q: 60, R: 52, S: 50,
T: 48, U: 52, V: 52, W: 62, X: 50, Y: 50, Z: 50,
a: 48, b: 52, c: 48, d: 52, e: 50, f: 38, g: 52, h: 50, i: 30, j: 32,
k: 48, l: 28, m: 58, n: 48, o: 52, p: 54, q: 52, r: 44, s: 48, t: 38,
u: 50, v: 50, w: 58, x: 48, y: 50, z: 48,
"0": 52, "1": 40, "2": 52, "3": 52, "4": 48, "5": 52,
"6": 50, "7": 48, "8": 52, "9": 50,
".": 26, ",": 26, "!": 26, "?": 48, "-": 44, "'": 26, ":": 26, ";": 26,
"♥": 48, "♠": 48, "♦": 48, "♣": 48,
};
function seededRand(seed: number): number {
const s = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
// ---------------------------------------------------------------------------
// Compositions
// ---------------------------------------------------------------------------
interface StickComposition {
name: string;
text: string;
bg: string;
colors: string[];
charColors?: Record<string, string[]>;
uiColor: "light" | "dark";
scale: number;
stickWidth: number;
letterGap: number;
looseness: number;
stickGap: number;
}
const compositions: StickComposition[] = [
{
name: "GIRAGIRA",
text: "GIRAGIRA",
bg: "#f0eeeb",
colors: ["#3a3a6a", "#9a9ec8", "#5a4aad", "#2a6a8a"],
uiColor: "dark",
scale: 1.8,
stickWidth: 8,
letterGap: 12,
looseness: 0.5,
stickGap: 8,
},
{
name: "Glare",
text: "Glare",
bg: "#1a1a1a",
colors: ["#e8e4e0", "#9a9ec8", "#c89018", "#e06820"],
uiColor: "light",
scale: 2.2,
stickWidth: 10,
letterGap: 14,
looseness: 0.4,
stickGap: 10,
},
{
name: "giragira",
text: "giragira",
bg: "#e6e0f0",
colors: ["#5a4aad", "#c86040", "#2a6a5a", "#3a3a6a"],
uiColor: "dark",
scale: 1.6,
stickWidth: 7,
letterGap: 10,
looseness: 0.6,
stickGap: 6,
},
{
name: "STICKS",
text: "STICKS",
bg: "#f5ede0",
colors: ["#c86040", "#2a6a8a", "#c89018", "#6a2020"],
uiColor: "dark",
scale: 2.0,
stickWidth: 9,
letterGap: 16,
looseness: 0.3,
stickGap: 12,
},
{
name: "LAB",
text: "LAB",
bg: "#e0ecf4",
colors: ["#2a4a6a", "#c86040", "#e6c820", "#2a6a5a"],
uiColor: "dark",
scale: 2.4,
stickWidth: 12,
letterGap: 18,
looseness: 0.55,
stickGap: 10,
},
{
name: "Suits",
text: "♠ ♥ ♦ ♣",
bg: "#0a1a0a",
colors: ["#e8e4e0"],
charColors: {
"♥": ["#e83838", "#cc2020"],
"♦": ["#e86830", "#d04820"],
"♠": ["#e8e4e0", "#c8c4c0"],
"♣": ["#e8e4e0", "#c8c4c0"],
},
uiColor: "light",
scale: 2.4,
stickWidth: 8,
letterGap: 14,
looseness: 0.15,
stickGap: 4,
},
{
name: "A♠ K♥ Q♦ J♣",
text: "A♠ K♥ Q♦ J♣",
bg: "#f5f0e8",
colors: ["#1a1a2e"],
charColors: {
"♥": ["#cc0000", "#b01020"],
"♦": ["#cc0000", "#c83020"],
"♠": ["#1a1a2e", "#2a2a3a"],
"♣": ["#1a1a2e", "#2a2a3a"],
"A": ["#1a1a2e", "#2a2a3a"],
"K": ["#cc0000", "#b01020"],
"Q": ["#cc0000", "#c83020"],
"J": ["#1a1a2e", "#2a2a3a"],
},
uiColor: "dark",
scale: 1.4,
stickWidth: 7,
letterGap: 10,
looseness: 0.3,
stickGap: 6,
},
{
name: "Uppercase",
text: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
bg: "#f0eeeb",
colors: ["#3a3a6a", "#9a9ec8", "#5a4aad", "#2a6a8a"],
uiColor: "dark",
scale: 1.0,
stickWidth: 5,
letterGap: 6,
looseness: 0.3,
stickGap: 5,
},
{
name: "lowercase",
text: "abcdefghijklmnopqrstuvwxyz",
bg: "#f0eeeb",
colors: ["#3a3a6a", "#9a9ec8", "#5a4aad", "#2a6a8a"],
uiColor: "dark",
scale: 1.0,
stickWidth: 5,
letterGap: 6,
looseness: 0.3,
stickGap: 5,
},
{
name: "0123456789",
text: "0123456789 .,!?-':;",
bg: "#f0eeeb",
colors: ["#3a3a6a", "#9a9ec8", "#5a4aad", "#2a6a8a"],
uiColor: "dark",
scale: 1.2,
stickWidth: 6,
letterGap: 8,
looseness: 0.3,
stickGap: 6,
},
];
const COLOR_PRESETS = [
"#3a3a6a", "#5a4aad", "#9a9ec8", "#2a4a6a", "#2a6a8a", "#2a6a5a",
"#6a2020", "#c86040", "#e06820", "#c89018", "#e6c820", "#e8e4e0",
];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function StickCanvas() {
const [index, setIndex] = useState(0);
const comp = compositions[index];
const isLight = comp.uiColor === "light";
const [stickWidth, setStickWidth] = useState(comp.stickWidth);
const [looseness, setLooseness] = useState(comp.looseness);
const [stickGap, setStickGap] = useState(comp.stickGap);
const [previewFamily, setPreviewFamily] = useState<string | null>(null);
const [customText, setCustomText] = useState("");
const [colors, setColors] = useState(["#2a6a5a", "#9a9ec8", "#c89018", "#e06820"]);
const [selectedColorIdx, setSelectedColorIdx] = useState(0);
const svgRef = useRef<SVGSVGElement>(null);
const exportSVG = useCallback(() => {
const svg = svgRef.current;
if (!svg) return;
const clone = svg.cloneNode(true) as SVGSVGElement;
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
const vb = clone.getAttribute("viewBox")!.split(" ").map(Number);
const bg = document.createElement("rect");
bg.setAttribute("x", String(vb[0]));
bg.setAttribute("y", String(vb[1]));
bg.setAttribute("width", String(vb[2]));
bg.setAttribute("height", String(vb[3]));
bg.setAttribute("fill", comp.bg);
clone.insertBefore(bg, clone.firstChild);
const blob = new Blob(
['<?xml version="1.0" encoding="UTF-8"?>\n' + clone.outerHTML],
{ type: "image/svg+xml" },
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `stick_${comp.text.replace(/\s+/g, "_")}.svg`;
a.click();
URL.revokeObjectURL(url);
}, [comp]);
const updateColor = useCallback((idx: number, hex: string) => {
setColors(prev => {
const next = [...prev];
next[idx] = hex;
return next;
});
}, []);
const handleClick = useCallback(() => {
const next = (index + 1) % compositions.length;
setIndex(next);
}, [index]);
// Build stick SVG elements
const { elements, vbW, vbH } = useMemo(() => {
const chars = (customText || comp.text).split("");
const sc = comp.scale;
const gap = comp.letterGap;
let totalW = 0;
chars.forEach((c) => { totalW += (STICK_WIDTHS[c] ?? 50) + gap; });
if (chars.length > 0) totalW -= gap;
const w = Math.max(totalW * sc, 1);
const h = 130 * sc;
let xCursor = 0;
const els: React.ReactNode[] = [];
chars.forEach((c, ci) => {
const segs = STICK_GLYPHS[c] ?? [];
const gw = STICK_WIDTHS[c] ?? 50;
const maxRotDeg = looseness * 24;
const maxJitter = looseness * 6;
segs.forEach(([x1, y1, x2, y2], si) => {
const seed = ci * 200 + si;
const rotDeg = (seededRand(seed) - 0.5) * 2 * maxRotDeg;
const jx = (seededRand(seed + 1) - 0.5) * 2 * maxJitter;
const jy = (seededRand(seed + 2) - 0.5) * 2 * maxJitter;
const cx = (x1 + x2) / 2 + xCursor + jx;
const cy = (y1 + y2) / 2 + jy;
const hdx = (x2 - x1) / 2;
const hdy = (y2 - y1) / 2;
const halfLen = Math.sqrt(hdx * hdx + hdy * hdy);
const shrink = halfLen > 0 ? Math.min(stickGap / 2, halfLen * 0.42) : 0;
const gapFactor = halfLen > 0 ? (halfLen - shrink) / halfLen : 1;
const ghdx = hdx * gapFactor;
const ghdy = hdy * gapFactor;
const rad = (rotDeg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const rhdx = ghdx * cos - ghdy * sin;
const rhdy = ghdx * sin + ghdy * cos;
const charPalette = comp.charColors?.[c];
const strokeCol = charPalette
? charPalette[Math.floor(seededRand(ci * 53 + si * 17 + 7) * charPalette.length)]
: colors[Math.floor(seededRand(ci * 53 + si * 17 + 7) * colors.length)];
// Round to 2dp — raw trig floats drift between server and client at
// the ~10th decimal and trigger SSR hydration mismatches.
const r2 = (n: number) => Math.round(n * 100) / 100;
els.push(
<line
key={`${ci}-${si}`}
x1={r2((cx - rhdx) * sc)}
y1={r2((cy - rhdy) * sc)}
x2={r2((cx + rhdx) * sc)}
y2={r2((cy + rhdy) * sc)}
stroke={strokeCol}
strokeWidth={stickWidth}
strokeLinecap="round"
/>
);
});
xCursor += gw + gap;
});
return { elements: els, vbW: w, vbH: h };
}, [comp, customText, stickWidth, looseness, stickGap, colors]);
const pad = stickWidth * 2;
return (
<div
className="fixed inset-0 cursor-pointer select-none transition-colors duration-700 flex items-center justify-center"
style={{ backgroundColor: comp.bg }}
onClick={handleClick}
>
<svg
ref={svgRef}
viewBox={`${-pad} ${-pad} ${vbW + pad * 2} ${vbH + pad * 2}`}
className="max-w-[85%] max-h-[60%]"
overflow="visible"
>
{elements}
</svg>
{/* 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>
{/* Hint */}
<div
className="fixed bottom-8 right-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
>
<motion.p
className="text-[12px] tracking-[0.08em] uppercase"
animate={{
color: isLight
? "rgba(255,255,255,0.3)"
: "rgba(23,23,23,0.3)",
}}
transition={{ duration: 0.6 }}
>
Click anywhere
</motion.p>
</div>
<DraggablePanel
isLight={isLight}
controls={[
{ label: "Weight", value: stickWidth, set: setStickWidth, min: 3, max: 16, step: 1 },
{ label: "Looseness", value: looseness, set: setLooseness, min: 0, max: 1, step: 0.05 },
{ label: "Gap", value: stickGap, set: setStickGap, min: 0, max: 16, 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>
<div style={{ marginBottom: 12 }}>
<div style={{
display: "flex", justifyContent: "space-between",
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: 6,
}}>
<span>Colors</span>
</div>
<div style={{ display: "flex", gap: 6, justifyContent: "center", marginBottom: 8 }}>
{colors.map((c, i) => (
<div
key={i}
onClick={() => setSelectedColorIdx(i)}
style={{
width: 24, height: 24, borderRadius: "50%",
backgroundColor: c,
outline: selectedColorIdx === i
? `2px solid ${isLight ? "#fff" : "#1a1a2e"}`
: "none",
outlineOffset: 2,
cursor: "pointer",
border: `1px solid ${isLight ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.1)"}`,
}}
/>
))}
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 3, marginBottom: 8 }}>
{COLOR_PRESETS.map((c) => (
<div
key={c}
onClick={() => updateColor(selectedColorIdx, c)}
style={{
width: 14, height: 14, borderRadius: 3,
backgroundColor: c,
cursor: "pointer",
border: `1px solid ${isLight ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.08)"}`,
}}
/>
))}
</div>
<div style={{ display: "flex", gap: 4, alignItems: "center" }}>
<input
type="text"
value={colors[selectedColorIdx]}
onChange={(e) => updateColor(selectedColorIdx, e.target.value)}
style={{
flex: 1,
padding: "4px 8px",
background: "transparent",
border: `1px solid ${isLight ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.08)"}`,
borderRadius: 6,
color: isLight ? "#fff" : "#1a1a2e",
fontSize: 11,
fontFamily: "monospace",
boxSizing: "border-box",
minWidth: 0,
}}
/>
<label style={{ position: "relative", cursor: "pointer", flexShrink: 0 }}>
<div style={{
width: 24, height: 24, borderRadius: 4,
backgroundColor: colors[selectedColorIdx],
border: `1px solid ${isLight ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.1)"}`,
}} />
<input
type="color"
value={/^#[0-9a-fA-F]{6}$/.test(colors[selectedColorIdx]) ? colors[selectedColorIdx] : "#000000"}
onChange={(e) => updateColor(selectedColorIdx, e.target.value)}
style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", opacity: 0, cursor: "pointer" }}
/>
</label>
</div>
</div>
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
<button
onClick={() => setPreviewFamily(exportStickFont(STICK_GLYPHS, STICK_WIDTHS, stickWidth, looseness, stickGap))}
style={{
flex: 1, padding: "8px 0", 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>
<button
onClick={exportSVG}
style={{
flex: 1, padding: "8px 0", 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 SVG
</button>
</div>
</DraggablePanel>
{/* Back */}
<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, ASCENDER, createFont, downloadFont, previewFont, roundedLineToPath } from "../fontExport";
type StrokeSeg = [number, number, number, number];
function seededRand(seed: number): number {
const s = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
const GLYPH_HEIGHT = 130;
const SCALE = UPM / GLYPH_HEIGHT;
export default function exportStickFont(
glyphs: Record<string, StrokeSeg[]>,
widths: Record<string, number>,
stickWidth: number,
looseness: number,
stickGap: number,
) {
const otGlyphs: Glyph[] = [];
for (const [char, segs] of Object.entries(glyphs)) {
if (char === " ") continue;
const code = char.charCodeAt(0);
const gw = (widths[char] ?? 50) * SCALE;
const path = new Path();
const maxRotDeg = looseness * 24;
const maxJitter = looseness * 6;
const sw = stickWidth * SCALE * 0.6;
segs.forEach(([x1, y1, x2, y2], si) => {
const seed = 0 * 200 + si;
const rotDeg = (seededRand(seed) - 0.5) * 2 * maxRotDeg;
const jx = (seededRand(seed + 1) - 0.5) * 2 * maxJitter;
const jy = (seededRand(seed + 2) - 0.5) * 2 * maxJitter;
const cx = (x1 + x2) / 2 + jx;
const cy = (y1 + y2) / 2 + jy;
const hdx = (x2 - x1) / 2;
const hdy = (y2 - y1) / 2;
const halfLen = Math.sqrt(hdx * hdx + hdy * hdy);
const shrink = halfLen > 0 ? Math.min(stickGap / 2, halfLen * 0.42) : 0;
const gapFactor = halfLen > 0 ? (halfLen - shrink) / halfLen : 1;
const ghdx = hdx * gapFactor;
const ghdy = hdy * gapFactor;
const rad = (rotDeg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const rhdx = ghdx * cos - ghdy * sin;
const rhdy = ghdx * sin + ghdy * cos;
const lx1 = (cx - rhdx) * SCALE;
const ly1 = ASCENDER - (cy - rhdy) * SCALE;
const lx2 = (cx + rhdx) * SCALE;
const ly2 = ASCENDER - (cy + rhdy) * SCALE;
roundedLineToPath(path, lx1, ly1, lx2, ly2, sw);
});
otGlyphs.push(new Glyph({
name: char.length === 1 ? char : `uni${code.toString(16).toUpperCase().padStart(4, "0")}`,
unicode: code,
advanceWidth: gw + sw,
path,
}));
}
const spaceCode = " ".charCodeAt(0);
otGlyphs.push(new Glyph({
name: "space",
unicode: spaceCode,
advanceWidth: (widths[" "] ?? 26) * SCALE,
path: new Path(),
}));
const w = Math.round(stickWidth);
const l = Math.round(looseness * 100).toString().padStart(3, "0");
const g = Math.round(stickGap);
const filename = `GRGR_Stick_w${w}_l${l}_g${g}.otf`;
const font = createFont("GRGR Stick", otGlyphs);
downloadFont(font, filename);
return previewFont(font, "GRGR-Stick-Preview");
}