Violin Plot
A four-state violin plot that estimates each group's density itself — Silverman bandwidth, a quartile box inside the silhouette, prominence-tested peak detection and an sr-only stats table.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-violin.jsonPrompt
Build a React + TypeScript + Tailwind "ChartViolin" card in plain SVG (no chart
library — recharts has no primitive for a mirrored density silhouette) with zod
for the contract.
Contract
- One zod schema is the source of truth, and the props are z.infer of it plus
the rendering options:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
unit?: string;
groups: { id: string; label: string; values: number[] }[] }.
RAW observations only, never a five-number summary: the density, the peak
count and the quartiles are all read off the sample, and a summary cannot be
un-summarised back into a shape. If the rows must stay server-side,
down-sample them; a few hundred values already give a stable silhouette.
- `values` may be empty, and empty is not absent: a group that was measured and
produced nothing gets its own "no data" band. Negative values are legal.
- Options: bandwidth (value units, default = Silverman per group), widthScale
("area" | "count" | "width", default "area"), trim (default true), points
("none" | "jitter"), peakThreshold (0-1, default 0.15), samples (24-256,
default 96), onRetry, onGroupSelect, className, plus the native div props via
forwardRef.
- Export the maths as pure functions beside the component — quantile,
silvermanBandwidth, gaussianKde, findModes, buildViolinModel, buildScale,
silhouettePath. They carry no pixels except the last one, they are what makes
the chart testable, and buildViolinModel is the single place where a group
becomes drawable.
Behavior
- BANDWIDTH is the one knob that can change the finding, so it gets a named
rule rather than a magic number: Silverman, h = 0.9 * min(sd, IQR / 1.349) *
n^(-1/5), computed per group. The IQR arm is what earns its keep — one far
outlier inflates sd until the estimate smooths the whole sample into a single
blob, while the IQR barely moves; 1.349 is the interquartile range of a
standard normal, so both arms estimate the same sigma and the smaller wins.
Use the sample sd (n - 1). Return 0 when the sample has no spread and never
divide by it.
- DENSITY is a gaussian kernel on `samples` evenly spaced grid points:
f(x) = 1 / (n*h*sqrt(2*pi)) * sum exp(-0.5 * ((x - xi)/h)^2). Sort the sample
and walk the grid with two forward-only pointers so only the kernels within 4
bandwidths of x are summed — O(samples + n) instead of O(samples * n), and
the truncation costs under 1e-4 of a kernel's peak. Build the grid as
from + i*step, never an accumulator.
- TRIM (default) cuts the grid at the observed min and max. A gaussian has
infinite support, so an untrimmed estimate always paints mass outside the
data — on latency that is a tail below 0 ms, which is a lie about the world.
Untrimmed runs 3 bandwidths past each end, where the kernel is spent.
- PEAKS are counted by prominence, not by local maxima: for each maximum walk
outward until a taller value appears, keep the lowest value seen on each
side, and accept the peak when its height minus the HIGHER of those two
floors is at least peakThreshold times the group's tallest peak. Bare local
maxima would report every wobble the estimator invents; prominence is what
separates "a second cluster" from "a shoulder". Report plateau centres so two
tied grid points are not two peaks.
- WIDTH SCALING decides what a violin's width means. "area" (default) applies
one density-to-pixels factor across every group, so all violins enclose the
same area — this is ggplot's default and it makes a tight group read fat and
a spread group flat. "count" multiplies each group's density by its n before
that shared factor, so a 40-observation violin cannot look as solid as a
160-observation one. "width" normalises each group to its own peak: shape
comparison only, sample size and density both discarded.
- DEGENERATE DATA, each handled deliberately and each announced in words:
n = 0 -> a dashed band and the words "no data" (never dropped, or the reader
reads "nobody asked"); 1 <= n <= 4 -> draw the observations themselves, no
silhouette and no quartiles reported, because quartiles interpolated from
four numbers are theatre; every value identical -> a spike has infinite
density, so draw a lozenge at that value instead of inventing a curve; all
groups empty -> render the empty branch instead of an axis with no domain; a
group with more than 120 observations draws a rank-systematic subsample of
the points (every k-th value of the sorted sample, which preserves the shape)
and says so under the chart, while the silhouette and every number still use
the full sample.
- The four states are first-class branches of one bg-card panel: pulsing
silhouette skeletons (aria-hidden, plus one sr-only role=status line), an
empty state, an error state whose "Try again" button exists only when onRetry
was passed, and ready.
- INTERACTION: one transparent hit rect per band, drawn last so it hit-tests
above the marks (a transparent fill receives pointer events, fill="none" does
not). Each is role="button", tabIndex 0, aria-pressed, with an aria-label
carrying that group's whole sentence. Hover or focus drives a readout line;
click or Enter/Space pins a group, which dims the others and calls
onGroupSelect; Escape releases the pin; Arrow keys, Home and End move focus
between bands, and preventDefault fires only for keys that were handled so
Tab still leaves the chart. The live input wins in the order hover, focus,
pin — a pin that swallowed hover would make every other violin feel dead.
- RESPONSIVENESS: measure the plot box with a ResizeObserver and set the
viewBox to the measured width, so one user unit is one CSS pixel and text
keeps its stated size at any card width. Fall back to a fixed width before
the first measurement, which draws the chart at natural size and centred
rather than clipped, and to a minimum width below which the whole chart
scales down instead of collapsing. Disconnect the observer on unmount and
whenever the node changes;
the SVG carries an explicit height attribute so it can never collapse to zero
in a flex parent. There are no timers and no rAF to cancel.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border,
muted for skeletons, muted-foreground for axis text, ring for the focus
outline, and var(--chart-1..5) cycling for the group silhouettes.
- Colour never carries a value. Every violin is labelled on the axis, and every
mark that means something is painted in --foreground: a 1px full-range line,
a rounded 7px quartile bar from Q1 to Q3, a median dot filled with --card so
it reads over the bar, and, when a group has more than one peak, a pair of
wedges biting into the silhouette at each peak. That is shape, not hue, so
the chart survives greyscale, colour blindness and a bad projector; the
palette repeats past five groups, which is harmless because the label was
always the identity.
- The silhouette is one path: down the right flank at cx + half(i), back up the
left at cx - half(i), closed. half(i) = scaled(i) / peakDensity * maxHalf,
where maxHalf is 42% of a band capped at 56px. A collapsed quartile box
(Q1 = Q3) keeps a 2px minimum height centred on the pair.
- Jittered points are placed at cx + frac((i+1)*phi) mapped to -1..1, times 72%
of the silhouette's own half-width at that value — a low-discrepancy sequence
spreads them without clumps, it is deterministic (unlike Math.random, which
would differ between server and client and between screenshots), and
constraining them by the local width makes the raw sample and the estimate
reinforce each other.
- The axis is the drawn extent plus a 6% margin, never rounded outwards (that
would spend half the plot on empty space), with an explicit tick list on the
1 / 2 / 2.5 / 5 x 10^n ladder. Compact notation past 100,000 so six-figure
labels do not overflow the axis lane. A dashed zero rule appears whenever the
domain crosses zero. Group labels live in a foreignObject one band wide,
clamped to two lines, so the browser does the fitting and a long label can
never overlap its neighbour or widen the card.
- ACCESSIBILITY: do NOT put role="img" on the plot — that makes the subtree
presentational and silences the focusable bands. Use role="group" labelled by
the card heading and described by the summary line, which states the finding
in words ("... GET /search has more than one peak"). 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 a narrow viewport picks up real horizontal
scroll) holds a table of n, median, Q1, Q3, min, max, peak positions and
bandwidth per group, with a caption naming the kernel, the bandwidth rule,
the trim setting, the quantile definition (R type 7) and the prominence
threshold. The visible readout line is aria-hidden, because the focused band
already announces the same sentence.
- Motion is decorative only: a 150ms opacity transition on dimming and the
skeleton pulse, both disabled under prefers-reduced-motion. Nothing about the
chart depends on it.
Customization levers
- bandwidth: leave it to Silverman per group for exploration; fix one number
when two groups must be compared on shape, since two bandwidths draw two
amounts of smoothing. Raising it is also the fastest way to check whether a
second peak is real: a genuine cluster survives a moderate increase.
- widthScale: "area" to compare shapes honestly, "count" when sample size is
part of the story, "width" when only the shape matters and every violin
should touch the band edge.
- trim: true for bounded quantities (latency, counts, scores), false when the
quantity is genuinely unbounded and rounded tails read better.
- points: "jitter" for samples up to a few hundred, where the raw observations
are the honest backup for the estimate; "none" for dense dashboards. Raise or
drop the 120-point cap with the subsampling note that follows it.
- peakThreshold: raise toward 0.3 for noisy samples where only a strong second
cluster should be reported; drop toward 0.05 when a small shoulder matters.
samples trades curve smoothness for work: 96 is plenty at card size.
- Density: PLOT_HEIGHT, the 42% band fill and the 56px half-width cap are the
three numbers that control how much room the violins get; drop the legend row
or the readout line for a compact card.
- Palette: re-point GROUP_INK to one token plus opacity steps for a monochrome
chart, or key the ink off a status rather than the index when a group's
colour should mean pass or fail.
- Interaction: onGroupSelect already carries the pinned id — wire it to a
drill-down, or to a linked table. The band rect is also the place to hang a
double-click or a context menu without touching the geometry.Concepts
- Kernel density estimate — every observation is replaced by a small gaussian bump and the bumps are summed, which turns a list of numbers into a curve without the arbitrary bin edges a histogram has to choose. The curve is an estimate, not the data: that is why the raw points, the quartile bar and the sample size all stay on the card next to it.
- Silverman bandwidth — the width of those bumps, picked per group from the smaller of the standard deviation and the interquartile range over 1.349. Too small and every trio of observations grows its own bump; too large and a genuinely two-cluster sample is smoothed into the single blob a box plot would have shown you anyway.
- Peak prominence — a bump counts as a peak only when it rises clear of the deepest valley separating it from any taller bump, by at least a fixed fraction of the group's tallest peak. Counting bare local maxima instead would report every wobble the estimator invents, and the whole reason to reach for a violin is to trust the answer to "is this one population or two".
- Trimmed tails — a gaussian kernel never really ends, so an untrimmed estimate always paints mass on both sides of the data. Cutting the curve at the observed minimum and maximum is the default because most measured quantities have a floor, and a latency violin with a tail below zero is a picture of something that never happened.
- Width scaling — what the horizontal axis of a violin means: equal area across groups, area proportional to sample size, or every group stretched to fill its band. The three answers rank the same groups differently, which is why it is a stated choice on the card rather than a silent default.
- Pinned band — hovering or tabbing a band drives the readout, and clicking or pressing Enter pins it so the numbers survive the pointer leaving; the other violins dim, Escape releases, and the pinned id is handed to the consumer. Live input still wins over the pin, so pinning one group never makes the others feel dead.
Chord Diagram
A four-state chord diagram drawn in plain SVG — entities on a ring sized by inflow plus outflow, bezier ribbons sized by volume, symmetric pairs or directed arrowheads, a keyboard-walkable ring and an sr-only flow table.
Ridgeline Plot
A four-state ridgeline (joyplot) that estimates every row's density itself — one shared axis, overlapping curves, keyboard-walkable rows with a peak and median readout, and a pinnable median comparison line.