Arc Diagram
A four-state arc diagram in plain SVG — nodes on one sequence-preserving baseline, half-ellipse arcs sized by weight, back-edges below the axis, hover to raise a node's arcs, and a keyboard walk over an sr-only node and arc table.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-arc-diagram.jsonPrompt
Build a React + TypeScript + Tailwind "ChartArcDiagram" card in plain SVG (no
chart library) with zod for the contract and lucide-react for the state icons.
Contract
- One zod schema is the source of truth, and the component's props are
z.infer of it plus the render levers — never a parallel interface:
{ status: "loading" | "empty" | "error" | "ready";
title: string;
unit?: string;
sequenceLabel?: string; // what left-to-right means
relation?: "directed" | "undirected";
nodes: { id: string; label: string; group?: string }[];
links: { source: string; target: string; value: number >= 0 }[] }
- THE ORDER OF `nodes` IS DATA. It is the sequence the baseline draws — call
order, timeline position, chapter number — and it is the one thing this
chart has that a chord diagram does not. Links reference nodes by id, never
by array index: index links are unreadable in a diff and silently re-point
the moment a node is inserted.
- `value` is a magnitude, so the schema refuses negatives outright: it is
painted as a stroke width, and a width cannot be negative. Signed data (a
correlation, a net balance) must be split into a magnitude plus a channel of
its own before it gets here, otherwise the arc silently encodes |x| and the
sign is gone with nothing to say so. Zero IS accepted — a co-occurrence
export is mostly zeros — and dropped at layout time, because a zero arc has
no width but would still claim a slot in the reading order and the keyboard
walk. superRefine rejects duplicate node ids and a ready state with no nodes.
- Props on top of the contract: order ("given" | "degree" | "group", default
"given"), side ("auto" | "above", default "auto"), fit ("scroll" | "scale",
default "scroll"), nodeGap (default 30, clamped 16-120), maxArcHeight
(default 120, clamped 40-320), minLinkWidth (default 1.2, clamped 0-6),
onNodeSelect, onRetry, emptyState, className, and the rest spread onto the
root div. Clamp with an explicit fallback, not with Math.min/max alone: NaN
survives both and silently pins the value to one end of the range.
- Ship a pure layout module beside the schema: buildArcDiagramLayout(nodes,
links, { relation, order, foldAbove }) returning per-node slot, sequence,
position, degree, strength and selfWeight; per-link source, target, from,
to, value, self, backward, above, span and slot; plus groups, total,
maxValue, tiedAtMax, maxStrength, isolated, belowCount and an `ignored`
count of the rows it refused. Keeping the maths pure is what lets this
prompt describe it and the component stay a renderer.
Behavior
- AGGREGATION: fold the rows into a sparse matrix keyed `from * size + to`.
Repeating a pair SUMS — two rows for one pair are two measurements of one
relationship, and drawing them twice doubles what the picture claims. Under
relation="undirected" normalise the key to (min, max) of the *contract*
index before summing, so `order` can never change which rows merge.
- BAD ROWS ARE COUNTED, NEVER THROWN. A link pointing at an unknown id, a zero
weight, a duplicate node id: drop it, count it, and print the counts in a
role="status" line above the drawing. This data is a trace or matrix export;
one bad row in 400 must not blank the diagram, and must not vanish silently
either.
- EVERY NODE KEEPS ITS SLOT, even one with no links at all. The axis is a
sequence, and a step that happened but talked to nobody is a finding. Draw
it hollow with a dashed outline and name it in the summary. (A chord diagram
may drop such an entity because its ring is made only of traffic; dropping
it here would silently rewrite the sequence.)
- SIDE ENCODES DIRECTION. With relation="directed", an arc whose sender ends
up to the RIGHT of its receiver is drawn below the axis; everything else is
drawn above. Direction then needs no arrowheads at all: above plus
left-to-right is forward, below is a return edge. Decide this in PAINTED
coordinates, so with order="given" it is exactly a back-edge, and under any
other order it degrades to the honest "points leftward here" instead of a
claim the drawing cannot back up. side="above" folds everything up (halves
the height, loses the cue); relation="undirected" has no sender, so every
arc is above and `side` is ignored — say so in that prop's JSDoc.
- HOVER RAISES, THE REST RECEDE. Pointing at a node lifts every arc touching
it to full opacity and drops every other arc to 0.10 and every other node to
0.30; pointing at an arc lifts that arc and its two ends. The arc under the
pointer also gets a card-coloured halo at strokeWidth + 4, so it reads as
lifted clear of the pile where a fat neighbour crosses it. Paint that halo
for the ACTIVE arc only, never for the whole highlighted set, or each
sibling's halo punches card-coloured holes through the very arcs it was
meant to bring forward.
- Guard the pointerleave reset (setHovered(cur => cur is me ? null : cur)):
leave on the old mark lands before enter on the new one, and an unguarded
reset blinks the whole drawing back to full opacity in between.
- KEYBOARD: the svg is a single tab stop with role="listbox" and
aria-activedescendant. Left/Right walk the baseline and CLAMP rather than
wrap — unlike a ring, a sequence has a first step and a last one, and
wrapping past the end denies exactly the property this chart exists to show.
Home/End jump to the ends. Down steps into the focused node's arcs
heaviest-first, Up climbs back out. Enter/Space pins a node (and fires
onNodeSelect) or, on an arc, follows it to its other end and pins that.
Escape drops the pin. Two layers, because the marks really are two layers:
an arc belongs to two nodes and cannot sit in one flat left-to-right order
without lying about one of them.
- PRECEDENCE: pointer beats keyboard beats the pin, so whichever the reader
moved last is what the readout shows. Derive the cursor position rather than
storing it — a feed that reloads with fewer nodes must not leave a cursor,
or a pin, pointing at a slot that no longer exists.
- The four states are first-class branches of one bg-card panel: a pulsing
arc-shaped skeleton (aria-hidden, next to a visually hidden role="status"
line), an empty state, an error state with the message and a "Try again"
button only when onRetry exists, and ready. A ready payload that produces no
nodes falls into the empty branch rather than inventing a fifth design.
- CLEANUP: this component owns no timers, no rAF, no listeners and no
observers — the layout is a viewBox, not a measuring pass — so there is
nothing to tear down and no leak to chase. Keep it that way; if you add a
ResizeObserver for a fluid variant, disconnect it in the effect's cleanup
AND when the observed node is replaced.
Rendering & styling
- GEOMETRY, all in one fixed design box that the viewBox scales:
x(i) = MARGIN_LEFT + i * nodeGap
width = max(240, MARGIN_LEFT + (n - 1) * nodeGap + MARGIN_RIGHT)
rx(link) = span * nodeGap / 2 (span = |to - from|, in slots)
ry(link) = min(rx, maxArcHeight)
path = M x0,axis A rx,ry 0 0 SWEEP x1,axis, with SWEEP = 1 above and
0 below — the endpoints are exactly a diameter apart, so the
sweep flag alone picks which half gets drawn.
Capping ry is what makes short arcs semicircles and long ones flattened
ellipses instead of something taller than the card: without it a 20-slot arc
at nodeGap 30 would demand 300px of headroom.
- SELF-LINK: the half-ellipse degenerates when its endpoints coincide (rx
collapses to 0 and the arc renders as nothing at all), so draw a cubic that
leaves and returns at the same point: M x,y C x-14,y-34 x+14,y-34 x,y. A
cubic with P0 = P3 peaks at 0.75 * h, which is the height it really claims
in the layout budget.
- HEIGHT BUDGET: axisY = max(deepest above arc + maxStroke/2, markerMax) + 6;
the label lane starts at axisY + max(deepest below arc + maxStroke/2,
markerMax) + 10; total height = laneTop + laneDepth + 6. Half the fattest
stroke on each side, or the outer edge of a 14px arc is shaved off by the
viewBox.
- THICKNESS: stroke = max(minLinkWidth, value / maxValue * 14). Be honest
about the floor — below it the width no longer encodes the weight (3 calls
out of 185,223 are painted 1.2px instead of 0.001px), so it buys
discoverability, not accuracy; the exact number lives in the tooltip, the
readout and the table, and minLinkWidth={0} restores strict proportionality.
Pair every arc with a transparent hit stroke at max(width, 12), because a
1.2px arc is not a hover target. DEGENERATE CASE: when every link weighs the
same (tiedAtMax === links.length, which also covers a single link) thickness
is carrying nothing, so draw them all at one neutral width instead of
pinning every arc to the ceiling and implying they are all as strong as it
gets.
- COLOUR is never the only encoding, and never invented: one palette slot per
group, tone = var(--chart-{slot % 5 + 1}). Because there are only five hues,
slot 6 wears slot 1's colour again, so two more channels carry the group.
The MARKER SHAPE cycles with the colour (circle, square, diamond, triangle,
hexagon — five groups told apart with no hue at all), and the LAP is a line
texture (slots 6-10 dashed, 11-15 dotted) applied to the arc's
strokeDasharray and, on a filled marker, as a card-coloured dashed overlay
that notches its edge. The legend and the sr-only table print the shape and
the texture as words, so the whole encoding is recoverable as text.
- NODE MARKERS are sized by strength: r = 3.4 + 3.6 * sqrt(strength / max). A
node with no links is hollow (fill var(--card), dashed muted outline) and
keeps its slot. Its hit target is a transparent circle at min(13, gap/2 + 2),
because a 7px marker is a miss.
- LABELS sit in a lane under the axis, rotated -45° with textAnchor="end".
Every label shares one angle, so they are parallel lines nodeGap * sin45°
apart and can never meet head-on however long they get — the lane depth is
then the only bound left, and it converts to a character budget through the
diagonal: chars = floor(lane / (fontSize * 0.62 * sin45°)). Round that
character ratio UP from the real ~0.55: a JS estimate runs about 30% narrow
on all-caps runs, and narrow is the expensive direction, because the lane is
a fixed band and the overflow has no ellipsis to explain itself. MARGIN_LEFT
has to hold a full-length label, since a -45° label grows down-LEFT.
- Draw in three passes: the baseline and its leader ticks first (aria-hidden),
so a below-axis arc crosses over them instead of being cut in half by them;
then the arcs widest-first, so the thinnest is painted last and can never end
up buried under a fat one it crosses; then the node markers and labels on
top of everything.
- RESPONSIVENESS: one viewBox, two strategies. fit="scroll" (default) sets the
width/height attributes so the drawing paints at 1:1 inside an
overflow-x-auto pane — an 11px label stays 11px however many nodes there are
and the pane scrolls sideways. fit="scale" drops those attributes for
h-auto w-full and lets the viewBox shrink everything to fit, keeping the
whole picture on screen and taking the text down with it. An explicit height
(or the viewBox ratio) is also what stops the svg collapsing to zero in a
flex parent.
- ACCESSIBILITY: the svg is role="listbox" aria-orientation="horizontal" with
an aria-label naming the title, the node count, the sequence and the link
count. Every node and every arc is a role="option" whose aria-label spells
the relation IN WORDS ("Payments to Auth service"), because an arrow glyph
is read out inconsistently and sometimes not at all — keep the arrow for the
<title> tooltip. Under the drawing: an aria-hidden readout line (the focused
option already announces itself, and a live region would say every number
twice) and an sr-only WRAPPER DIV holding a prose summary plus two real
tables — every node with slot, group, marker, partners, weight and share,
and every arc with weight, share, span and side. Put sr-only on the WRAPPER,
never on a table: CSS width is only a lower bound for a table box, so
width:1px does not hold one back and a 375px viewport picks up hundreds of
px of horizontal scroll.
- The summary is the text alternative, so it has to carry the FINDING, not the
shape: busiest node and what it touches, the strongest link (or "they all
weigh the same"), how many arcs point back, and which nodes have no links.
- Motion is opacity only, 200ms, with motion-reduce:transition-none. Nothing
about the chart depends on it: with motion off the highlight is instant and
every readout, tooltip and table is unchanged.
Customization levers
- order: "given" keeps the sequence and is the point of the chart; "degree"
turns it into a ranking and pulls a hub's fan into one place; "group" blocks
the axis by group, so cross-group traffic becomes the long arcs. Remember
the side cue follows the drawn order, so under a non-given order "below the
axis" means "points leftward here", not "return edge".
- side: "above" for the classic one-sided arc diagram — half the height, and
the right call whenever the data is really undirected or the return edges
are noise. Keep "auto" when a back-edge is the interesting event (retries,
recursion, callbacks, a character reappearing).
- fit: "scroll" when the labels must stay readable (long names, many nodes);
"scale" when the card must never scroll, e.g. inside a fixed dashboard tile
or a print/PDF export.
- Density: nodeGap is the single knob for how wide the picture gets and,
through rx, how tall the arcs get; maxArcHeight caps the tallest one and so
decides the card's proportions; drop the label lane entirely for a sparkline
variant and leave the names to the table.
- minLinkWidth: 0 for strictly proportional widths (fine while the
widest-to-narrowest ratio stays under about 200:1), 3-6 when the tail
matters more than the arithmetic. It only changes paint, never a number.
- Palette: re-point the tone formula and markers, arcs and legend follow
together. Colour by the receiver instead of the sender when "who is being
called" matters more than "who calls"; colour by span to make long-range
links pop; keep the shape cycle whatever you do, because it is the channel
that survives greyscale.
- Interaction: onNodeSelect is where you drill into a service, a tag or a
character. Consumers own the navigation — nothing here renders as a link, so
there is no fake interaction left to unwire.Concepts
- Sequence-preserving baseline — the contract's node order is the picture's x axis, so the chart answers "who relates to whom" without ever giving up "and in what order did they happen". That is the whole trade against a chord diagram: you lose the ring's compactness and gain a timeline you can read straight across.
- Back-edge below the axis — direction is carried by which side of the line an arc bulges to, not by an arrowhead and not by a hue. Forward is above and left-to-right; a return edge (a retry, a recursion, a callback, a character walking back on) drops below, where it reads as "this went backwards" from across the room and still does in greyscale.
- Capped arc height — an arc is a half-ellipse whose horizontal radius is half the span and whose vertical radius is capped. Short relationships stay semicircles, long ones flatten, and the tallest arc — not the widest span — is what bounds the card's height.
- Uniform-weight degeneracy — when no link is thicker than another, thickness is carrying no information, so every arc is drawn at one neutral width instead of all of them at the ceiling. Pinning them to the maximum would say "these are all as strong as it gets", a claim the data never made.
- Raise and recede — the highlight is one gesture with two halves: the marks touching what you point at go to full opacity and gain a card-coloured halo, everything else drops to a tenth. The halo belongs to the active mark alone; one per sibling would punch holes through the arcs it was meant to lift.
- Two-layer walk — an arc belongs to two nodes, so it cannot sit in one flat left-to-right order without lying about one of them. Left and right walk the baseline (clamping, because a sequence has ends), down steps into a node's arcs heaviest-first, and Enter follows an arc to its other end — which is how you traverse a graph with four keys and one tab stop.
Waffle Chart
A four-state waffle (square pie) that apportions categories onto a countable grid with largest-remainder rounding, reserves a cell for shares worth less than one, and keeps the palette readable past five categories.
Chat Sidebar
A conversation-history sidebar — calendar-day groups cut in the display time zone, in-place rename with rollback, delete confirmed by title, pinning, local search with its own no-match state, and real loading/empty/error branches.