Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Area,
  CartesianGrid,
  ComposedChart,
  Line,
  Scatter,
  XAxis,
  YAxis,
  ZAxis,
} from "recharts"

Installation

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

Prompt

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 is k. With k = 0 the ribbon says where the line is; with k = 1 it 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² of ln y. The tile says so instead of quietly presenting it as fit quality on y.
  • 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. prints an em dash where 1 − SSE/SST is 0/0; it never prints NaN and never quietly prints 0.

On This Page