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
}
| Rule | Behavior |
| Direction convention | The direction the wind comes from — the meteorological convention. For "blowing toward", add 180° to your data. |
| Degrees wrap | 370 and -10 are both meaningful headings and normalize into [0, 360). |
| Radians are checked | With 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 dropped | A 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 job | Readings are sorted oldest → newest internally, so "the latest reading" means the same thing whatever order they arrived in. |
Config — top level
| Property | Type | Default | Description |
data required | WindDataSet | — | See above. |
radius | number (0..1) | 0.92 | Outer radius as a fraction of the square rose box's half-extent. |
innerRadius | number (0..0.95) | 0 | Minimum central hole, as a fraction of the outer radius. The calm circle may push the petals' root further out. |
north | number (deg) | 0 | Rotation of due north, clockwise. 0 puts N at 12 o'clock. |
clockwise | boolean | true | Run bearings clockwise — the compass convention. |
grainDensity | number | 0.6 | Grain budget multiplier; segments share it by area, so adding readings subdivides the same sand. |
maxGrains | number | 100000 | Hard grain ceiling. |
colors | string[] | DEFAULT_PALETTE | Cycled per band in 'bands' mode; read as sequential ramp stops over the intensity range in 'observations' mode. |
background | string | transparent | Canvas clear color. |
grain | object | — | sizePx (3), shape ('disc'), jitter (0.6), settleJitter (0.004). Same as bar chart. |
sectors | SectorConfig | — | Direction binning. See below. |
petals | PetalConfig | — | How a petal splits into segments. See below. |
radial | RadialConfig | — | What a petal's length measures. See below. |
bands | BandsConfig | — | Intensity bands. See below. |
calm | CalmConfig | — | Central calm circle. See below. |
highlight | HighlightConfig | on | Latest-value emphasis. See below. |
table | WindRoseTableConfig | off | The time table. See below. |
segments | RoseStyleConfig | off | Solid fill/border per segment. See below. |
animation | object | — | Timing. See below. |
interaction | object | — | hover and dim as on the bar chart, plus mouse hooks. |
axes | { x?, y? } | off | Re-mapped to polar. See below. |
legend | LegendConfig | off | Names the bands. Only meaningful in 'bands' mode — see the note in petals. |
title | TitleConfig | off | Chart title; shares the legend's placement vocabulary. |
currentValue | object | off | Same as bar chart; format receives a SegmentMeta. |
fps | FpsConfig | off | Same as bar chart. |
backend | 'auto' | 'webgpu' | 'canvas2d' | 'auto' | Force a rendering backend. |
sectors — direction binning
| Property | Type | Default | Description |
count | number (≥ 2) | 16 | Sectors 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
| Property | Type | Default | Description |
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. |
maxSegmentsPerSector | number | 120 | Cap per sector; the outermost tail beyond it merges into one aggregated segment. |
width | number (0..1) | 0.9 | Petal sweep as a fraction of its sector. 1 gives touching petals. |
padAngle | number (deg) | 0 | Gap trimmed from each petal's sweep. |
rampSteps | number | 16 | Steps 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
| Property | Type | Default | Description |
measure | 'count' | 'percent' | 'intensitySum' | 'intensityMax' | 'count' | See the table below. |
max | number | largest petal | Explicit axis maximum, in the measure's units — use it to hold the scale steady while data streams. |
labelAngle | number (deg) | half a sector | Spoke 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. |
| measure | Petal 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'.
| Property | Type | Default | Description |
thresholds | number[] | — | 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. |
count | number | 4 | How 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
| Property | Type | Default | Description |
below | number | 0 | Readings with intensity strictly below this are "calm". 0 means nothing is. |
show | boolean | true | Draw the calm circle when any reading is calm. |
color | string | first palette entry | Fill 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)
| Property | Type | Default | Description |
show | boolean | true | Emphasise the latest reading(s). |
select | 'latest' | 'latestN' | 'latestTimestamp' | 'latest' | The single newest reading; the newest count; or every reading sharing the newest timestamp. |
count | number | 3 | How many, when select is 'latestN'. |
ramp | boolean | true when count > 1 | Fade 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. |
color | string | segment color | Stroke/fill color for 'outline' and 'color'. |
gain | number | 1.8 | Brightness gain when 'glow'. |
outlinePx | number | 2 | Stroke 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.
| Property | Type | Default | Description |
show | boolean | false | Mount 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. |
maxRows | number | 500 | Rows kept in the DOM, so a long stream cannot grow it without bound. |
interactive | boolean | true | Click a row to select that reading. |
stickyHeader | boolean | true | Keep the header visible while the body scrolls. |
followSelection | boolean | true | Scroll the selected row into view when the selection changes. |
maxHeight / maxWidth | string | '100%' / '220px' | CSS lengths bounding the layer on its edge. |
fontPx / fontFamily / color | number / string / string | 11 / system-ui / subdued | Text styling. |
timeFormat | (t: Date) => string | clock, or date+clock | The default widens to include a date only when the readings actually span more than a day. |
directionFormat | (deg: number) => string | compass bearing | Default 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.
| Property | Type | Default | Description |
duration | number (ms) | 900 | Per-grain travel time. |
stagger | number (ms) | 500 | Spread of pour start times. |
ease | 'linear' | 'easeOutCubic' | 'easeOutQuint' | 'easeOutCubic' | Grain travel easing. |
morphDuration | number (ms) | duration + stagger | Transition 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. |
morphGrains | boolean | true | false 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.mouse | Mark type | Built-in behavior |
onPetalHover | SegmentMeta | Highlight the hovered segment. |
onPetalClick | SegmentMeta | Select it (clicking the selected one again clears). |
onPetalDblClick | SegmentMeta | Clear the selection. |
onBackgroundClick | null | Clear the selection. |
onTableRowClick | ObservationMeta | Select that reading. |
| Return | Effect |
false | Suppress the built-in behavior. |
true / nothing | Run 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.
| Block | Draws | Notes |
axes.x | Compass 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.y | Concentric 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
| Method | Description |
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(): WindDataSet | The 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;
}
| Event | Payload |
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 },
},
});