Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, ChevronRight, LayoutGrid } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  buildTreemapModel,
  type ChartTreemapData,
  type TreemapGroup,
  type TreemapLeaf,
  type TreemapModel,
} from "./chart-treemap.contract"

Installation

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

Prompt

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.

On This Page