Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, ChevronDown, ChevronRight, GitBranch } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  buildTidyTreeLayout,
  tidyTreeAncestors,
  tidyTreeBranchesAtDepth,
  tidyTreeLinkPath,
  type ChartTidyTreeData,
  type ChartTidyTreeNode,
  type TidyTreeLayoutNode,

Installation

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

Prompt

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 orientation is 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-posinset and aria-setsize carry 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.

On This Page