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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-regression-scatter.jsonPrompt
Build a React + TypeScript + Tailwind "ChartRegressionScatter" card on the
shadcn chart primitives (ChartContainer / ChartTooltip over a recharts
ComposedChart) with zod. Every statistic is computed inside the component —
there is no statistics dependency, because simple least squares is about forty
lines plus one table of t values.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title; xLabel; yLabel;
model?: "linear" | "logarithmic" | "exponential";
items: { id, label, x: number, y: number }[] }.
Refine that "ready" carries at least one item.
- Component props = z.infer of the schema plus band?: "confidence" |
"prediction" | "none" (default "confidence"), level?: 0.9 | 0.95 | 0.99
(default 0.95), outlierThreshold?: number (default 2), onRetry?: () => void
and className. No hand-written parallel interface.
- xLabel / yLabel are used verbatim by the axis titles, the caption, the
tooltip, the aria summary and the data table, so one rename moves everything.
Behavior - the fit
- All three models are ordinary least squares over a linearised pair, so one
set of sums serves them all: linear fits (x, y); logarithmic fits (ln x, y)
and needs x > 0; exponential fits (x, ln y) and needs y > 0. The inverse
transform maps the fitted curve and both band edges back into user units, and
because exp() is monotone the two edges stay in order.
- Sums are taken in two passes (mean first, then deviations), never as
Sxx = Sum(x^2) - (Sum x)^2 / n. The one-pass form subtracts two large nearly
equal numbers and on data like epoch timestamps it returns a negative Sxx,
which becomes a NaN slope and a blank chart.
- SSE is accumulated from the residuals directly rather than as
Syy - slope*Sxy: that subtraction goes slightly negative on a perfect fit,
and sqrt() of it is NaN.
- Print R^2, the slope, how many points were fitted and what the band means as
four labelled figures above the plot. For the exponential model least squares
minimised ln y, so the tile reads "R^2 (on ln y)" - quoting it as an R^2 on y
would be a lie. The slope tile is relabelled per model too ("Slope", "Slope
per ln x", "Rate (ln y per x)"), because the coefficient means a different
thing in each.
- Band half-width at x is t * s * sqrt(k + 1/n + (x - xbar)^2 / Sxx), where s
is the residual standard error, k = 0 for a confidence band (where the line
is) and k = 1 for a prediction band (where one new observation lands). That
is what makes a confidence band a hyperbola pinched at xbar. Sample it at
~72 x positions across the padded domain and render it as a range Area with
the fitted curve as a Line on top.
- t comes from a small table of two-sided critical values at 90 / 95 / 99 % by
degrees of freedom, not from a library. Lookup takes the largest tabulated df
at or below the real one, so an in-between sample size errs wide, never
narrow.
- Outliers are flagged by studentised residual: e / (s * sqrt(1 - h)) with
leverage h = 1/n + (x - xbar)^2 / Sxx. Dividing by sqrt(1 - h) is the point -
the raw residual beside a high-leverage observation is small precisely
because the line chased it. Flag, never drop: excluding a point silently
changes the very numbers the card reports, and that decision belongs to the
reader.
Behavior - the inputs that break naive regression charts
Each of these plots the observations and prints a sentence saying what
happened. None of them may produce NaN geometry or an empty rectangle.
- non-finite x or y: dropped before anything is measured; "ready" with nothing
left renders the empty branch.
- fewer than 3 usable points: no fit at all. Two points determine a line
exactly, which leaves zero degrees of freedom, an undefined residual variance
and a band of width NaN - a line through two points is interpolation, not
evidence.
- zero variance in x (Sxx = 0): the slope is a division by zero, so no line is
drawn.
- zero variance in y (Syy = 0): the flat line IS the fit, but R^2 = 1 - SSE/SST
is 0/0, so R^2 prints an em dash and says why. Not 0, not 1.
- an exact fit (SSE = 0): R^2 is 1, the band collapses onto the line, and the
studentised residuals are 0 by definition rather than 0/0.
- values outside the model's domain (x <= 0 under log, y <= 0 under
exponential): plotted as crosses, excluded from the fit, and counted in the
"Points" tile as "5 of 7".
- numeric props are clamped: an outlierThreshold below 0.5 or non-finite falls
back to 2 (0 would flag every point, NaN would flag none), and an unknown
band or level falls back to a 95% confidence band.
- the y domain grows at most one full data span past the observations and the
axis clips the rest, so an exponential band on weak evidence cannot squash
every point onto one row.
Rendering & styling
- Semantic tokens only. Observations are var(--chart-2) at 45% fill with a
full-opacity stroke; outliers are var(--chart-5); the fit line and its band
are var(--primary) (line at 2px, band at 14% opacity). The data gets a chart
hue and the model gets the theme's foreground pair: neighbouring chart tokens
sit only about 1.3:1 apart, so a fit line inside that ramp can land a step
away from the dots it has to be read against, while --primary flips with the
theme and is always the strongest mark on the card.
- Colour is never the only channel. Outliers are diamonds carrying their own
label, out-of-domain points are crosses, and a drawn (not coloured-text)
legend keys all four marks. --chart-N is never used as a text colour.
- Draw the outlier marker and its name in one custom Scatter `shape`, not with
a LabelList: a LabelList label inherits the symbol's own width as its wrap
width, so "Run 34" comes out stacked two lines high on a 10px dot.
- Guard every Scatter behind a non-empty length check. recharts reads an empty
`data` array as "no data of my own" and falls back to the chart-level
dataset, minting one phantom symbol per curve sample.
- Axis ticks are snapped to a 1/2/5x10^n step, because padding the domain for
clearance pushes the ends onto values like -0.5 or 107. Notation is then
chosen once per axis from those ticks, and compact is only used if it still
tells them apart: picking it from the magnitude alone renders an axis of
epoch seconds as four ticks that all read "1.7B" - they do not overlap, they
are not clipped, and they say nothing. Always an explicit "en-US" locale,
never Intl(undefined).
- No animation anywhere: isAnimationActive={false}. A regression line drawing
itself in tells nobody anything, and it makes prefers-reduced-motion a
non-issue; only the loading skeleton pulses, under motion-reduce:animate-none.
- Four first-class branches inside one bg-card panel: loading (skeleton stat
row plus a tilted ribbon silhouette in the same 300px box), empty (dashed
frame with a rising dotted line), error (message plus a "Try again" button
only when onRetry exists), ready.
- Accessibility: the chart is one role="img" whose aria-label states n, the
equation, R^2, the band, the outliers by name and every degeneracy notice.
Because role="img" makes its subtree presentational, give the recharts svg
tabIndex={-1} so it is not a tab stop that announces nothing, and put the
real numbers in a table rendered as a SIBLING of it, one row per observation
(x, y, fitted, residual in standard errors, flag). Wrap that table in a div
carrying sr-only: a bare table is display:table and ignores the 1px width.
Customization levers
- Band: "confidence" answers "where is the line", "prediction" answers "where
will the next observation land" (much wider - measured 9x at n = 44), "none"
drops the ribbon when only the trend and R^2 are wanted. level picks
90 / 95 / 99 %; extending the t table is how you add another level.
- Model: "linear" for a constant rate, "logarithmic" for diminishing returns
and learning curves, "exponential" for compounding growth. A power law (fit
ln y on ln x) is one more entry in the MODELS map - the fitting code does not
change.
- outlierThreshold: 2 standard errors is the usual first cut; 3 for noisy
telemetry where only the screaming ones matter; 1.5 when the point of the
chart is to surface candidates for review.
- Density: the plot is h-[300px] with 7% / 9% domain padding. A shorter plot
wants more y padding, because the outlier labels live in that band.
- Palette: swap the observation hue to another var(--chart-N) to match a host
palette, but keep the fit on a theme-flipping token - a fixed chart token is
exactly what makes a line vanish against its own dots in one of the themes.
- Interaction: dots are inert by design. Give Scatter an onClick and route it
to the item id if a point should open a record; the tooltip payload already
carries the whole contract item plus its fitted value and residual.Concepts
- Least squares, computed here — the slope, intercept, R², residual standard error and every band edge come out of about forty lines of arithmetic plus one table of Student's t values. The table is read conservatively: a sample size between two rows takes the lower degrees of freedom, so the band errs wide rather than narrow.
- Confidence band vs prediction band — both are
t·s·sqrt(k + 1/n + (x−x̄)²/Sxx)and the only difference isk. Withk = 0the ribbon says where the line is; withk = 1it also carries a new observation's own scatter and says where the next point will land. On the 44-run demo the second is 9× taller — same data, a completely different promise. - The pinch at x̄ — a confidence band is a hyperbola, narrowest at the mean of x and flaring at both ends, because the further you extrapolate the more the uncertainty in the slope costs. Measured on the demo: 2.15× wider at the domain edge than at x̄.
- Studentised, not raw, residuals — an observation with high leverage drags the line toward itself, which shrinks its own residual and hides it. Dividing by
sqrt(1 − h)puts every point on one scale before the threshold is applied, so an outlier at the edge of the range is as visible as one in the middle. - Flag, never drop — a point past the threshold gets a diamond and its name, and stays in the fit. Excluding it would silently change the very numbers the card reports; deciding to exclude an observation belongs to the person reading the chart.
- R² describes the scale it was fitted on — the exponential model minimises squared error in
ln y, so its R² is an R² ofln y. The tile says so instead of quietly presenting it as fit quality ony. - Degeneracy is a rendered sentence, not a blank rectangle — fewer than three points, a constant x, a constant y and an exact fit each hit a named branch that still plots the observations and explains what could not be computed.
R²prints an em dash where1 − SSE/SSTis0/0; it never printsNaNand never quietly prints0.
Error Bars
A four-state categorical error-bar chart: one point estimate per category with an interval whose meaning — SD, SE, CI or custom bounds — is declared in the contract and printed in the legend, tooltip and data table.
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.