Seasonality
A four-state seasonal decomposition card — one series split into trend, season and residual across three panels on one shared x axis, with anomalous residuals marked against a robust cut-off and undecomposable feeds refused instead of fitted.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-seasonality.jsonPrompt
Build a React + TypeScript + Tailwind "ChartSeasonality" component — a classical
time-series decomposition triptych — on the shadcn chart primitives
(ChartContainer over recharts) with zod. The component computes the
decomposition itself; the feed only ever sends the raw series.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; seriesLabel?: string; unit?: string;
period: number; model?: "additive" | "multiplicative";
points: { date: string; value: number }[] }.
- Points are evenly spaced by position: index i sits at cycle position
i % period, and `date` is a label only. A feed with a missing bucket must send
that bucket with a non-finite value, never omit the row — omitting shifts
every later point onto the wrong cycle position.
- period is the observations per cycle (12 monthly, 7 daily, 24 hourly). Refine
it to an integer >= 2 and refine ready feeds to points.length >= 2 * period,
so a feed that cannot be decomposed fails loudly upstream as well.
- Component props = z.infer of the schema plus anomalySigma?: number (default 3,
clamped to [1, 10], non-finite falls back to 3), onRetry?: () => void and
className. No parallel hand-written interface.
Behavior
- Refuse before computing, and say exactly what is missing. Three refusals, each
with a headline, a sentence of reasoning and the rule that was broken:
(1) period non-finite or < 2 after truncation; (2) fewer than 2 * period
points — print how many more are needed; (3) multiplicative asked for on a
series that touches or crosses zero, where a factor model is undefined. In all
three the card plots the raw series alone, labelled as raw, and claims no
trend, season or residual. Fitting a curve nobody can justify is worse than an
empty panel, and clamping period into range would silently answer a different
question than the one the feed asked.
- Trend = centred moving average of length `period`. For an even period that is
the 2xperiod average (half weight on the two end buckets), the only way to
centre a window with no middle bucket; for an odd period it is the plain
average. A window containing a non-finite value yields null — never
interpolate across a gap. That leaves trend (and therefore residual)
undefined for the first and last floor(period / 2) points; disclose it under
the card rather than trimming the axis.
- Detrend (subtract for additive, divide for multiplicative), then average the
detrended values at each cycle position, then normalise: additive indices are
shifted to sum to zero, multiplicative indices are scaled to average one, so
the season carries no level of its own. A cycle position with no observation
falls back to the neutral value (0 or 1).
- Residual = value - trend - seasonal, or value / (trend * seasonal). Anomalies
are judged on the additive-equivalent residual — the log of the ratio under
the multiplicative model, where 2x and 0.5x are the same size of surprise —
as |dev - median| > anomalySigma * 1.4826 * MAD, falling back to the standard
deviation when MAD is zero and flagging nothing when both are. A median/MAD
cut-off is used precisely because the anomalies are in the sample.
- Report trend and seasonal strength (Hyndman): 1 - var(R) / var(T + R) and
1 - var(R) / var(S + R), clamped to [0, 1], computed on the
additive-equivalent components (logs under the multiplicative model, where the
identity is additive).
- Four first-class branches: loading (three silhouettes at the panels' real
heights with the same axis gutter), empty, error (message plus a "Try again"
button only when onRetry exists), ready.
- No animation: recharts isAnimationActive={false} everywhere, so
prefers-reduced-motion has nothing to switch off. The only motion is the
loading pulse, which carries motion-reduce:animate-none.
Rendering & styling
- Three stacked panels — Trend (with the raw series drawn underneath at low
opacity), Seasonal, Residual — each headed by its name and a note: the window
length and trend strength, the cycle length and seasonal strength, the anomaly
count with the dates named.
- The shared x axis is a construction, not a hope: identical margins, identical
YAxis width and an explicit scale="band" on every XAxis. recharts otherwise
picks band or point per chart depending on whether that chart holds a bar —
the residual panel does and the other two do not — which puts the same index
half a band apart between panels. Only the bottom panel renders tick labels;
it pays for them out of its own height so the plot boxes stay identical.
A shared syncId (React.useId, never a literal, or two cards on one page sync
with each other) puts the crosshair on the same date in all three panels.
- YAxis domain={["auto", "auto"]}: recharts anchors a numeric axis at 0 by
default, which spends half the trend panel on empty space below a series that
never goes near zero and flattens a seasonal factor that lives at 1.
- The residual panel plots the deviation from the baseline, not the raw value: a
bar grows from the axis zero, so a multiplicative residual around 1 would be a
wall of identical full-height bars. The baseline is added back into every
label, tooltip and table cell.
- Anomalies get three channels, never colour alone: a var(--destructive) bar,
a dot on the bar tip, and the dates spelled out in the panel note. Two dashed
cut-off lines carry ifOverflow="extendDomain" so the band is always visible.
- Colours: trend var(--chart-1) with the raw series in the same hue at 0.35
opacity, season var(--chart-2), residual var(--chart-3), anomalies
var(--destructive), baselines var(--border). Panels are told apart by their
heading, so no two series ever depend on hue to be distinguished.
- Numbers: one formatter per panel picked from that panel's own magnitude, plus
a compact variant for axis ticks only — a tooltip that reads "10.9K" for both
the observation and its trend hides the very gap the residual panel is about.
Multiplicative seasonal and residual print as factors ("1.12x"). Explicit
"en-US" locale, never Intl(undefined). Tick date wording follows the median
gap between points (hourly / daily / monthly), and every date format falls
back to the raw string when it does not parse — Intl throws on an invalid
Date and would take the card down.
- Accessibility: accessibilityLayer={false} and tabIndex={-1} on every chart,
because recharts otherwise leaves a tab stop inside a role="img" subtree that
announces nothing. The card is a role="group" with a summary sentence, each
panel a role="img" with its own sentence, and an sr-only table carries every
date, observation, trend, seasonal and residual figure. The sr-only class goes
on a wrapper div, not on the table element: width:1px is only a minimum for a
table box, so a bare sr-only table stays content-wide and pushes real
horizontal overflow onto the page.
Customization levers
- Period is the first thing to expose to your users: 12 / 7 / 24 covers most
feeds, and a period picker plus the refusal panel is a complete "not enough
history yet" flow on its own.
- anomalySigma trades sensitivity for noise. Swap the MAD estimator for a
rolling one if the series changes variance over time, or drop the dashed
cut-off lines and keep only the dots if the panel gets busy.
- Classical decomposition is not robust: a large shock leaks into its own
window's trend and lifts its own cycle position's index, which can make the
other cycles of that position look anomalous. With few cycles, either shorten
the analysed window or swap the cycle-position mean for a median — the shape
of the component and everything downstream of it stays the same.
- Panel heights (h-[104px], h-[132px] for the one carrying the axis) and the
46px axis gutter are one edit each — keep them equal across panels or the
shared axis stops being shared.
- Drop the raw series from the trend panel if you want that panel scaled to the
trend alone; add a fourth "observed" panel if your audience expects the
statsmodels four-row layout.
- The strength readouts, the cut-off lines and the notes line are independent
blocks: remove any of them without touching the decomposition.Concepts
- Fixed three-component semantics — the panels are not facets. Trend, season and residual are one identity (
value = trend + season + residual, or the product of the three), they are produced by the decomposition rather than supplied, and swapping their order would be as meaningless as swapping a numerator and a denominator. - Centred moving average — the trend is the average of a whole cycle centred on each point, so the season averages out of it. An even period has no middle bucket, so the two ends of the window take half weight (the 2×period average); either way the first and last
period / 2points have no window at all, and the card says so instead of quietly extrapolating. - Cycle-position average, then normalised — the seasonal index for position p is the mean of every detrended observation at that position, shifted to sum to zero (additive) or scaled to average one (multiplicative). Without that normalisation the season would carry part of the level, and the trend panel would be reading the wrong number.
- Refusal over extrapolation — a period the data cannot support still produces a curve; that curve is an artefact of the window, not of the series. Fewer than two whole cycles, a period below 2, or a multiplicative model on a series that touches zero all end in a refusal panel that names the rule and shows the raw series, never a fitted one.
- One x lattice — three separate charts share an axis only because they are built to: identical margins, identical y-axis width and an explicitly declared band scale, which is what stops recharts from placing the same index half a band apart in the panel that holds bars. A synced crosshair then moves through all three panels at once.
- Robust residual cut-off — anomalies are flagged at k × 1.4826 × MAD from the median residual, because the anomalies are themselves in the sample and a mean/σ cut-off would be widened by the very points it is meant to catch. Under the multiplicative model the test runs on the log of the ratio, so 2× and 0.5× count as the same size of surprise.
- Strength before modelling — trend and seasonal strength (1 − var(residual) / var(component + residual)) turn "is it seasonal?" into a number between 0 and 1, which is usually the reason someone opened this chart in the first place.
Tornado Chart
A four-state sensitivity chart — one row per driver, low and high segments measured out from a base-case line that is never assumed to be zero, ranked by swing into a funnel, on an axis that is never forced symmetric.
Connected Scatter
A four-state connected scatter: both axes carry a metric, the stops are joined in time order, and direction is encoded four ways — a width ramp, pixel-spaced arrowheads, a start ring against a terminal arrowhead, and named endpoint labels.