A box-drawing flowchart whose connectors are a real orthogonal router: dragging a node re-routes every edge touching it live, cell by cell, recomputing junction glyphs (┼ ├ ┤ ┬ ┴) from scratch as paths cross.
npx shadcn add https://design.helpmarq.com /r/diagram-ascii-flow.jsonregistry/core/diagram-ascii-flow/component.tsx"use client";
import { useMemo, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// AsciiFlowDiagram — a box-drawing flowchart where dragging a node re-routes
// every connector touching it LIVE, cell by cell, on a monospace glyph grid.
// Unlike tree-box-drawing (a fixed hierarchy whose connectors only change on
// expand/collapse) or container-box-drawing (a single static frame), the
// graph here has free node positions and an actual orthogonal router: each
// edge is traced as a Manhattan path (a single-bend Z, horizontal or
// vertical depending on whether the two nodes overlap in column or row) onto
// a shared occupancy grid, and every cell's glyph — a plain rule, a corner,
// or a junction (┼ ├ ┤ ┬ ┴) — is resolved from which of its four neighbor
// directions are actually in use THAT frame, recomputed from scratch on
// every node move. Clicking (not dragging) a node selects it and reports its
// live connection count in a small readout below the canvas; arrow keys move
// the currently-focused node by one grid cell, so re-routing is reachable
// without a pointer at all. Pure DOM text + CSS, zero dependencies.
// ---------------------------------------------------------------------------
export interface FlowNode {
id: string;
label: string;
}
export interface FlowEdge {
from: string;
to: string;
}
export interface AsciiFlowDiagramProps {
nodes?: FlowNode[];
edges?: FlowEdge[];
className?: string;
}
const COLS = 30;
const ROWS = 13;
const CELL_W = 12;
const CELL_H = 17;
const NODE_W = 7;
const NODE_H = 3;
const COL_MIN = 1;
const COL_MAX = COLS - NODE_W - 2;
const ROW_MIN = 1;
const ROW_MAX = ROWS - NODE_H - 2;
const DEFAULT_NODES: FlowNode[] = [
{ id: "ingest", label: "Ingest" },
{ id: "parse", label: "Parse" },
{ id: "route", label: "Route" },
{ id: "cache", label: "Cache" },
{ id: "merge", label: "Merge" },
{ id: "emit", label: "Emit" },
];
// A layered left-to-right DAG: every edge either connects the two stacked
// nodes within one column, or crosses to the immediately adjacent column at
// the SAME row — never a diagonal that skips a column and would otherwise
// cut straight through an unrelated node's box.
const DEFAULT_EDGES: FlowEdge[] = [
{ from: "ingest", to: "parse" },
{ from: "route", to: "cache" },
{ from: "merge", to: "emit" },
{ from: "ingest", to: "route" },
{ from: "parse", to: "cache" },
{ from: "route", to: "merge" },
{ from: "cache", to: "emit" },
];
const INITIAL_POS: Record<string, { col: number; row: number }> = {
ingest: { col: 1, row: 1 },
parse: { col: 1, row: 7 },
route: { col: 11, row: 1 },
cache: { col: 11, row: 7 },
merge: { col: 21, row: 1 },
emit: { col: 21, row: 7 },
};
// Direction bits: North / East / South / West.
const N = 1;
const E = 2;
const S = 4;
const W = 8;
const GLYPH: Record<number, string> = {
0: " ",
1: "│",
2: "─",
4: "│",
8: "─",
5: "│", // N+S
10: "─", // E+W
3: "└", // N+E
9: "┘", // N+W
6: "┌", // S+E
12: "┐", // S+W
7: "├", // N+E+S
13: "┤", // N+W+S
14: "┬", // E+W+S
11: "┴", // E+W+N
15: "┼", // all four
};
function dirBit(dx: number, dy: number): number {
if (dx === 1) return E;
if (dx === -1) return W;
if (dy === 1) return S;
if (dy === -1) return N;
return 0;
}
const OPPOSITE: Record<number, number> = { [N]: S, [S]: N, [E]: W, [W]: E };
function tracePolyline(points: [number, number][], grid: Map<string, number>) {
for (let s = 0; s < points.length - 1; s++) {
let [x, y] = points[s];
const [ex, ey] = points[s + 1];
const dx = Math.sign(ex - x);
const dy = Math.sign(ey - y);
if (dx !== 0 && dy !== 0) continue; // segments are always axis-aligned
while (x !== ex || y !== ey) {
const nx = x + dx;
const ny = y + dy;
const out = dirBit(dx, dy);
const into = OPPOSITE[out];
const key1 = `${x},${y}`;
const key2 = `${nx},${ny}`;
grid.set(key1, (grid.get(key1) ?? 0) | out);
grid.set(key2, (grid.get(key2) ?? 0) | into);
x = nx;
y = ny;
}
}
}
type Pos = { col: number; row: number };
function edgePath(src: Pos, dst: Pos): [number, number][] {
const colsOverlap = !(dst.col + NODE_W <= src.col || src.col + NODE_W <= dst.col);
if (!colsOverlap) {
// side by side: route from right/left mid-edge, single-bend Z on X
const srcRight = dst.col > src.col;
const ex = srcRight ? src.col + NODE_W : src.col - 1;
const ey = src.row + 1;
const tx = srcRight ? dst.col - 1 : dst.col + NODE_W;
const ty = dst.row + 1;
if (ey === ty) return [[ex, ey], [tx, ty]];
const midX = Math.round((ex + tx) / 2);
return [
[ex, ey],
[midX, ey],
[midX, ty],
[tx, ty],
];
}
// stacked: route from bottom/top mid-edge, single-bend Z on Y
const srcBelow = dst.row > src.row;
const midCol = Math.floor(NODE_W / 2);
const ex = src.col + midCol;
const ey = srcBelow ? src.row + NODE_H : src.row - 1;
const tx = dst.col + midCol;
const ty = srcBelow ? dst.row - 1 : dst.row + NODE_H;
if (ex === tx) return [[ex, ey], [tx, ty]];
const midY = Math.round((ey + ty) / 2);
return [
[ex, ey],
[ex, midY],
[tx, midY],
[tx, ty],
];
}
function rectsOverlap(a: Pos, b: Pos, buffer: number): boolean {
return !(
a.col + NODE_W + buffer <= b.col ||
b.col + NODE_W + buffer <= a.col ||
a.row + NODE_H + buffer <= b.row ||
b.row + NODE_H + buffer <= a.row
);
}
// This component used to color every node via JS: read --foreground/
// --background/etc with getComputedStyle at mount, hold them in state, patch
// a MutationObserver on <html> to react to theme flips, and apply the result
// as raw hex through inline `style`. That's a real bug, not just an unusual
// choice: SSR always runs with no `document`, so the FIRST server-rendered
// markup is baked with the light-theme fallback hex; React's hydration does
// not force every mismatched inline-style property to the client's value the
// way it does for text content, so on a genuinely dark-themed load the
// node's `background` inline style silently stayed the SSR-fallback white
// forever — confirmed live: the component's own render logged the correct
// dark hex on every pass while the DOM's computed background-color stayed
// white indefinitely, because that particular value never CHANGED between
// renders (React only patches a DOM attribute when the new render's value
// differs from the previous render's, not from whatever's actually painted).
// Tailwind classes bound to the same custom properties sidestep the whole
// class of bug: the cascade resolves --background per theme at PAINT time,
// with no JS/hydration step in the loop at all.
export function AsciiFlowDiagram({
nodes = DEFAULT_NODES,
edges = DEFAULT_EDGES,
className = "",
}: AsciiFlowDiagramProps) {
const [positions, setPositions] = useState<Record<string, Pos>>(() => {
const init: Record<string, Pos> = {};
nodes.forEach((n) => {
init[n.id] = INITIAL_POS[n.id] ?? { col: 1, row: 1 };
});
return init;
});
const [selectedId, setSelectedId] = useState<string | null>(null);
const [hoverId, setHoverId] = useState<string | null>(null);
const dragState = useRef<{
id: string;
startClientX: number;
startClientY: number;
startCol: number;
startRow: number;
moved: boolean;
} | null>(null);
const suppressClickRef = useRef(false);
const grid = useMemo(() => {
const g = new Map<string, number>();
for (const edge of edges) {
const src = positions[edge.from];
const dst = positions[edge.to];
if (!src || !dst) continue;
tracePolyline(edgePath(src, dst), g);
}
return g;
}, [positions, edges]);
const rows = useMemo(() => {
const out: string[] = [];
for (let y = 0; y < ROWS; y++) {
let line = "";
for (let x = 0; x < COLS; x++) {
const mask = grid.get(`${x},${y}`) ?? 0;
line += GLYPH[mask] ?? " ";
}
out.push(line);
}
return out;
}, [grid]);
const connectionsFor = (id: string) => edges.filter((e) => e.from === id || e.to === id).length;
const clampPos = (col: number, row: number, id: string): Pos => {
const nc = Math.max(COL_MIN, Math.min(COL_MAX, col));
const nr = Math.max(ROW_MIN, Math.min(ROW_MAX, row));
const candidate = { col: nc, row: nr };
for (const other of nodes) {
if (other.id === id) continue;
const op = positions[other.id];
if (op && rectsOverlap(candidate, op, 1)) {
return positions[id];
}
}
return candidate;
};
const onPointerDown = (id: string) => (e: React.PointerEvent<HTMLButtonElement>) => {
if (e.button !== 0) return;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragState.current = {
id,
startClientX: e.clientX,
startClientY: e.clientY,
startCol: positions[id].col,
startRow: positions[id].row,
moved: false,
};
};
const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
const drag = dragState.current;
if (!drag) return;
const dCol = Math.round((e.clientX - drag.startClientX) / CELL_W);
const dRow = Math.round((e.clientY - drag.startClientY) / CELL_H);
if (dCol === 0 && dRow === 0) return;
drag.moved = true;
const next = clampPos(drag.startCol + dCol, drag.startRow + dRow, drag.id);
setPositions((p) => (p[drag.id].col === next.col && p[drag.id].row === next.row ? p : { ...p, [drag.id]: next }));
};
const endDrag = () => {
suppressClickRef.current = dragState.current?.moved ?? false;
dragState.current = null;
};
const nudge = (id: string, dCol: number, dRow: number) => {
const cur = positions[id];
const next = clampPos(cur.col + dCol, cur.row + dRow, id);
setPositions((p) => ({ ...p, [id]: next }));
};
return (
<div className={`ns-daf font-mono ${className}`}>
<style>{CSS}</style>
<div
className="ns-daf-canvas relative select-none"
style={{ width: COLS * CELL_W, height: ROWS * CELL_H, maxWidth: "100%" }}
onPointerDown={(e) => {
if (e.target === e.currentTarget) setSelectedId(null);
}}
>
{/* Each character is its own fixed-width span rather than one flowing
text string per row: a browser's real monospace glyph advance
almost never equals CELL_W exactly (measured ~13.75px at this
font-size vs the assumed 12px), and the connector grid must land
on the SAME px grid the absolutely-positioned node buttons use —
a flowing string drifts column alignment cumulatively across the
row until connectors miss the node edges they're meant to touch. */}
<div aria-hidden className="pointer-events-none absolute inset-0 text-muted" style={{ fontSize: 11 }}>
{rows.map((line, y) => (
<div key={y} style={{ height: CELL_H, lineHeight: `${CELL_H}px`, whiteSpace: "nowrap" }}>
{line.split("").map((ch, x) => (
<span key={x} style={{ display: "inline-block", width: CELL_W, textAlign: "center" }}>
{ch}
</span>
))}
</div>
))}
</div>
{nodes.map((n) => {
const pos = positions[n.id];
const selected = selectedId === n.id;
const hovered = hoverId === n.id;
return (
<button
key={n.id}
type="button"
data-diagram-node={n.id}
className={`ns-daf-node absolute flex items-center justify-center border bg-background text-[11px] transition-colors duration-150 motion-reduce:transition-none ${
selected
? "border-accent text-foreground"
: hovered
? "border-accent/40 text-foreground"
: "border-border text-muted"
}`}
style={{
left: pos.col * CELL_W,
top: pos.row * CELL_H,
width: NODE_W * CELL_W,
height: NODE_H * CELL_H,
cursor: "grab",
}}
aria-label={`${n.label} node, ${connectionsFor(n.id)} connection${connectionsFor(n.id) === 1 ? "" : "s"}. Press Enter to select, arrow keys to move.`}
aria-pressed={selected}
onPointerEnter={() => setHoverId(n.id)}
onPointerLeave={() => setHoverId((c) => (c === n.id ? null : c))}
onPointerDown={onPointerDown(n.id)}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={() => {
dragState.current = null;
}}
onClick={() => {
// pointerup always fires a trailing click on the captured
// element; a real drag (moved) suppresses it here so
// dragging a node never also toggles its selection. Keyboard
// activation (Enter/Space) never touches pointer handlers at
// all, so it always reaches this branch and selects.
// Deliberately not a toggle: clicking an already-selected
// node re-selecting itself is a no-op state-wise, never a
// deselect — deselect is Escape or clicking empty canvas
// only, so a repeated click on the same node (e.g. a second
// automated press) can't silently clear the readout.
if (suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
setSelectedId(n.id);
}}
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
e.preventDefault();
nudge(n.id, 0, -1);
} else if (e.key === "ArrowDown") {
e.preventDefault();
nudge(n.id, 0, 1);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
nudge(n.id, -1, 0);
} else if (e.key === "ArrowRight") {
e.preventDefault();
nudge(n.id, 1, 0);
} else if (e.key === "Escape") {
setSelectedId(null);
}
}}
>
{n.label}
</button>
);
})}
</div>
<div className="mt-3 min-h-[1.5em] font-mono text-xs text-muted">
{selectedId ? (
<span data-diagram-selection>
Selected: <strong className="text-foreground">{nodes.find((n) => n.id === selectedId)?.label}</strong> —{" "}
{connectionsFor(selectedId)} connection{connectionsFor(selectedId) === 1 ? "" : "s"}
</span>
) : (
<span>Click or select a node to inspect its connections.</span>
)}
</div>
</div>
);
}
const CSS = `
.ns-daf-node:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
`;
A box-drawing flowchart whose connectors are a real orthogonal router: dragging a node re-routes every edge touching it live, cell by cell, recomputing junction glyphs (┼ ├ ┤ ┬ ┴) from scratch as paths cross. Clicking a node selects it and reports its live connection count; arrow keys move the focused node without a pointer.
a small process/data-flow graph where the reader benefits from actually moving nodes around and seeing the routing react — pick tree-box-drawing instead for a strict hierarchy whose connectors only change on expand/collapse, or network-packet-trace for a FIXED topology whose interest is traffic pulses rather than user-editable layout.
Build a flowchart from `nodes` (FlowNode[] — `{id, label}`) and `edges` (FlowEdge[] — `{from, to}` referencing node ids), laid out on a fixed 26x13 monospace character grid. Every node keeps a live `{col, row}` position in React state (seeded from a small default layout for the demo's 6-node/7-edge dataset) and is rendered as an 8x3-cell bordered box (`border-border` at rest) with a real `<button data-diagram-node={id}>` overlay covering it — never a div with a click handler. THE ROUTER (the actual mechanic): for every edge, `edgePath(src, dst)` decides orientation by whether the two node rectangles overlap in column range — if they don't, it's a side-by-side route exiting the source's right or left mid-edge and entering the destination's opposite side; if they do (one stacked over the other), it's a top/bottom route off the vertical mid-edge instead. Either way the path is a single-bend Manhattan Z (straight through if the exit and entry already share a row/column) built as an explicit polyline of grid points. `tracePolyline` walks every edge's polyline one grid cell at a time and OR-accumulates a 4-bit direction mask (N/E/S/W) per cell into one shared `Map<'x,y', mask>` covering ALL edges at once — so where two edges' paths cross or merge, that cell's mask naturally picks up bits from both. A 16-entry lookup table turns every possible mask into its box-drawing character: pure straight runs (`│`/`─`), the four corners (`┌┐└┘`), the three-way junctions (`├┤┬┴`), and the four-way crossing (`┼`) — recomputed as one `useMemo` over ALL node positions, so any node move re-derives the entire glyph grid from a blank map rather than patching stale cells. Grid glyphs render as `aria-hidden` monospace text rows UNDER the node boxes (edges are routed to stop one gap-cell short of every node's border, so they never draw through a node's interior). DRAG: pointerdown on a node's button captures the pointer and records the start client position plus the node's start cell; pointermove converts client-pixel delta into a whole-cell delta (`round(dx/CELL_W)`, `round(dy/CELL_H)`) and, if the resulting cell is inside grid bounds and does not overlap another node's rectangle (a 1-cell buffer, simple AABB check — colliding moves are silently rejected, keeping the last valid position), commits the new `{col, row}`, which re-triggers the router memo so every touched connector visibly re-routes on the same frame — this live re-route during the drag, not a static diagram, is the entire point of the component. SELECT: a pointerup that never moved the node (or a keyboard Enter/Space, which never touches the pointer handlers) fires the node's `onClick`, which toggles a `selectedId` and renders a `data-diagram-selection` readout below the canvas naming the node and its live edge count (`edges.filter(e => e.from === id || e.to === id).length`); a genuine drag sets a ref flag that suppresses the trailing synthetic click browsers fire after a captured pointerup, so dragging a node never also toggles its selection. Clicking empty canvas space, or Escape, clears the selection. KEYBOARD: every node button is a real, always-tabbable, individually-labeled control (`aria-label` states the node name, its connection count, and both available actions) — arrow keys nudge the focused node by exactly one grid cell through the same clamp+collision path the pointer drag uses, so keyboard users reach the identical re-routing mechanic with no pointer at all. Selected node's border and label switch to `--accent`; hover alone (unselected) brightens border toward `--accent` at 40% mix and label to `--foreground` — visibly distinct from both rest and selection. Tokens only (`--background --foreground --muted --border --accent`, read via `getComputedStyle` on the document root, re-read on a `MutationObserver` watching its class attribute) — no hardcoded hex, correct in both themes. No rAF loop: every recompute is a direct response to a pointer or keyboard event, so there is nothing to gate behind `prefers-reduced-motion` beyond the interaction itself, which stays fully available. Pure DOM + CSS, zero dependencies.