Hexbin Density Plot
A four-state hexbin that bins the raw scatter itself — nearest-centre binning, sqrt / linear / quantile bands, colour paired with hexagon size, a live bin-radius control and a ranked screen-reader table.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-hexbin.jsonPrompt
Build a React + TypeScript + Tailwind "ChartHexbin" card in plain SVG with zod.
Recharts has no hexbin primitive and none of this is a cartesian series, so the
lattice, the class breaks and the label fitting 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 hexbin exists because the sample is too
dense to draw one mark per row, so nothing downstream can address an
individual reading; `sample` names the rows instead ("1,204 sessions") so the
text alternative never has to say "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 radius (default 18, clamped
8-44, snapped to the slider's 2-unit step), adjustableRadius (default true),
steps (bands, default 5, clamped 3-7), binScale: "linear" | "sqrt" |
"quantile" (default "sqrt"), showCounts: boolean | "auto" (default "auto"),
locale (default "en-US"), formatX / formatY, onSelect, 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 the Prompt can
describe it: binPoints(), hexagonPath(), hexColumnPitch(), hexRowPitch(),
densityBands(), bandOf(), bandRange(), bandFraction(), bandSizeFactor(),
resolveAxisScale(), niceStep().
Behavior
- LATTICE. Pointy-top hexagons of radius r: column pitch dx = r*sqrt(3) (the
flat-to-flat width), row pitch dy = 1.5*r, odd rows offset by dx/2. The
hexagon path is six vertices at (r*sin(k*60 deg), -r*cos(k*60 deg)), centred
on the origin so a cell only needs a translate.
- BINNING is nearest-centre, and the naive version is wrong in a way that looks
fine. Take py = y/dy, j = round(py), px = x/dx - (j & 1)/2, i = round(px).
Because the rows interlock, that rectangle is not the hexagon's catchment: a
point in either triangle near a row edge belongs to the staggered neighbour.
|py - j| * 3 > 1 is exactly the condition under which that can happen, so
inside it compare (i, j) against (i + sign(px - i)/2, j + sign(py - j)) and
keep the nearer — reading the row parity BEFORE j moves, because the
half-column correction depends on the row you are leaving. Skip the guard and
a smooth cloud grows a faint rectangular seam.
WEIGHT THAT COMPARISON by dx^2 and dy^2. The two offsets are in different
units — a column step is r*sqrt(3), a row step is 1.5r — so comparing them
unweighted (what d3-hexbin does) is not a Euclidean comparison and misfiles
about 2% of a uniform sample into a cell it is not nearest to: measured, 76
of 4,000 random points disagreed with a brute-force nearest-centre search,
and with the weights all 4,000 agreed. A bin counts what is nearest to it, so
the weights are not optional.
- Bin in PLOT space with the lattice anchored at the plot's top-left corner, so
the radius means the same thing whatever the axes measure. Project first:
resolve each axis (fitted domains snapped outward onto the tick step, fixed
domains honoured exactly), then map data -> view units, then bin.
- OUTSIDE THE DOMAIN IS COUNTED, NEVER CLAMPED. A hexagon is a claim about a
location; clamping an outlier onto the frame inflates an edge bin and invents
a peak that was never measured. Non-finite coordinates are counted the same
way. 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.
- BANDS. Break the occupied counts into `steps` classes: "sqrt" spaces the
breaks in sqrt(count) (the default, because bin counts in a real cloud are
heavily skewed and a linear ramp spends most of its classes on a tail of a
few bins), "linear" spaces them in count, "quantile" puts an equal NUMBER OF
BINS in each class. Breaks are integers, deduplicated, and dropped outside
(min, max]. When ties leave fewer distinct breaks than asked for, the ramp
COLLAPSES to fewer bands and says so in the legend — inventing empty classes
would paint the whole plot at the bottom of the ramp and add swatches nothing
uses. All counts equal (a lattice of stacks, or one lone bin) is one band at
the middle of the ramp: with nothing to compare, neither end is honest.
- RADIUS is a control, not a constant. The slider (8-44, step 2) re-bins live;
the prop seeds it, and a change of prop resets it by adjusting state during
render rather than in an effect, which would paint one frame at the stale
radius and re-bin the sample twice.
- KEYBOARD. One roving tab stop over the bins, in reading order (row, then
column). Tab lands on the DENSEST bin, because on a density plot that is the
finding and nobody should have to arrow through 200 cells to reach it. The
tab stop is stored as a lattice key ("i:j"), not an index, so it survives a
change of radius; if that cell no longer exists, it falls back to the peak.
ArrowLeft / ArrowRight previous / next bin in reading order, no wrap
ArrowUp / ArrowDown nearest occupied bin, by centre x, in the row
above / below; no move when that row is empty
Home / End first / last bin in reading order
Enter / Space onSelect(cell) when onSelect is given; without it
Space is left alone so the page still scrolls
- DEGENERATE DATA is the test that matters. Zero points, or zero drawable
points, renders the empty branch (and names how many arrived but could not be
placed). One point pads its zero-width domain by +/-50% and lands mid-plot.
Every reading on one spot gives one bin, one band. Negative coordinates are
legal and get a dashed zero rule on the axis that straddles zero. A count too
wide for its hexagon drops the in-cell label rather than overflowing it — the
number is still in the readout, the aria label and the table.
- CLEANUP: there is nothing to tear down. No timer, no rAF, no listener, no
ResizeObserver — responsiveness is viewBox + preserveAspectRatio, and the
plot is a pure function of props plus three pieces of interaction state.
Keep it that way; a chart that leaks is a chart with an observer in it.
Rendering & styling
- 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 band sits at
fraction b/(bands-1) along it and mixes its two neighbouring stops with
color-mix(in oklab, ...) when it falls between them, so 3 bands and 7 bands
read as one scale. No hex, no invented hue.
- COLOUR IS NEVER ALONE. The same band also sets the hexagon's size, 0.5r to
1.0r, so density survives greyscale printing and colour blindness; the legend
swatch is the plot's own mark at the same size and tint, with the band's
count range printed next to it; and every bin carries its number in the
readout, its aria-label and the table.
- Give each cell a full-size transparent hexagon for hit testing under the
painted one: a low band paints at half the lattice cell, and half a cell is
not a pointer target. Full-size cells tile exactly, so a pointer can never
fall between two occupied bins.
- Clip the hex layer to the plot rect. A cell whose centre sits near the frame
keeps half its hexagon outside it, and without the clip that half paints over
the axis. The count is unaffected — clipping is paint, not data.
- LABELS. Print a count inside a cell only when it fits: painted radius >= 13
units and len * fontSize * 0.62 <= dx(painted radius) * 0.86. Above 9,999 use
compact notation ("12k") for the in-cell label only; everywhere a number is
read on its own it stays exact. "auto" prints while radius >= 22 and there
are at most 90 bins. Every glyph gets a --card halo via paint-order: stroke,
the SVG equivalent of a text outline, so it stays legible over any fill.
- 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 in how many bins, what the densest bin holds and
where it sits, how few bins hold half the sample, both axis ranges, the band
scale, and anything not drawn. Each bin is role="img" (or role="button" with
onSelect) with its own label. Below, an sr-only WRAPPER DIV holds a real
table of the densest bins, ranked, with centre, count, share and band; cap it
(120 rows) and account for the tail in the caption, because a table nobody
can reach the end of is not an alternative to anything. 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.
- The visible readout line under the plot follows hover or focus and is
aria-hidden on purpose — a focused bin already announces itself, and a live
region would say all of it twice.
- Motion: the only animation is the loading honeycomb's pulse, and it carries
motion-reduce:animate-none. Nothing else moves, so nothing else has to stop.
Customization levers
- radius / adjustableRadius: the single most important knob. 8-12 shows the
shape inside a cluster, 26-44 turns the plot into a readable table of numbers
with showCounts on. Set adjustableRadius={false} in a dashboard where every
card must stay comparable, and pass a fixed radius instead.
- binScale: "sqrt" for a typical skewed cloud, "linear" when the counts really
are evenly spread and class widths have to be comparable, "quantile" when the
interesting structure is in the tail and you would rather lose comparable
widths than lose it. steps trades resolution for legibility; 3 bands read
from across a room, 7 need a legend the reader can study.
- showCounts: true turns the plot into a hex-shaped table, false keeps it
purely graphical for a small card. The fit test still applies, so a number
never overflows its cell.
- Palette: re-point the ramp to any run of tokens — the band fraction is the
only thing the colour function consumes. Widen or narrow the size range
(0.5-1.0) to make the second channel louder or quieter; take it to 1.0-1.0 to
drop it entirely, and accept that colour is then the only encoding.
- Axes: fixed domains make two renders comparable and turn outliers into a
counted, named quantity; fitted domains snap outward onto round numbers.
formatX / formatY take over the ticks for dates, currencies or SI units.
- Interaction: onSelect turns every bin into a button — wire it to a drill-down
that filters the underlying rows by the bin's centre plus one radius. The
readout line, the legend and the summary are the three places to change the
wording for a domain that is not "readings".Concepts
- Density binning — the answer to overplotting. Once a few thousand marks overlap, a scatter plot stops encoding anything: the middle is solid ink and ten points look like ten thousand. Binning replaces "where is each reading" with "how many landed here", which is the question that still has an answer at that scale.
- Nearest-centre assignment — hexagons interlock, so the cell a point belongs to is not the one you get by rounding both coordinates. Only points near a row edge are ambiguous, and those get an explicit distance comparison against the staggered neighbour — weighted by the two pitches, because a column step and a row step are not the same length. Skip the comparison and a smooth cloud grows a faint rectangular seam; skip the weights and about 2% of the sample lands in a cell it is not nearest to.
- Bin size is a screen quantity — the radius is in view units, not data units, so it answers "how much overlap can I stand" rather than "how wide is a bucket". That is why it is a live control: coarse to read totals, fine to see the shape inside a cluster, and the reader decides which question they are asking.
- Band collapse — class breaks are computed over the counts and deduplicated, so a sample where every bin holds 1 or 2 readings cannot pretend to five classes. The ramp shrinks to the number of honest bands and the legend admits it, instead of publishing four swatches nothing uses.
- Redundant encoding — the count drives the tint and the hexagon's size, and both are printed in the legend. Colour alone fails on a greyscale printout, on a bad projector and for a red-green reader; two channels that agree fail only when both do.
- Counted, not clamped — readings outside a fixed domain, and readings with no usable coordinates, are named out loud rather than pushed onto the frame. Clamping would pile them into an edge bin and invent a peak that nothing was ever measured at.
Population Pyramid
A four-state back-to-back band chart — two cohorts mirrored on one shared scale, with per-side totals, an earlier period overlaid as dashed outlines, and every band keyboard-reachable with a readout.
Word Cloud
A four-state word cloud that sizes terms by the square root of their count and packs them along a deterministic spiral, with every word a focusable button and a ranked list for screen readers.