Polar Area Chart
A Nightingale rose where every period owns an equal angle and the radius is area-corrected, drawing measured zeros, missing readings and below-zero periods each in their own way.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-polar-area.jsonPrompt
Build a React + TypeScript + Tailwind "ChartPolarArea" card — a Nightingale
rose (polar area chart) drawn as hand-written SVG, with zod for the contract.
No chart library: value-to-radius with an equal angle per slot is not a
primitive any of them expose, and faking it with a pie whose sectors carry
per-slice radii gives up the layout maths that this chart is entirely made of.
Contract
- One zod schema is the source of truth, and the component's props are
z.infer of it plus presentation options — never a parallel interface:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
cycle?: string; // what one turn is: "the day", "the year"
unit?: string;
segments: { id: string; label: string; value: number | null;
group?: string }[] }
- ONE ROW PER SLOT OF THE CYCLE, including the slots that measured nothing.
The array is the cycle. A rose that is only handed the periods that
happened re-spaces the wheel silently, and 20 reported hours are then
drawn as a whole day.
- value === null means "not measured". It is a different fact from a
measured 0 and the two are drawn differently. zod 4 already rejects NaN
and Infinity, so a number here is finite.
- The schema refuses duplicate ids (they are React keys) and a "ready"
status with zero segments (that is what "empty" is for).
- Component props: radiusScale ("sqrt" default | "linear"), holeRatio
(0.1, clamped 0-0.6), startAngle (degrees, default 0), padAngle
(degrees, default 0.7, clamped 0-3), showMean (true), locale
("en-US" — Intl.*(undefined) desyncs SSR from the visitor's locale),
formatValue, onSelect, onRetry, className, and the rest of the native
div props spread onto the panel. forwardRef to the panel div.
- Ship the layout as a pure function beside the schema —
buildPolarAreaLayout(segments, { innerRadius, outerRadius, scale,
startAngle, tickCount }) — returning per-sector angles, radius, kind,
share, value/mean and rank, plus sweep, total, max, min, mean, peak,
trough, aboveMean, flat, missing/negative/zero counts, groups and grid
ticks. It touches no DOM and measures nothing, so the whole chart is
testable without a renderer.
Behavior
- EQUAL ANGLE, UNEQUAL RADIUS. Every period gets 360/n degrees whatever it
measured; only the radius carries the quantity. That is the whole
difference from a donut (where the angle is the quantity) and from a
radar (where the vertices are connected into one closed line).
- AREA CORRECTION is the point of the chart. The area of an annular sector
from r0 to r is (theta/2)(r^2 - r0^2), so making ink track value means
solving it for r:
r = sqrt( r0^2 + (R^2 - r0^2) * v / vmax )
which collapses to R*sqrt(t) when there is no hole. The naive mapping,
r0 + (R - r0)*t, is offered as radiusScale="linear" precisely so the cost
is visible: on the reference dataset the busiest hour is 20.9x the
quietest, and the linear radius paints that as 436x the ink.
- FOUR KINDS OF READING, four ways of drawing, decided per period:
value -> a filled sector out to its radius;
zero -> a 4-unit tick on the floor ring. A measured 0 is a reading,
so it must not become a gap that looks like missing data;
missing -> the whole slot outlined, dashed, never filled: "we did not
measure this" must not be able to look like a magnitude;
negative -> a hatched stub at the hub, held out of the scale. A turn
has no negative arc; clamping it to zero would file a real
measurement away as "nothing happened", so it is named in
the footnote and printed exactly in the table instead.
- STATISTICS THAT REFUSE TO LIE. mean/total/rank are computed over the
reported periods only (missing and negative excluded, and the caption
says so). When every reported period is equal there is no peak: report
"flat" rather than naming whichever one array order put first. Ties share
a rank. A peak/trough ratio is only printed when the trough is above 0.
- GRID RINGS on a 1 / 2 / 2.5 / 5 x 10^n ladder below max, plus a solid rim
ring at max. Under the square-root scale the rings crowd toward the rim —
that is the honest tell that the radial axis is not linear, so never
space them evenly. Thin the tick LABELS from the rim inward — the rim
carries max, the number the whole axis is calibrated against, so it keeps
its label and a ring closer than 11 units to the last kept one loses its
own. The ring itself always stays.
- LABELS ride the rim at the middle of their period, anchored away from the
centre so each grows into the margin it owns. The character budget is
what is left between the anchor and the edge of the box, computed per
label: 63 characters at twelve o'clock, 7 at three o'clock. Longer labels
are truncated with an ellipsis. When the wheel is crowded, print every
k-th label with k = ceil(label width / chord spacing between neighbours)
— 52 ISO weeks come out at k=2 — and drop the last labelled period when n
is not a multiple of k, or it collides with the first at the seam. A
period that loses its label loses nothing else: readout, aria-label and
table row all stay.
- THE HIT REGION IS THE SLOT, not the petal: a transparent wedge from the
hole to the rim, laid over the paint with the UNPADDED angles, so the
gaps are not dead zones and a 4-unit zero tick is as easy to point at as
the tallest period. Hover and focus share one highlight (a --card stroke
under a --foreground stroke, traced around the slot) and one readout line
under the chart.
- KEYBOARD: one roving tab stop for the whole wheel. Left/Up = previous
period, Right/Down = next, and BOTH WRAP — the axis is a closed cycle, so
23:00 really is next to 00:00 and clamping there would invent an edge the
data does not have. Home/End jump to the first/last period, Enter/Space
call onSelect. preventDefault only on keys actually handled, so arrowing
never eats the page's scroll on keys this chart ignores. Click selection
is guarded with event.detail > 1 so a double click fires once.
- FOUR STATES are first-class branches of one bg-card panel: a petal
skeleton (aria-hidden, plus an sr-only role="status"), an empty state
that asks for one row per period, an error state with a "Try again"
button only when onRetry exists, and ready. A "ready" payload that
arrives with zero rows lands on the empty branch rather than a blank card
— TypeScript cannot see a zod refine.
- CLEANUP: there is nothing to clean. No timers, no rAF, no ResizeObserver,
no window listeners — the layout is responsive through viewBox alone and
every handler is React's own. The ref map that holds the focusable paths
deletes its entry when a path unmounts.
Rendering & styling
- Semantic tokens only. Sector fills cycle var(--chart-1..5) by group;
grid rings are stroke-border, floor ticks and hatch are muted-foreground,
the mean ring and the highlight are --foreground, the hairline between
neighbouring sectors and every text halo are --card, the error heading is
text-destructive. Merge the consumer's className with cn().
- COLOUR IS NEVER THE ONLY ENCODING, and here it is not even the primary
one: magnitude is radius, identity is position on the wheel plus the rim
label plus the table row. Group colour is a redundant channel; when there
are more than five groups the tokens repeat, and the legend prints each
group's first and last period so a repeated hue is never the only way to
tell two of them apart. Below-zero periods get a hatch, not a hue, so
they survive greyscale and colour blindness.
- ONE ANNULAR-SECTOR PATH BUILDER, three cases, and the first is why it is
hand-written: a full turn (a single-period cycle, and every hit wedge on
one) has coincident start and end points, and an SVG elliptical arc
between two identical points is defined to draw NOTHING — so the one
period there is would silently vanish. Draw it as two half turns, with
the inner circle wound the other way so the non-zero fill rule punches
the hole out. Case two is holeRatio 0 (a pie wedge through the centre),
case three is the ordinary outer arc / radial edge / inner arc / close.
- padAngle comes out of the PAINT, never out of the allocation: every
period still owns exactly one nth of the turn. Clamp the gap to a quarter
of the sweep per side so a sliver keeps at least half of itself, and give
a lone period that owns the whole turn no gap at all — it has no
neighbour to be parted from, and cutting one would slit a solid disc.
- Text on the canvas carries a --card halo via paint-order:stroke, the SVG
equivalent of a knockout, so a tick or rim label stays readable wherever
a sector happens to reach. The peak marker is a small --card triangle
drawn INSIDE the tallest sector: outside it would fight the rim label for
the same margin, and it only appears when the sector is thick enough to
hold it.
- Layout is responsive through viewBox + preserveAspectRatio="xMidYMid
meet" on an aspect-square svg inside a max-width wrapper with min-w-0, so
it can never collapse to zero height in a flex parent and never overflows
a narrow card.
- Motion is limited to the skeleton's pulse, which carries
motion-reduce:animate-none, and 150ms colour transitions on the retry
button. Nothing about reading the chart depends on motion.
- ACCESSIBILITY, in full: the panel heading owns an id; the svg is
role="group" with an aria-label that states the key map and an
aria-describedby pointing at an sr-only paragraph that reports the
finding, not the shape — peak, trough and their ratio, how many periods
sit above the mean, and how many are missing or below zero. Every hit
wedge is a focusable path with an aria-label ("08:00: 940 hires, 9.2% of
the total, 2.1 times the mean, highest period") and role="button" when
onSelect is wired, role="img" when it is not. Under the chart, an sr-only
WRAPPER DIV holds a real table with every period: value, share, ratio to
the mean and rank. Put sr-only on the wrapper, never on the table — 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. The visible readout line is aria-hidden on purpose: the focused
period already announces all of it, and a live region would say every
word twice.
Customization levers
- radiusScale: keep "sqrt" for anything a reader will compare. "linear"
exists for audiences who expect the naive mapping, and for showing the
two side by side when explaining why the default is not it.
- holeRatio: 0 for a pure Nightingale rose, and accept that measured zeros
collapse into one dot at the hub; 0.1-0.25 when zeros or missing periods
matter, because the hole is what gives them a floor to sit on. The area
formula already accounts for the hole, so this changes the look without
changing what the ink means.
- startAngle: 0 puts the first period's leading edge at twelve o'clock,
which is what a clock face or a calendar wants. Pass -180/n to centre the
first period on the axis instead; pass an offset to put, say, the fiscal
year's first month at the top.
- padAngle: 0 for a solid seamless disc (wind roses read better this way),
0.7-2 to make individual periods countable.
- Grouping and colour: drop `group` for a single-token wheel where only the
shape matters; group by season / shift / sector to bring the five tokens
in. Re-point the token list to colour by rank instead if "which are the
big ones" beats "which part of the cycle is this".
- Density: the rim radius, the label ring offset and the character budget
are the three numbers that trade diagram size against label room. The
tick ladder count and the 11-unit tick-label gap control how busy the
radial axis looks.
- Interaction: onSelect receives the whole sector (value, share, rank,
kind), so wire it to filter a table below, open a day drill-down, or
drive a range picker. Nothing else in the chart depends on it.Concepts
- Area-corrected radius — a sector's ink, not its reach, is what a reader compares, and ink grows with the square of the radius. Solving the annular-sector area for r keeps the two in step; mapping value straight to radius squares every difference, turning a 20.9:1 spread into 436:1.
- Equal angle, unequal radius — the angle is reserved for which period this is, so the wheel stays a faithful clock face and neighbouring periods stay neighbours. Any chart that spends the angle on the quantity has to give that up.
- Measured zero vs missing reading — two facts that a single number cannot hold. A measured 0 is drawn as a tick on the floor ring; a null is drawn as an outlined, unfilled slot. Collapsing them loses the difference between «nothing happened» and «nobody looked».
- Held out of the scale — a turn has no negative arc, so below-zero periods are hatched at the hub and named in the footnote instead of being clamped to nothing. The exact number survives in the table; only the geometry gives up.
- Slot hit region — the target is the whole wedge from hole to rim, not the painted petal, so a 4-unit zero tick is as easy to reach as the tallest period and the gaps between petals are not dead zones.
- Closed-cycle keyboard — arrow keys wrap at both ends because the axis genuinely closes: the period after the last one is the first. Clamping there would invent an edge the data does not have.
Venn Diagram
An area-proportional Venn for two or three sets that measures what it drew and falls back to a labelled region table when circles cannot tell the truth.
Radial Bar Chart
Concentric arc bars — one ring per category on one shared ceiling, with a per-ring target tick, capped overflow, anticlockwise negatives and an sr-only value table.