Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CartesianGrid, Cell, ReferenceArea, Scatter, ScatterChart, XAxis, YAxis, ZAxis } from "recharts"

import { type ChartConfig, ChartContainer, ChartTooltip } from "@/components/ui/chart"
import { cn } from "@/lib/utils"
import type {
  ChartMarginalHistogramAxis,
  ChartMarginalHistogramBinning,
  ChartMarginalHistogramData,
  ChartMarginalHistogramPoint,
} from "./chart-marginal-histogram.contract"

Installation

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

Prompt

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 binning is 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 size around 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 edges array 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 tellbinCount: 0 becomes 2 bins, binWidth: 0 falls 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.

On This Page