Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Bar,
  CartesianGrid,
  Cell,
  ComposedChart,
  Line,
  ReferenceDot,
  ReferenceLine,
  XAxis,
  YAxis,
} from "recharts"

Installation

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

Prompt

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 / 2 points 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.

On This Page