Word Tree
A four-state word tree that branches raw phrases out from a root term — ribbon width is that continuation's share, every word is a focusable button that re-roots the tree, and the breadcrumb walks back.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-word-tree.jsonPrompt
Build a React + TypeScript + Tailwind "ChartWordTree" card in plain SVG plus
absolutely positioned HTML, with zod. No charting library: a word tree is a
trie laid out over text rows, and no recharts primitive lays one out.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready";
title: string; description?: string; unit?: string;
root: string;
phrases: { id: string; text: string; count?: number > 0 }[] }
The component takes RAW SENTENCES and tokenises them itself. Do not accept a
pre-built tree: the tree depends on the root term, the root term changes
every time the visitor re-roots, and a caller who has to rebuild it on every
click has been handed the hard half of the component.
- `count` is how many times this identical phrase occurred, for feeds that
arrive pre-aggregated (SELECT text, COUNT(*) GROUP BY text). Omitted means
once. Both shapes must produce the identical picture — count is a
convenience, not a second code path.
- Component props = z.infer of that, plus direction ("suffix" | "prefix",
default "suffix"), maxDepth (4, clamped 1-12), maxChildren (8, clamped
1-24), maxRows (28, clamped 4-200), rowHeight (28, clamped 16-64),
maxBranchWidth (18, clamped 2-48), minBranchWidth (1.5, clamped 0-8),
maxHeight (460, clamped 180-1200), locale ("en-US"), formatValue,
onRootChange, onRetry, className, and the remaining native div props spread
on the root.
- Ship the layout as a PURE EXPORTED FUNCTION beside the schema:
buildWordTreeLayout(phrases, options) returning { ok: true, layout } or
{ ok: false, issue: { code, message } } with codes "empty-root",
"no-phrases" and "no-match". The layout carries, per node, id / token /
kind / count / terminal / depth / parent / children / childCount /
hiddenBranches / hiddenCount / path / posInSet / setSize / branch and the
geometry x, y, width, height, centerY, plus the column neighbours prev,
next, first and last; per link source, target, count, width, sourceY,
targetY and grouped; plus width, height, total, rootTerminal and a stats
block. Everything the picture is made of is a number a test can print.
Also export tokenizeWords(), wordTreeBranchPath() and wordTreeAncestors().
Behavior
- TOKENISING strips punctuation only at the edges of a word, so "don't",
"state-of-the-art" and "v2.1" stay one token while (brackets) and "quotes",
lose their wrapping. Match on the lowercased form with toLowerCase() and NOT
toLocaleLowerCase(): the second is host-locale-dependent (Turkish dotless i
is the classic case), which would build two different trees for two
visitors, and here the tree IS the answer.
- MATCH EVERY OCCURRENCE, not the first. A sentence that uses the term twice
really did continue twice and contributes two branches. Count phrases that
never contain it as misses and say so on the card; a tree that quietly
ignored them would be claiming to have read a corpus it did not.
- SANITISE OUT LOUD. Duplicate ids (first wins), texts with no words in them
and counts that are not a positive finite number are dropped and COUNTED in
a visible note. Props are types, not a guarantee: a NaN out of a division
arrives as a `number`.
- DIRECTION is not a mirror. "suffix" keeps the words after the match,
"prefix" the words before it REVERSED, so both walk away from the root and
one trie serves both; only the x axis is flipped at the end. The two answer
different questions and generally have different shapes.
- THE ROW BUDGET is the whole scaling story. A fourth level over real prose is
thousands of rows, so the drawn slice is chosen best-first: repeatedly
expand the heaviest drawn node the budget still has room for, where
expanding a leaf into m children costs m - 1 rows. Stop when the budget is
gone. Anything left folded keeps its count on the word, wears a dashed +n,
is listed in the table and is one Enter away — it is a RENDERING budget,
never a filter, and every figure on the card counts the whole corpus.
- OVER maxChildren, the surplus children collect into ONE bucket labelled
"+n rarer" carrying their summed count. The bucket is a real focusable mark,
drawn dashed and italic, and it is the one mark that cannot be re-rooted
because it stands for several different words at once: give it
aria-disabled, guard the handler, and announce why instead of going native
`disabled` and dropping it out of the tab order.
- RE-ROOTING is the interaction. Activating a word sets the root to
(current root + that word's path) and rebuilds. Keep only the added words in
state, seeded by the `root` prop and the direction — a path of suffix words
means nothing in a prefix tree, so changing either drops it — and reset it
during render, not from an effect, which would leave a frame drawing the old
path under the new root. Re-rooting at a word nothing follows is legal and
draws a root with no branches, not an empty state.
- FOCUS MUST NEVER LAND ON <body>. Re-rooting unmounts every mark, so park
focus on the stage synchronously BEFORE the state change, set a one-shot ref,
and hand focus to the first continuation of the new tree in the effect after
it; read and clear that ref in the same tick.
- The four states are first-class branches of one bg-card panel: a skeleton in
the tree's own shape (root plus three widening columns, aria-hidden, pulse
gated on prefers-reduced-motion), an empty state, an error state carrying
either the transport message or the specific refusal plus a "Try again"
button only when onRetry exists, and ready. "No phrase contains this term"
is a refusal with its own sentence and the number of phrases read — it is a
different story from an empty corpus and only one of the two is the reader's
to fix.
Rendering & styling
- LAYOUT MATHS. Columns are one per depth; a column is as wide as its widest
label, clamped to 46-168px. Widths come from an estimated advance width and
are trusted for nothing else: JS estimation is off by tens of percent
against a real font, so the box also truncates in CSS and the full word stays
in the tooltip, the accessible name and the table. Leaves take consecutive
rows of rowHeight. A parent sits at the COUNT-WEIGHTED centre of its
children, not at their midpoint, so the trunk points at the continuation
most of the phrases actually took. Compute it in reverse pre-order, which is
a post-order, so every child is settled before its parent.
- THICKNESS IS THE ENCODING: stroke = (count / occurrences of the root) *
maxBranchWidth, one global scale, so a ribbon means the same thing at every
depth. Siblings STACK ACROSS the parent's own stroke rather than all leaving
its centre, so a fork reads like a river splitting and the unused part of a
trunk is exactly the phrases that stop there. Paint thickest first so the
thinnest ribbon is never hidden under a fat one it crosses.
- MINIMUM WIDTH: an 8-in-639 continuation is 0.23px of stroke — invisible.
minBranchWidth floors it at 1.5px, which is nearly seven times its true
share, so be honest: below the floor width no longer encodes share and a
fork can overshoot its trunk by up to (floor - true width) per sibling. The
floor buys discoverability, not accuracy; the exact figure is printed beside
every word, and minBranchWidth={0} restores strict proportionality.
- COLOUR NEVER CARRIES A NUMBER. One --chart-* slot per top-level
continuation, cycling after five, inherited by everything under it, mixed as
color-mix(in oklab, var(--chart-N) 72%, var(--foreground)) — the five tokens
are one fixed lightness ramp shared by both themes, so the palest of them
all but vanishes as a hairline on the light card; mixing toward --foreground
deepens it in light mode and brightens it in dark. Every word is written out
with its count beside it, thickness is the encoding, the bucket is dashed
and italic, folded words wear a dashed +n, and the whole tree repeats as a
table.
- ACCESSIBILITY. The drawing is not role="img" — that is
children-presentational and would silence every focusable word. Use a flat
role="tree" over absolutely positioned buttons with role="treeitem" and
aria-level / aria-posinset / aria-setsize carrying the structure, DOM order
in the trie's pre-order so tab and reading order walk the phrase outward,
and ONE tab stop with roving tabindex. The root is a label, not a treeitem:
the breadcrumb above already names it and is where you go back.
KEYBOARD MAP (mapped spatially, so it turns with the drawing): the key
pointing away from the root steps into the heaviest child, or re-roots in
place when the child is folded; the key pointing back goes to the parent and
stops at the first column; up and down walk the words in a column in PAINTED
order (the word below is usually a cousin, and on screen a cousin is what
sits there); Home and End jump to that column's ends; Enter or Space
re-roots; Backspace drops the last word from the root; Escape returns to the
root the props asked for. preventDefault only on keys you handled.
A polite sr-only live region says one sentence per re-root, because
re-rooting replaces the whole picture. An sr-only WRAPPER DIV — never the
table itself, CSS width is only a lower bound for a table box — holds the
full continuation list with counts, shares, stops-here and folded-behind.
- Ticks and secondary text are text-muted-foreground text-xs, the stage
scrolls inside maxHeight rather than shrinking the tree, and the only
animation is a motion-safe fade replayed on re-root; the chart is complete
and readable with animation off.
Customization levers
- maxRows / maxChildren / maxDepth are the three density knobs and they trade
against each other: raise maxDepth for longer phrases and lower maxRows to
pay for it. maxChildren 3-4 makes a deliberately narrow "top three and the
rest" reading; 24 with a large budget makes an exhaustive one.
- minBranchWidth: 0 when the ratio between the widest and thinnest drawn
branch stays under about 20:1 and the arithmetic matters more than the tail;
3-4 when a long tail is the point. It only changes paint, never a number.
- maxBranchWidth against rowHeight is the "river or hairlines" dial: 18 over a
28px row reads as flow, 4 over 20px reads as a dendrogram.
- direction: ship both on one card when the term is a hinge ("what leads to
it" beside "what follows it"); ship the suffix alone for a verbatim explorer.
- Palette: re-point the token list and every ribbon of a family follows
together; drop the branch tokens for a single --foreground mix when the card
already spends its colour elsewhere.
- Wire onRootChange to a URL search param and the whole exploration becomes
shareable and back-button-able; wire it to a KWIC list beside the chart and
the tree becomes the filter for the verbatims.
- The bucket label, the printed counts (formatValue) and the unit noun are the
three strings that make it read like your domain rather than like a demo.Concepts
- Re-rooting — the whole interaction. Activating a word makes “root + that word” the new root and rebuilds the tree from the same corpus, so a folded branch is never a dead end and depth is unbounded even though the drawing is not. The breadcrumb is the inverse move, and Backspace and Escape are its keyboard equivalents — the gesture is never the only path.
- Best-first row budget — rows are what a word tree costs, so the heaviest word is always expanded before a lighter one and the budget stops the rest. A rendering budget, not a filter: what is folded keeps its count on the word, appears in the table and is one keystroke away, and every figure on the card still counts the whole corpus.
- Rarer-continuations bucket — past
maxChildrenthe surplus words collect into one dashed, italic “+n rarer” mark carrying their summed count. It is focusable and countable but not re-rootable, because it stands for several different words at once; it says so instead of going nativedisabledand vanishing from the tab order. - Stacked fork — siblings leave the parent stacked across its own stroke rather than all from its centre, so the picture reads like a river splitting. The unused part of a trunk is not slack: it is exactly the phrases that stop there.
- Count-weighted centre — a parent sits at the count-weighted mean of its children's rows, not at their midpoint, so the trunk visibly leans toward the continuation most phrases took instead of splitting the difference with a rare one.
- Suffix versus prefix — not a mirror of one picture but two questions over two halves of every sentence. Prefix mode reverses the words before the match so both directions walk away from the root, one trie serves both, and only the x axis is flipped at the end.
Renko
A four-state renko chart: one brick per box of price on a zero-anchored grid, time compressed out of the axis, hollow-up and solid-down bricks, two-box reversals marked, a derived-or-given box size, and a refusal when the box is too small to draw.
Vector Field
A four-state quiver plot on a derived lattice: one centred arrow per node carrying direction, length and colour, evenly spaced streamlines traced through the same data, holes that stay holes, and a keyboard-reachable readout on every vector.