Same matchstick-segment glyph data as stick, re-exposed with simpler controls and a kansei-panel UI. Looseness jitters and rotates each segment identically (seeded sin-hash), but the component is a plain server-rendered SVG with no composition carousel -- just a text input, single stroke colour (or a multi-colour toggle that picks from a fixed palette), and explicit stroke/background colour pickers. Coordinates are rounded to two decimal places to dodge SSR/browser trig hydration mismatches.
Stick Lines
37 segments
Source
"use client";
import { useState } from "react";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import { PanelColor, PanelToggle, PanelText } from "../_kansei/controls";
import { seededRand } from "../_kansei/helpers";
import { STICK_GLYPHS, STICK_WIDTHS, STICK_PALETTE } from "./glyphs";
export default function StickLines() {
const [text, setText] = useState("Philosopher");
const [scale, setScale] = useState(1.1);
const [stickWidth, setStickWidth] = useState(12);
const [letterGap, setLetterGap] = useState(20);
const [looseness, setLooseness] = useState(0.55);
const [stickGap, setStickGap] = useState(10);
const [multiColor, setMultiColor] = useState(false);
const [strokeColor, setStrokeColor] = useState("#1a1814");
const [bgColor, setBgColor] = useState("#f8f7f4");
// Round emitted coords to 2dp: Math.sin/cos differ in the last digit between
// the SSR (Node) and browser engines, which otherwise trips a hydration mismatch.
const r = (n: number) => Math.round(n * 100) / 100;
const chars = text.split("");
let totalW = 0;
chars.forEach((c) => {
totalW += (STICK_WIDTHS[c] ?? 50) + letterGap;
});
if (chars.length > 0) totalW -= letterGap;
const vbW = Math.max(totalW * scale, 1);
const vbH = 130 * scale;
let xCursor = 0;
const lines: 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 strokeCol = multiColor
? STICK_PALETTE[Math.floor(seededRand(ci * 53 + si * 17 + 7) * STICK_PALETTE.length)]
: strokeColor;
lines.push(
<line
key={`${ci}-${si}`}
x1={r((cx - rhdx) * scale)}
y1={r((cy - rhdy) * scale)}
x2={r((cx + rhdx) * scale)}
y2={r((cy + rhdy) * scale)}
stroke={strokeCol}
strokeWidth={stickWidth}
strokeLinecap="round"
/>,
);
});
xCursor += gw + letterGap;
});
return (
<div className="fixed inset-0 flex items-center justify-center select-none" style={{ background: bgColor }}>
<svg
viewBox={`${-stickWidth} ${-stickWidth} ${vbW + stickWidth * 2} ${vbH + stickWidth * 2}`}
className="w-[92vw] max-w-[1200px] h-auto z-[1]"
xmlns="http://www.w3.org/2000/svg"
>
{lines}
</svg>
<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 text-foreground/50">Stick Lines</p>
<p className="text-[12px] tracking-[0.08em] mt-1 text-foreground/30">{lines.length} segments</p>
</div>
<DraggablePanel
isLight={false}
controls={[
{ label: "Scale", value: scale, set: setScale, min: 0.4, max: 3, step: 0.05 },
{ label: "Stroke width", value: stickWidth, set: (v) => setStickWidth(Math.round(v)), min: 1, max: 24, step: 1 },
{ label: "Letter gap", value: letterGap, set: (v) => setLetterGap(Math.round(v)), min: 0, max: 40, step: 1 },
{ label: "Looseness", value: looseness, set: setLooseness, min: 0, max: 1, step: 0.05 },
{ label: "Stick gap", value: stickGap, set: (v) => setStickGap(Math.round(v)), min: 0, max: 24, step: 1 },
]}
>
<PanelText label="Text" value={text} set={setText} placeholder="Philosopher" isLight={false} />
<PanelToggle label="Multi-colour" value={multiColor} set={setMultiColor} isLight={false} />
<PanelColor label="Stroke" value={strokeColor} set={setStrokeColor} isLight={false} />
<PanelColor label="Background" value={bgColor} set={setBgColor} isLight={false} />
</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)] text-foreground/60"
style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
/>
</div>
</div>
);
}
// Stroke-segment glyphs for the Stick Lines effect — each letter is a list of
// [x1,y1,x2,y2] line segments in a ~50×90 glyph box. Ported from kansei.
export type StrokeSeg = [number, number, number, number];
export const STICK_GLYPHS: Record<string, StrokeSeg[]> = {
" ": [],
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]],
q: [[14, 20, 36, 20], [10, 26, 10, 74], [14, 80, 36, 80], [40, 24, 40, 108]],
H: [[8, 8, 8, 85], [42, 8, 42, 85], [8, 46, 42, 46]],
h: [[8, 2, 8, 85], [8, 44, 40, 44], [40, 44, 40, 85]],
I: [[25, 8, 25, 85], [8, 8, 42, 8], [8, 85, 42, 85]],
i: [[22, 30, 22, 85], [18, 10, 26, 10]],
L: [[10, 8, 10, 85], [10, 85, 46, 85]],
l: [[22, 2, 22, 85]],
O: [[14, 8, 36, 8], [40, 14, 40, 76], [14, 82, 36, 82], [10, 14, 10, 76]],
o: [[14, 20, 36, 20], [40, 26, 40, 74], [14, 80, 36, 80], [10, 26, 10, 74]],
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]],
s: [[38, 22, 14, 22], [14, 22, 5, 35], [5, 35, 40, 50], [40, 50, 44, 64], [44, 64, 14, 78]],
E: [[10, 8, 10, 85], [10, 8, 44, 8], [10, 46, 38, 46], [10, 85, 44, 85]],
e: [[8, 24, 38, 24], [8, 24, 8, 78], [8, 78, 38, 78], [8, 50, 44, 50]],
R: [[10, 8, 10, 85], [10, 8, 36, 8], [36, 8, 44, 26], [10, 46, 44, 26], [10, 46, 44, 85]],
r: [[8, 24, 8, 85], [8, 24, 34, 24], [34, 24, 42, 40]],
p: [[10, 10, 10, 112], [10, 10, 38, 10], [38, 10, 44, 44], [10, 44, 44, 44]],
A: [[4, 85, 26, 8], [26, 8, 46, 85], [12, 52, 40, 52]],
a: [[14, 22, 36, 22], [8, 26, 8, 60], [14, 64, 38, 64], [38, 22, 38, 85]],
B: [[10, 8, 10, 85], [10, 8, 38, 8], [38, 8, 42, 42], [10, 42, 38, 42], [38, 42, 42, 82]],
b: [[8, 2, 8, 85], [8, 44, 38, 44], [42, 48, 42, 76], [8, 80, 38, 80]],
C: [[42, 12, 8, 16], [8, 16, 8, 74], [8, 74, 42, 78]],
c: [[40, 26, 8, 28], [8, 28, 8, 72], [8, 72, 40, 74]],
D: [[10, 8, 10, 85], [10, 8, 38, 8], [42, 14, 42, 76], [10, 82, 38, 82]],
d: [[44, 2, 44, 85], [44, 20, 14, 20], [10, 24, 10, 76], [44, 80, 14, 80]],
F: [[10, 8, 10, 85], [10, 8, 44, 8], [10, 46, 38, 46]],
f: [[28, 5, 28, 85], [12, 24, 42, 24], [28, 5, 36, 8]],
G: [[42, 12, 8, 16], [8, 16, 8, 74], [8, 74, 42, 78], [42, 78, 42, 48], [42, 48, 24, 48]],
g: [[14, 20, 36, 20], [10, 24, 10, 74], [14, 80, 36, 80], [40, 24, 40, 108], [40, 108, 10, 108]],
J: [[8, 8, 44, 8], [38, 8, 38, 72], [38, 72, 14, 82], [14, 82, 8, 68]],
j: [[18, 10, 26, 10], [22, 30, 22, 80], [22, 80, 8, 88], [8, 88, 4, 76]],
K: [[8, 8, 8, 85], [8, 48, 42, 8], [8, 48, 42, 85]],
k: [[8, 2, 8, 85], [8, 52, 38, 24], [8, 52, 38, 85]],
M: [[5, 85, 5, 8], [5, 8, 28, 46], [28, 46, 50, 8], [50, 8, 50, 85]],
m: [[5, 24, 5, 85], [5, 24, 25, 44], [25, 44, 44, 24], [44, 24, 44, 85]],
N: [[5, 85, 5, 8], [5, 8, 46, 85], [46, 85, 46, 8]],
n: [[5, 24, 5, 85], [5, 24, 38, 24], [38, 24, 38, 85]],
T: [[4, 8, 46, 8], [25, 8, 25, 85]],
t: [[22, 5, 22, 85], [6, 24, 40, 24]],
U: [[5, 8, 5, 64], [5, 64, 12, 80], [12, 80, 38, 80], [38, 80, 46, 64], [46, 64, 46, 8]],
u: [[5, 24, 5, 68], [5, 68, 12, 80], [12, 80, 38, 80], [38, 80, 46, 68], [46, 68, 46, 24]],
V: [[4, 8, 25, 85], [25, 85, 46, 8]],
v: [[4, 24, 25, 85], [25, 85, 46, 24]],
W: [[4, 8, 16, 85], [16, 85, 27, 46], [27, 46, 38, 85], [38, 85, 50, 8]],
w: [[4, 24, 14, 85], [14, 85, 26, 52], [26, 52, 38, 85], [38, 85, 48, 24]],
X: [[5, 8, 46, 85], [46, 8, 5, 85]],
x: [[5, 24, 44, 85], [44, 24, 5, 85]],
Y: [[5, 8, 26, 46], [46, 8, 26, 46], [26, 46, 26, 85]],
y: [[5, 24, 26, 62], [46, 24, 26, 62], [26, 62, 14, 112]],
Z: [[5, 8, 46, 8], [46, 8, 5, 85], [5, 85, 46, 85]],
z: [[5, 24, 44, 24], [44, 24, 5, 85], [5, 85, 44, 85]],
};
export 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,
};
export const STICK_PALETTE = [
"#cc4010", "#8aaad0", "#c89018", "#5a4830",
"#e06820", "#686460", "#7aa0c8", "#b07818",
];