Tidy Tree
A four-state node-link hierarchy on a Reingold–Tilford tidy layout — contour-packed coordinates from a pure function, foldable branches, curved, elbow or straight links, either orientation, and a flat ARIA tree over the drawing.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-tidy-tree.jsonPrompt
Build a React + TypeScript + Tailwind "ChartTidyTree" card — a node-link
hierarchy on a Reingold-Tilford tidy layout — out of one SVG link layer plus
absolutely positioned DOM buttons for the nodes (no charting library), with zod
for the contract and lucide-react for the caret and the two state icons.
Contract
- One zod schema is the source of truth, and the component's props are z.infer
of it plus the render knobs — never a parallel hand-written interface:
{ status: "loading" | "empty" | "error" | "ready";
title: string; description?: string; unit?: string;
nodes: { id: string; parentId?: string | null; label: string;
meta?: string; value?: number }[] }
- The payload is a FLAT list with parent pointers, not a nested object, because
that is what the sources of a tree chart emit: `SELECT id, manager_id`, a
category table, a CSV export. It also keeps a diff readable — a re-parented
node is a one-line change instead of a whole subtree moving.
- The price of the flat shape is that it can express things a tree cannot, so
inspectTidyTreeNodes() runs before any layout and refuses: duplicate ids, a
node that is its own parent, a parent id nobody defined, and any cycle. The
cycle check is not a nicety — every walk in the layout (the roll-up, the
drawn-set collection, both layout passes) assumes a parent chain terminates,
and a loop turns each of them into an infinite one. Detect it by walking each
chain once with three colours (unseen / on the current chain / proven to
terminate) so no node is walked twice, and name the loop in the message.
- ROOT SELECTION is deliberately NOT in the schema: which node is the root
depends on the `rootId` prop, which a schema cannot see. buildTidyTreeLayout
resolves it and returns its own refusals for "several nodes have no parent"
(name up to three of them and say to pass rootId) and "rootId is not one of
the nodes". A two-tenant export is legal data and an illegal tidy tree; the
fix is to say which tree, never to draw one and drop the other.
- `value` is booked on the node ITSELF, not on its subtree, and the layout rolls
it up into `subtotal` so a branch reads either way. Geometry never depends on
it — a tidy tree encodes structure, not size — which is exactly why an
unusable figure may cost the caller a number but never a shape.
- Ship the layout as pure exported functions beside the schema, so a test can
print the same numbers the picture is made of: buildTidyTreeLayout(nodes,
options) returning { ok: true, layout } | { ok: false, issue };
tidyTreeLinkPath(layout, link, shape) returning one `d` string;
tidyTreeAncestors(layout, index); and tidyTreeBranchesAtDepth(nodes, level,
rootId) for the "open the top N levels" seed. Every node in the layout carries
id, label, meta, value, subtotal, parent, drawn children, childCount,
descendants, hidden, collapsed, depth, posInSet, setSize, branch, x, y,
centerX, centerY and its level neighbours prev / next / first / last.
Behavior
- THE LAYOUT is Reingold-Tilford in Buchheim, Junger and Leipert's linear-time
form, and it is the whole reason this component exists. Two alternatives look
simpler and are both worse: giving each subtree its own reserved box (what
nested CSS hands you for free) never lets a deep, narrow subtree tuck under a
shallow, wide neighbour, so the drawing comes out far wider than it needs to
be; spacing siblings by leaf count has the same fault one level down and also
stops a parent sitting over the middle of its children. Tidy layout pushes two
adjacent subtrees apart by exactly how far their facing CONTOURS collide,
level by level — the narrowest drawing with no overlap, and the one where a
parent is always centred over its children.
- Implement it as the paper does. First walk, post-order: a leaf takes its left
sibling's position plus one separation; a branch lays its children out, calls
apportion after each one, runs executeShifts, then centres itself over its
first and last child (and, if it has a left sibling, keeps the difference as
its `mod`). apportion walks the right contour of everything already placed
against the new subtree's left contour and moves the whole subtree right by
the worst collision, spreading the correction over the siblings in between
through the shift / change pair rather than one pass per colliding pair.
Contours are followed through THREAD pointers borrowed by leaves, which is
where the linear time comes from. Second walk, pre-order: a node's real
position is its preliminary one plus every `mod` collected on the way down.
- Write the first walk with an EXPLICIT STACK, not recursion. Its depth is the
depth of the caller's data, and an imported hierarchy with a 12,000-link chain
would otherwise take the whole tab down with "Maximum call stack size
exceeded". Measured: 5,060 nodes including a 5,000-deep chain lay out in about
10ms with no stack growth at all.
- SEPARATION is per pair, not one constant: two boxes that share a parent get
siblingGap, two boxes whose parents differ get the wider subtreeGap. That
single distinction is what makes a family read as a family, and it costs one
comparison inside the distance function.
- ORIENTATION is a breadth/depth swap, not a second implementation. Run the
algorithm in abstract "breadth" and "depth" axes, decide which is x and which
is y at the very end, and the vertical org chart and the horizontal deep-tree
view come out of exactly the same code and the same numbers.
- LINK SHAPES: "curved" is a cubic whose control points sit on the midway line,
so it leaves and arrives perpendicular to its level; "elbow" is the org-chart
bus (out to the midway line, across, in again) whose shared segment IS the
sibling group; "straight" is the bare edge, the honest one for a taxonomy
where a bus would imply an ordering the data does not have. Round the emitted
coordinates to two decimals: well under a device pixel, and it keeps the `d`
strings byte-identical between the server and the client render.
- FOLDING. A folded branch's children are simply not in the drawn set, so the
layout re-runs over what is left and the whole picture re-packs — which is the
point, and something a reserved-box layout cannot do. Keep only the visitor's
own decisions in state (id -> folded) and DERIVE the folded set as
props-seed + overrides. Seeding a state variable at mount instead — the
obvious way — silently loses the level budget in the flow every real app has:
the first render is `loading` with no nodes, the initialiser runs against
nothing, and the tree that arrives afterwards opens fully. Deriving also keeps
a branch the visitor opened by hand open across a refetch.
- ACTIVATION: a branch folds or unfolds, a leaf pins the readout (again to
unpin), and onSelect fires for both. Guard the click with the event's own
`detail` counter — the second click of a double lands on whatever the
re-render moved under the pointer.
- KEYBOARD, and it is never optional: one roving tab stop over the boxes, arrows
mapped SPATIALLY so the same key always means the same movement on screen. In
"vertical", Up goes to the parent, Down opens a folded branch in place (press
again to step into it), Left/Right walk the level; in "horizontal", the pairs
swap. A level is a segment, not a loop, so walking off its end STOPS — and
preventDefault is called for every key the widget claims, whether or not the
move lands, so arrowing never scrolls the page. Home/End jump to the ends of
the level, Enter and Space activate natively, Escape clears the pin, and
Backspace folds the branch you are in.
- FOCUS MUST NEVER LAND ON <body>. Backspace unmounts the box the keystroke came
from, so move focus to the parent FIRST, while both boxes are still mounted,
and fold afterwards. Separately, a node can vanish under a focused box because
the data was swapped: one effect notices the focused id is no longer drawn,
checks that the vanished box really did take focus with it (activeElement is
body), and hands focus to the nearest ancestor still on screen. That effect
moves focus and does not setState — the box's own onFocus is what writes the
tab stop back.
- FOUR STATES are first-class branches of one bg-card panel: a skeleton that is
the REAL layout run over a placeholder tree, so the loading card is already
the shape of the chart that replaces it (aria-hidden, one sr-only status line,
no timers); a zero-node panel; an error panel with a Try again button only
when onRetry exists. A fifth outcome shares that last panel and needs its own
sentence: parsed fine, but the structure cannot be drawn — that is where the
cycle, forest and unknown-parent messages surface.
- DEGENERATE DATA, each handled on purpose: a single node (no breadth to lay
out, stage is exactly one box wide, picture still complete); a label far wider
than its box (CSS truncation, so the browser measures the real font, with the
full name in the tooltip, the accessible name and the table); an eleven-way
fan-out that makes the widest level wider than any card (the stage scrolls,
nothing is scaled down to fit); a chain deep enough to scroll the other way;
and figures that are negative or not finite, DROPPED AND COUNTED in a visible
footnote rather than coerced. Props are types, not a guarantee — re-check the
numbers in the layout.
- CLEANUP: there is nothing to clean up, and that is a design decision. Geometry
is computed from props alone, so no ResizeObserver, no timers, no rAF and no
window listeners; element references live in a Map written by ref callbacks,
so unmount removes them through the same callback that added them. The server
render is the finished picture, not a blank box waiting for a measurement.
Rendering & styling
- Semantic tokens only: var(--chart-1..5) for the branches, --card,
--foreground, --muted, --muted-foreground, --border, --ring, --destructive.
Zero hex, zero rgb(), zero invented hues. cn() merges every className, the
root is forwardRef with the remaining native div props spread on it.
- COLOUR: one --chart-* slot per top-level branch, cycling after five; every
descendant keeps its branch's token and gets paler with depth through
color-mix(in oklab, var(--chart-N) S%, var(--card)) with S falling
26-20-16-12-10-8-7-6. The mix is toward --card because every box carries text:
landing the fill on the opaque --card makes that text's contrast deterministic
whatever sits behind the chart. The ceiling is well under the 42% those same
tokens were measured to carry text-foreground at, because this chart also
prints a muted-foreground second line and that line runs out first. Links and
the accent edge use a different formula — color-mix(in oklab, var(--chart-N)
62%, var(--foreground)) — because a 1.5px line in the palest token all but
vanishes on the light card; mixing toward --foreground deepens it in light
mode and brightens it in dark mode while keeping the hue that ties it to its
family. The root belongs to no branch and takes a neutral --foreground tint.
- COLOUR IS NEVER THE ONLY CHANNEL: every box is labelled, depth is readable
from the level it sits on, a branch is traceable along the link that arrives
at it, the accent edge sits on the side the link comes in from, and a folded
branch is a right-pointing caret plus a "+n" count plus a DASHED trailing edge
— texture, not hue, so it survives greyscale and a bad projector.
- Nodes are DOM buttons over an aria-hidden SVG link layer, never canvas: every
box is then focusable, has real text the browser truncates, gets a native
title tooltip for free, and needs no glyph-width estimation. Both layers share
one stage sized exactly to the layout, centred with mx-auto while it fits and
scrolled by an overflow-auto wrapper when it does not.
- ACCESSIBILITY CONTRACT: role="tree" over the box layer with an aria-label that
states the keyboard map, and aria-describedby pointing at an sr-only summary
that states the actual finding — nodes drawn of nodes total, levels, the
widest level, the largest branch, how many nodes are folded away, how many
figures were dropped. The tree is FLAT: the boxes are absolutely positioned so
they cannot be nested in role="group" wrappers, and aria-level, aria-posinset
and aria-setsize carry the structure instead, with DOM order written in the
tree's pre-order so tab and reading order still walk it top to bottom. Every
fold and unfold is announced through a polite live region, because removing
boxes from a picture is obvious to a sighted visitor and invisible otherwise.
Below the plot, an sr-only WRAPPER DIV (never the table itself — CSS width is
only a lower bound for a table box, so width:1px does not hold one back and a
narrow viewport picks up hundreds of px of horizontal scroll) holds a table of
every drawn node with what it reports to, its level and position, how many
nodes hang off it, its own figure, its subtree figure and its fold state. The
visible readout line is aria-hidden, because the focused box already announces
all of it and a live region would say every word twice.
- No entrance animation and no position transition, on purpose: link `d`
strings cannot be interpolated by CSS, so boxes gliding over links that snap
reads as a glitch rather than as motion. The only animation in the component
is the loading pulse, and it carries motion-reduce:animate-none; the colour
transition on a box carries motion-reduce:transition-none. The chart is
complete and readable with animation off.
Customization levers
- orientation: "vertical" for the classic org chart on a wide card;
"horizontal" when the tree is deep or the labels are long, because depth then
costs one box width per LEVEL instead of one box width per LEAF. Nothing else
changes: same numbers, same code path, arrows re-map themselves.
- linkShape: "elbow" for reporting lines, where the shared segment reads as the
sibling group; "curved" for taxonomies and decision trees; "straight" when a
bus would imply an order the data does not have.
- nodeWidth / nodeHeight / levelGap / siblingGap / subtreeGap are the density
knobs, and they are the whole layout: raise subtreeGap relative to siblingGap
to make families read as blocks, drop nodeHeight below 38 to lose the second
line and get a compact one-line tree, raise nodeWidth when labels matter more
than breadth. maxHeight decides when the stage starts scrolling instead of
growing the page.
- expandDepth is a RENDERING budget, not a filter: fold everything at level N
and below on arrival, and the visitor opens what they need. defaultCollapsed
folds specific ids on top of it. Both stay in the table and in the summary.
- rootId turns the same payload into a subtree view — the answer to a forest,
and also the cheap way to build a drill-down: keep it in state, set it from
onSelect, and give the visitor a way back.
- Palette: re-point the five --chart-* slots and boxes, links and accents follow
together. Flatten or steepen the depth ramp by editing the eight-step array,
or key the colour off the node's own parent instead of the top-level branch
when local grouping matters more than provenance. Keep the ceiling under about
50% or the second line loses AA.
- Wiring: onSelect to open a detail drawer, onToggle to mirror the fold state
into the URL or into a store, formatValue for currency, bytes or durations,
locale for the number format. For a static figure, drop the roving tabIndex
and the click handler and keep the table — the picture is still complete.Concepts
- Contour packing — the idea the whole layout rests on. Two neighbouring subtrees are pushed apart by how far their facing outlines actually touch, level by level, rather than by how wide their bounding boxes are. That is what lets a five-level chain tuck under a one-level neighbour, and it is exactly what nested CSS cannot do, because a nested box is a bounding box by construction.
- Threads — the borrowed pointers that make the contour walk linear. A leaf on a contour has no child to continue with, so it lends a pointer to the next node along that side; following a contour then costs one step per level instead of a search. They are pure bookkeeping and never appear in the output.
- Parent centred over children — not decoration, but the property that tells you where a family starts and ends without any lines being traced. It is also what forces the shift/change bookkeeping: moving one subtree right has to be spread over the siblings in between, or the parent stops being centred.
- Sibling gap vs subtree gap — one comparison inside the separation function, and the difference between "these four belong together" and "here are eight boxes". Boxes that share a parent close up; the seam between two subtrees opens.
- Breadth and depth, not x and y — the algorithm runs on abstract axes and only picks which is horizontal at the end, so
orientationis a swap rather than a second implementation, and the two views are guaranteed to agree. - Folding re-packs — a folded branch is simply absent from the drawn set, so the layout runs again over what is left and the whole picture tightens. A reserved-box layout can only blank the space out; this one reclaims it.
- Flat ARIA tree — the boxes are absolutely positioned, so they cannot nest inside
role="group"wrappers.aria-level,aria-posinsetandaria-setsizecarry the structure instead, DOM order stays in the tree's pre-order, and every fold is announced politely — because boxes disappearing from a picture is obvious to a sighted visitor and silent to everyone else.
Dendrogram
A four-state hierarchical-clustering tree where every bracket sits at the height its two sides merged at, with a cut-height slider that colours and letters the resulting clusters, folded wedges instead of dropped tips, and counted refusals for loops, danglers and double-claimed joins.
Circle Packing
A four-state zoomable circle packing in hand-rolled SVG — front-chain sibling packing with a verified smallest enclosing circle, area strictly proportional to value, zoom as a re-projection rather than a re-layout, keyboard-walkable circles and an sr-only breakdown table.