Wind Rose
A compass rose of directional frequencies, where each sampled direction owns its wedge, stacks its magnitude classes from the hub outward, and is read against labelled percent-of-total rings with calm counted in the hub.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-wind-rose.jsonPrompt
Build a React + TypeScript + Tailwind "ChartWindRose" card — a compass rose of
directional frequencies drawn as hand-written SVG, with zod for the contract.
No chart library: an annular band whose inner and outer radii are cumulative
percentages, on wedges allocated from irregular bearings, is not a primitive any
of them expose, and faking it with a stacked radial bar gives up the layout
maths 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;
magnitude?: string; // what the classes measure
observation?: { one: string; many: string }; // "hour" / "hours"
bins: { id: string; label: string }[]; // hub outward
sectors: { id: string; label: string; bearing: number;
counts: number[] }[]; // one entry per bin, positional
calm?: number; calmLabel?: string }
- ONE ROW PER SAMPLED DIRECTION, including the directions that recorded
nothing. Wedges are allocated from the bearings that arrive, so a rose handed
only the directions that happened silently re-spaces the compass and draws
eleven busy directions as a whole horizon.
- COUNTS, NOT PERCENTAGES. The component divides by the grand total itself so
the petals, the calm figure and the rings all sit on one denominator. A
payload of pre-computed percentages that do not sum to 100 draws a rose that
cannot be read against its own axis.
- `counts` is positional against `bins`: a short row re-labels every class after
the gap, so the schema refuses one, and the renderer reads the missing entries
as zero rather than shifting them.
- The schema also refuses duplicate ids, negative counts, and two directions on
one bearing (they would get zero degrees each). Bearings are meteorological —
the direction the wind blew FROM — and any real number is wrapped into
[0, 360), so -45 and 315 are the same direction.
- Component props: radiusScale ("linear" default | "sqrt"), holeRatio (0.18,
clamped 0-0.5), rotation (degrees, default 0 = north at twelve o'clock),
domainPercent (rim floor, default 0 = fit the data), padAngle (degrees,
default 0.6, clamped 0-3), tickCount (default 4, clamped 2-8), showResultant
(true), locale ("en-US" — Intl.*(undefined) desyncs SSR from the visitor's
locale), onSelect, onRetry, description, className, and the rest of the native
div props spread onto the panel. forwardRef to the panel div.
- Ship the layout as pure functions beside the schema — buildWindRoseLayout(
bins, sectors, calm, { innerRadius, outerRadius, scale, rotation,
domainPercent, tickCount }) plus normalizeBearing, compassAllocations,
windRoseRadius, windRosePercentTicks and windRoseBandAt. They touch no DOM,
measure nothing and use no randomness, so every number in the picture can be
printed by a test without a renderer.
Behavior
- WEDGES ARE ALLOCATED, NOT ASSUMED. Each direction owns everything closer to
it than to either neighbour — a Voronoi partition of the circle — so an
unevenly sampled rose stays honest: a direction with a 90-degree hole behind
it gets the room that hole represents, instead of a slice sized for a compass
it was never on. Evenly spaced input collapses to the obvious 360/n. A single
direction owns the whole turn, which the path builder must handle rather than
reject.
- STACKED, HUB OUTWARD, ALWAYS IN ARRAY ORDER. Band k runs from the cumulative
percentage below it to the cumulative percentage including it, so distance
from the hub is an encoding channel of its own: the calmest class is nearest
the centre on every petal, whatever the palette does. Under the default
"linear" radius a band's THICKNESS is its share, which is what lets a reader
land on an edge and follow it round to a labelled ring. "sqrt" makes a band's
INK proportional instead — honest about area, at the price that the rings
crowd toward the rim and thickness stops meaning anything on its own:
r = sqrt(r0^2 + (R^2 - r0^2) * p / domain)
which collapses to R*sqrt(t) when there is no hub.
- CALM IS IN THE DENOMINATOR, NOT ON THE COMPASS. Observations below the
instrument's start-up threshold happened, so they belong in the total, but
they have no bearing to be drawn at. Print them as a figure in the hub. An
area at the centre of a radial axis reads as a magnitude in some direction,
and calm is the one reading that has none.
- A DIRECTION THAT RECORDED NOTHING IS A READING. It gets a tick on the floor
ring, never a gap that looks like missing data.
- THE RIM IS A FLOOR, NOT A CAP. domainPercent sets the smallest the rim may
stand for, which is how two cards are made comparable; a rose that goes past
it raises the rim and says so in the footnote. Comparability never wins over
drawing the data. Grid rings sit on a 1 / 2 / 2.5 / 5 x 10^n ladder below the
rim, and the rim always carries the domain itself.
- STATISTICS THAT REFUSE TO LIE. An arithmetic mean of bearings is meaningless
(350 and 10 average to 180, the exact opposite of the truth), so the summary
reports the MEAN RESULTANT: sum the directions as unit vectors weighted by
their counts, and report its bearing plus a concentration from 0 (the
directions cancel and nothing prevails) to 1 (every observation on one
bearing). Ties share a rank. When every direction recorded the same number
there is no peak and no resultant worth drawing: say the rose is level rather
than crowning whichever direction sorts first.
- DEGENERATE DATA MUST NOT BREAK GEOMETRY, and must not be swallowed either.
NaN, Infinity and negative counts are dropped, COUNTED, and named in a
visible footnote; a row shorter than `bins` is read as zeros and counted as
short; a row longer has its extra entries dropped and counted; directions that
collide on one bearing get no wedge but stay in the table and on the keyboard.
Nothing is silently clamped.
- THE HIT REGION IS THE WEDGE, not the petal: a transparent sector from the hub
to the rim laid over the paint with the UNPADDED angles, so the gaps are not
dead zones and a 3-unit floor tick is as easy to point at as the prevailing
direction. The pointer's RADIUS then decides the class, through the same pure
windRoseBandAt() the tests use.
- KEYBOARD, and this chart has two axes so it needs both. One roving tab stop
for the whole rose. Left/Right walk the compass and WRAP — the axis genuinely
closes, and clamping at NNW would invent an edge the data does not have.
Up/Down walk the magnitude stack of the direction you are on and CLAMP — the
stack runs from calmest to strongest and has real ends. The class index is
kept while you walk the compass, so one class can be followed all the way
round. Home/End jump to the first/last direction, Enter/Space call onSelect.
preventDefault only on keys actually handled. A gesture is never the only
path: everything hover does, arrows do.
- 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 sampled direction, an error state with a "Try again" button only when
onRetry exists, and ready. A "ready" payload with no directions, no classes or
a zero total lands on the empty branch rather than a blank card — TypeScript
cannot see a zod refine. Pressing "Try again" unmounts that button, so catch
the transition once (a ref read and written synchronously) and move focus to
the panel; otherwise focus falls back to the document body.
- 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 holding the focusable wedges deletes its
entry when a wedge unmounts. Nothing at render reads the clock or a random
number, so server and client agree.
Rendering & styling
- Semantic tokens only. The magnitude ramp is ONE token mixed into the surface —
color-mix(in oklab, var(--chart-1) X%, var(--card)) with X from 30% to 96% —
because the classes are an ORDERED scale and five categorical hues would say
they are unrelated. Mixing into --card makes the ramp travel away from the
surface in both themes. Rings and spokes are stroke-border, floor ticks are
muted-foreground, the resultant and the highlight are --foreground, hairlines
and text halos are --card, the error heading is text-destructive. Axis and
tick text is text-muted-foreground at text-xs. Merge the consumer's className
with cn().
- COLOUR IS NEVER THE ONLY ENCODING. Position in the stack says what the ramp
says, the legend NUMBERS each class so it is a key to position and not only to
fill, the strongest class is hatched as well as darkest (a knockout pattern in
--card, which survives greyscale and colour blindness), and every class is
reachable as text through the readout, the aria-label and the table.
- ONE ANNULAR-SECTOR PATH BUILDER, three cases, and the first is why it is
hand-written: a full turn (a mast that only ever reported one direction, and
every hit region on such a rose) has coincident start and end points, and an
SVG elliptical arc between two identical points is defined to draw NOTHING —
so the one direction 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 hub out. Case two is holeRatio 0 (a 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: a direction
still owns exactly the wedge the compass gave it. Clamp the gap to a quarter
of the sweep per side, and give a lone direction 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.
- Compass labels ride the rim along their own bearing, 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 elided
with an ellipsis, never allowed to overflow. When the compass is crowded,
print every k-th label with k = ceil(label width / chord spacing) — 36
headings come out at k=2 — and drop the last labelled direction when n is not
a multiple of k, or it collides with the first at north. A direction that
loses its label loses nothing else: readout, aria-label and table row stay.
Thin the RING labels from the rim inward, because the rim carries the domain,
the number the whole axis is calibrated against.
- Text on the canvas carries a --card halo via paint-order:stroke, the SVG
equivalent of a knockout, so a ring label stays readable wherever a petal
reaches. Layout is responsive through viewBox + preserveAspectRatio 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 a colour transition 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 — the prevailing
direction and its dominant class, the calm share, the mean resultant and its
concentration. Every hit wedge is a focusable path with its own aria-label
("WSW, 247.5 degrees: 17.1% of all hourly observations, 1,498 hourly
observations, rank 1 of 16, mostly 7-11 kt") and role="button" when onSelect
is wired, role="img" when it is not. Class moves keep the same focused
element, so its aria-label is not re-read: announce them through a polite live
region instead. The visible readout line is aria-hidden on purpose — the
focused wedge already says it, and a live region would say it twice. Under the
chart, an sr-only WRAPPER DIV holds a real table with every direction, its
bearing, count, share, rank and per-class shares, plus a calm row. 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.
Customization levers
- radiusScale: keep "linear" whenever the percent rings are meant to be read —
a band's thickness is then its share. Switch to "sqrt" for a rose whose peak
dwarfs everything else, and accept that the rings crowd toward the rim.
- domainPercent: leave at 0 for a rose read on its own; set it to a round
ceiling above every peak in the set (20 for a group of roses peaking at 13-17%)
to make a wall of cards comparable at a glance. It can only raise the rim.
- holeRatio: 0.14-0.24 keeps room for the calm figure and gives the empty
directions a floor to sit on; 0 is the traditional hubless rose, and the cost
is that calm and the zero ticks lose their footing.
- padAngle: 0 for the traditional seamless compass, 0.6-2 to make individual
directions countable. rotation aligns the rose with a runway heading or a site
plan; leave it at 0 and north stays at twelve o'clock.
- Bins: 4-6 classes is the readable range. Fewer than 4 and the stack stops
saying anything the outline did not; more than 6 and the inner bands are
thinner than the hairline between them. The labels are yours — knots, m/s,
Beaufort, dB, ppm.
- Colour: re-point the ramp at any single token, or widen MIN_MIX/MAX_MIX for a
heavier or lighter rose. If your theme has a second hue, ramp between two
tokens with color-mix rather than assigning one hue per class — the classes
are ordered, and categorical colour would deny it. Keep the hatch on the top
class as the colour-independent channel.
- Trim: showResultant={false} for a rose that is only about shape;
tickCount for a busier or quieter radial axis; drop onSelect and the wedges
become role="img" figures instead of buttons.
- Interaction: onSelect receives the whole petal (bearing, percent, rank, bands,
dominant class), so wire it to filter a table below, drive a sector-by-sector
drill-down, or set a directional filter on a map. Nothing else depends on it.
- Beyond wind: any "frequency by direction, split by strength" reads on this
chart — noise or odour complaints by bearing, pollutant concentration by
source direction, ship traffic by heading, antenna gain by azimuth.Concepts
- Directional frequency — the question is “how often did it come from there, and how hard”, so the angle is reserved for the direction and the radius spends itself on a share of the whole sample. A chart that spends the angle on the quantity has given up the compass.
- Calm has no bearing — readings below the instrument's start-up threshold are real observations with no direction. They stay in the denominator, so every percentage on the card is a share of everything that happened, and they are printed in the hub as a figure because an area at the centre of a radial axis would read as a magnitude in some direction.
- Allocated wedges — each direction owns everything closer to it than to either neighbour, so a compass with holes in it stays honest: the sampled directions do not quietly stretch to cover the ones that were never measured. Evenly spaced bearings collapse to the obvious 360/n.
- Distance from the hub is a channel — the class order is fixed hub-outward, so the calmest class is nearest the centre on every petal and the reader can tell the classes apart with the colour switched off. The legend numbers them for the same reason; colour is the redundant channel here, not the primary one.
- The rim is a floor, not a cap — a fitted rim fills the frame with whatever this site happens to record, which is right for a rose read alone and wrong for a wall of them. A shared ceiling makes them comparable, and a rose that exceeds it raises the rim and says so rather than painting off the edge.
- Mean resultant — bearings cannot be averaged arithmetically (350° and 10° average to 180°, the exact opposite of the truth), so the directions are summed as unit vectors weighted by their counts. Its length is a concentration: 0 means the directions cancel and nothing prevails, 1 means every observation came from one bearing.
ECDF
A four-state empirical CDF that bins nothing — one step curve per group on a shared percent axis, percentiles read off the steps themselves, named crossings between curves, a keyboard-driven scan line and a vertex cap that states its own error.
Correlogram
A correlation matrix drawn half as coefficient-shaped glyphs and half as numbers, with non-significant pairs hatched instead of blanked and the axes reorderable by hierarchical clustering.