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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-autocorrelation.jsonPrompt
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/√nand 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.
Pacing Strategy
A four-state pacing chart for one athlete against a plan — diverging bars of seconds banked or lost per split around a target baseline, with the cumulative banked time overlaid on a right axis and the finish read against the target.
Quantile Dot Plot
A four-state quantile dot plot that turns a predictive distribution into countable outcomes — N equal-probability dots stacked into non-overlapping columns, an optional threshold that splits them, and the share on each side reported as a number you can verify by counting.