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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-dendrogram.jsonPrompt
Build a React + TypeScript + Tailwind "ChartDendrogram" card in plain SVG with
zod. Recharts has no dendrogram primitive and the layout is not a cartesian
series, so the tree resolution, the slot layout, the cut and the axis are done
by hand in small pure functions that live beside the schema. No new dependency,
no force simulation, no d3.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
metric?: string; unit?: string; item?: { one: string; many: string };
cut?: number;
leaves: { id: string; label: string; meta?: string }[];
merges: { id: string; left: string; right: string; distance: number }[] }.
- LEAVES CARRY NO QUANTITY. That is the whole difference from every other
hierarchy chart: an icicle, a treemap or a sunburst encode a value per node,
a dendrogram encodes the HEIGHT AT WHICH TWO THINGS JOINED. The numbers live
on the joins.
- `merges` is a linkage by ID, not by row index the way scipy writes `n + i`.
An index is unreadable in a payload, breaks the moment anything upstream is
filtered, and cannot be checked by a schema. Ids can: every structural
mistake a linkage can hold becomes a validation issue instead of a silently
wrong picture. Leaves and merges share ONE namespace, so a reference is never
ambiguous.
- `distance` is non-negative: the axis starts at the tips, and a metric that
produces negative heights has to be shifted before it gets here rather than
having its sign quietly folded away.
- superRefine, in the order the mistakes actually happen: ready needs at least
one leaf; ids unique across leaves AND merges; no join of a node with itself;
every `left`/`right` resolves; no node claimed by two joins (in a linkage
every node has exactly one parent); and a LOOP check, which nothing above can
catch — each node in a loop has one parent and every reference resolves, so
it only shows up as a merge no walk from the unclaimed merges can reach.
Guard every access: sibling refinements all run, so a ragged payload has to
produce an issue rather than a TypeError thrown out of safeParse.
- Props = z.infer of the schema plus rowHeight (default 18, clamped 12-48),
maxLeaves (default 32, clamped 8-500), order ("compact" | "input", default
"compact"), showCutControl (default true), onCutChange, onRetry, className
and the div's native props, forwardRef to the card.
- Export the maths so it is testable and so this prompt can describe it:
buildDendrogramForest(), cutDendrogram(), suggestDendrogramCut(),
clusterLetter(), collapseToBudget(), layoutDendrogram(), niceStep(),
buildDistanceTicks().
Behavior
- RESOLUTION NEVER THROWS AND NEVER RETURNS A BROKEN TREE. Claims are settled
in payload order (first join to name a node keeps it), then a memoised DFS
builds the nodes. A join that cannot stand — no usable height, a missing
child, a child already joined, a loop — is dropped and COUNTED, and whichever
of its children did resolve is left with no parent, so it comes back as a
root of its own. A dropped join never takes its items with it: the tip count
on the card always equals the tip count in the payload. The card names what
was refused in a footnote; several roots get their own footnote, because "the
linkage never finished" is a finding and inventing a height to join them
would be a lie.
- LAYOUT is the whole chart and it is two rules: a tip takes one row, in tree
order; a join sits at the midpoint of its two children. Nothing to relax,
nothing to iterate, nothing to cancel on unmount. Sibling order is the one
free choice and the component says so out loud — a linkage fixes the tree
only up to swapping the two sides of every join — so `order` is a prop:
"compact" puts the smaller, tighter branch on top (what hclust does, and what
makes the ladder readable), "input" keeps the payload's tip order.
- THE AXIS IS REVERSED: root at the left, tips at the right, distance growing
leftwards, always anchored at zero. Tip labels then read outward at the edge
of the card instead of being crushed between the picture and the frame, and
ArrowRight means "toward the tips" for the eye and for the keyboard at the
same time. Ticks on the 1 / 2 / 2.5 / 5 x 10^n ladder, one per ~88px,
explicit "en-US" locale, and the end labels anchor to their ends so they
cannot spill out of the frame.
- CUTTING. `cutDendrogram(roots, h)` severs every link crossing h; a cluster is
the subtree of a node whose own join is at or below h and whose parent's is
above it — exactly what scipy's fcluster(criterion="distance") returns, so
the picture and the analysis agree by construction. A tip whose parent is
above the cut is a cluster of one, not a leftover. Clusters come back in
drawing order and are lettered A, B, C … AA.
- WHERE THE CUT STARTS. `data.cut` if the job supplied one; otherwise the
middle of the WIDEST GAP between consecutive merge heights — the oldest way
of reading a dendrogram, because a tall empty band means the next join had to
reach much further than everything below it. Gaps are measured only between
heights that occur, so the suggestion can never land below the lowest join
(every tip its own cluster) or above the top one (one cluster of everything),
and the summary line says "(widest gap)" so a suggestion is never mistaken
for a decision someone made. The slider is uncontrolled after that, and a new
`cut` prop re-seeds it (state adjusted during render, not in an effect).
- FOLD, NEVER DROP. Past maxLeaves rows the tree keeps expanding its HIGHEST
join until the budget is spent; whatever is still standing in for a subtree
becomes a wedge labelled with how many items it holds and the first and last
name inside it. That is scipy's truncate_mode="lastp" and it keeps the top of
the tree, where the structure is; slicing off the last N tips would keep an
arbitrary corner and throw the shape away. A wedge that hides more than one
cluster refuses to claim a colour and says how many it is holding. Click a
wedge or press Enter and it opens — spending one budget's worth of rows
INSIDE that branch, so one keystroke can never turn 183 items into 183 rows.
Every height on the card is computed from the whole tree either way.
- DEGENERATE DATA MUST NOT BREAK THE GEOMETRY: one tip and no joins (no axis
span, so the domain falls back to 1 and the cut control disappears rather
than pretending to do something), every join at height 0, a tip nobody
merged, several roots, and INVERSIONS — a join drawn lower than a join inside
it, which centroid and median linkage genuinely produce. Inversions are drawn
as they came and counted in a footnote: the crossing is in the data, and
straightening it would be the chart editing the analysis.
- INTERACTION. The plot is a role="tree" with roving tabindex — one tab stop,
arrows inside. Down/Up walk the picture top to bottom (on a chart "the next
one" is the row below, which is not the same as the next node in tree order);
Right descends to the first child or opens a fold; Left folds an open join or
climbs to the parent; Home/End jump; Enter/Space toggles a fold.
preventDefault fires only for keys that were handled, so Tab still leaves the
chart. Folding focuses the join FIRST, before its descendants unmount, so
focus can never fall back to <body>. Pointer targets are transparent rects: a
narrow one around each join's connector, and one per tip covering its own
link and its label, starting just clear of the parent's connector so the
elbow belongs to the finer target.
- A GESTURE IS NEVER THE ONLY PATH: the cut lives on a native range input, so
arrows, Home/End and PageUp/Down work for free; the rule in the plot is drawn
but not draggable, because a second mechanism for one value is a second thing
to get out of sync.
- CLEANUP: one ResizeObserver, disconnected on unmount and whenever the node
changes. No timers, no rAF, nothing else to stop.
- Four first-class branches of one card: loading (deterministic skeleton
brackets, aria-hidden, plus one sr-only role=status line), empty (worded so
it cannot be mistaken for a failed fetch: a dendrogram needs the linkage
itself, and a table of items with a cluster label has already thrown it
away), error (a Try again button only when onRetry was passed), ready. A
ready payload with no resolvable tip renders the empty branch.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border
for the frame and the gridlines, muted for the skeleton, muted-foreground for
axis text, the cut rule and every link above the cut, ring for the focus
outline, foreground for the cluster letters, accent-primary for the slider,
and var(--chart-1..5) cycling for the clusters. Never a chart token as text
colour.
- COLOUR IS NEVER ALONE. A cluster is a colour, AND a letter on a marker where
its stem crosses the cut rule, AND the same letter in a bordered chip beside
every one of its tip labels, AND a row in the sr-only table. Past five
clusters the ramp repeats, which costs nothing precisely because the letter
was always the identity. A link that crosses the cut is SPLIT at the rule —
its cluster's colour up to the cut, muted-foreground beyond — so every stem
visibly reaches the rule instead of stopping short of it.
- Folds are a shape, not a shade: a wedge from the join's own height out to the
tips, the classic collapsed clade, with its count in the gutter.
- Tip labels live in a foreignObject in the right-hand gutter (30% of the
width, floored at 96px), truncated with the whole string on the title
attribute — one row is one tip, and a wrapped name would quietly take the row
under it.
- ACCESSIBILITY: do NOT put role="img" on the plot — that is
children-presentational and would silence the tree inside it. Use
role="group" labelled by the heading and described by the summary line, which
states the finding in words: how many tips, how many joins, the range of
heights, the cut and how many clusters it makes. Every node's aria-label is a
sentence (what joined what, at what height, in which cluster, and whether it
is an inversion); joins carry aria-expanded, and every node carries
aria-level / aria-posinset / aria-setsize. The slider carries aria-valuetext
with the height and the cluster count. One polite role="status" region says
what folding did to the rest of the card — the one thing aria-expanded on the
focused node cannot say — and the visible readout line is aria-hidden,
because the focused node already announces it. An sr-only WRAPPER DIV (never
sr-only on the table itself: CSS width is only a lower bound for a table box,
so a narrow viewport picks up real horizontal scroll) holds one row per
cluster: letter, size, the height it forms at, the height it joins the rest
at, and its members.
- Motion: the only animation is the loading skeleton's pulse, carrying
motion-reduce:animate-none. Nothing else moves, so nothing else has to stop,
and the chart is complete with animation off.
Customization levers
- rowHeight / maxLeaves: the two density knobs. 14 and 60 give a tall analysis
panel; 24 and 12 give a summary figure of a big tree, all wedges and no
detail. maxLeaves is a legibility budget, not a data limit — the disclosure
follows it automatically, so there is no setting in which a tip disappears
without the card saying so.
- order: "input" when the tips already carry a meaning of their own (severity,
time, an axis shared with a heatmap beside the chart); the default when the
ladder is what you want people to read.
- cut: pass the threshold the job used to make the picture agree with the
numbers in the report, or leave it out and let the widest gap propose one.
Wire onCutChange to a linked table, a fcluster call or a saved view — it
carries both the height and the cluster count.
- showCutControl={false} for a static figure (a PDF export, a card in a grid);
the tree stays fully keyboard-navigable without it.
- Palette: re-point CLUSTER_INK to one token for a monochrome print figure —
the letters and the wedge counts carry the whole distinction without colour.
Keeping the cut rule and above-the-cut links on muted-foreground is what
makes "below the cut" read as the answer.
- Orientation is the one deep change: the layout returns slot / distance pairs
and the component maps them to x and y in two functions, so a top-down tree
(tips along the bottom, distance up) is those two functions plus rotated tip
labels — and the arrow-key map has to be rotated with them, or the keyboard
stops agreeing with the picture.
- Second axis: a dendrogram is most useful pinned to the rows of a heatmap.
Feed the same `forest.order` to the matrix beside it and the two are aligned
by construction; that ordering is exported for exactly this reason.Concepts
- Merge height is the data — a tidy tree spends both of its axes on structure; a dendrogram spends one of them on a number, so the position of every bracket is the dissimilarity at which its two sides became one cluster. Everything else on the card follows from protecting that one claim: the axis is anchored at zero, heights are never rounded outwards, and an inversion is drawn crossing rather than straightened.
- Cutting is the reading — sever every link that crosses a height and the clusters fall out: the branches whose own joins all happened at or below it. That is the same rule as
fcluster(criterion="distance"), so the picture cannot disagree with the analysis it came from, and the slider turns "how many clusters?" from an argument into something you can watch. - The widest gap — with no threshold supplied the chart proposes one in the middle of the tallest empty band between merge heights, because a band means the next join had to reach much further than everything under it. It is a suggestion and the card labels it as one; measuring only between heights that occur keeps it from ever proposing "every tip alone" or "everything together".
- Fold, never drop — past the row budget the deepest branches become wedges carrying their item count and their first and last name, and the top of the tree survives intact. Nothing is unreachable: a wedge opens on Enter or a click, one budget's worth of rows at a time, and every height on the card was computed from the whole tree regardless of what is folded.
- One namespace, one parent — leaves and joins share an id space and every node can be claimed by exactly one join, which turns four kinds of broken linkage (a missing child, a node joined twice, a self-join, a loop) into validation issues at parse time and counted footnotes at render time. A refused join leaves its items standing as separate roots, so the tip count on the card always equals the tip count in the payload.
- Arrows follow the picture — the axis is reversed so the root sits left and the tips right, which is what lets Right mean "toward the tips" for the eye and for the keyboard at once. Down and Up then walk the drawn rows in visual order rather than tree order, because on a chart the next thing is the row below; the cut lives on a real range input, so no part of this needs a mouse.
Raincloud Plot
A four-state raincloud that stacks a half-violin density, a Tukey box summary and the jittered raw readings in three lanes on one shared scale — id-hashed jitter, keyboard-walkable rows with landmark announcements, and an out-loud sampling notice.
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.