Raincloud Plot
A four-state raincloud that stacks a half-violin density, a Tukey box summary and the jittered raw readings in three lanes on one shared scale — id-hashed jitter, keyboard-walkable rows with landmark announcements, and an out-loud sampling notice.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-raincloud.jsonPrompt
Build a React + TypeScript + Tailwind "ChartRaincloud" card in plain SVG with
zod. Recharts has no primitive for a density silhouette, and the three lanes of
a raincloud are not a cartesian series, so the estimation, the layout and the
axis are done by hand in small pure functions that live beside the schema. No
d3, no new dependency.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
unit?: string; sample?: { one: string; many: string };
groups: { id: string; label: string;
points: { id: string; value: number; label?: string }[] }[] }.
RAW readings only, never a five-number summary: the cloud is a density
estimated from the sample, and a summary cannot be un-summarised back into a
shape.
- Every reading carries an id, and it is load-bearing rather than decorative:
the jitter is a hash of it. superRefine rejects duplicate point ids inside a
group precisely because two readings sharing an id would hash to the same
offset, land on the same pixel and read as one drop.
- `points` may be empty, and empty is not absent: a group that was measured and
produced nothing keeps its row and says so, because a silently missing row
reads as "nobody asked".
- superRefine: a ready chart needs at least one reading somewhere; group ids
unique; point ids unique inside a group. Guard every access — sibling
refinements all run, so a ragged payload has to produce an issue rather than
a TypeError thrown out of safeParse.
- Props = z.infer of the schema plus bandwidth (default: Silverman per row),
widthScale ("area" | "count" | "width", default "area"), trim (default true),
rain ("jitter" | "none", default "jitter"), showBox (default true),
rowHeight (default 96, clamped 64-260), dotRadius (default 2.6, clamped
1.2-6), maxPoints (default 300, clamped 20-1200), samples (default 96,
clamped 24-256), onRetry, onGroupSelect, className and the div's native
props, forwardRef to the card.
- Export the maths beside the schema so a test can print the same numbers the
picture is made of: quantile(), silvermanBandwidth(), gaussianKde(),
boxSummary(), rankSample(), hashUnit(), jitterOffset(),
buildRaincloudModel(), niceStep(), buildScale(), layoutRaincloudRows(),
cloudPath().
Behavior
- THREE LANES, ONE SCALE. Each row is cloud (top, half of the row height), box
lane (18px), rain (the rest, floor of one drop diameter + 2px). Every row has
the same height and every row shares one x scale — a raincloud is read by
comparing rows, so a row that grew to fit its own data would make two equal
densities look different. The cloud is a half violin, not a mirrored one:
the mirrored half carries no information and the space it costs is exactly
where the raw readings go.
- DENSITY. Gaussian KDE on a grid of `samples` points; bandwidth is Silverman's
h = 0.9 * min(sd, IQR / 1.349) * n^(-1/5) per row unless one is passed. The
IQR arm earns its keep: one far outlier inflates sd until the whole sample
smooths into a single blob, while the IQR barely moves — and a raincloud is
drawn for samples that have far outliers. Both arrays are sorted, so two
forward pointers cover the kernels that matter and everything past 4
bandwidths is skipped (under 1e-4 of a kernel's peak): O(samples + n), so a
1,000-reading row still re-renders on hover. trim=true clips the estimate at
the observed extremes, because an untrimmed gaussian always paints mass on
both sides of the data — a latency cloud with a tail below 0 ms.
- BOX. R-7 quartiles (same definition as numpy/pandas and this library's box
plot, violin and beeswarm, so the four charts agree about one sample), Tukey
1.5*IQR fences, and whiskers that stop on the most extreme reading still
inside a fence — never on the fence itself, which would claim a reading at a
value nothing was measured at. Everything past a fence is an outlier and is
drawn as a DIAMOND, so the distinction survives greyscale.
- JITTER IS HASHED, NOT RANDOM. FNV-1a over the reading's id folded into
[-1, 1] and scaled by the rain lane's half height. Math.random() would draw a
different picture on the server and on the client; hashing the index instead
of the id would make every drop twitch the moment one reading is filtered
upstream, which reads as new data. Nothing else in the component is
time-derived or random, so the whole chart is SSR-stable.
- HONEST DEGENERATE CASES, every one of them named on the card:
* n = 0: the row stays, says "measured, no readings", and refuses to be
pinned as a baseline (there is no median to compare against).
* 0 < n < 5: no cloud and no box. A density estimated from four numbers is
noise wearing a costume and fences computed from four numbers call half the
sample outliers, so those rows get their drops and a median tick, and a
note under the chart says why.
* no spread at all: no cloud (a spike has infinite density and inventing a
silhouette would be fiction); the box collapses to a 2px sliver and the row
says "no spread — no density".
* non-finite values: dropped, COUNTED, and reported in a sentence under the
chart. Never silently.
* over maxPoints: the rain draws a rank-systematic sample — every k-th value
of the sorted sample with both extremes kept, because "the first N" keeps
the floor and throws the whole right tail away. Said out loud in four
places: the summary line, the gutter ("rain 300/900"), the row's accessible
label and the data table. The cloud, the quartiles and the fences always
use the whole sample: only the rain is ever a subsample.
* labels too long for the gutter: line-clamp-2 + wrap-anywhere, full text in
the title tooltip and in the data table. Never overflow.
- INTERACTION, AND NO GESTURE IS THE ONLY PATH. Hovering a row lights it, dims
the others and prints its whole summary. Clicking pins that row's median as a
dashed comparison baseline drawn across every row, and fires onGroupSelect.
Keyboard: rows are role="option" inside a role="listbox" with a ROVING
TABINDEX — one tab stop for the whole chart. Up/Down move between rows,
Left/Right walk that row's LANDMARKS in axis order (low outlier, lower
whisker, Q1, median, Q3, upper whisker, high outlier — or the readings
themselves on a sparse row), Home/End jump to the ends, Enter/Space pin,
Escape releases. The cursor draws a caret at the landmark and a polite live
region announces one sentence per move ("v2.3.0, Q3 372 ms — three quarters
below, and the box spans 68 ms"). preventDefault fires only for keys that
were handled, so Tab still leaves the chart.
- FOCUS IS NEVER DROPPED. Try again unmounts itself on success, so the retry
handler sets a ref and an effect reads-and-clears it once and hands focus to
the first row; Clear baseline focuses the row it belongs to before it
disappears. No control is ever natively disabled.
- Four first-class branches of one card: loading (deterministic skeleton rows —
cloud, box bar, rain — aria-hidden plus one sr-only role=status line), empty
(a valid contract with no readings, worded so it cannot be mistaken for a
failed fetch or for a summary that arrived pre-aggregated), error (Try again
only when onRetry was passed), ready. A ready chart with zero drawable
readings renders the empty branch.
- CLEANUP: one ResizeObserver, disconnected on unmount and whenever the node
changes. No timers, no rAF, no simulation.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border
for the frame and the gridlines, muted for the skeleton, muted-foreground for
axis, gutter and notes, ring for the focus outline, var(--chart-1..5) cycling
for the clouds and their drops.
- COLOUR NEVER CARRIES A VALUE. It only separates neighbouring rows. Every row
is labelled in the gutter with its n and its median; every mark that means a
number — box, whiskers, median tick, outlier diamond, baseline rule — is
--foreground; an outlier is told apart by SHAPE, not by hue. Past five rows
the ramp repeats, which is harmless precisely because the label was always
the identity. Never use a chart token as a text colour.
- The cloud is a filled path at ~0.24 opacity with a full-strength stroke, so
two neighbouring rows still read apart when a tail runs long. Drops take a
hairline --card stroke so two touching neighbours read as two. The box is
filled --card so the whisker passing behind it does not show through.
- Gutter labels live in a foreignObject with pointer-events:none — an HTML box
hit-tests its whole rectangle even with no background, so it would otherwise
swallow hovers meant for the row under it.
- ACCESSIBILITY: do NOT put role="img" on the plot — that is
children-presentational and would silence every focusable row. Use
role="group" labelled by the heading and described by the summary line, which
states the finding in words. Each row's accessible name is a full sentence
(n, median, middle half, whiskers, outliers, sampling). The visible readout
is aria-hidden, because the focused row already announces itself and the live
region carries the cursor — anything else double-speaks. Below the plot an
sr-only WRAPPER DIV (never sr-only on the table itself: 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 real horizontal scroll) holds n, median, Q1, Q3, whiskers
and outliers per row, with a caption stating the quantile and fence rules.
- Motion: the loading pulse and a 150ms opacity fade on the dimmed rows, both
carrying motion-reduce. Nothing moves on entrance, so the chart is complete
and readable with animation off.
Customization levers
- rowHeight / dotRadius: the two knobs that set density. 76 with dotRadius 2
packs a dashboard column; 140 with dotRadius 3.5 makes one row the hero of a
slide. The cloud always takes half the row and the rain takes what is left,
so a shorter row spends its budget on the rain lane first.
- widthScale: "area" to compare shapes at equal enclosed area, "count" when
sample size is part of the argument (a row of 48 must not look as solid as
one of 900), "width" to compare shape alone. Changing it changes what the
reader concludes — say which one you picked in the caption.
- bandwidth: leave it off for per-row Silverman; pass one fixed value when two
rows must be compared on shape, since two bandwidths mean two amounts of
smoothing. trim=false only where the quantity really is unbounded.
- rain / showBox: turn the rain off for a compact cloud+box card (the outliers
stay, as diamonds on the box lane), or the box off for a pure distribution.
Both are one branch each in the row renderer, and the model already holds
every number either way.
- maxPoints: raise it when every reading really must be drawn and you accept
the density; drop it to 80-120 for a sparkline-sized card. Whatever it is,
the disclosure follows automatically — there is no setting in which a drop
disappears without the card saying so.
- Palette: re-point GROUP_INK to one token for a monochrome chart, or key the
ink off a status ("passed" / "failed") rather than the row index when colour
should mean something; keep every summary mark on --foreground either way.
- Axis: a fixed domain makes two cards comparable — add it as a prop and then
decide out loud what happens to readings outside it (count and name them,
never clamp, or a drop would claim a value nothing was measured at).
TICK_PITCH trades tick density against crowding at 375px.
- Interaction: onGroupSelect already carries the pinned row's id — wire it to a
linked table, a filter or a drill-down. The landmark list is one array per
row, so adding p95 or a target line is one entry, and it becomes reachable by
keyboard and announced in the same breath.Concepts
- Three lanes, one scale — a raincloud is a half violin, a box and the raw readings stacked in separate lanes above one shared axis, so each layer answers a different question without occluding the others: the cloud says what shape the sample has, the box says which five numbers you may quote, the rain says how many readings there actually are and where they pile up. The half is the point — the mirrored side of a violin carries no information, and the space it costs is exactly where the rain goes.
- What the box hides — a box plot draws a bimodal sample and a unimodal one identically, with the median parked in the valley between the two humps, at a value almost nobody experienced. Keeping the density above the box and the raw drops below it is what stops that summary from being the whole story, while still leaving the quartiles quotable.
- Deterministic jitter — the rain's vertical offset is an FNV-1a hash of each reading's id folded into [−1, 1], never
Math.random(). It renders identically on the server, in the browser and in a screenshot diff, and it is keyed on identity rather than on array position, so filtering one reading upstream leaves every other drop exactly where it was instead of twitching the whole row as though the data had changed. - Whiskers stop on observations — the 1.5×IQR fences only decide which readings count as outliers; each whisker is then drawn to the most extreme reading still inside its fence. A whisker drawn at the fence itself would claim a measurement at a value nothing was ever measured at, and the outliers past it are marked by shape (a diamond) rather than by colour, so the distinction survives greyscale and a projector.
- Disclosed downsampling — only the rain is ever capped: past
maxPointsa row draws every k-th value of the sorted sample with both extremes kept, and the gutter count changes from900torain 300/900. The density, the quartiles and the fences are always computed from every reading, so the picture is a sample of the marks and never a sample of the maths. - Landmarks, not tab stops — arrow keys walk each row's summary marks (lower whisker, Q1, median, Q3, upper whisker, and any outlier extremes) off a single roving tab stop, announcing one sentence per move into a polite live region. It is the keyboard equivalent of pointing at a whisker end, without giving a five-row chart twenty-five tab stops or making a pointer the only way to read a number.
Chart Gantt
A four-state schedule chart — lanes that pack overlapping work into sub-rows, an axis that follows the span from minutes to years, progress-filled bars, finish-to-start arrows that flag the dependencies the dates do not honour, and a now line from an injected instant.
Dendrogram
A four-state hierarchical-clustering tree where every bracket sits at the height its two sides merged at, with a cut-height slider that colours and letters the resulting clusters, folded wedges instead of dropped tips, and counted refusals for loops, danglers and double-claimed joins.