Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildContours,
  densityThresholds,
  estimateDensity,
  isFiniteNumber,
  levelProbabilities,
  resolveAxisScale,
  silvermanBandwidth,
  type ChartContourData,

Installation

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

Prompt

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.

On This Page