Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartRaincloudData,
  ChartRaincloudGroup,
  ChartRaincloudPoint,
} from "./chart-raincloud.contract"

export type RaincloudWidthScale = "area" | "count" | "width"

export interface ChartRaincloudProps

Installation

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

Prompt

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 maxPoints a row draws every k-th value of the sorted sample with both extremes kept, and the gutter count changes from 900 to rain 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.

On This Page