Charts

Autocorrelation (ACF)

A four-state ACF/PACF stem plot that computes the correlations from the raw series — one stem per lag against a ±1.96/√n band, lags outside it emphasised by shape as well as colour, and a toggle between the two panels.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  type AutocorrelationRow,
  type ChartAutocorrelationBand,
  type ChartAutocorrelationData,
  type ChartAutocorrelationLevel,
  buildAutocorrelationModel,
  describeCorrelogram,
  pickLagTicks,
  symmetricScale,

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartAutocorrelation" card in plain SVG
with zod. Recharts has no stem primitive, and the geometry is not the hard part
anyway: the x axis is a lag index rather than a measured quantity, the band is a
function of the sample size, and both series on the card are DERIVED from the
raw readings. The maths lives in small pure functions beside the schema and
there is no statistics dependency.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    caption?: string; series?: number[];
    lags?: { lag >= 1; acf in [-1,1]; pacf? in [-1,1] }[];
    maxLag?: int >= 1; sampleSize?: int >= 2; period?: int >= 2 }.
- TWO input paths, one model. `series` is the raw readings in time order and is
  preferred: the correlations and the band both depend on n, so a card that
  receives the series can guarantee they were computed from the same sample.
  `lags` is for correlations someone else computed, and then `sampleSize` is
  REQUIRED — without n there is no band, and a stem plot with no band is a
  picture of noise.
- superRefine: a ready card needs one or the other; precomputed lags need
  sampleSize; lag numbers unique (a repeat collides as a React key). Guard every
  access — sibling refinements all run, so a ragged payload must produce an
  issue rather than a TypeError out of safeParse.
- Props = z.infer of the schema plus view ("acf" | "pacf" | "both", controlled),
  defaultView (uncontrolled, "both"), onViewChange, band ("white-noise" |
  "bartlett" | "none", default "white-noise"), level (0.9 | 0.95 | 0.99, default
  0.95), height (one panel, 150, clamped 90-320), onRetry, onLagSelect,
  className and the div's native props; forwardRef to the card.
- Export the maths beside the schema: prepareSeries(), resolveMaxLag(),
  computeAcf(), computePacf(), acfBandHalfWidths(), criticalValue(),
  buildAutocorrelationModel(), describeCorrelogram(), symmetricScale(),
  pickLagTicks().

Behavior
- THE MATHS IS THE COMPONENT.
  * r_k divides by the FULL sum of squares over all n readings at every lag —
    the "biased" estimator R's acf() and statsmodels both use. Dividing by the
    number of overlapping pairs instead looks fairer per lag and is not: it can
    produce a sequence that is not positive semi-definite, which makes the
    recursion below blow up and lets |r_k| exceed 1 out where the overlap is
    thinnest. The price is a known shrink toward zero at high lags, which is the
    honest direction to be wrong in.
  * The PACF comes from the ACF by Durbin-Levinson: the correlation between t
    and t-k once the k-1 lags between them are regressed out. That is the whole
    reason two panels exist — an AR(1) makes every lag correlate with lag 1's
    shadow, so the ACF decays geometrically while the PACF cuts off after the
    one lag that is real. Entries go null from the first non-invertible step
    onward; a value divided out of ~0 is numerical debris, not a correlation.
  * Band half-widths: "white-noise" is the flat z/sqrt(n) every textbook draws
    and it tests ONE null — the whole series is white noise. "bartlett" is
    sqrt((1 + 2*sum of r_j^2 for j<k)/n), which widens with the correlations
    already found and therefore tests whether lag k adds anything beyond the
    lags before it. A PACF is always read against the flat band (Quenouille), so
    the choice only ever changes the ACF panel.
- LAG 0 IS NEVER PLOTTED. It is 1 by definition; drawing it would own the y axis
  and say nothing. Print that on the card so its absence is a decision.
- maxLag defaults to min(24, floor(n/4)) and is clamped to n-2. Past the n/4
  rule of thumb the card keeps drawing but says the far lags average few pairs.
- THE VERDICT IS A SENTENCE, not a colour, and it is withheld when the sample
  cannot support one. Five shapes, checked in this order: at most as many lags
  outside as chance would put there (white noise — say the expected count out
  loud, since a 95% band leaves about one lag in twenty outside on a PERFECT
  sample); a long positive run still above 0.5 at lag 10 (trend or random walk —
  difference it first); significant lags landing on multiples of `period`
  (seasonal); ACF decaying while the PACF cuts off (AR(p)); ACF cutting off
  while the PACF trails (MA(q)). Under 30 readings no shape is called at all:
  the band is then ±0.36 and would swallow most real structure. With
  band="none" there is no verdict either, because every sentence is phrased in
  units of "outside the band".
- REFUSALS ARE SPECIFIC. A flat series (zero variance) makes every r_k 0/0 —
  refuse and say so, because a row of stems lying on the baseline reads as a
  clean white-noise result. Fewer than three readings: no lag exists yet.
  Precomputed lags with no sampleSize: no band, therefore no chart. Every
  reading non-finite: say THAT, not "no series arrived" — one did.
- NON-FINITE READINGS ARE HOLES, not deletions. They are COUNTED on the card and
  never interpolated (an invented reading would be paired with real ones at
  every lag), and they are dropped PAIRWISE — the reading keeps its position,
  every pair that touches it is skipped, r_k still divides by the sum of squares
  over the finite readings, which is what R's acf(na.action = na.pass) does.
  Compacting the array instead pulls everything after the hole one period
  earlier and files those pairs under the wrong lag, which invents spikes.
- INTERACTION. One column hit target per lag spanning both panels, so one
  pointer position reads the ACF and the PACF at the same lag. The plot is one
  role="listbox" with a roving tabindex: one tab stop, Left/Right steps a lag,
  Home/End jump to the ends, movement clamps and never wraps (lags run one way),
  Enter/Space fires onLagSelect with the computed row, Escape clears it. The
  view switch is a role="radiogroup" — three readings of one dataset, one drawn
  at a time — with arrows that move AND select, and the PACF options DISABLED
  when the feed carried no partial correlations, with the reason printed.
- CLEANUP: one ResizeObserver, disconnected on unmount and whenever the node
  changes. No timers, no rAF, nothing time-derived at render.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border for
  the frame, gridlines and zero baseline, muted for the skeleton,
  muted-foreground for axis text and notes, ring for the keyboard cursor,
  var(--chart-1) for ACF stems, var(--chart-3) for PACF stems, var(--primary)
  for the band. The data is a chart hue and the MODEL is --primary on purpose:
  --chart-N is a five-hue ramp whose neighbours sit about 1.3:1 apart, so a band
  painted in it can land a step away from the stems it exists to be read
  against.
- COLOUR NEVER CARRIES SIGNIFICANCE ALONE. A lag outside the band is a thick
  stem with a FILLED dot and its number set in foreground weight on the axis;
  one inside is a hairline with a HOLLOW ring. Turn the palette to one hue and
  the chart still reads.
- Symmetric domain, always: a correlogram is read for sign as much as size, and
  an off-centre zero would make the negative stems look shorter than they are.
  The step ladder holds the axis at 5-7 gridlines whatever the extent. The
  domain clamps to ±1 only when the data stays inside it — a supplied
  correlation past ±1 is a broken feed and hiding it outside the frame would
  hide the bug.
- Axis labels: the thinned lag ticks first, then every significant lag, then the
  seasonal multiples, added only while there is room, so a 40-lag axis on a
  phone thins out instead of smearing. Panel names sit ABOVE each frame, never
  inside it — a lag-1 stem at 0.98 owns that corner.
- ACCESSIBILITY: do NOT put role="img" on the plot — that is
  children-presentational and would silence the focusable lags inside. Use
  role="group" named by the heading and described by a summary that states the
  finding in words: how many lags, from how many readings, the band and where it
  came from, how many are outside versus expected, which lags, and the verdict.
  Each lag is a role="option" whose accessible name reads both panels. Below the
  plot an sr-only WRAPPER DIV (never sr-only on the table itself: CSS width is
  only a lower bound for a table box, so a narrow viewport picks up real
  horizontal scroll) repeats every lag as a row. The visible readout is
  aria-hidden because focus already announces it; a polite live region carries
  only the pointer-driven readout.
- Motion: the only animation is the loading skeleton's pulse, carrying
  motion-reduce:animate-none. Nothing else moves, so nothing else has to stop.

Customization levers
- band: the most consequential knob. "white-noise" answers "is there anything
  here at all"; "bartlett" answers "does this lag add anything beyond the ones
  before it" and typically drops a decaying tail from 7 significant lags to 5;
  "none" is for a figure whose correlations feed a model rather than a decision.
- level: 0.95 by default. 0.99 for evidence, 0.90 for exploration — the expected
  count printed beside the actual one moves with it, so the comparison never
  goes stale.
- view + defaultView: ship "acf" for a compact dashboard tile, "both" when the
  card is being used to pick a model order. Controlled `view` lets a page-level
  segmented control drive several cards at once.
- maxLag + height: 24 lags at 150px per panel is a dashboard card; 40 lags at
  260px is an analysis page. Raising maxLag past n/4 is allowed and disclosed
  rather than blocked.
- period: pass 7 for daily data, 12 for monthly, 24 for hourly. It draws the
  guides, forces those lags onto the axis and lets the verdict name the cycle
  instead of describing it.
- Palette: re-point ACF_INK / PACF_INK / BAND_INK. Keep the band on --primary
  and keep significance carried by SHAPE, so a monochrome card loses nothing but
  decoration.
- Interaction: onLagSelect already carries the computed row (correlation, band
  half-width, verdict) — wire it to a lag input on a model form, or to a second
  chart that plots the series against itself at that lag.

Concepts

  • Correlation with its own past — the x axis is not time and not a category: it is how many periods back. Lag 7 at 0.74 means "a Tuesday looks like the Tuesday before it", which is a statement no plot of the series against time makes directly, however long you stare at it.
  • The band is the whole reading — a stem is only a finding relative to what noise alone would produce, so the half-width is z/√n and the card prints both the band and the count chance would put outside it. Without that comparison "lag 20 is significant" on 24 lags at 95% is arithmetic being read as a discovery.
  • Partial means "with the middle lags removed" — an AR(1) makes lag 2 correlate with lag 0 purely through lag 1. The PACF regresses those intervening lags out, which is why the pair of panels identifies an order that either panel alone cannot: ACF decaying plus PACF cutting off after one spike is AR(1), and the reverse is MA(1).
  • Slow decay is a different disease — an ACF still above 0.5 ten lags out is not a long memory, it is a series that never settles: a level rather than a rate. The card says to difference it first, because every model fitted to that picture is fitting the trend.
  • Refusal beats a drawn guess — a flat series would make every correlation 0/0 and a row of stems lying on the baseline reads as a clean white-noise result, so the card declines and names the reason. The same goes for precomputed lags with no sample size, and for a verdict under 30 readings.
  • One estimator, stated — dividing by the full sum of squares at every lag guarantees a sequence the PACF recursion can run on, at the cost of shrinking the far lags toward zero. Both choices are defensible; making it invisible is not, so it is written down where the numbers are.

On This Page