Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  bandFraction,
  bandOf,
  bandRange,
  bandSizeFactor,
  binPoints,
  densityBands,
  hexColumnPitch,
  hexRowPitch,

Installation

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

Prompt

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.

On This Page