Charts

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.

Preview in your theme

Loading preview…

"use client"

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

import { cn } from "@/lib/utils"
import {
  buildCirclePackTree,
  walkCirclePackTree,
  type ChartCirclePackingData,
  type CirclePackNode,
} from "./chart-circle-packing.contract"

export interface ChartCirclePackingProps

Installation

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

Prompt

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.

On This Page