Contour
A four-state 2-D density contour plot — Gaussian KDE on a grid, marching-squares rings drawn at highest-density levels, a live smoothing slider, an optional raw scatter underneath, and a hard sample floor under which it refuses to smooth at all.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-contour.jsonPrompt
Build a React + TypeScript + Tailwind "ChartContour" card in plain SVG with zod.
It draws a 2-D kernel density estimate of a raw scatter as filled contour bands.
Recharts has no contour primitive and none of this is a cartesian series, so the
estimate, the level set and the ring tracing are all done by hand, in small pure
functions that live beside the schema.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
description?: string;
x: { label: string; unit?: string; domain?: [number, number] };
y: { label: string; unit?: string; domain?: [number, number] };
sample?: { one: string; many: string };
points: { x: number; y: number }[] }.
Points carry NO id and NO label: a contour is an ESTIMATE of where the sample
is dense, so nothing on the canvas points back at one row. `sample` names the
rows ("2,000 sessions") so the text alternative never says "items".
- superRefine rejects a ready chart with zero points and any fixed domain whose
two bounds are equal.
- Component props = z.infer of the schema plus resolution (default 72, clamped
32-160, snapped to 8), bandwidth (default 1, clamped 0.25-6, snapped to 0.25 -
a MULTIPLIER of the automatic bandwidth, not data units), adjustableBandwidth
(default true), levels (default 5, clamped 2-7), fill (default true),
showPoints: boolean | "auto" (default "auto"), minSample (default 30, clamped
5-1000), locale (default "en-US"), formatX / formatY, onRetry, className, and
the rest of the div's native props. forwardRef to the card element.
- Export the maths beside the schema so it is testable and so this prompt can
describe it: silvermanBandwidth(), estimateDensity(), levelProbabilities(),
densityThresholds(), marchingSquares(), buildContours(), resolveAxisScale(),
niceStep().
Behavior
- THE SAMPLE FLOOR IS THE FIRST BRANCH, not a footnote. Under `minSample`
drawable readings the component refuses to estimate: it plots the raw points,
says how many it has against how many it needs, and draws no surface. A
Gaussian kernel over a dozen readings produces a confident two-peaked hill
built entirely out of whichever gaps the sampling happened to leave, and it
looks exactly as authoritative as one built from ten thousand. Refusing is the
feature.
- ESTIMATE IN PLOT SPACE. Resolve both axes first (fitted domains snapped
outward onto the tick step, fixed domains honoured exactly), project the
sample into plot-local view units, then estimate. The bandwidth then means the
same thing whatever the axes measure, and "is the bandwidth finer than a grid
cell" becomes a comparison between two numbers in one space.
- OUTSIDE THE DOMAIN IS COUNTED, NEVER CLAMPED. A kernel is a claim about a
neighbourhood; clamping an outlier onto the frame builds a ridge along an edge
nothing was measured at. Non-finite coordinates are counted the same way, and
both totals appear in the summary and under the plot. Compare against the
bounds with a relative slack of 1e-9 * span, because a fitted domain is
snapped through toPrecision and can land a hair inside the value that made it.
- BANDWIDTH. Start from Silverman's rule, h = 0.9 * min(sd, IQR/1.34) *
n^(-1/5), per axis. The min() half is the one that matters: on a bimodal
sample the standard deviation is inflated by the gap between the modes, and a
bandwidth taken from sd alone smooths them into one hill - the exact failure
this chart exists to expose. The prop is a MULTIPLIER of that, because the two
axes have different units and the honest starting point is the one the sample
implies. Floor it at 0.75 of a grid cell in each direction: below that the
binning grid is showing through and every reading grows its own ring. A
zero-spread sample (Silverman returns 0) lands on the same floor instead of
dividing by zero. Say when the floor is in force.
- FAST KDE, NOT THE TEXTBOOK ONE. Deposit each reading BILINEARLY across the
four surrounding grid nodes, then convolve once along x and once along y with
a 1-D Gaussian truncated at 3 sigma. A Gaussian is separable, so that is the
same kernel as the 2-D one at O(n + cols*rows*(sx+sy)) instead of
O(n*cols*rows) - measured, 2,000 readings on a 72-node grid: about 1.5 ms per
estimate, and it runs on every frame of the bandwidth slider. Nearest-node
binning is the tempting shortcut and it re-quantises the sample onto the grid:
measured, shifting the sample half a cell then moves a contour's area by up to
5%, against under 2% with bilinear binning.
- Mass that smears past the frame is DROPPED, not reflected. Reflecting
conserves mass by assuming the density is symmetric about the frame, which
paints a ridge along an edge the sample simply runs off.
- LEVELS ARE HIGHEST-DENSITY REGIONS, not evenly spaced density values. The 50%
contour is "the smallest region holding half the sample" - a sentence a reader
can act on - where "the ring at 0.0043 readings per unit squared" is not. Sort
the node values once, keep a running total beside them, then walk the
requested probabilities in ASCENDING order so the cursor only moves forward
(walking them descending silently returns the same threshold for every level
and collapses the whole set to one ring). Fold in ties: the drawn region is
every node at or above the threshold, which can be more nodes than the walk
stopped on. Print the share the ring ACTUALLY encloses, measured back off the
grid, not the share that was asked for. Duplicate thresholds are dropped and
the count is owned up to in the legend.
- MARCHING SQUARES with the grid padded by a ring of zeros ONE FULL CELL outside
the frame. The padding is what makes every contour a closed ring: a ridge
running off the edge would otherwise leave an open curve, which cannot be
filled and cannot be counted as a region. Because the pad sits a whole cell
out, its geometry is outside the plot rect too, so the closing run is clipped
away and the fill simply reaches the frame.
- Both saddle cases (5 and 10) are resolved on the average of the four corners.
Guessing there is what makes two neighbouring bumps merge and unmerge as the
slider moves.
- STITCH SEGMENTS INTO RINGS BY EXACT ENDPOINT MATCH, and compute a shared edge
as (i+1)*cellW, never x0+cellW. Two neighbouring cells must agree on that edge
to the last bit or the stitcher cannot tell they meet: measured, the sloppy
form left the two ends of a ring 3 ulps apart and one ring in five came back
OPEN - which paints as a fill with a straight chord across it. An HDR
threshold is always one of the node values, so a contour also always runs
exactly through at least one node; the zero-length segment that produces is
dropped, and the ring stays whole because its two neighbours already meet
there.
- COUNT REGIONS BY THE SHOELACE SIGN. The case table keeps the dense side on one
hand, so an outer boundary and a hole come out with opposite signs. That is
what separates "two modes" from "one mode with a dip in it", with no
point-in-polygon test. Report it from a MIDDLE contour, never the innermost:
the innermost level sits on the noisiest part of the estimate and can
legitimately split on a perfectly unimodal sample.
- DEGENERATE DATA is the test that matters. Zero drawable readings renders the
empty branch. One reading, or a whole sample stacked on one spot, pads its
zero-width domain by +/-50% and lands mid-plot. Every reading identical gives
a floored bandwidth and a collapsed level set, and both are stated out loud.
- CLEANUP: one ResizeObserver, disconnected on unmount. Nothing else - no timer,
no rAF, no listener. The plot is a pure function of props plus one piece of
interaction state.
Rendering & styling
- MEASURE THE CARD, then make the viewBox match the measured width so the scale
is exactly 1 and fontSize={11} really is 11px. A fixed viewBox on a 375px
screen shrinks an 11px tick label to 5px. Below 300px it stops re-flowing and
scales down as a whole. One x tick per ~92px and one y tick per ~58px, fed
into the axis resolver, so a narrow card grows fewer ticks instead of
overlapping ones.
- COLOUR. The five chart tokens are ordered so each step moves further from the
card in both themes (light 0.62 -> 0.30 lightness, dark 0.58 -> 0.90), so
var(--chart-1) .. var(--chart-5) already IS a sequential ramp, monotone in
lightness, which is what keeps it a ramp in greyscale. A level sits at
index/(levels-1) along it and mixes its two neighbouring stops with
color-mix(in oklab, ...) when it falls between them. No hex, no invented hue.
- COLOUR IS NEVER ALONE. Nesting is the primary channel - an inner ring is
inside an outer one whatever the colours do - and each ring is DIRECTLY
LABELLED with the share it encloses, placed at the top of its largest region.
Labels are placed greedily and a level whose anchor collides with one already
printed is skipped rather than overprinted; nothing is lost, because the
legend and the table carry every level. Every glyph gets a --card halo via
paint-order: stroke, the SVG equivalent of a text outline.
- Paint the bands lowest threshold first so each covers the previous, and give
every level's rings ONE path with fill-rule="evenodd" so a hole stays a hole.
With fill={false} the same paths are stroked in the ramp colour instead.
- The raw scatter goes UNDER the bands. Inside the outermost contour the dots
are a smudge the contour already describes, so what stays visible is exactly
the tail it does not cover. "auto" draws it up to 1,500 readings. Draw it as
ONE path, not 1,500 circle elements.
- Clip the contour layer to the plot rect - the closing run of an edge-hugging
ring lives a cell outside it, and without the clip it paints over the axis.
- ACCESSIBILITY. The plot lives in a <figure> labelled by the card heading and
described by an sr-only paragraph that carries the FINDING, not a coordinate
dump: how many readings, the grid it was estimated on, where the densest part
sits, what each contour encloses, whether the middle contour breaks into
separate regions, the smoothing in real axis units, both axis ranges, and
anything not drawn. Below it, an sr-only WRAPPER DIV holds a real table with
one row per contour: share of the sample, separate regions, share of the
plotted area. Put sr-only on the wrapper, never on the table - 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 hundreds of px of horizontal scroll.
- Ticks, gridlines, the scatter and the ring labels are all aria-hidden: every
number in them is already in the summary or the table.
- Motion: the only animation is the loading rings' pulse, and it carries
motion-reduce:animate-none. Nothing else moves.
Customization levers
- bandwidth / adjustableBandwidth: the single most important knob, and the one
that should stay visible. Under 1 you see structure and noise; over about 3
neighbouring modes merge into one hill. Set adjustableBandwidth={false} and
pass a fixed multiplier in a dashboard where every card must stay comparable -
and then print the number somewhere, because a contour without its bandwidth
is an opinion without its assumption.
- resolution: 32 for a deliberately coarse, poster-like plot (and accept the
visible polygons plus the forced bandwidth floor), 72 for normal use, 120-160
when the shape has fine detail worth resolving. Cost is roughly linear in the
node count: measured 0.8 ms at 32 nodes, 1.5 ms at 72 and 4.4 ms at 160 for
2,000 readings, which is why the ceiling is a clamp and not a suggestion.
- levels: 3 reads from across a room, 7 needs a legend the reader can study. The
probabilities are evenly spaced from 90% down to 10%; change
levelProbabilities() to publish a house set (95/50 is common in statistics) -
everything downstream takes whatever it returns.
- fill / showPoints: filled bands for a poster, lines-only over the scatter for
an analysis view where individual readings still matter. Both together on a
small sample is the most honest configuration there is.
- minSample: raise it for a noisy measure, lower it only when you can defend it.
It is the difference between a chart that says "I don't know yet" and one that
invents an answer.
- Palette: re-point rampColor() at any run of tokens - the level fraction is the
only thing it consumes. Axes: fixed domains make two renders comparable and
turn outliers into a counted, named quantity; formatX / formatY take over the
ticks for dates, currencies or SI units.Concepts
- Kernel density estimation — the answer to overplotting when the question is about shape. Every reading is replaced by a small hill, the hills are added up, and what you draw is the total. It buys a continuous surface you can put contours on; it costs one assumption, the bandwidth, which is why that assumption is a visible control rather than a constant buried in the source.
- Bandwidth is the whole argument — too narrow and every reading grows its own ring; too wide and two genuine modes melt into one. Silverman's rule sets a defensible starting point from the sample itself, and the
min(σ, IQR/1.34)inside it is what stops a bimodal sample choosing a bandwidth wide enough to hide its own second mode. - Highest-density regions — the levels are chosen so that each ring is the smallest region holding this share of the sample, which is a sentence, rather than an evenly spaced density value, which is a number with no intuition attached. The printed share is measured back off the grid, so it is what the ring encloses and not what was requested.
- Marching squares — a contour is traced cell by cell: each cell looks at which of its four corners are above the level and emits the one or two segments that follow from it, and the segments are stitched into closed rings. Padding the grid with a ring of zeros is what guarantees they close, even for a ridge that runs straight off the frame.
- A hole is not a second mode — separate regions and holes come out of the tracer with opposite winding, so counting the sign counts modes. The count is reported from a middle contour: the innermost level sits on the noisiest part of the estimate and will happily split a single Gaussian in two.
- Refusing to smooth — under the sample floor there is no surface, only points and a sentence explaining why. A kernel does not know how much data it was given, and a plot drawn from fourteen readings looks exactly as confident as one drawn from fourteen thousand. That is the failure a density chart has to be built against.
Marginal Histogram
A four-state scatter plot with a binned distribution along each axis — three regions on one shared, pixel-aligned coordinate system, linked by hover.
Survival Curve
A four-state Kaplan–Meier card — stepped survival curves computed from raw follow-up times, censoring ticks on the curve, a log-log confidence band and a numbers-at-risk table pinned to the axis.