Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildPolarAreaLayout,
  type ChartPolarAreaData,
  type PolarAreaScale,
  type PolarAreaSector,
} from "./chart-polar-area.contract"

export interface ChartPolarAreaProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect" | "title">,

Installation

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

Prompt

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.

On This Page