Visual

Wind rose

Readings bin by direction into compass sectors; sand settles into stacked annular petal segments, then a solid fill/border resolves on top. The most recent reading is highlighted, and a scrolling time table can stand beside the rose — clicking a row selects that reading's mark, and clicking a mark selects its row.

Four structural differences from the other charts. (1) data is a WindDataSet — one dimension (time) and two indicators (direction, intensity), a shape neither Point nor MeshPoint has a slot for. (2) A petal is an aggregate: many readings stack into one mark, and petals.mode decides whether a segment is an intensity band (default) or a single reading. (3) The latest reading(s) are emphasised, composing with hover and dim rather than competing with them. (4) Pointer behavior is overridable through cancellable hooks.

Constructor

new WindRoseChart(host: HTMLElement, config: WindRoseChartConfig)

Mounts a canvas into host and boots the best backend (WebGPU → Canvas2D). Only data is required. Single series — there is no series dimension, no slider, and no legend isolation; the dim machinery drives selection instead.

Data model — WindDataSet

Additive alongside Point/DataSet and MeshPoint/MeshDataSet, which are untouched.

interface WindPoint<Custom = unknown> {
  t: Date | number;      // the dimension: when the reading was taken
  direction: number;    // indicator 1 — the compass direction the wind comes FROM
  intensity: number;    // indicator 2 — speed / magnitude
  custom?: Custom;       // opaque payload, carried through to events and the table
}

interface WindDataSet<Custom = unknown> {
  points: WindPoint<Custom>[];
  directionUnit?: 'deg' | 'rad';   // default 'deg'
  intensityLabel?: string;         // table header; default 'Speed'
  intensityUnit?: string;          // e.g. 'kt'; labels only
}
RuleBehavior
Direction conventionThe direction the wind comes from — the meteorological convention. For "blowing toward", add 180° to your data.
Degrees wrap370 and -10 are both meaningful headings and normalize into [0, 360).
Radians are checkedWith directionUnit: 'rad', a value outside [0, 2π] throws a DataError — it is almost always degrees mislabelled, and silently reinterpreting it would rotate the whole rose.
Unusable rows are droppedA non-finite time/direction/intensity, or a negative intensity, is dropped rather than clamped: a NaN heading has no sector. The count is available from getDroppedCount().
Sorting is the chart's jobReadings are sorted oldest → newest internally, so "the latest reading" means the same thing whatever order they arrived in.

Config — top level

PropertyTypeDefaultDescription
data requiredWindDataSetSee above.
radiusnumber (0..1)0.92Outer radius as a fraction of the square rose box's half-extent.
innerRadiusnumber (0..0.95)0Minimum central hole, as a fraction of the outer radius. The calm circle may push the petals' root further out.
northnumber (deg)0Rotation of due north, clockwise. 0 puts N at 12 o'clock.
clockwisebooleantrueRun bearings clockwise — the compass convention.
grainDensitynumber0.6Grain budget multiplier; segments share it by area, so adding readings subdivides the same sand.
maxGrainsnumber100000Hard grain ceiling.
colorsstring[]DEFAULT_PALETTECycled per band in 'bands' mode; read as sequential ramp stops over the intensity range in 'observations' mode.
backgroundstringtransparentCanvas clear color.
grainobjectsizePx (3), shape ('disc'), jitter (0.6), settleJitter (0.004). Same as bar chart.
sectorsSectorConfigDirection binning. See below.
petalsPetalConfigHow a petal splits into segments. See below.
radialRadialConfigWhat a petal's length measures. See below.
bandsBandsConfigIntensity bands. See below.
calmCalmConfigCentral calm circle. See below.
highlightHighlightConfigonLatest-value emphasis. See below.
tableWindRoseTableConfigoffThe time table. See below.
segmentsRoseStyleConfigoffSolid fill/border per segment. See below.
animationobjectTiming. See below.
interactionobjecthover and dim as on the bar chart, plus mouse hooks.
axes{ x?, y? }offRe-mapped to polar. See below.
legendLegendConfigoffNames the bands. Only meaningful in 'bands' mode — see the note in petals.
titleTitleConfigoffChart title; shares the legend's placement vocabulary.
currentValueobjectoffSame as bar chart; format receives a SegmentMeta.
fpsFpsConfigoffSame as bar chart.
backend'auto' | 'webgpu' | 'canvas2d''auto'Force a rendering backend.

sectors — direction binning

PropertyTypeDefaultDescription
countnumber (≥ 2)16Sectors around the compass.
align'centered' | 'edge''centered''centered' centers a sector on its compass point, so N spans −11.25°..+11.25° at 16 sectors — the classic rose. 'edge' starts sector 0 at due north.
Bins are half-open [lo, hi), so a heading landing exactly on a boundary belongs to exactly one sector, and a heading just shy of a full turn wraps into the north bin rather than falling off the end.

petals — what a segment is

PropertyTypeDefaultDescription
mode'bands' | 'observations''bands''bands' segments by intensity band — the classic meteorological rose. 'observations' gives every reading its own segment.
order'intensity' | 'time''intensity'Inner → outer ordering in 'observations' mode.
maxSegmentsPerSectornumber120Cap per sector; the outermost tail beyond it merges into one aggregated segment.
widthnumber (0..1)0.9Petal sweep as a fraction of its sector. 1 gives touching petals.
padAnglenumber (deg)0Gap trimmed from each petal's sweep.
rampStepsnumber16Steps the intensity ramp is quantized to for grain coloring in 'observations' mode.
What the default mode costs you. In 'bands' mode the smallest addressable mark is a band, so a table-row click highlights the band containing that reading, and the latest-value highlight lands on the band holding the newest one. Exact one-row-one-mark selection is what petals.mode: 'observations' buys. Both behaviors are real; the readout and the table's selected-row styling say which is in play.
The cap merges, it does not drop. Unlike the pie's maxSlices, exceeding maxSegmentsPerSector folds the tail into one aggregated segment rather than discarding readings — dropping them would shorten the petal and misstate how often the wind blew that way. Aggregated segments report aggregated: true and say so in the readout.
Legend. In 'observations' mode the colors are a continuous ramp with nothing discrete to name, so legend.show is ignored rather than mislabelled. The radial axis and the readout carry the intensity story there.
Why rampSteps exists. Grains are batched once per palette entry, so an unquantized per-segment palette would cost a full pass over every grain for every segment. The solid overlay always uses the exact ramp color; only the sand is quantized.

radial — what a petal's length measures

PropertyTypeDefaultDescription
measure'count' | 'percent' | 'intensitySum' | 'intensityMax''count'See the table below.
maxnumberlargest petalExplicit axis maximum, in the measure's units — use it to hold the scale steady while data streams.
labelAnglenumber (deg)half a sectorSpoke the ring labels are drawn along. The default falls in the gap between two sectors, so labels clear both the petals and the rim labels.
measurePetal length
'count'How many readings came from that direction — the classic frequency rose.
'percent'The same, as a share of every reading (calm included), labelled %.
'intensitySum'Summed intensity — where the energy came from, not where the wind most often came from.
'intensityMax'The strongest reading from that direction. Segments still subdivide the petal so each stays addressable, but a segment's own length carries no independent meaning in this mode.

bands — intensity bands

Used by petals.mode: 'bands'.

PropertyTypeDefaultDescription
thresholdsnumber[]Explicit interior thresholds, ascending. Overrides derive/count. Band 0 is (-∞, t₀), the last is [tₙ, ∞).
derive'equal' | 'quantile''equal'How thresholds are derived when none are given.
countnumber4How many bands to derive.
Derived edges that could only ever produce an empty band are dropped (quantiles do this whenever the data ties heavily), because an empty band still claims a legend entry and a color. Explicit thresholds are left exactly as given — there, an empty band is the caller's deliberate scale.

calm — the central circle

PropertyTypeDefaultDescription
belownumber0Readings with intensity strictly below this are "calm". 0 means nothing is.
showbooleantrueDraw the calm circle when any reading is calm.
colorstringfirst palette entryFill color. Worth setting: the default is also band 0's color, so a distinct one tells the calm disc apart from the weakest band.
A calm reading has no meaningful direction, so it belongs in the middle rather than in whichever sector the vane happened to be pointing at. The circle sits on the same radial scale as the petals — which grow from its edge — but is capped at half the radial span, so an overwhelmingly calm dataset cannot swallow the rose it sits inside.

highlight — the latest reading(s)

PropertyTypeDefaultDescription
showbooleantrueEmphasise the latest reading(s).
select'latest' | 'latestN' | 'latestTimestamp''latest'The single newest reading; the newest count; or every reading sharing the newest timestamp.
countnumber3How many, when select is 'latestN'.
rampbooleantrue when count > 1Fade emphasis by recency so the newest reads strongest and the trail fades.
mode'glow' | 'outline' | 'color''glow''glow' brightens the segment and its grains; 'outline' strokes it; 'color' repaints it.
colorstringsegment colorStroke/fill color for 'outline' and 'color'.
gainnumber1.8Brightness gain when 'glow'.
outlinePxnumber2Stroke width when 'outline'.
The highlight rides the same per-segment weight machinery as hover and dim, so the three compose: the newest petal stays lit while the pointer is elsewhere, and hovering it reads as brighter still rather than as a competing effect.

table — the time table

A scrolling list of readings, newest first, mounted on an edge exactly like the legend. Off by default. Placement comes from the shared TableConfig in core/chrome (the table itself is chart-agnostic); the wind rose adds how its columns are formatted.

PropertyTypeDefaultDescription
showbooleanfalseMount the table.
position'left' | 'right' | 'top' | 'bottom''right'Edge to mount it on.
align'start' | 'center' | 'end''center'Cross-axis alignment, matching the legend's.
maxRowsnumber500Rows kept in the DOM, so a long stream cannot grow it without bound.
interactivebooleantrueClick a row to select that reading.
stickyHeaderbooleantrueKeep the header visible while the body scrolls.
followSelectionbooleantrueScroll the selected row into view when the selection changes.
maxHeight / maxWidthstring'100%' / '220px'CSS lengths bounding the layer on its edge.
fontPx / fontFamily / colornumber / string / string11 / system-ui / subduedText styling.
timeFormat(t: Date) => stringclock, or date+clockThe default widens to include a date only when the readings actually span more than a day.
directionFormat(deg: number) => stringcompass bearingDefault prints N/NNE/… — what a reader parses, and what the rim labels say. Pass (d) => `${Math.round(d)}°` for raw degrees.
customColumn{ label, format }Opt-in fourth column fed from WindPoint.custom; omitted entirely when unset.
A table, a legend and a title on the same edge stack rather than overlap: each layer is told how far in the previous one already pushed, and the plot is inset by the total.

segments — the solid layer

Mirrors the pie's slices block: fill.opacity (1), border.show (true when a border block is present), border.width (1), border.opacity (1), and reveal — the same particle→solid crossfade every visual shares (start, duration, ease, grainsTo). Off by default; a config without segments renders as pure sand.

animation

Units: duration, stagger and morphDuration are in milliseconds.
PropertyTypeDefaultDescription
durationnumber (ms)900Per-grain travel time.
staggernumber (ms)500Spread of pour start times.
ease'linear' | 'easeOutCubic' | 'easeOutQuint''easeOutCubic'Grain travel easing.
morphDurationnumber (ms)duration + staggerTransition window for live changes.
reflow'translate' | 'reshuffle' | 'withPetal''translate'How an existing segment's grains move. withPetal locks them rigidly to the wedge — no delay, no fade flash.
morphGrainsbooleantruefalse snaps grains to their new positions so only the solid layer tweens.
enter'grow' | 'pour' | 'rise''grow'How a new segment appears. 'grow' opens it at the petal's rim — the streaming case, where a new reading extends the petal it joins rather than arriving from elsewhere.
exit'shrink' | 'fall' | 'vanish''shrink'How a removed segment leaves.
Segment identity is sector + segment key — the reading's id in 'observations' mode, the band index in 'bands' mode. So appending a reading grows the petal it belongs to instead of re-pouring the rose, and changing radial.measure sweeps every petal to its new length. Changing sectors or petals.mode invalidates every key and re-pours — honestly, because the marks genuinely are different marks.

Overridable mouse behavior

The chart's built-in pointer behavior is handed to you as defaultAction(), so a hook can run it, run it late, or not at all — overriding behavior without forking the chart.

interface MouseHookContext<M, E> {
  meta: M | null;              // the mark under the pointer, null for background
  native: E;                  // the raw DOM event — modifiers, coordinates, preventDefault
  px: { x: number; y: number };  // pointer in CSS px, relative to the chart element
  defaultAction(): void;      // the built-in behavior; idempotent
}

type MouseHook<M, E> = (ctx: MouseHookContext<M, E>) => boolean | void;
interaction.mouseMark typeBuilt-in behavior
onPetalHoverSegmentMetaHighlight the hovered segment.
onPetalClickSegmentMetaSelect it (clicking the selected one again clears).
onPetalDblClickSegmentMetaClear the selection.
onBackgroundClicknullClear the selection.
onTableRowClickObservationMetaSelect that reading.
ReturnEffect
falseSuppress the built-in behavior.
true / nothingRun it after the hook — unless the hook already called defaultAction(), which latches so it can never run twice.
Events still fire. hover and select are emitted whether or not a hook cancelled the default: an observer is not an override, and a caller who suppresses the built-in selection should still get to hear about the click.

Compass & rings — axes

The shared AxisConfig blocks, re-mapped to polar meanings, so one mental model covers every chart.

BlockDrawsNotes
axes.xCompass labels around the rim.ticks thins them by a stride so the survivors stay evenly spaced (16 sectors with ticks: 4 labels N/E/S/W). tickFormat receives the sector's centre bearing in degrees. Consecutive duplicate labels are dropped — past 16 sectors two neighbours round to the same compass point.
axes.yConcentric rings and their values.ticks is the approximate ring count; tickFormat receives the measure value. Rings start at the petals' root, not the disc centre — with a calm circle in the middle, a ring at radius 0 would claim the calm disc's edge is zero when the scale actually starts there.

Methods

MethodDescription
whenReady(): Promise<void>Resolves once the backend is initialized and the first frame is scheduled.
get backend'webgpu' | 'canvas2d' | null.
on('hover' | 'select' | 'highlight', fn)Subscribe; returns an unsubscribe function. See Events.
selectObservation(id | null)Select the mark holding that reading — the same code path a table-row click and a petal click take, so a scripted selection and a clicked one are indistinguishable downstream.
selectSegment(key | null)Select a segment by its stable key.
getSelected(){ segmentKey: string | null; observationId: number | null }.
getHighlighted()ObservationMeta[] — what the latest-value highlight currently resolves to.
getSegments()SegmentMeta[] — every drawn segment, in draw order.
getDroppedCount()How many source rows were dropped as unusable.
setData(data)Replace the whole WindDataSet; segments morph (no re-pour).
add(points)Append readings — the streaming case; petals grow to take them per animation.enter.
remove(indices)Drop readings by source index (negative counts from the end).
repour()Re-run the pour-in animation with the current data.
getData(): WindDataSetThe current dataset.
dispose()Cancel the loop, remove listeners, free the backend and DOM.

Events

// SegmentMeta — one drawn segment (hover, select, currentValue.format)
{
  segmentId: number;      // grain barId slot
  key: string;            // stable identity: morph, selection and table key
  sector: number;         // -1 for the calm circle
  bearingDeg: number;     bearing: string;   // 315, 'NW'
  band: number;           // -1 in 'observations' mode
  bandLo: number;         bandHi: number;
  aggregated: boolean;    // the merged tail of a capped sector
  calm: boolean;
  observationIds: number[];   // source WindPoint indices
  count: number;
  value: number;          petalValue: number;   // in the measure's units
  intensityMin: number;   intensityMax: number;
  latestT: number;        // newest member's time, epoch ms
  a0: number; a1: number; rInner: number; rOuter: number;
  color: RGBA;
}

// ObservationMeta — one reading (table rows, highlight, onTableRowClick)
{
  id: number;             // index into data.points
  t: number;              directionDeg: number;   bearing: string;
  intensity: number;      custom: Custom | undefined;
  segmentKey: string | null;
}
EventPayload
hover{ segment: SegmentMeta | null }
select{ segmentKey, observationId, segment } — fires for petal clicks, table-row clicks and selectObservation()/selectSegment() alike.
highlight{ observations: ObservationMeta[] } — fires whenever the data changes, because "latest" moves with the data rather than with the pointer.

Examples

Classic frequency rose

new WindRoseChart(el, {
  data: { points, intensityUnit: 'kt' },
  sectors: { count: 16 },
  bands: { derive: 'equal', count: 4 },
  calm: { below: 1, color: '#5b6472' },
  segments: { fill: { opacity: 0.9 }, border: { show: true } },
  axes: { x: { show: true, ticks: 8 }, y: { show: true, ticks: 4 } },
  legend: { show: true },
});

Per-reading petals with a time table

const chart = new WindRoseChart(el, {
  data: { points, intensityUnit: 'kt' },
  petals: { mode: 'observations' },   // every reading is its own addressable mark
  colors: ['#1d3b53', '#2f7fa8', '#5ac8c8', '#e8c45a'],   // read as ramp stops
  highlight: { select: 'latestN', count: 3, mode: 'outline', color: '#fff' },
  table: { show: true, position: 'right', maxRows: 200 },
});

// Clicking a table row selects that exact reading's mark, and vice versa.
chart.on('select', ({ observationId }) => console.log(observationId));

Streaming readings

const chart = new WindRoseChart(el, {
  data: { points: [], intensityUnit: 'kt' },
  animation: { enter: 'grow' },        // the petal grows to take the new reading
  radial: { measure: 'percent', max: 30 },  // hold the scale steady while it streams
});

setInterval(() => {
  chart.add([{ t: new Date(), direction: heading(), intensity: speed() }]);
  if (chart.getData().points.length > 500) chart.remove([0]);
}, 1000);

Overriding a click

new WindRoseChart(el, {
  data,
  interaction: {
    mouse: {
      onPetalClick(ctx) {
        if (ctx.native.shiftKey) {
          openInspector(ctx.meta);
          return false;         // suppress the built-in selection
        }
        ctx.defaultAction();      // otherwise select as usual
      },
    },
  },
});

Raw degrees in the table, plus a custom column

new WindRoseChart<{ station: string }>(el, {
  data: { points },   // points carry custom: { station: 'stn-07' }
  table: {
    show: true,
    directionFormat: (deg) => `${Math.round(deg)}°`,
    customColumn: { label: 'Station', format: (c) => (c as any).station },
  },
});

© 2026 · License · Data model →