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.
Trace
dashed outline
Source
"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>
);
}