Rolling Average
A four-state rolling-average smoother: the raw noisy series stays visible as faint dots over a hairline, the trailing-window mean is drawn on top, a ±1 SD band from the same window is shaded under it, and the window size is named in the legend.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-rolling-average.jsonPrompt
Build a React + TypeScript + Tailwind "ChartRollingAverage" card in plain SVG
with zod and a useResizeObserver hook. It smooths one noisy series: the raw
readings stay visible as faint dots over a hairline, the trailing-window
rolling mean is drawn on top, a ±1 SD band computed from the SAME window is
shaded under it, and the legend names the window size.
A charting library is the wrong tool even though the shape looks cartesian:
the rolling mean, the SD band and the warm-up rule are statistics the
component itself computes from raw points, and that maths belongs in small
exported pure functions beside the schema, where a test can print the exact
numbers the picture is made of.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; unit?: string;
window: number; // trailing window, int >= 2
points: { x: number; y: number; label?: string }[] }
- x is numeric on purpose (epoch ms, a game index, a sample number): a
trailing window assumes the readings have an order, and repairing an
unsorted feed needs a real axis to sort along. label is what ticks and
readouts print; it falls back to the formatted x.
- superRefine: a ready chart needs at least one point — an empty ready feed
is status:"empty" wearing the wrong flag.
- Export the maths beside the schema: prepareRollingSamples() (drop
non-finite points, dedupe x keeping the first, sort — counting everything
it changed), normalizeRollingWindow() (>= 2, whole, NaN-safe),
computeRollingStats() (per-index trailing mean + POPULATION SD, null
through the warm-up, a fresh two-pass sum per window — the incremental
sum-of-squares form cancels catastrophically), niceValueScale() and
pickTickIndices().
- Props = z.infer of the schema plus height (default 220, clamped 140–480),
maxTicks (default 5, clamped 3–8), formatValue, onRetry, onPointSelect,
className and the div's native props, forwardRef to the card. Destructure
the window prop under another name — it shadows the global inside the
component otherwise.
Behavior
- THE COMPONENT COMPUTES THE STATS. The feed hands over raw readings and one
window size; mean and SD per index come out of computeRollingStats. Nothing
upstream pre-aggregates, so changing the window is a prop edit, not a
pipeline change.
- WARM-UP IS HONEST: entries before window − 1 draw no mean and no band. A
mean over fewer readings than the window promises would be a different
statistic wearing the same line. The readout says "warming up" and names
the reading where the mean starts.
- The value axis includes the band edges, not just the raw extent: mean ± SD
can poke past the raw min/max, and a band clipped by the frame silently
understates the spread.
- A window that never fills (window > readings) still draws the raw layer,
still names the window in the legend, and says why no mean is drawn in a
feed-notes line. A window that fills only on the last reading draws its
band as a 3px column — a zero-width polygon paints nothing.
- Every repair is counted out loud in the feed notes: dropped non-finite
points, duplicate x, re-sorting, a repaired window value.
- INTERACTION: the pointer maps to a reading through hit columns that each
reach halfway to their neighbours, converted with getScreenCTM().inverse()
so it stays correct while the viewBox scales. Keyboard: ONE tab stop, a
roving tabindex over role="option" rects in a role="listbox" group; Left /
Right step, Home / End jump, movement clamps and never wraps; Enter / Space
fire onPointSelect with the prepared reading plus its window stats; Escape
clears only when a handler exists. Focus is never dropped on <body> when a
feed update removes the reading the keyboard was on.
- Four first-class branches: loading (a deterministic skeleton whose mean and
band start a third of the way in, mirroring the warm-up; aria-hidden plus
one sr-only role=status line), empty (worded so it cannot be mistaken for a
failed fetch; a ready feed whose every point was dropped says so), error
(Try again only when onRetry was passed), ready.
- CLEANUP: one ResizeObserver behind the hook, disconnected on unmount and on
every node swap. No timers, no rAF, no animation loop to stop.
Rendering & styling
- ONE SERIES, ONE HUE. Raw layer, mean and band are three derivations of the
same series, so they all wear var(--chart-1): the raw hairline at 0.3
opacity, the raw dots at 0.45, the band at 0.16 fill, the mean at full
strength and double width. Identity is carried by shape and weight (dots vs
solid line vs flat fill), which survives greyscale — a second hue would
claim the mean is a different measurement.
- Panel: rounded-xl border bg-card; gridlines use the border token; axis and
tick text is text-muted-foreground text-xs tabular-nums; the keyboard rule
uses stroke-ring; the active dots ring themselves with var(--card) so they
read on top of any layer. cn() merges className. No hex, rgb or oklch.
- Long tick labels sit in foreignObjects that truncate and carry the full
text in a title attribute instead of overflowing the slot.
- ACCESSIBILITY: no role="img" on the plot (children-presentational would
silence the focusable readings) — role="group" labelled by the heading and
described by an sr-only summary that states the finding in words: how many
readings, where the mean starts, its range, its latest value, how many
readings sit outside their own band and where the band is widest. Each
reading rect carries a one-sentence aria-label (raw, mean, band, inside or
outside). The visible readout is aria-hidden; a separate polite role=status
carries the pointer-driven readout, which no focus event announces. An
sr-only WRAPPER DIV (never sr-only on the table itself) holds the full
table: reading, raw, mean, ±1 SD, band.
- Motion: the only animation is the skeleton pulse, with
motion-reduce:animate-none. Nothing else moves.
Customization levers
- window: the whole personality of the card. 5 tracks form week to week, 10
reads a month of games, 30 is a quarter's run rate; the legend, the readout
sentences, the table header and the warm-up all follow the one prop.
- Band multiplier: ±1 SD describes the window; widen to ±2 SD in one place
(the two spots that add/subtract sd) if the band should read as "rare"
rather than "typical spread" — but if you need breach rules and alarms,
that is a control chart, not this card.
- Raw layer density: for feeds of many hundreds of points drop the dots and
keep the hairline (one map call); for sparse feeds enlarge RAW_DOT_RADIUS
so single games stay tappable landmarks.
- height / maxTicks: the density knobs. 140–160 with three ticks makes a
dashboard row; 320+ makes the smoother the hero of a review page.
- Colour: everything derives from one token (var(--chart-1)); re-point INK at
another chart token to slot several smoothers into one dashboard palette.
- onPointSelect carries the PREPARED reading — sorted, deduplicated, with the
mean and SD the picture drew. Wire it to a game log, a drill-down or a
linked table.
- Headline stat: the latest windowed mean with its label; swap it for a delta
against the previous window if the card's question is "trending up or
down?" rather than "at what level?".Concepts
- Trailing window, honest warm-up — every statistic at index i describes exactly the last
windowreadings ending there, and the firstwindow − 1entries draw nothing rather than a mean over fewer readings than the legend promises. A smoother that extrapolates its own warm-up is quietly plotting a different statistic under the same label. - The component computes the stats — the feed stays raw points plus one window size, so changing the smoothing is a prop edit and the maths lives in exported pure functions a test can interrogate; nothing upstream needs a pre-aggregation pipeline.
- One hue, three derivations — raw dots, mean line and SD band are all
var(--chart-1)at different weights, because they are one series looked at three ways; identity rides on shape (dots vs solid line vs fill), which survives greyscale, and a second hue would claim a second measurement. - Band as description, not verdict — ±1 SD says how spread the window itself was; it is not a control limit and fires no alarms. Watching it widen through a slump and tighten through a steady stretch is the reading; breach rules belong to a control chart.
- Domain includes the band — mean ± SD can poke past the raw extent, so the band edges join the axis pool; a band clipped by the frame would silently understate the very spread it exists to show.
- Repairs said out loud — non-finite points are dropped whole, duplicate x keeps the first, unsorted feeds are sorted, an illegal window is rounded up to legal — and each repair is counted in a feed-notes line instead of silently smoothing a different series than the one that arrived.
Precision-Recall Curve
A four-state precision-recall card — one curve per model with its AUC-PR in the legend, a dashed no-skill baseline at the prevalence, and optional muted iso-F1 guide arcs.
Anomaly Timeline
A four-state anomaly-detection timeline: one metric line with the detector's flagged readings marked as destructive diamonds, anomalous stretches hatched, ribboned and counted, and an optional expected-range band the line is judged against.