Circle Packing
A four-state zoomable circle packing in hand-rolled SVG — front-chain sibling packing with a verified smallest enclosing circle, area strictly proportional to value, zoom as a re-projection rather than a re-layout, keyboard-walkable circles and an sr-only breakdown table.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-circle-packing.jsonPrompt
Build a React + TypeScript + Tailwind "ChartCirclePacking" card in hand-rolled
SVG (no charting library — recharts has no pack layout, and the whole component
is a geometry problem, not a series problem) with zod for the contract and
lucide-react for the two state icons.
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;
nodes: { id: string; label: string; parent?: string;
value?: number }[] }
- The hierarchy arrives FLAT, with a parent pointer, because that is the
shape a `GROUP BY a, b, c`, a `du` export or a package manifest walk
actually produces. A nested payload would push tree-building onto the
caller, and a hand-built tree is exactly where duplicate ids and accidental
loops get in unseen.
- `value` belongs to a LEAF. A node with children always reports the sum of
its children and its own `value` is ignored, so a group circle can never
claim an area its contents disagree with. There is no equivalent of an
icicle's self-time gap: the empty space inside a group is packing slack,
not an unattributed amount, so a group's own charge has to be modelled as
a real child ("Other", "Unassigned") when it matters.
- The schema refuses, at parse time, everything that is not a tree:
duplicate ids, a parent that does not exist, a node that is its own
parent, a parent chain that loops, and a set of rows in which nothing is
top level. The loop check matters twice over — an unguarded roll-up
recurses until the stack gives out on `A -> B -> A`, and that same cycle
leaves no root, so a builder that only scans for parentless rows draws an
empty frame and reports nothing wrong.
- Ship two pure functions beside the schema: inspectCirclePackNodes() for the
structural pass, and buildCirclePackTree(nodes, { rootLabel, order })
returning { ok: true, root, stats } or { ok: false, issue }. A built node
carries id, label, path, depth, total, shareOfParent, shareOfTotal, height,
leafCount and children; `stats` counts what was refused — negative leaves,
zero / unset / non-finite leaves, and groups that vanished because their
whole subtree summed to nothing.
- Extra props: description, maxDepth (default 3, clamped 1-5), padding in
viewBox units (default 2, clamped 0-10), order ("value" | "input", default
"value"), 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
- RADIUS. A leaf's radius is sqrt(value). That is the entire point: AREA
carries the number, so a leaf worth four times another is drawn four times
the area and twice the width. A group is never given an area of its own —
it is exactly the circle that encloses its packed children.
- SIBLING PACKING, front-chain method, exported as a pure function
packSiblingCircles(radii) -> { circles, radius }. Place the first three by
hand (the second beside the first, the third tangent to both). Keep a
doubly linked FRONT CHAIN of the circles currently on the outer boundary.
For every further circle: place it tangent to the two chain members closest
to the centroid, then walk the chain forward and backward AT THE SAME TIME,
always advancing the side that has covered less arc, looking for the
nearest circle it would overlap. On a hit, cut the chain back to that
circle and retry the SAME circle (the loop index steps backwards); on a
miss, splice it in, rescore the chain and let the pair nearest the centroid
become the next insertion point — that rescoring is what keeps the
arrangement round instead of growing a spiral arm.
- ENCLOSING CIRCLE, Welzl's move-to-front algorithm over the front chain,
also exported. Two things it must not do: use Math.random for the shuffle
Welzl wants (the server and the client would draw two different pictures of
the same data — use a fixed-seed LCG instead), and trust its own answer.
Three nearly collinear circles make the three-circle Apollonius solve
ill-conditioned, and one NaN centre scales the whole figure to nothing, so
VERIFY the result — every input inside, every number finite — and fall back
to a plain centroid-plus-reach bounding circle if not. Looser, but always
a real circle that really contains the input.
- PADDING needs two passes, because the gap and the scale are circular: the
gap is wanted in final view units, but has to be applied in the layout's
own units, which are only known once the unpadded figure has been sized.
Measure with no padding, then re-pack with padding converted through that
measurement, inflating each child before packing and deflating after — so
the gap falls out of the same tangency maths instead of a second pass that
would push circles around and could reintroduce overlap. Adding padding
grows the figure, so the fit rescale shrinks the drawn gap slightly below
the number asked for. Name that approximation in the prop's doc comment.
- ZOOM IS A RE-PROJECTION, NOT A RE-LAYOUT. Pack the whole tree once; framing
a group only changes the window: k = (size/2 - margin) / frame.r, and every
circle is mapped through it. Nothing moves relative to anything else, so
the arrangement the reader has just learned survives the zoom — which is
the opposite trade to a sunburst, where each zoom re-tiles the ring. The
frame path is stored as ids and RE-RESOLVED against the current tree on
every render, so swapping `nodes` can never strand the view inside a
subtree that no longer exists: the chain stops at the last id that still
resolves, then backs out of anything that has since become a leaf.
- DEPTH BUDGET. Only levels within maxDepth of the frame are drawn. A circle
sitting exactly at the budget with children of its own gets a dashed inner
ring — texture, not hue — still opens on click, and still appears in the
table. Inset the ring by a fixed amount but never past the middle of a
small circle, or the annotation ends up outside the thing it annotates.
- FOCUS AFTER ZOOM. Every zoom unmounts the circle that was just activated,
so focus would fall to <body>. Move it to the first circle of the new
frame, keyed on a zoom COUNTER rather than on the circle array, so
re-laying out for any other reason never steals focus.
- DOUBLE CLICK. Guard the zoom with the click event's own `detail` counter:
the second click of a burst lands on whatever the re-render moved under the
pointer, which is never what the reader meant. Reading it off the event
means there is no timer to schedule and none to clean up.
- FOUR STATES are first-class branches of one bg-card panel: four pulsing
circles inside a quiet frame (aria-hidden, plus one sr-only status line, no
timers) for loading; a zero-data panel for empty; an error panel printing
either the transport message or the specific structural issue, with a Try
again button only when onRetry exists. A fifth outcome is reachable from
`ready` and needs its own sentence: parsed fine, but nothing survived with
positive area.
- DEGENERATE DATA, each handled on purpose: zero rows; one row (a single
circle filling the frame); a chain in which every group has exactly one
child (concentric circles — a one-element front chain has no neighbour to
be placed against and must be short-circuited, not fed to the general
loop); every value identical (equal radii drive the packer into its densest
arrangement, where any rounding error shows up as a visible overlap, and
size then encodes nothing so labels and enclosure have to carry it);
negative values, DROPPED AND COUNTED rather than folded in as their
absolute value — publishing a refund as revenue of the same size is the
failure mode to avoid; zeros, unset values and NaN, dropped the same way,
because one NaN radius poisons every coordinate it touches and the card
goes blank with no error anywhere; a group whose whole subtree is zero,
which disappears with it; labels longer than their circle, cut to a chord
budget with the full text kept in a <title>; and values so small that their
circle is sub-pixel — floor the DRAWN radius at 0.4 units so it stays a
real focusable element instead of an r="0" the browser declines to render,
and say in the footnote how many such circles there are.
- CLEANUP. One effect (the post-zoom focus hand-off) and no subscriptions: no
timers, no rAF, no ResizeObserver, no window listeners — responsiveness
comes from the viewBox, not from measuring. Element references live in a
Map written by ref callbacks, so they are removed on unmount by the same
callback that added them.
Rendering & styling
- Semantic tokens only: var(--chart-1..5) for the branches, --foreground,
--card, --muted, --muted-foreground, --border, --ring, --destructive. Zero
hex, zero rgb(), zero invented hues. cn() merges every className.
- COLOUR. Each top-level branch takes one --chart-N slot, cycling after five,
and the slot is decided by the TOP-LEVEL ancestor, not by position inside
the current frame — so a zoom never recolours anything. A leaf keeps its
branch's token and mixes toward --foreground as it goes deeper
(color-mix(in oklab, var(--chart-N) S%, var(--foreground)), S falling
100 -> 87 -> 74 -> 61). The direction is the opposite of the usual
fade-inward, on purpose: mixing toward --card would dissolve the innermost
circles into the panel, and the palette has no headroom for it — every
--chart-* token is tuned to sit just over 3:1 against the card, so ANY mix
toward the card puts the deepest leaves under that bar. A GROUP is not a
mark but a container: a 13% wash of its token, plus a full-strength stroke
of the same token for its boundary.
- COLOUR IS NEVER THE ONLY CHANNEL: hierarchy is physical containment, every
leaf carries a --card hairline so two same-token neighbours still part,
circles are labelled wherever the chord allows, the group's name is pinned
to the inside of its top edge, size encodes rank, the legend repeats the
frame's children with their shares, and the sr-only table carries every
exact number.
- LABELS come from the CHORD, not from the string: budget =
floor(chordAt(offset) * 0.88 / (fontSize * 0.58)), so the room shrinks with
the circle and a line near the rim gets less than one through the middle.
A leaf decides its value line FIRST, because whether it is drawn is what
moves the name off centre — a name shifted up over an empty gap looks like
a bug. Under four characters, no label at all: three characters render as
two glyphs and an ellipsis, and "La…" over two neighbouring circles that
are Layout and Language servers is worse than nothing, because it looks
like information. Anything cut, and anything unlabelled, keeps its full
description in a <title>. The halo is paint-order:stroke with a --card
stroke under a --foreground fill — the SVG equivalent of a text-shadow ring
— so a label stays readable over any fill in either theme without picking a
text colour per circle.
- HIGHLIGHT. Hover and focus share one indicator, drawn last so a nested
circle can never bury it: two stacked strokes, --card at 4 under
--foreground at 2. Suppress the UA outline on the circles. Everything not
on the highlighted circle's ancestor-or-descendant path fades to 0.3
opacity, transitioned and gated behind motion-reduce. The frame behind
everything is a real hit target, so sliding off a circle onto the
whitespace must clear the highlight, or the readout keeps naming a circle
the pointer has left.
- RESPONSIVE via viewBox plus preserveAspectRatio and an aspect-square block
svg inside a max-width wrapper, so nothing is measured and the figure
cannot collapse to zero height in a flex parent. Every constant is in
viewBox units and scales with it.
- ACCESSIBILITY CONTRACT. The svg is role="group" with an aria-label that
names the frame and states the keyboard map, and aria-describedby pointing
at an sr-only summary that states the actual finding: total, entry count,
how many circles and how many levels are drawn, the largest and smallest
child with their shares, how many circles are unlabelled, how many hold
hidden levels. Each circle is a role="button" with an aria-label reading
label, value, share of its container and level, plus "N inside, opens" when
it drills. ONE roving tab stop over the circles: ArrowLeft / ArrowRight
walk siblings and WRAP; ArrowDown goes in to the first child, ArrowUp out
to the parent; Home / End jump to the first and last sibling; Enter and
Space activate; Backspace zooms out one level; preventDefault only on keys
actually handled, so the page keeps its own scrolling. Every one of those
links is resolved over the DRAWN set, never the whole tree, so a key that
has nowhere to go does nothing rather than blackholing focus. A zoom is
announced once in a polite live region ("Framed Vendor. 4 circles inside,
7 entries in total."), because the picture changes wholesale and a moved
focus alone does not say so. Below it, 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 entry with its container,
level, value, share of group and share of the whole. The visible readout
line is aria-hidden, because the focused circle already announces all of it
and a live region would say every word twice.
Customization levers
- maxDepth: 1 turns the same data into a share-of-total picture (top level
only, everything else behind dashed rings); 4-5 makes it an explorer.
Deeper levels stay reachable by framing and stay in the table either way.
- padding: 0 for a dense "how much" figure where every pixel is area, 6-10
when the grouping is the message and you want the nesting obvious at a
glance — the cost is real, since the frame has to hold the whitespace and
every circle is drawn smaller.
- order: "input" whenever sibling position already carries meaning —
severity, price tier, calendar months. Note this is not a paint choice: the
packer places circles in the order it is handed, so "input" genuinely
changes the arrangement and comes out looser than "value".
- Palette: re-point the five --chart-* slots and circles, legend swatches and
the depth ramp all follow. Change the 13-point step to flatten or steepen
the ramp. To recolour on zoom instead of keeping colour stable, key the
branch off the frame's children rather than the top-level ancestor.
- Labels: raise the four-character floor to five or six for a denser figure,
or drop leaf labels entirely and let the legend plus the readout carry the
names when the figure is mostly small circles.
- Dropping the long tail is a DATA decision, not a rendering one: group
everything under a threshold into one "Other" node upstream, so the figure
still sums to the total. Do not add a minimum circle size in the renderer.
- Interaction: onZoom to sync the frame with the URL or a side panel,
onSelect to open a detail drawer for a leaf. To make the chart read-only,
drop the roving tabIndex and the click handler and switch the svg to
role="img" with the summary as its aria-label — the static picture plus the
table is still complete.Concepts
- Enclosure as the hierarchy channel — depth is not a colour, a line or an axis position here; it is literal containment. That buys an immediately readable grouping and costs whitespace: a circle packing can never tile, so roughly 30% of every group is slack, and the loss compounds at each level. That trade is the whole reason to pick this over a treemap, or to reject it.
- Area, not radius — a leaf's radius is the square root of its value, so four times the number is four times the ink rather than four times the width. Radius-encoded bubbles overstate big values by the square and are the single most common way this chart lies.
- Front chain — the packer keeps a linked ring of the circles currently on the boundary, places each newcomer tangent to the pair nearest the centroid, and searches that ring in both directions at once for the first collision. After each success the ring is rescored, which is what keeps the arrangement compact and round instead of spiralling outward.
- Verified enclosure — the smallest enclosing circle comes from Welzl's algorithm, whose shuffle is a fixed-seed generator rather than
Math.random, so the server and the browser draw the same figure. Its answer is then checked (everything inside, every number finite) and replaced by a plain bounding circle if the three-circle solve went ill-conditioned — a slightly loose frame beats a NaN that scales the whole card to nothing. - Zoom as re-projection — the tree is packed once and framing a group only changes the window onto it, so relative positions never move and the map the reader just built stays true. The opposite trade to a sunburst, which re-tiles its rings on every drill.
- Chord budget — a label's room is the chord of its circle at the height the text sits, not the length of the string, so it shrinks with the circle. Below four characters nothing is drawn at all: two glyphs and an ellipsis look like information and are not, so the name goes to the tooltip, the readout and the table instead.
Tidy Tree
A four-state node-link hierarchy on a Reingold–Tilford tidy layout — contour-packed coordinates from a pure function, foldable branches, curved, elbow or straight links, either orientation, and a flat ARIA tree over the drawing.
Voronoi Territory Map
A four-state Voronoi tessellation that derives territory from seed positions — half-plane clipping, an optional capacity-weighted power diagram, map colouring so no two neighbours share a fill, direct labels elided to the cell that holds them, and a lift on hover or focus.