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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-marginal-histogram.jsonPrompt
Build a React + TypeScript + Tailwind "ChartMarginalHistogram" card on the
shadcn chart primitives (ChartContainer/ChartTooltip over recharts
ScatterChart) with zod: a scatter plot with a binned distribution drawn along
each axis, all three regions sharing one coordinate system.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
x: { label: string; unit?: string; binning?: Binning };
y: { label: string; unit?: string; binning?: Binning };
points: { id: string; x: number; y: number; label?: string }[] }
Binning = { mode: "auto" }
| { mode: "count"; binCount: 2..80 }
| { mode: "width"; binWidth: > 0 }
- Binning is PER AXIS: the two variables rarely want the same resolution, and
a caller may want auto on one axis and a fixed 100 ms width on the other.
- Both coordinates are required. A point with only one usable number cannot
join the joint view, and binning it into one margin alone would make the two
marginals disagree about the sample size — the one thing this chart must
never do.
- Component props = z.infer of the schema plus onRetry?: () => void and
className. No hand-written parallel interface.
Behavior — binning (export it as a pure buildMarginal(values, binning) so it
can be unit-tested and reused for both axes)
- Sort a finite-only copy once. "auto" = Freedman-Diaconis width
h = 2 * IQR / n^(1/3); when the IQR collapses (heavily tied data) fall back
to Sturges, ceil(log2 n) + 1.
- "count" = k equal-width bins over the observed range; "width" = fixed w with
the first edge snapped down to floor(min / w) * w so edges are round numbers.
- Clamp everything: bin count into [2, 80], so binCount 0, -5 and 1e9 all land
inside that window; NaN and Infinity fail an isFinite guard and fall through
to auto; a binWidth that would ask for more than 80 bins widens instead of
blowing up the DOM. Zero variance expands to [min - 0.5, max + 0.5] (numpy's
rule) so a constant column still gets a drawable scale.
- Return, from ONE shared edges array: the bins, and a per-input bin index
(-1 = unplaceable). That index is what links a dot to its two bars, so the
highlight can never disagree with the bar it highlights, and the counts sum
to the number of placeable inputs by construction. Bins are left-closed /
right-open with the last one closed, so the maximum can never fall out.
- The shared axis domain is [firstEdge, lastEdge] padded by 4% of the binned
span on each side, so a dot on the extreme value is drawn whole. Both the
scatter axis and the marginal bars lay out against that padded domain, never
against the raw data range.
Behavior — three regions that stay aligned
- CSS grid: marginal strip and corner on the top row, plot and marginal strip
on the second. The marginals are plain DOM bars, NOT extra recharts charts.
- Alignment without measuring: recharts reserves exactly margin + axis size
around its plot, so declare the axis sizes as constants (YAxis width, XAxis
height, chart margin), derive the four insets from those same constants, and
pad the strips with them. Bars are then positioned as percentages of the
shared domain and land on the plot's coordinate system to the pixel — no
ResizeObserver, no measurement pass, correct at every width. Measured: 0.00px
error on all four edges at 1280px and at 375px. This coupling is the thing to
keep intact when editing; desyncing an inset from its axis constant is an
8-10px silent drift.
- Each bar's hover target is the FULL depth of the strip, not the drawn bar, so
a one-count bin is as easy to hit as the peak.
- A bin with a non-zero count gets a 2px floor, so 1-in-300 never renders as
nothing; an empty bin renders nothing at all.
Behavior — linked highlighting
- Hover a marginal bar: that bar takes a theme-following token while the rest
of the strip drops to 0.28 opacity, a ReferenceArea paints the matching band
on the plot, dots outside the bin fade to 0.1 fill / 0.12 stroke, and the
header readout switches to "<range> · <n> of <N> points · <share>".
- Hover a dot: the bin it belongs to lights up on BOTH margins and the readout
names the point. The cloud is deliberately NOT dimmed here — dimming the
neighbourhood you are pointing into hides the very thing being read.
- Only two pieces of state: the hovered bin (axis + index) and the hovered
point. Everything else is derived.
- No animation: isAnimationActive={false}. Hundreds of dots growing on mount is
noise, every hover rewrites the <Cell> list (an animated series would re-run
its transition on each pointer move), and switching it off makes
prefers-reduced-motion a non-issue in the plot. The skeleton pulse still
carries motion-reduce:animate-none and the bar fade
motion-reduce:transition-none.
Rendering & styling
- Semantic tokens only. Bars and dots share one chart token (var(--chart-2) via
the ChartConfig) so the margins visibly belong to the cloud; the focused bar
flips to var(--primary), which inverts with the theme and therefore reads on
both a white and a near-black card. Never use a chart token for text.
- Dots: 0.55 fill opacity with a full-opacity stroke of the same colour, so a
dense cluster still resolves into separate rings.
- Tick labels drop the unit and go compact past 10,000 — measured: "100 ms" is
wide enough that recharts silently word-wraps an axis tick onto two lines (a
27px label inside a 15px band). The decimals for compact notation come from
the tick STEP, not the value: at a 10K step under a 5M maximum, one decimal
prints 4.99M and 5.00M identically, and two identical ticks are worse than
one long label. Units live in the card caption instead, once.
- Ticks are snapped to a 1/2/5x10^n step, because the padded domain ends land
on values like -0.5 or 45.6.
- Responsive by container query, not viewport: @container on the card, strips
36px and plot 210px tall below @md, 56px / 280px above. Nothing is dropped on
the way down — measured at a 375px viewport the card is 333px, the plot
199 x 180px and each strip 36px, with all three regions still readable.
- Four first-class state branches in one bg-card panel: loading (the same three
regions and the same insets in skeleton form, so nothing moves when data
lands), empty (a miniature of the three-region layout plus one line), error
(message + a "Try again" button only when onRetry exists), ready. A "ready"
payload with nothing plottable renders the empty branch.
- Accessibility: the whole grid is one role="img" with a sentence-long label
(n, both ranges, both medians, both bin layouts, both busiest bands). Because
that role makes its subtree presentational, the numbers live OUTSIDE it as
two sr-only tables, one per margin, every bin a row. Wrap them in a div —
sr-only on a bare <table> is ignored (a table box treats width:1px as a
floor) and drags horizontal scroll onto the page. Per-point coordinates are
deliberately not enumerated and the label says so: hundreds of raw pairs are
not an alternative, the binned distributions are.
- accessibilityLayer={false} plus tabIndex={-1} on the chart: recharts would
otherwise leave a tab stop inside the image that announces nothing.
Customization levers
- Binning: `binning` is per axis and a plain prop, so a parent can wire a bins
slider or an auto/10/25/50 segmented control straight through; the component
recomputes on prop change with no internal mode state to fight.
Freedman-Diaconis takes its width from the IQR, so on strongly bimodal data
the IQR spans the gap between the modes and asks for very few bins — pin
{ mode: "count" } there.
- Density: strip thickness (2.25rem / 3.5rem) and plot height (210px / 280px)
are the two knobs; keep the strips under about a fifth of the plot or the
margins start competing with the thing they annotate.
- Axis geometry: AXIS_Y_WIDTH / AXIS_X_HEIGHT / PLOT_MARGIN are what the strip
insets are derived from. Widen the y axis for longer numbers by changing the
constant only — never by padding a strip, or the two drift apart.
- Palette: dots and bars read one ChartConfig entry, so re-theming is one
token. Keep the focus colour on a theme-flipping token (var(--primary)); a
fixed chart token would sink into the bars in one of the two themes.
- Third dimension: add a ZAxis dataKey and a min/max range to turn the dots
into bubbles; the marginals are unaffected because they only read x and y.
- Selection: dots and bars are inert by design. Give <Scatter> an onClick, or
the bar wrapper an onClick, and route it to the item id — the payload already
carries the whole contract point plus its two bin indices.
- Rug instead of bars: the strips are plain DOM, so swapping each bar for a 1px
tick per observation turns the same layout into a rug plot.Concepts
- Joint plus marginal, in one reading — the cloud answers "how do these two move together" and the two strips answer "what does each one look like on its own". Averages hide bimodality and long tails; a margin does not. Pulling the three regions apart would mean re-reading three scales instead of one.
- Derived bins, not given rows — the component is fed raw pairs and owns the bin layout on each axis, which is why
binningis a rendering prop rather than part of the data, and why it is per axis: payload in KB and latency in ms rarely want the same resolution. - One shared domain — the scatter axis and its marginal are drawn against the same padded
[firstEdge, lastEdge], so a bar always sits over the slice of the plot it counts. Laying the bars out against the raw data range instead shifts every bar by the padding — invisible until you look for it. - Alignment by construction, not by measurement — recharts reserves exactly
margin + axis sizearound its plot area, so the strips are padded from the same constants rather than from a ResizeObserver. Measured error: 0.00px on all four edges at both 1280px and 375px; desyncing an inset from its axis constant reproduces an 8–10px drift. - The index is the link — binning returns a per-point bin index out of the same
edgesarray that produced the counts, so "which bar does this dot belong to" and "how tall is that bar" can never disagree, and the counts sum to the plotted point total. - Two hover directions, deliberately asymmetric — a bar filters the cloud down to its own bin, which is what a margin is for; a dot lights its bin on both margins but leaves the cloud alone, because dimming the neighbourhood you are pointing into hides the thing being read.
- Clamp with a tell —
binCount: 0becomes 2 bins,binWidth: 0falls back to auto, a zero-variance axis widens to ±0.5, and points carrying NaN or Infinity are dropped before anything is measured; the header always prints the bin layout that actually ran, so a clamp is visible rather than guessed at. - Image plus tables — recharts paints paths a screen reader cannot read, so the whole grid is one
role="img"with a summarising sentence, and the numbers live beside it as one visually hidden table per margin. Hundreds of raw coordinate pairs are not a text alternative; the binned distributions are, and the label says so.
Regression Scatter
A four-state scatter that fits its own least-squares trend — linear, logarithmic or exponential — draws the confidence or prediction band beside R², slope and sample size, flags outliers by studentised residual, and refuses to invent a fit it cannot compute.
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.