Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildDendrogramForest,
  cutDendrogram,
  suggestDendrogramCut,
  type ChartDendrogramData,
  type DendrogramNode,
  type DendrogramOrder,
} from "./chart-dendrogram.contract"

Installation

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

Prompt

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.

On This Page