Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildWindRoseLayout,
  type ChartWindRoseData,
  type WindRoseBand,
  type WindRosePetal,
  type WindRoseScale,
  windRoseBandAt,
} from "./chart-wind-rose.contract"

Installation

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

Prompt

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.

On This Page