NEMAWASHI LAB

Trace

SVG

SVG text rendered with fill none and a dashed stroke, so only the outlines of the glyphs appear as stitched contour lines. Dash length and gap are independently adjustable, letting you dial from tight dot-matrix to loose stitch patterns. An optional label overlay uses getExtentOfChar() to compute bounding boxes per glyph and places small monospaced annotations at calculated anchor points around each letter, giving the composition a technical-drawing annotation quality.

kansei

Trace

dashed outline

Controls
Font size280
Stroke width1.5
Dash7
Gap4
Text
Font
Stroke
Label
Background

Source

Trace.tsx142 lines
"use client";

import { useEffect, useRef, useState } from "react";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import { PanelColor, PanelSelect, PanelToggle, PanelText } from "../_kansei/controls";
import { computeGlyphLabels } from "../_kansei/labels";
import type { FontChoice } from "../_kansei/fonts";

type Box = { x: number; y: number; width: number; height: number };

const FAMILY: Record<FontChoice, string> = {
  display: "var(--font-display)",
  sans: "var(--font-geist-sans)",
  noto: "var(--font-noto-sans-jp)",
};

export default function Trace() {
  const [patternText, setPatternText] = useState("kansei");
  const [patternFontSize, setPatternFontSize] = useState(280);
  const [strokeColor, setStrokeColor] = useState("#7a7a14");
  const [strokeWidth, setStrokeWidth] = useState(1.5);
  const [dashLen, setDashLen] = useState(7);
  const [gapLen, setGapLen] = useState(4);
  const [labelColor, setLabelColor] = useState("#e040a0");
  const [showLabels, setShowLabels] = useState(false);
  const [fontChoice, setFontChoice] = useState<FontChoice>("display");
  const [bgColor, setBgColor] = useState("#f3f1ea");

  const textRef = useRef<SVGTextElement>(null);
  const [bbox, setBbox] = useState<Box>({ x: 0, y: 0, width: 0, height: 0 });
  const [charExtents, setCharExtents] = useState<Box[]>([]);

  useEffect(() => {
    const measure = () => {
      const el = textRef.current;
      if (!el) return;
      try {
        const b = el.getBBox();
        if (b.width > 0) setBbox({ x: b.x, y: b.y, width: b.width, height: b.height });
        const n = el.getNumberOfChars();
        const ext: Box[] = [];
        for (let i = 0; i < n; i++) {
          const e = el.getExtentOfChar(i);
          ext.push({ x: e.x, y: e.y, width: e.width, height: e.height });
        }
        setCharExtents(ext);
      } catch {
        /* getBBox can throw before paint */
      }
    };
    measure();
    document.fonts.ready.then(measure);
  }, [patternText, patternFontSize, fontChoice]);

  const traceW = 720;
  const traceH = Math.max(320, Math.round(patternFontSize * 1.6));
  const tl = bbox.width > traceW ? traceW : undefined;
  const labelPoints = computeGlyphLabels(charExtents, bbox);

  return (
    <div className="fixed inset-0 flex items-center justify-center select-none" style={{ background: bgColor }}>
      <svg
        viewBox={`0 0 ${traceW} ${traceH}`}
        className="w-[92vw] max-w-[1000px] h-auto z-[1]"
        xmlns="http://www.w3.org/2000/svg"
      >
        <text
          ref={textRef}
          x="50%"
          y="55%"
          textAnchor="middle"
          dominantBaseline="middle"
          fill="none"
          stroke={strokeColor}
          strokeWidth={strokeWidth}
          strokeDasharray={`${dashLen} ${gapLen}`}
          fontSize={patternFontSize}
          textLength={tl}
          lengthAdjust={tl ? "spacingAndGlyphs" : undefined}
          style={{ fontFamily: FAMILY[fontChoice], fontStyle: fontChoice === "display" ? "italic" : "normal" }}
        >
          {patternText}
        </text>
        {showLabels && bbox.width > 0 &&
          labelPoints.map(({ label, idx, dotX, dotY }) => (
            <g key={`${label}-${idx}`}>
              <circle cx={dotX + 3} cy={dotY} r={2.5} fill={labelColor} />
              <text
                x={dotX + 10}
                y={dotY}
                fill={labelColor}
                fontSize={9}
                fontStyle={idx % 2 === 0 ? "italic" : "normal"}
                textAnchor="start"
                dominantBaseline="middle"
                style={{ fontFamily: "var(--font-geist-mono)" }}
              >
                {label}
              </text>
            </g>
          ))}
      </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">Trace</p>
        <p className="text-[12px] tracking-[0.08em] mt-1 text-foreground/30">dashed outline</p>
      </div>

      <DraggablePanel
        isLight={false}
        controls={[
          { label: "Font size", value: patternFontSize, set: (v) => setPatternFontSize(Math.round(v)), min: 80, max: 380, step: 1 },
          { label: "Stroke width", value: strokeWidth, set: setStrokeWidth, min: 0.5, max: 5, step: 0.1 },
          { label: "Dash", value: dashLen, set: (v) => setDashLen(Math.round(v)), min: 1, max: 30, step: 1 },
          { label: "Gap", value: gapLen, set: (v) => setGapLen(Math.round(v)), min: 1, max: 30, step: 1 },
        ]}
      >
        <PanelText label="Text" value={patternText} set={setPatternText} placeholder="kansei" isLight={false} />
        <PanelSelect label="Font" value={fontChoice} options={["display", "sans", "noto"] as const} set={setFontChoice} isLight={false} />
        <PanelToggle label="Labels" value={showLabels} set={setShowLabels} isLight={false} />
        <PanelColor label="Stroke" value={strokeColor} set={setStrokeColor} isLight={false} />
        <PanelColor label="Label" value={labelColor} set={setLabelColor} 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>
  );
}

NEMAWASHI — Kotaro Abe