Forest Viz
A multi-tree forest renderer. Callers describe the forest as a flat nodes array — each node carries a stable id, an optional parentId, and an optional label / tone. Roots are nodes whose parentId is null, omitted, or self-referential (the union-find convention). The renderer groups nodes into trees, lays each tree top-down with children below their parents, and arranges the trees side-by-side along a single row.
Reach for it whenever the story is several disjoint trees at once — union-find forests, disjoint-set components, parallel recursion stacks, classification taxonomies — anything where "how many groups are there?" matters as much as "what does each group look like?".
Installation
npx shadcn@latest add https://craftbits.dev/r/forest-viz.jsonUsage
import { ForestViz, type ForestNode } from "@craft-bits/core";
const nodes: ForestNode[] = [
// Tree A
{ id: "a", label: "a", tone: "resolved" },
{ id: "a1", parentId: "a", label: "1" },
{ id: "a2", parentId: "a", label: "2" },
// Tree B
{ id: "b", label: "b", tone: "resolved" },
{ id: "b1", parentId: "b", label: "3" },
];
<ForestViz nodes={nodes} showRootMarkers />Drive the focused node from outside (e.g. wire it to a step-through scrubber):
const [focusedId, setFocusedId] = useState(null);
<ForestViz
nodes={nodes}
focusedId={focusedId}
onFocusedIdChange={setFocusedId}
/>Highlight multiple nodes via the selection set — for example to colour every node on the path from a child to its root:
<ForestViz
nodes={nodes}
selectedIds={new Set(["a1", "a"])}
/>Understanding the component
- Flat nodes, derived forest. Instead of a
roots: Tree[]shape, the API takes a single flat list. Each node carries anidand an optionalparentId. A node withparentId === null(or omitted, or self-referential) is a root. The renderer climbs from every non-root to determine which tree it belongs to and groups them accordingly. - Side-by-side layout. Each tree is laid out top-down — the root sits on row 0, children below, and internal nodes are placed at the centroid of their visible descendants. Trees are then stacked horizontally with a fixed gap. The component sizes its outer SVG to fit, so the parent layout never has to guess.
- Optional
treeIdfor grouping. PasstreeIdon nodes to force them into the same tree column even when theirparentIdchains don't connect — useful when callers want to colour components by id or merge two visually-separate single-node trees under one heading. - Focus is one node at a time.
focusedIdfollows the Radix pattern —focusedIdplusonFocusedIdChangefor controlled mode,defaultFocusedIdfor uncontrolled. Tapping a node toggles its focus; the focused node and any incident edge highlight to accent. Tapping the focused node again clears focus. - Selection is a set.
selectedIdscarries a persistent highlight across nodes — perfect for traced paths (every node from a child up to its root) or student-selected groups. Double-tap a node to toggle its membership; the highlight rides alongside focus without overriding it. - Root markers, optionally. Set
showRootMarkersto draw the union-find self-loop above each root. Off by default — most stories read fine with the root simply having no outgoing edge. - Tone encodes semantic state.
toneof"default","resolved", or"dimmed"accents the ring around each node. Resolved roots get an accent border; dimmed nodes drop to ~55% opacity. - Reduced motion.
usePrefersReducedMotion()collapses node and edge transitions to instant. Focus rings still update on click — just without the spring.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
nodes | readonly ForestNode[] | required | Flat list of every node in the forest. |
focusedId | string | null | — | Controlled focused node id. |
defaultFocusedId | string | null | null | Uncontrolled initial focused node. |
onFocusedIdChange | (id: string | null) => void | — | Fires whenever the focused node changes. |
selectedIds | ReadonlySet<string> | — | Controlled set of nodes carrying a persistent highlight. |
defaultSelectedIds | ReadonlySet<string> | new Set() | Uncontrolled initial selected set. |
onSelectedIdsChange | (ids: ReadonlySet<string>) => void | — | Fires whenever the selected set changes. |
showRootMarkers | boolean | false | Draw a self-loop arrow above each root. |
compact | boolean | false | Squeeze padding ~15% for embedded usage. |
transition | Transition | SPRINGS.smooth | Node and edge transition. |
className | string | — | Merged onto the outer SVG via cn(). |
Accessibility
- The outer SVG is
role="img"with a<title>summarising the tree count, total node count, and the currently focused node (if any). - Every node carries a tabIndex'd transparent hit circle so keyboard users can tab through nodes;
EnterandSpacetoggle focus exactly like a click. - Every node's hit area is at least 44 × 44 px even when the visible circle shrinks under
compact, satisfying WCAG 2.5.8. - Color is never the only signal — every node renders its
label, and the focused / selected stroke widths visibly differ from the resting state. - Motion respects
prefers-reduced-motion: node entries, edge draws, and focus transitions all collapse to instant.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/viz/ForestViz.tsx). The original was an index-keyed primitive —parent: number[]plusnodeCount, with semantic colour overrides driven by externalcomponentColorsmaps and a fixed hard-coded tone palette. The library version flips to a flatForestNode[]shape keyed by stableid, which makes the same component work for stringy ids (graph component labels, union-find handles, recursion frame names) and exposes the Radix controlled / uncontrolled levers onfocusedIdandselectedIdsinstead of an opaqueactiveNodeplus a separately-managedtracedPath. Self-loops are now opt-in viashowRootMarkers, and styling routes through thecb-*semantic tokens so the forest sits next to the rest of the algo-viz family without visual drift.