NEMAWASHI LAB

Stick Lines

SVG

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

Controls
Scale1.10
Stroke width12
Letter gap20
Looseness0.55
Stick gap10
Text
Stroke
Background

Source

StickLines.tsx125 lines
"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>
  );
}
glyphs.ts74 lines
// 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",
];

NEMAWASHI — Kotaro Abe