Treemap
A four-state squarified treemap in hand-rolled SVG — two levels tiled by area, branches fenced and named, click-to-zoom with a breadcrumb, spatial arrow-key navigation and an sr-only breakdown table.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-treemap.jsonPrompt
Build a React + TypeScript + Tailwind "ChartTreemap" card — a squarified,
two-level treemap — in hand-rolled SVG, with zod for the contract and
lucide-react for the two state icons. No charting library: a treemap is a
tessellation, and the guarantee that matters (the parts sum exactly to the
whole, at every level, with no minimum tile size) has to live in your own
layout function to be true at all.
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; rootLabel?: string; unit?: string;
items: { id: string; label: string; group?: string; value: number }[] }
- The payload is FLAT with an optional group column, not a nested tree and not
rows with parent pointers, because that is what the sources of a treemap
actually emit: a GROUP BY team, service; a bundle report with a chunk column;
a cost export with a tag column. Two properties fall out of it for free and
are worth stating in the JSDoc: there is no parent to dangle and no chain to
loop, so there is no structural error class at all; and the depth is fixed at
two, which is the depth a treemap can label honestly.
- IDENTITY IS (group, id). Two rows carrying the same pair are the same leaf
measured twice and are SUMMED — exactly what a GROUP BY with a forgotten
column produces — so a repeated id is data, not an error. Count the merges
and say so in the footnote. An item with no group at all is a leaf standing
directly on the root, which is the "one file that belongs to nothing" case.
- `value` is what the tile's AREA encodes. zod 4 already rejects NaN and
Infinity at `z.number()`, so a parsed payload cannot carry one; the builder
still drops and COUNTS them, because the component has to survive a caller
who skipped the parse, and a partition that silently shrinks is how a
breakdown starts disagreeing with the report it came from.
- Ship buildTreemapModel(items, { rootLabel, order }) beside the schema. It
cannot fail structurally, so it returns { model, stats } rather than a result
union. A branch carries key (prefixed and percent-encoded, so a group named
"i/api" cannot collide with an ungrouped item whose id is "api"), label,
branch (its --chart-* slot, assigned AFTER ordering so the biggest branch
takes --chart-1), total, shareOfTotal, standalone, drillable and its leaves;
a leaf carries key, id, label, groupKey, groupLabel, value, rank,
shareOfGroup, shareOfTotal. `rank` is by VALUE and is computed independently
of the draw order, so the shade ramp still carries magnitude when
order: "input" has taken it out of the positions. `stats` counts rows dropped
for being negative, zero or non-finite, and rows merged into an earlier row.
- SORT BEFORE YOU SUM, in the same pass. Summing one set of floats in two
different orders gives two slightly different totals, and a row of tiles
would then miss its box's edge by a fraction of a pixel. Order siblings with
a byte comparison, never localeCompare: the server and the browser have to
agree or hydration rearranges the picture under the reader.
- Extra props: description, layout ("nested" | "flat", default "nested"),
height (default 300, clamped 160-720), order ("value" | "input"), tileGap
(default 2, clamped 0-8), labelMinWidth (default 44), locale (default "en-US"
— Intl.*(undefined) desyncs SSR from the visitor), formatValue, onSelect,
onZoom, onRetry, className, plus forwardRef and the rest of the native div
props spread on the root.
Behavior
- SQUARIFY, as a pure exported function: squarify(values, rect) returns one
rect per value, in input order. Grow a row along the SHORT edge of what is
left while the worst aspect ratio inside it keeps improving — worst(row) =
max(side^2 * maxArea / sum^2, sum^2 / (side^2 * minArea)) — then lay the row
down, shrink the free rect and repeat. Rows along the short edge are what
keeps tiles near square, and a near-square tile is the one whose area the eye
can actually compare. Take the first item of a row unconditionally: a row of
zero tiles has no worst ratio to compare against, and refusing it loops
forever.
- EXACT TILING. Every boundary inside a row comes from a RUNNING SUM of areas
rather than from adding lengths one at a time; the last tile of a row snaps
onto the row's far edge and the last row snaps onto the box's far edge. Float
error then cannot accumulate into a seam or an overlap, which is the failure
mode that quietly stops a tiling from being a part-of-whole picture.
- NO MINIMUM TILE SIZE, deliberately. Area given to one tile is area taken from
a sibling, so a floor breaks the guarantee that the parts sum to the whole. A
0.02% leaf is drawn at its true sub-pixel size, stays focusable, stays in the
tooltip, the readout and the table. Count the sub-pixel ones and say so in the
footnote instead of hiding them.
- THREE VIEWS FROM ONE FUNCTION: layoutTreemap(model, { rect, focusKey, layout,
headerHeight, minGroupWidth, minGroupHeight }) returns { tiles, regions }.
Root nested = branches tiled, then each branch's leaves tiled inside its own
box under a header band. Root flat = every leaf in one pass, which gives the
leaves better aspect ratios and keeps far more of them labelled; branches
survive as colour and as adjacency, because squarify packs the list in order
and a branch's leaves are consecutive in it. Zoomed = one branch's leaves over
the whole box.
- A BRANCH WITH ONE LEAF IS DRAWN AS THAT LEAF. Wrapping a single tile in a
header band spends a fifth of its height saying the same thing twice, and
zooming into it would show one tile at 100%, which says nothing.
- A BRANCH TOO SMALL TO SUBDIVIDE STAYS WHOLE. Below minGroupWidth or
minGroupHeight there is no room for a header plus a readable body, so the
branch is painted as one tile with a dashed inner edge — texture, not hue —
and it still opens on click. Its leaves are never lost: they are in the table,
and zooming gives them the whole box.
- LABELS, and the honest handling of the ones that do not fit. Every tile gets
an SVG <title> and an aria-label carrying the full name and the numbers,
always. Text is printed only when the tile clears labelMinWidth and is tall
enough for a line; the name is elided to a character budget derived from the
box width. The figure is printed only when it fits WHOLE — a truncated number
reads as a different number, while a missing one sends the reader to the
tooltip and the table, which carry it in full. A branch header puts name and
figure on one line, the figure right-aligned and only when the name keeps its
own budget; a leaf stacks them. State how many tiles carry no label at all.
- ZOOM. Clicking a branch (its header band, or the whole tile when it is drawn
whole) makes it the entire box; a leaf click pins it in the readout and fires
onSelect, and clicking it again unpins. The zoom target is stored as a branch
key and RE-RESOLVED against the current model on every render, so swapping
`items` can never strand the view inside a branch that no longer exists — or
one that has since shrunk to a single leaf, which has nothing to zoom into.
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.
- FOCUS NEVER FALLS TO BODY. A zoom unmounts the tile that was just activated
and a breadcrumb jump unmounts the crumb that just became current, so hand
focus to the first tile of the new view — keyed on a zoom COUNTER, not on the
tile array, because the array is also rebuilt on a resize and keying off it
would let a window drag steal focus. The same holds for the retry button,
which unmounts with the error panel: set a ref straight from its click, read
it once in an effect on `status`, skip "loading" (a retry usually goes error
→ loading → ready) and only take focus if it is still on body.
- KEYBOARD IS SPATIAL. A treemap has no rows and no rings to walk, so "next
sibling" would send the focus ring jumping across the box in a way nothing on
screen explains. Ship nearestTile(from, tiles, direction) as a pure function:
candidates are the tiles whose centre lies strictly in the pressed direction,
and the winner minimises (distance along + 2 x drift across), so a tile that
is slightly further but straight ahead beats a near one far off to the side.
Enter and Space activate, Home and End jump to the ends of the reading order,
Backspace zooms out. preventDefault every key the widget claims — including
the ones whose move lands nowhere, since a box is not a loop and walking off
its edge must stop rather than scroll the page.
- A GESTURE IS NEVER THE ONLY PATH. Branch chips under the plot are real
buttons that zoom: that is the keyboard-and-touch route into a branch whose
tiles are too small to hit, and the only zoom target at all in the flat
layout, where no branch tile is drawn.
- SIZING. Squarify works in real pixels — the whole algorithm is about aspect
ratios — so unlike a linear partition the geometry cannot be percentages: one
ResizeObserver supplies the width, rAF-deferred (writing state straight from
the callback re-enters layout in the same frame), sub-0.5px deltas ignored,
cancelled and disconnected on unmount, observe() wrapped in try/catch. Until
the first measurement lands, fall back to an assumed width so the server
render is a complete picture rather than a blank box.
- FOUR STATES are first-class branches of one bg-card panel: a pulsing skeleton
built by running the same squarify over a fixed list of shares, so the
placeholder has the geometry of a real tiling (aria-hidden, plus one sr-only
status line, no timers); a zero-data panel for empty; an error panel with a
Try again button only when onRetry exists. A fifth outcome is reachable from
`ready` and needs its own sentence: parsed fine, and nothing survived with
positive area.
- DEGENERATE DATA, each handled on purpose: a single item (one tile at 100%, so
area encodes nothing — do not dress it up); one item worth 99.5% of the box,
which turns every sibling into a sliver and some into hairlines; duplicate
(group, id) rows, which sum; negative values, which cannot have area and are
DROPPED AND COUNTED rather than folded in as their absolute value; zeros and
non-finite values, dropped the same way; more branches than palette slots, so
the ramp cycles and the header names do the telling apart; names longer than
the tile they sit in.
- CLEANUP: one ResizeObserver, one focus effect, one retry effect. No timers, no
rAF loops, no window listeners; element references live in a Map written by
ref callbacks, so unmount removes them through the same callback that added
them.
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.
- COLOUR. Each branch takes one --chart-N slot, cycling after five; a leaf keeps
its branch's token and gets paler down the RANK, not down the value, so a long
tail of near-equal leaves still steps: color-mix(in oklab, var(--chart-N) S%,
var(--card)) with S falling from about 36% to 14%. The mix is toward --card,
the opposite of what a sunburst wants, because every tile here carries text:
landing the fill on the opaque --card makes the contrast deterministic
whatever sits behind the chart. Keep the ceiling near 40% — that is a contrast
budget, not taste, and the strongest tiles are where readability fails first,
silently.
- COLOUR IS NEVER THE ONLY CHANNEL: area encodes the value, position encodes
rank (squarified puts the biggest tile in the top-left corner), a branch is
fenced by its own backdrop and named in its header band, a branch drawn whole
carries a dashed edge, tiles are labelled wherever they fit, and the sr-only
table carries every exact number.
- THE GUTTER IS THE MORTAR. Paint a branch backdrop behind its tiles at a low
mix of the same token; the gap between two tiles inside a branch then shows
that branch's colour, while the gap between branches shows --card. Grouping
becomes visible as the mortar between tiles instead of as a legend the reader
has to memorise. The gap itself comes out of the PAINT, never out of the
allocation, and is clamped to a quarter of the tile per side: unclamped, a 1px
tile minus a 2px gap has negative size and disappears, and that sliver is
often the outlier worth seeing.
- SVG rather than DOM boxes or canvas: the tiles are one flat tessellation with
no text flow to inherit, halos come free from paint-order:stroke, and every
tile is still a real focusable element with a native tooltip — which canvas
cannot give at all.
- No entrance animation anywhere, so prefers-reduced-motion has nothing to
disable; the only animation is the loading pulse and the chip hover, and both
carry motion-reduce variants.
- ACCESSIBILITY CONTRACT. The plot is role="group" with an aria-label naming the
view and stating the keyboard map, and aria-describedby pointing at an sr-only
summary that states the actual finding: view total, tile count, branch count,
largest and smallest tile with their shares, how many tiles are unlabelled,
how many branches are drawn whole. Deliberately not role="tree": nothing
expands in place, so aria-expanded and aria-level would promise semantics the
widget does not have. Every tile is a role="button" rect with one roving tab
stop over the set. A zoom moves every tile at once, which focus alone cannot
report, so announce the new view in a polite live region — while the visible
hover readout stays aria-hidden, because a focused tile already says all of it
and a live region would say every word twice. 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 item in the
current view with its branch, rank, value, share of branch and share of whole.
Customization levers
- layout: "nested" when the branch is the subject and you want a rectangle to
point at; "flat" when the leaves are the subject and the grouping is only
context — flat gives better aspect ratios and keeps far more tiles labelled,
and the chips stay the zoom path.
- height and tileGap are the density knobs. A taller box lets more tiles clear
the label thresholds; tileGap 0 gives a solid mosaic where the border hairline
is the only parting, 6-8 gives an airy card at the cost of the smallest tiles.
- labelMinWidth: raise it to keep only the big tiles labelled and let the small
ones be pure area, lower it for a denser, busier read. The figure thresholds
beside it are what stop a number from being truncated into a different number;
move them together with your formatter.
- minGroupWidth / minGroupHeight are layout options rather than props, and they
decide when a branch stops subdividing. Raise them for a calmer overview of
the top branches, lower them to push detail into the first screen — either way
nothing is lost, because a branch drawn whole still opens. Promote them to
props if the same card has to serve a phone and a wallboard.
- order: "input" whenever sibling position already carries meaning — severity,
price tier, release order. "value" (default) makes rank readable from position
as well as from the shade, which is worth more when the labels are arbitrary.
- Palette: re-point the five --chart-* slots and the tiles follow. Flatten or
steepen the rank ramp by moving its two ends, or key the shade off the leaf's
share instead of its rank when magnitude matters more than order.
- Dropping the long tail is a DATA decision, not a rendering one: fold
everything under a threshold into one "other" item upstream, so the tiling
still sums to the total. Do not add a minimum tile size in the renderer.
- Wiring: onZoom to mirror the branch into the URL or a side panel, onSelect to
open a detail drawer for a leaf, formatValue for bytes, currency or durations.
To make the chart read-only, drop the roving tabIndex and the click handler
and keep the table — the static picture is still complete.Concepts
- Squarified tiling — the family the chart belongs to. Both dimensions are spent on magnitude, which is what lets a treemap hold many more leaves than a bar chart in the same card; the price is that depth has to be carried by nesting rather than by a position on an axis. Growing each row along the short edge of the space that is left is the whole trick: it keeps tiles near square, and only a near-square tile has an area the eye can compare.
- Exact tiling from running sums — boundaries come from a cumulative total rather than from adding lengths one at a time, siblings are sorted in the same pass that sums them, and the last tile of every row is snapped onto the edge. Sum the same floats in two orders and the two totals differ, which is enough to open a seam between a branch and the leaves inside it.
- No minimum tile size — area given to one tile is area taken from a sibling, so a floor would break the promise that the parts sum to the whole. Sub-pixel tiles stay sub-pixel on purpose, stay focusable, and are counted in the footnote rather than hidden.
- The gutter is the mortar — a branch paints a faint backdrop of its own token behind its tiles, so the gap inside a branch carries the branch colour while the gap between branches carries the card. Grouping becomes something you see in the picture instead of something you look up in a legend.
- Drawn whole, not dropped — a branch whose box cannot hold a header plus a readable body is painted as one tile with a dashed edge. It still opens, its leaves are still in the table, and the footnote says how many branches are in that state; the alternative — subdividing anyway — produces rows of unlabelled crumbs that look like data and are not.
- Spatial arrow keys — on a tessellation there is no row and no ring to walk, so an arrow means the nearest tile whose centre lies that way, scoring drift across the direction at twice the distance along it. That is what makes the focus ring move the way the picture looks, instead of jumping to whatever happened to be next in the array.
Network Force
A four-state force-directed relationship graph in plain SVG — a hand-written repulsion, spring and gravity simulation that converges and then stops, nodes you can drag and anchor, colour by group or by degree, and hover to light a node's neighbours and fade the rest.
Calendar Heatmap
A year of days as one week-column grid — quantile or linear colour steps with the bounds printed on the legend, measured zero kept apart from no data, and every tile reachable by keyboard.