Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { ChartViolinData, ChartViolinGroup } from "./chart-violin.contract"

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

export interface ChartViolinProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartViolinData {
  /**
   * Kernel bandwidth in **value units**, applied to every group. Omit for

Installation

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

Prompt

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.

On This Page