Two alphabets, one dot. Free alignment walks Tube's line-and-arc skeletons -- joined by digits and a few marks drawn in the same vocabulary -- and lays a row of round dots along every stroke: the pitch is fitted per run rather than globally, and each stroke docks onto what is already drawn, one landing on an earlier stroke splitting it at that point so the junction gets exactly one dot with even spacing either side and E's arms, K's diagonals and A's crossbar stop crowding their stems. Grid alignment does not sample curves at all -- that only ever yields a broken octagon for O -- it spells with the shared hand-drawn 6x8 single-stroke bitmap alphabet, the same one Char Bitmap sets its tokens on, one dot per lit cell on a fixed 6-cell advance with the letter gap in whole cells and the leading in whole rows. Eight rows across seven gaps put the top row on the cap line and the bottom on the baseline, so every glyph in a setting shares one matrix. Export writes each dot as its own circle contour scaled into the 1080-unit em, carrying whichever alphabet is on screen.
Source
"use client";
import { useRef, useMemo, useEffect, useState, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { motion } from "framer-motion";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import { PanelSelect, PanelTextArea } from "../_kansei/controls";
import exportDotsFont from "./exportDotsFont";
import { DOTS_GLYPHS, CAP, SPACE_W } from "./dotsAlphabet";
import {
sampleGlyphDots, gridGlyphDots, GRID_CELL, GRID_ADVANCE,
type AlignMode, type Dot,
} from "./sampleDots";
const VB = 1000;
// typewriter idle motion
const TYPE_DELAY = 900; // ms before the first character
const TYPE_STEP = 160; // ms per character
const LOOP_HOLD = 1200; // ms the finished line holds before retyping (preview clips only)
const POP = 120; // ms for one character's dots to arrive
const ALIGN_MODES = ["free", "grid"] as const;
const r2 = (v: number) => Math.round(v * 100) / 100;
interface Composition {
name: string;
text: string;
bg: string;
dotColor: string;
uiColor: "light" | "dark";
/** multiplies the Dot size slider — lets one composition print coarser */
dotScale?: number;
/** starting align mode; the panel overrides it once touched */
align?: AlignMode;
}
const compositions: Composition[] = [
{ name: "Working", text: "WORKING\nON\nIT", bg: "#214c31", dotColor: "#f2efe6", uiColor: "light", align: "free" },
{ name: "Nemawashi", text: "NEMAWASHI", bg: "#efece6", dotColor: "#1f1d1a", uiColor: "dark" },
{ name: "Giragira", text: "GIRAGIRA\nLAB", bg: "#b83a2a", dotColor: "#f2e9d0", uiColor: "light" },
{ name: "2026", text: "2026", bg: "#e9e2d5", dotColor: "#111111", uiColor: "dark" },
{ name: "Hello", text: "HELLO\nWORLD", bg: "#cfd8c4", dotColor: "#1b1b1b", uiColor: "dark" },
{ name: "Coarse", text: "DOTS\n0123", bg: "#121212", dotColor: "#f0eeeb", uiColor: "light", dotScale: 1.4 },
];
// ---------------------------------------------------------------------------
// Layout — same cursor walk as Tube, but each glyph contributes dots instead
// of strokes, tagged with the index of the character they belong to so the
// typewriter can reveal them a character at a time. Sampling is memoised per
// pitch because every glyph is re-dotted whenever the pitch moves.
// ---------------------------------------------------------------------------
interface PlacedDot extends Dot {
/** index in the whole (uppercased) text, newlines included */
ci: number;
}
interface Layout {
dots: PlacedDot[];
charCount: number;
totalW: number;
totalH: number;
}
export function gridGapCells(tracking: number): number {
return Math.max(0, Math.min(2, Math.round(tracking / GRID_CELL)));
}
export function gridGapRows(lineGap: number): number {
return Math.max(1, Math.min(8, Math.round(lineGap / GRID_CELL)));
}
function layoutText(
text: string,
pitch: number,
tracking: number,
lineGap: number,
align: AlignMode,
): Layout {
const grid = align === "grid";
const cache = new Map<string, Dot[] | null>();
const dotsFor = (ch: string): Dot[] | null => {
if (!cache.has(ch)) {
cache.set(ch, grid
? gridGlyphDots(ch)
: (DOTS_GLYPHS[ch] ? sampleGlyphDots(DOTS_GLYPHS[ch], pitch) : null));
}
return cache.get(ch) ?? null;
};
const upper = text.toUpperCase();
const lines = upper.split("\n");
// grid: letter gap in whole cells, line gap in whole rows — one shared matrix
const trk = grid ? gridGapCells(tracking) * GRID_CELL : tracking;
const gap = grid ? gridGapRows(lineGap) * GRID_CELL : lineGap;
const totalH = lines.length * CAP + (lines.length - 1) * gap;
const snap = (v: number) => (grid ? Math.round(v / GRID_CELL) * GRID_CELL : v);
const advance = (ch: string): number => {
if (grid) return GRID_ADVANCE; // fixed 6 cells, blanks included
if (ch === " ") return SPACE_W;
const g = DOTS_GLYPHS[ch];
return g ? g.w : 0;
};
const lineWidths = lines.map((line) => {
let w = 0;
for (let i = 0; i < line.length; i++) {
w += advance(line[i]);
if (i < line.length - 1) w += trk;
}
return w;
});
const maxLineW = Math.max(...lineWidths, 1);
const dots: PlacedDot[] = [];
let ci = 0;
for (let li = 0; li < lines.length; li++) {
const line = lines[li];
const lineOx = snap((maxLineW - lineWidths[li]) / 2);
const lineOy = totalH - (li + 1) * CAP - li * gap;
let cursor = lineOx;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
const glyphDots = dotsFor(ch);
if (glyphDots) {
for (const d of glyphDots) dots.push({ x: d.x + cursor, y: d.y + lineOy, ci });
}
cursor += advance(ch);
if (i < line.length - 1) cursor += trk;
ci += 1;
}
if (li < lines.length - 1) ci += 1; // the newline itself draws nothing
}
return { dots, charCount: upper.length, totalW: maxLineW, totalH };
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function DotsCanvas() {
const searchParams = useSearchParams();
const initialIndex = Math.abs(Number(searchParams.get("v")) || 0) % compositions.length;
/* ?loop=1 is what the preview recorder asks for: the Finder tile is a 4.5 s
clip, so there the text retypes forever. The opened window types once. */
const loop = searchParams.get("loop") === "1";
const [index, setIndex] = useState(initialIndex);
const nextIndex = (index + 1) % compositions.length;
const handleClick = useCallback(() => {
setIndex(nextIndex);
window.history.replaceState(null, "", `/lab/dots?v=${nextIndex}`);
}, [nextIndex]);
const [vbH, setVbH] = useState(VB);
const stageRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = stageRef.current;
if (!el) return;
const update = () => {
const aspect = el.clientHeight / Math.max(1, el.clientWidth);
setVbH(Math.round(VB * aspect));
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const comp = compositions[index];
const isLight = comp.uiColor === "light";
const [pitch, setPitch] = useState(100);
const [dotRatio, setDotRatio] = useState(0.55);
const [tracking, setTracking] = useState(90);
const [lineGapRatio, setLineGapRatio] = useState(0.6);
const [alignChoice, setAlignChoice] = useState<AlignMode | null>(null);
const [customText, setCustomText] = useState("");
const [previewFamily, setPreviewFamily] = useState<string | null>(null);
const align = alignChoice ?? comp.align ?? "free";
const activeText = customText || comp.text;
const lineGap = CAP * lineGapRatio;
const activeRatio = dotRatio * (comp.dotScale ?? 1);
const effPitch = align === "grid" ? GRID_CELL : pitch;
const dotR = (effPitch * activeRatio) / 2;
const layout = useMemo(
() => layoutText(activeText, pitch, tracking, lineGap, align),
[activeText, pitch, tracking, lineGap, align],
);
/* Typewriter entrance. The server renders the whole text (shown === null)
so the article card is never blank and hydration matches; the text types
in once per composition after mount, then holds — no looping. The layout is always built from the FULL text, so
nothing recentres as characters arrive. */
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const [shown, setShown] = useState<number | null>(null);
useEffect(() => {
if (!mounted) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setShown(null);
return;
}
const total = layout.charCount;
let timer = 0;
const tick = (i: number) => {
if (i > total) {
if (!loop) {
setShown(null); // typed once; from here the text simply stays
return;
}
timer = window.setTimeout(() => {
setShown(0);
timer = window.setTimeout(() => tick(1), TYPE_STEP * 2);
}, LOOP_HOLD);
return;
}
setShown(i);
timer = window.setTimeout(() => tick(i + 1), TYPE_STEP);
};
setShown(0);
timer = window.setTimeout(() => tick(1), TYPE_DELAY);
return () => clearTimeout(timer);
}, [mounted, index, activeText, layout.charCount, loop]);
// the skeleton box plus one dot radius of bleed on every side, so the outer
// dots sit inside the frame rather than half off it
const margin = 40;
const boxW = layout.totalW + dotR * 2;
const boxH = layout.totalH + dotR * 2;
const scale = Math.min((VB - 2 * margin) / boxW, (vbH - 2 * margin) / boxH);
const svgOx = (VB - boxW * scale) / 2 + dotR * scale;
const svgOy = (vbH - boxH * scale) / 2 + dotR * scale;
// one <g> per character, so a character's dots arrive together
const groups = useMemo(() => {
const r = r2(dotR * scale);
const byChar = new Map<number, { cx: number; cy: number }[]>();
for (const d of layout.dots) {
let list = byChar.get(d.ci);
if (!list) { list = []; byChar.set(d.ci, list); }
list.push({
cx: r2(d.x * scale + svgOx),
cy: r2((layout.totalH - d.y) * scale + svgOy),
});
}
return [...byChar.entries()]
.sort((a, b) => a[0] - b[0])
.map(([ci, circles]) => ({ ci, circles, r }));
}, [layout, scale, svgOx, svgOy, dotR]);
return (
<div
ref={stageRef}
className="fixed inset-0 select-none transition-colors duration-700 cursor-pointer"
style={{ backgroundColor: comp.bg }}
onClick={handleClick}
>
<svg
className="absolute inset-0 w-full h-full z-[1]"
viewBox={`0 0 ${VB} ${vbH}`}
preserveAspectRatio="xMidYMid meet"
overflow="hidden"
>
{groups.map(({ ci, circles, r }) => {
const on = shown === null || ci < shown;
return (
<g
key={ci}
style={{
opacity: on ? 1 : 0,
transform: on ? "scale(1)" : "scale(0.4)",
transformOrigin: "center",
transformBox: "fill-box",
transition: `opacity ${POP}ms ease-out, transform ${POP}ms cubic-bezier(.2,.8,.3,1)`,
}}
>
{circles.map((c, i) => (
<circle key={i} cx={c.cx} cy={c.cy} r={r} fill={comp.dotColor} />
))}
</g>
);
})}
</svg>
{/* Composition 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>
<DraggablePanel
isLight={isLight}
controls={
/* the grid has a fixed cell (CAP / 7 rows), so Pitch is meaningless
there and is dropped; the other two slide in whole cells and rows
off the same state the free sliders use */
align === "grid"
? [
{ label: "Dot size", value: dotRatio, set: setDotRatio, min: 0.25, max: 0.9, step: 0.05 },
{ label: "Letter gap", value: gridGapCells(tracking), set: (v: number) => setTracking(v * GRID_CELL), min: 0, max: 2, step: 1 },
{ label: "Line rows", value: gridGapRows(lineGap), set: (v: number) => setLineGapRatio((v * GRID_CELL) / CAP), min: 1, max: 8, step: 1 },
]
: [
{ label: "Pitch", value: pitch, set: setPitch, min: 60, max: 160, step: 5 },
{ label: "Dot size", value: dotRatio, set: setDotRatio, min: 0.25, max: 0.9, step: 0.05 },
{ label: "Tracking", value: tracking, set: setTracking, min: 0, max: 200, step: 5 },
{ label: "Line gap", value: lineGapRatio, set: setLineGapRatio, min: 0.2, max: 1.2, step: 0.05 },
]
}
>
<PanelSelect
label="Align"
value={align}
options={ALIGN_MODES}
set={setAlignChoice}
isLight={isLight}
/>
<PanelTextArea
label="Preview text"
value={customText}
set={setCustomText}
placeholder={comp.text}
isLight={isLight}
/>
<button
onClick={() => setPreviewFamily(exportDotsFont(pitch, activeRatio, tracking, align, gridGapCells(tracking)))}
style={{
width: "100%", padding: "8px 0", marginTop: 4, 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>
</DraggablePanel>
<div
className="fixed bottom-8 right-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"
style={{ color: isLight ? "rgba(255,255,255,0.3)" : "rgba(23,23,23,0.3)" }}
>
Click anywhere
</p>
</div>
<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: "clamp(14px, 2.3vw, 32px)",
color: isLight ? "#fff" : "#1a1a2e",
letterSpacing: "0.05em",
margin: 0,
overflowWrap: "anywhere",
}}>
ABCDEFGHIJKLM NOPQRSTUVWXYZ 0123456789
</p>
</div>
)}
</div>
);
}
import { THIN_FONT, THIN_GLYPH_W, THIN_GLYPH_H } from "../charbits/thinfont";
import { CAP, type Stroke, type TubeGlyph } from "./dotsAlphabet";
export type AlignMode = "free" | "grid";
export interface Dot {
x: number;
y: number;
}
/* Coordinates are rounded to 2 dp so the trig below cannot produce a different
last bit on the server and in the browser (hydration mismatch). */
const r2 = (v: number) => Math.round(v * 100) / 100;
function strokeLength(s: Stroke): number {
if (s.t === "L") {
const dx = s.x2 - s.x1;
const dy = s.y2 - s.y1;
return Math.sqrt(dx * dx + dy * dy);
}
return (Math.abs(s.sweep) * Math.PI) / 180 * s.r;
}
/* t runs on arc length for both stroke kinds: a line is linear in t, an arc
steps its angle uniformly, so an even t-step is an even dot step. */
function pointAt(s: Stroke, t: number): Dot {
if (s.t === "L") {
return { x: s.x1 + (s.x2 - s.x1) * t, y: s.y1 + (s.y2 - s.y1) * t };
}
const a = ((s.start + s.sweep * t) * Math.PI) / 180;
return { x: s.cx + s.r * Math.cos(a), y: s.cy + s.r * Math.sin(a) };
}
// ---------------------------------------------------------------------------
// Free mode — junction-aware sampling
// ---------------------------------------------------------------------------
interface Proj {
t: number;
x: number;
y: number;
d: number;
}
/** Closest point on a stroke to (px, py), or null if it falls off the ends. */
function projectOnStroke(s: Stroke, px: number, py: number): Proj | null {
if (s.t === "L") {
const dx = s.x2 - s.x1;
const dy = s.y2 - s.y1;
const l2 = dx * dx + dy * dy;
if (l2 < 1e-9) return null;
const t = ((px - s.x1) * dx + (py - s.y1) * dy) / l2;
if (t < 0 || t > 1) return null;
const x = s.x1 + dx * t;
const y = s.y1 + dy * t;
return { t, x, y, d: Math.hypot(px - x, py - y) };
}
if (Math.abs(s.sweep) < 1e-9) return null;
const ang = (Math.atan2(py - s.cy, px - s.cx) * 180) / Math.PI;
let delta = (((ang - s.start) % 360) + 360) % 360; // [0, 360)
if (s.sweep < 0) delta -= 360; // (-360, 0]
const t = delta / s.sweep;
if (t < 0 || t > 1) return null;
const p = pointAt(s, t);
return { t, x: p.x, y: p.y, d: Math.hypot(px - p.x, py - p.y) };
}
interface Seg {
s: Stroke;
/** t params where another stroke lands, forcing a dot and an even re-fit */
cuts: number[];
}
/* Split a stroke at its junction params and fit each sub-segment's spacing on
its own, so the junction always gets a dot and the runs either side stay
even instead of straddling it. */
function segDots(seg: Seg, pitch: number): Dot[] {
const total = strokeLength(seg.s);
const ts = [0, ...seg.cuts, 1].sort((a, b) => a - b);
const out: Dot[] = [];
for (let k = 0; k < ts.length - 1; k++) {
const a = ts[k];
const b = ts[k + 1];
const len = total * (b - a);
if (len <= 0) continue;
const n = Math.max(1, Math.round(len / pitch));
for (let i = 0; i <= n; i++) out.push(pointAt(seg.s, a + ((b - a) * i) / n));
}
if (!out.length) out.push(pointAt(seg.s, 0)); // zero-length stroke (the full stop)
return out;
}
function setEnd(seg: Seg, end: 0 | 1, x: number, y: number) {
// arcs keep their endpoints: moving one would change the radius or the sweep
if (seg.s.t !== "L") return;
if (end === 0) { seg.s.x1 = x; seg.s.y1 = y; }
else { seg.s.x2 = x; seg.s.y2 = y; }
}
function freeDots(glyph: TubeGlyph, pitch: number): Dot[] {
const segs: Seg[] = glyph.strokes.map((s) => ({ s: { ...s }, cuts: [] }));
// Strokes are taken in order; each one docks onto what is already drawn.
for (let i = 0; i < segs.length; i++) {
for (const end of [0, 1] as const) {
const p = pointAt(segs[i].s, end);
// 1. Landing ON an earlier stroke, between its dots: cut that stroke
// there so it re-fits around the junction rather than straddling it.
let docked = false;
for (let j = 0; j < i && !docked; j++) {
const pr = projectOnStroke(segs[j].s, p.x, p.y);
if (!pr || pr.d > pitch * 0.25) continue;
const len = strokeLength(segs[j].s);
// too near an end to be a real junction — that end is already a dot
if (len * pr.t < pitch * 0.25 || len * (1 - pr.t) < pitch * 0.25) continue;
if (!segs[j].cuts.some((c) => Math.abs(c - pr.t) * len < 0.01)) {
segs[j].cuts.push(pr.t);
}
setEnd(segs[i], end, pr.x, pr.y);
docked = true;
}
if (docked) continue;
// 2. Otherwise, if it merely lands near an existing dot, sit on it.
let best: Dot | null = null;
let bestD = pitch * 0.75;
for (let j = 0; j < i; j++) {
for (const d of segDots(segs[j], pitch)) {
const dist = Math.hypot(d.x - p.x, d.y - p.y);
if (dist < bestD) { bestD = dist; best = d; }
}
}
if (best) setEnd(segs[i], end, best.x, best.y);
}
}
const raw: Dot[] = [];
for (const seg of segs) raw.push(...segDots(seg, pitch));
// Safety net for dots that coincide outright (shared junctions, the tangency
// of 8's two circles): anything inside half a pitch collapses to the mean.
const tol2 = (pitch * 0.5) ** 2;
const clusters: { sx: number; sy: number; n: number; x: number; y: number }[] = [];
for (const d of raw) {
let hit: (typeof clusters)[number] | undefined;
for (const c of clusters) {
const dx = c.x - d.x;
const dy = c.y - d.y;
if (dx * dx + dy * dy < tol2) { hit = c; break; }
}
if (hit) {
hit.sx += d.x; hit.sy += d.y; hit.n += 1;
hit.x = hit.sx / hit.n; hit.y = hit.sy / hit.n;
} else {
clusters.push({ sx: d.x, sy: d.y, n: 1, x: d.x, y: d.y });
}
}
return clusters.map((c) => ({ x: r2(c.x), y: r2(c.y) }));
}
// ---------------------------------------------------------------------------
// Grid mode — the shared hand-drawn dot matrix
// ---------------------------------------------------------------------------
/* Sampling curves onto a coarse lattice turns O into a broken octagon, so grid
mode does not sample anything: it spells with the same hand-drawn 6x8
single-stroke bitmap alphabet Char Bitmap uses, one dot per lit cell. Rows
are lattice lines, not cells — 8 rows across 7 gaps puts row 0 on the cap
line and row 7 on the baseline. */
export const GRID_CELL = CAP / (THIN_GLYPH_H - 1);
/** fixed 6-cell advance — trimming the blank columns would lose the matrix */
export const GRID_ADVANCE = THIN_GLYPH_W * GRID_CELL;
/** Dots for one character, or null when the bitmap alphabet has no such glyph. */
export function gridGlyphDots(ch: string): Dot[] | null {
const rows = THIN_FONT[ch];
if (!rows) return null;
const out: Dot[] = [];
for (let r = 0; r < rows.length; r++) {
for (let c = 0; c < rows[r].length; c++) {
if (rows[r][c] !== "#") continue;
// half a cell of x offset centres the 6 columns inside the 6-cell advance
out.push({ x: (c + 0.5) * GRID_CELL, y: (THIN_GLYPH_H - 1 - r) * GRID_CELL });
}
}
return out;
}
// ---------------------------------------------------------------------------
/**
* Lay a row of dots along every stroke of a glyph (free alignment).
*
* The pitch is fitted per stroke — and per sub-segment between junctions —
* with n + 1 dots including both endpoints, so a stroke always begins, ends
* and crosses on a dot, and the spacing stays even either side of a junction.
*
* Returns glyph-local coordinates — y up, cap height at CAP, like Tube.
* Grid alignment does not come through here; see gridGlyphDots.
*/
export function sampleGlyphDots(glyph: TubeGlyph, pitch: number): Dot[] {
return freeDots(glyph, Math.max(1, pitch));
}
import { TUBE_GLYPHS, CAP, SPACE_W, type Stroke, type TubeGlyph } from "../tube/tubeAlphabet";
export { CAP, SPACE_W };
export type { Stroke, TubeGlyph };
const L = (x1: number, y1: number, x2: number, y2: number): Stroke => ({
t: "L", x1, y1, x2, y2,
});
const A = (cx: number, cy: number, r: number, start: number, sweep: number): Stroke => ({
t: "A", cx, cy, r, start, sweep,
});
/* Digits in the same monoline L/A vocabulary as Tube's caps: geometric,
Futura-ish, every stroke ending where the next one begins so the dot sampler
can collapse the shared junction. Round forms are built from circles (no
ellipses exist in the vocabulary), so 0 is a stadium — which also keeps it
apart from the perfect circle of O. `wm` is never set: there is no pen
contrast in a dotted face. */
const DIGITS: Record<string, TubeGlyph> = {
// stadium: two semicircles joined by straight flanks
"0": { w: 480, strokes: [
A(240, 500, 200, 0, 180),
A(240, 200, 200, 180, 180),
L(40, 200, 40, 500),
L(440, 200, 440, 500),
] },
// stem plus a short rising flag
"1": { w: 300, strokes: [
L(230, 0, 230, 700),
L(50, 540, 230, 700),
] },
// 230 deg shoulder, straight diagonal down to the base rule
"2": { w: 480, strokes: [
A(240, 480, 210, 180, -230),
L(374.99, 319.13, 40, 0),
L(40, 0, 450, 0),
] },
// two bowls meeting at the waist
"3": { w: 480, strokes: [
A(240, 525, 175, 160, -250),
A(240, 175, 175, 90, -250),
] },
// open four: apex, diagonal into the bar's left end, bar
"4": { w: 480, strokes: [
L(340, 0, 340, 700),
L(340, 700, 40, 200),
L(40, 200, 440, 200),
] },
// bar, stem, waist, bowl
"5": { w: 480, strokes: [
L(60, 700, 430, 700),
L(60, 700, 60, 390),
L(60, 390, 230, 390),
A(230, 195, 195, 90, -250),
] },
// closed bowl plus a wide spine tangent to it at the left
"6": { w: 480, strokes: [
A(240, 195, 195, 180, 360),
A(550, 195, 505, 180, -75),
] },
// bar and diagonal
"7": { w: 480, strokes: [
L(40, 700, 440, 700),
L(440, 700, 140, 0),
] },
// small circle over a larger one, tangent at the waist
"8": { w: 480, strokes: [
A(240, 540, 160, -90, 360),
A(240, 190, 190, 90, 360),
] },
// 6 turned through 180 degrees
"9": { w: 480, strokes: [
A(240, 505, 195, 0, 360),
A(-70, 505, 505, 0, -75),
] },
};
const MARKS: Record<string, TubeGlyph> = {
".": { w: 120, strokes: [L(60, 0, 60, 0)] },
",": { w: 120, strokes: [L(70, 140, 20, 0)] },
"-": { w: 280, strokes: [L(40, 350, 260, 350)] },
};
export const DOTS_GLYPHS: Record<string, TubeGlyph> = {
...TUBE_GLYPHS,
...DIGITS,
...MARKS,
};
/* PostScript names for the non-letter glyphs — a name may not start with a
digit, and the marks want their conventional names in the post table. */
export const DOTS_GLYPH_NAMES: Record<string, string> = {
"0": "zero", "1": "one", "2": "two", "3": "three", "4": "four",
"5": "five", "6": "six", "7": "seven", "8": "eight", "9": "nine",
".": "period", ",": "comma", "-": "hyphen",
"!": "exclam", "?": "question",
};
import { Glyph, Path } from "opentype.js";
import { ASCENDER, createFont, downloadFont, previewFont, circleToPath, normalizeWinding } from "../fontExport";
import { THIN_FONT } from "../charbits/thinfont";
import { DOTS_GLYPHS, DOTS_GLYPH_NAMES, CAP, SPACE_W } from "./dotsAlphabet";
import {
sampleGlyphDots, gridGlyphDots, GRID_CELL, GRID_ADVANCE,
type AlignMode, type Dot,
} from "./sampleDots";
const SCALE = ASCENDER / CAP;
/* Every glyph is just its dots as filled circle contours — no stroke
expansion, no joins to repair. normalizeWinding still runs so that a dot
overlapping its neighbour (dot size pushed past the pitch) accumulates under
the nonzero rule instead of cancelling into a hole. */
export default function exportDotsFont(
pitch: number,
dotRatio: number,
tracking: number,
align: AlignMode = "free",
gapCells = 1,
): string {
const otGlyphs: Glyph[] = [];
const grid = align === "grid";
const cell = grid ? GRID_CELL : pitch;
const radius = (cell * dotRatio) / 2 * SCALE;
// grid: the fixed 6-cell advance plus whole gap cells keeps a set line on one
// matrix, exactly as the canvas lays it out
const gridAdv = (GRID_ADVANCE + gapCells * GRID_CELL) * SCALE;
const addGlyph = (char: string, dots: Dot[], advanceWidth: number) => {
const path = new Path();
for (const d of dots) circleToPath(path, d.x * SCALE, d.y * SCALE, radius);
normalizeWinding(path);
otGlyphs.push(new Glyph({
name: DOTS_GLYPH_NAMES[char] ?? char,
unicode: char.charCodeAt(0),
advanceWidth,
path,
}));
};
if (grid) {
// grid mode spells with the shared 6x8 bitmap alphabet, so that is the
// character set the font carries
for (const char of Object.keys(THIN_FONT)) {
if (char === " ") continue;
addGlyph(char, gridGlyphDots(char) ?? [], gridAdv);
}
} else {
for (const [char, glyph] of Object.entries(DOTS_GLYPHS)) {
addGlyph(char, sampleGlyphDots(glyph, pitch), glyph.w * SCALE + tracking * SCALE);
}
}
otGlyphs.push(new Glyph({
name: "space",
unicode: 32,
advanceWidth: grid ? gridAdv : SPACE_W * SCALE,
path: new Path(),
}));
const d = Math.round(dotRatio * 100);
const filename = grid
? `GRGR_Dots_grid_g${gapCells}_d${d}.otf`
: `GRGR_Dots_free_p${Math.round(pitch)}_d${d}.otf`;
const font = createFont("GRGR Dots", otGlyphs);
downloadFont(font, filename);
return previewFont(font, "GRGR-Dots-Preview");
}