Charts

Quantile Dot Plot

A four-state quantile dot plot that turns a predictive distribution into countable outcomes — N equal-probability dots stacked into non-overlapping columns, an optional threshold that splits them, and the share on each side reported as a number you can verify by counting.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartQuantileDotPlotData,
  ChartQuantileDotPlotDistribution,
  ChartQuantileDotPlotThreshold,
} from "./chart-quantile-dot-plot.contract"

export interface ChartQuantileDotPlotProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartQuantileDotPlotData {

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartQuantileDotPlot" card in plain SVG
with zod. Recharts has no dot-plot primitive and the layout is a binning and
stacking problem rather than a cartesian series, so the quantile function, the
binning, the dot-size fit and the axis are small pure functions beside the
schema. No new dependency.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    unit?: string; axisLabel?: string;
    outcome?: { one: string; many: string };
    dotCount?: number (int, 5-100, default 20);
    distribution:
      | { kind: "quantiles"; values: number[]; probs?: number[] }
      | { kind: "samples";   values: number[] };
    threshold?: { value: number; label?: string;
                  direction?: "at-or-below" | "at-or-above" } }.
- The two distribution shapes exist because callers have one or the other: a
  posterior / Monte-Carlo sample, or the five percentiles a metrics API
  returns. Both collapse to ONE question — "what value sits at cumulative
  probability p?" — and every dot is one answer to it.
- `probs` is what lets a five-number summary be re-quantised into twenty dots,
  and it is also what tells the component that the tails beyond p5 and p95 were
  never measured. Omit it and the values are read as an equal-probability grid,
  value j standing at (j + 0.5) / m.
- superRefine: a ready card needs at least one usable number; quantile values
  must ascend (a quantile function may flatten, never descend); probs must pair
  up with values, sit strictly inside (0, 1) and strictly increase. Guard every
  access — sibling refinements all run, so a ragged payload must produce an
  issue rather than a TypeError thrown out of safeParse.
- Props = z.infer of the schema plus dotRadius (default 13, clamped 2-20),
  maxStackHeight (default 150, clamped 60-420), onRetry, onColumnSelect,
  className and the div's native props, forwardRef to the card.
- Export the maths so it is testable and this prompt can describe it:
  quantile(), buildQuantileSource(), dotValues(), binDots(), fitDots(),
  stackHeight(), buildQuantileDotModel(), niceStep(), buildScale().

Behavior
- THE DOTS ARE THE MESSAGE. Draw dotCount equally likely outcomes at the
  MIDPOINTS of equal probability slices, p_i = (i + 0.5) / dotCount. Midpoints,
  not edges: edges would ask for p = 0 and p = 1, which for most distributions
  is not a number at all and would make the two extreme dots claim a certainty
  nothing measured. One dot is therefore worth exactly 1/dotCount, and the card
  prints that ("one dot = 1 in 20, 5%") next to the chart.
- Samples are read with the R type 7 quantile (numpy/pandas' definition, so the
  card agrees with the histogram and box plot next to it). A quantile grid is a
  piecewise-linear inverse CDF between its knots, CLAMPED outside them — never
  extrapolated — and when the clamp bites, the card names the range the grid
  actually covers against the range the dot grid asks for.
- BINNING is Wilkinson's: walk the sorted dots and open a new column whenever
  the next value sits a full dot pitch past the value that opened the current
  one. Data-driven, so a gap in the distribution is a gap on the card rather
  than an empty lattice cell.
- A THRESHOLD IS A HARD BIN BOUNDARY. Bin the two sides separately, and walk
  each side AWAY from the line: below it a column hangs off its highest dot and
  covers (high - pitch, high], above it off its lowest and covers [low, low +
  pitch). Both halves are load-bearing. Binning across the line two-tones a
  column — one x position claiming both answers. Binning both sides from the
  same end leaves the last column before the line standing at "first dot + half
  a pitch", so a dot just under the threshold is drawn PAST the rule: coloured
  as counted, positioned where the reader counts it against. Walking outwards
  puts every column centre at least half a pitch clear of the line, so the
  counted dots are all on the counted side at any dot size, dot count or
  threshold position, and the rule stands in the 6% lane between two columns.
- A column is drawn at the CENTRE OF THE SLICE its dots fall in, never at the
  mean of its dots. That is what makes two column centres provably at least one
  pitch apart, so stacks can never overlap; the price is that the position is a
  slice rather than a point, so the slice each column covers is printed in the
  readout and the table instead of being implied.
- DOT SIZE IS FITTED, NOT CONFIGURED. Walk a ladder of radii down from dotRadius
  to the smallest legible one and take the first whose tallest stack fits
  maxStackHeight. A descending scan, NOT a bisection: the height is not monotone
  in the radius, because a wider dot moves the bin boundaries as well as filling
  them, so a slightly wider dot can split a cluster and come out shorter — and a
  bisection reads that dip as proof that everything above it overflows, handing
  back a dot much smaller than what fits (or calling the card overflowed when it
  is not). Every rung the scan returns is a size measured to fit. Then the field
  is exactly as tall as
  the dots need: empty air above a dot plot is a bar chart pretending to be
  one. When even the smallest legible dot does not fit, the field GROWS past
  the ceiling and says so. Nothing is ever clipped, dropped or overlapped: the
  number of circles on screen must always equal dotCount, because "count them"
  is the entire claim.
- THE THRESHOLD splits the dots by value, and the binning splits the columns by
  the same comparison, so a column is one colour or the other and never both.
  The card reports the count first ("5 in 20 deliveries fall at or below 10
  days — 25%") and
  then, when it differs by more than a tenth of a dot, the distribution's OWN
  probability ("the forecast says 24%; the grid resolves to 5% per dot"). Two
  numbers, both true, and never one dressed up as the other. If the input
  cannot pin the exact probability down — a threshold outside a supplied
  quantile grid — say that instead of guessing.
- The axis is the dots' extent WIDENED TO HOLD THE THRESHOLD (a rule pushed off
  the edge would quietly turn "1 in 20" into "none visible") plus a 6% margin,
  deliberately not rounded outwards. Ticks on the 1 / 2 / 2.5 / 5 x 10^n ladder,
  one per ~92px, explicit "en-US" locale, compact notation past 100,000, first
  and last labels anchored to their ends.
- INTERACTION. One transparent hit rect finds the nearest column itself and
  converts through its own client box, so it stays correct when the SVG scales
  below its minimum width; the click handler re-runs that search rather than
  trusting hover, because a tap is not guaranteed to send a pointermove first.
  Keyboard: the plot is a role="listbox" with tabIndex 0 and
  aria-activedescendant — one tab stop, not one per column — Left/Right walk,
  Home/End jump, Enter/Space pin (onColumnSelect), Escape releases.
  preventDefault fires only for keys that were handled, so Tab still leaves.
- Four first-class branches of one card: loading (a deterministic skeleton in
  the same hump shape, aria-hidden, plus one sr-only role=status line), empty
  (worded so a point estimate lands there knowing why), error (Try again only
  when onRetry was passed), ready. A ready payload with nothing drawable
  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 axis and grid, muted for the skeleton, muted-foreground for axis and
  footnotes, destructive for the error headline, ring for the focus rect,
  var(--chart-1) for the counted side and var(--chart-2) for the rest. The
  threshold rule and its label are --foreground, because it is the one mark
  that is not a category; the label carries a --card halo via paint-order:
  stroke so it stays readable over a grid line.
- The rule is drawn OVER the dots. No dot can sit on it once the threshold is a
  bin boundary, so drawing it last hides nothing countable — and it is the only
  way the rule survives a dense stack pressed against it: underneath, the
  hairline --card ring of the nearest column eats into the lane and the line
  breaks up exactly where the reader is counting against it.
- Each dot takes a hairline --card stroke, so two touching neighbours still read
  as two — the difference between counting 5 and counting 4.
- ACCESSIBILITY: do not put role="img" on the plot — children-presentational
  would silence every column. role="listbox" labelled by the heading and
  described by an sr-only paragraph that states the finding in words: how many
  outcomes, what one dot is worth, the median, the split and whether the dot
  grid rounds it. 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 a row per column: values covered, dots, share, running total, and how
  many are on the counted side. The visible readout line is aria-hidden,
  because the focused listbox already announces the active column.
- Motion: the only animation is the skeleton pulse and a 150ms opacity fade on
  the dimmed columns, both carrying motion-reduce. Nothing else moves.

Customization levers
- dotCount: the chart's resolution. 20 dots resolve to 5 percentage points and
  are countable at a glance; 50 give 2 points and start to read as a shape; 100
  is a histogram made of dots. No number of dots makes the forecast sharper
  than it is, which is why the card keeps printing the distribution's own
  probability beside the count.
- dotRadius / maxStackHeight: the two knobs for density. Big dots and a low
  ceiling give a wide, chunky, "count me" card for a dashboard hero; radius 5-7
  with a 200px ceiling packs a tall hump into a sidebar. Both are ceilings the
  auto-fit may lower, never raise.
- threshold: drop it for a plain distribution card (every dot then takes
  --chart-1), flip direction to count the other side, or drive value from a
  slider upstream — the split, the headline and the table all follow one prop.
- Palette: MET_INK / REST_INK are one line each. Point them both at --chart-1
  for a monochrome card, or key MET_INK off a status token when the threshold
  means pass/fail rather than one side of a line.
- Wording: `outcome` is the noun the whole card counts in — deliveries,
  requests, evenings, patients — and it is what turns "0.25 probability" into a
  sentence someone can act on. `unit` and `axisLabel` do the same for the axis.
- Interaction: onColumnSelect carries the whole column (values, count, running
  total), so it can drive a linked table, a "what if we ship a week later"
  panel, or a second card sharing the axis.

Concepts

  • Frequency framing — the card answers "how likely" with a number of things rather than a probability. A density curve asks the reader to integrate an area by eye; twenty dots ask them to count, which is a skill everyone already has. The whole component exists to keep that count honest.
  • Equal-probability discretisation — dots sit at the midpoints of equal probability slices, p = (i + 0.5) / N, so every dot is worth exactly the same 1/N and none of them claims the impossible certainty at p = 0 or p = 1. That is what makes "3 dots in 20" a probability statement rather than a drawing.
  • One quantile function, two inputs — raw samples and a five-percentile grid are the same thing seen from different distances: both are asked "what value sits at probability p?". Samples answer with the R type 7 quantile; a grid answers with piecewise-linear interpolation between its knots, and clamps outside them, because a p5/p95 grid knows nothing about the outermost 5% and inventing a tail there would be the chart making data up.
  • Non-overlapping by construction — Wilkinson binning opens a new column once a value sits a full dot pitch past the one that opened the current column, and the column is drawn at the centre of that slice rather than at the mean of its dots. The mean would be truer to the data and would let two stacks collide; the slice centre cannot, so the position is honest about being a slice and the exact span is printed instead.
  • Fitted, never clipped — the dot size is stepped down a ladder of radii until the tallest stack fits the ceiling, and if no legible size fits, the field grows instead. It cannot be bisected: a wider dot shifts the bin boundaries as well as filling them, so the height dips and rises as the radius grows, and a binary search would stop at the first dip and shrink the dots for nothing. A quantile dot plot that hides a dot to make a card fit has broken the only promise it makes, so the number of circles on screen always equals dotCount.
  • Threshold as a split, not a filter — the rule divides the dots by value, and the binning obeys the same comparison, so the colour a dot takes and the side of the line it is drawn on can never disagree. That takes two things: the threshold is a hard bin boundary (a column is never two-toned), and each side is binned away from the line (below it a column hangs off its highest dot, above it off its lowest). Bin both sides from the same end instead and the last column before the rule stands half a pitch past the dot that opened it — a dot counted as "at or below" drawn on the far side of the very line it was counted against, which is the one error a card that says "count them yourself" cannot survive. Walking outwards keeps every column centre half a pitch clear, so the rule stands in the lane between two columns and is drawn over them; the axis is widened to hold it, because a threshold nobody can see turns "1 in 20" into "none visible".
  • Counted number vs. exact number — the card leads with what you can verify (5 of 20 dots) and then, when they differ, prints what the distribution itself says (24%) with the grid's resolution named. Rounding to the nearest dot is the price of countability; pretending it did not happen would be the chart lying about its own precision.

On This Page