Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, RefreshCcw, Spline } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  type ArcDiagramLayout,
  type ArcDiagramLink,
  type ArcDiagramNode,
  type ArcDiagramOrder,
  buildArcDiagramLayout,
  type ChartArcDiagramData,
} from "./chart-arc-diagram.contract"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-arc-diagram.json

Prompt

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.

On This Page