Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { useResizeObserver } from "@/registry/hooks/use-resize-observer"
import type {
  ChartAnomalyTimelineBand,
  ChartAnomalyTimelineData,
  ChartAnomalyTimelineInterval,
  ChartAnomalyTimelinePoint,
} from "./chart-anomaly-timeline.contract"

export interface ChartAnomalyTimelineProps

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartAnomalyTimeline" card in plain SVG
with zod and a useResizeObserver hook. It renders a detector's verdicts over
one metric: the metric as a line, readings the detector flagged as emphasized
marks, stretches it flagged as shaded intervals, and an optional expected-range
band (a lower/upper series) the line is judged against.

The component NEVER detects anomalies itself. The flags and intervals are the
pipeline's output; re-deriving them in the chart would let the picture silently
disagree with the system it renders. What the chart DOES compute is the audit:
readings that sit outside the stated range without being flagged are counted
and said out loud, never quietly re-flagged.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    caption?: string; unit?: string;
    points: { t: number; label: string; value: number; anomaly?: boolean }[];
    intervals?: { from: number; to: number; label?: string }[];
    band?: { lower: (number | null)[]; upper: (number | null)[];
             label?: string } }
- t is epoch ms or a plain ordinal — the component only compares, subtracts
  and interpolates it; label is what a reader sees, so no clock or Date is
  ever read at render and SSR output is deterministic.
- band.lower / band.upper are ALIGNED WITH points BY INDEX, because a detector
  emits its envelope alongside the readings as a matrix; the refinement checks
  both lengths rather than leaving it to guesswork. null is a STATED absence
  (a rolling baseline still warming up) — the band breaks open around it and
  is never drawn straight through.
- superRefine: band arrays match the points length; no two points share a t
  (they would stack on one pixel column and fight over one React key); an
  interval with to <= from has no width — an anomalous instant is a point
  flag, not an interval; a ready chart needs at least one finite reading.
  Guard every access — sibling refinements all run, so a ragged payload must
  produce an issue rather than a TypeError thrown out of safeParse.
- Props = z.infer of the schema plus height (default 220, clamped 140-420),
  maxTicks (default 5, clamped 3-8), formatValue, onRetry, onPointSelect,
  className and the div's native props, forwardRef to the card.
- Export the maths: prepareAnomalyTimeline(), prepareAnomalyIntervals(),
  buildAnomalyScale(), niceStep(), usableRuns(), metricPath(), bandRunPath(),
  boundPath(), columnEdges(), pickTickIndices().

Behavior
- PREPARE, and count every repair out loud under the chart. A point with no
  usable t is dropped (it has nowhere to stand). NaN / Infinity readings and
  bounds become holes, never zeros. Band bounds are carried onto each point
  BEFORE sorting — they are index-aligned with the caller's array, and a sort
  would tear them apart. A bound pair with lower above upper is swapped and
  counted. Points are sorted by t on a copy; duplicates / re-sorting appear in
  a "Feed notes" line. The caller's arrays are never mutated.
- THE AUDIT: for every finite reading with stated bounds, compute whether it
  sits outside them. Outside + unflagged is counted in the ledger ("N
  unflagged readings outside the expected range") — the one fact the detector
  cannot report about itself. A flagged reading whose value is a hole cannot
  be placed on the plot, so it is counted in the feed notes instead of
  vanishing.
- INTERVALS are clamped into the window and counted: one entirely outside is
  dropped (shading a stretch nobody can see is a highlight of nothing), one
  leaning out is clamped to the edge, one arriving reversed is swapped. The
  ledger states the interval count and the t-weighted share of the window they
  cover, measured on the UNION of the spans — overlapping intervals are
  counted once, so the share can never exceed 100%.
- THE LINE lifts its pen over every hole; a reading whose neighbours are both
  holes is drawn as a dot, because a lone reading paints nothing as a path.
  The band is one polygon per unbroken run (out along upper, back along
  lower); a run needs BOTH bounds, so a hole in either splits it, and a run of
  a single reading becomes a 3px column centred on its t. The value axis
  includes the band bounds in its domain — an envelope clipped by the frame is
  an envelope nobody can judge the line against.
- 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 is scaling the plot. Keyboard: ONE tab
  stop for the whole plot, a roving tabindex over role="option" rects inside a
  role="listbox" group. Left / Right step a reading, Home / End jump to the
  ends, Page Up / Page Down jump between FLAGGED readings (the finding, not
  every reading), Enter / Space fire onPointSelect with the PREPARED sample,
  Escape clears it — and Escape is only intercepted when a handler exists,
  otherwise it belongs to the dialog this card might live in. Movement clamps
  and never wraps: a time axis has two ends. preventDefault only for handled
  keys, so Tab still leaves the chart. The focus rule (ring token) and the
  dashed hover rule are INDEPENDENT marks: the option rects suppress the
  native outline, so the ring rule is the focus indicator and must stay
  visible while the pointer rests somewhere else on the plot.
- FOCUS IS NEVER DROPPED ON <body>: when a feed update removes the reading the
  keyboard was on, the browser silently blurs it, so the tab stop takes focus
  back — and only in exactly that case (we owned focus, the index we owned is
  gone, nothing else has claimed focus since).
- Four first-class branches of one card: loading (a deterministic
  band-plus-line skeleton, aria-hidden, plus one sr-only role=status line),
  empty (a valid contract with no readings, worded so it cannot be mistaken
  for a failed fetch), error (a Try again button only when onRetry was
  passed), ready. A ready chart with nothing drawable renders the empty
  branch.
- CLEANUP: one ResizeObserver behind the hook, disconnected on unmount and on
  every node swap. No timers, no rAF loop, nothing to leak.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border
  for gridlines, muted for the skeleton, muted-foreground for axis text, ring
  for the keyboard rule, var(--chart-1) for the metric line AND its expected
  band (one phenomenon, one hue), var(--destructive) for every verdict — the
  flag diamonds, their halos, the interval hatch and the interval ribbon. No
  hex, rgb, hsl or oklch anywhere; legend swatches are built with
  color-mix(in oklab, ...) from the same tokens.
- A FLAG IS SHAPE PLUS COLOUR, never colour alone: a flagged reading is a
  diamond with a soft halo, an interval is a 45° hatch BEHIND the data plus a
  solid ribbon under the axis where nothing can cover it — all three survive a
  greyscale printout and any colour vision deficiency. The band keeps thin
  dashed edge lines over its fill, so the envelope stays legible where the
  fill is faint.
- A headline count sits in the card corner: "N flagged of M readings", in the
  destructive text token only when N > 0.
- Axis and tick text is text-muted-foreground text-xs with tabular-nums;
  x tick labels sit in foreignObjects that truncate with the full text on a
  title attribute, so a long label elides instead of overflowing its slot.
- ACCESSIBILITY: do NOT put role="img" on the plot — that is
  children-presentational and would silence every focusable reading. Use
  role="group" labelled by the heading and described by an sr-only summary
  that states the finding in words: how many readings, which were flagged,
  what the intervals cover, how many readings sit outside the range unflagged.
  Each reading rect carries a one-sentence aria-label with its value, its
  expected bounds, the verdict and its interval. The visible readout is
  aria-hidden (a focused reading already announces itself); a separate polite
  role=status carries the POINTER-driven readout, which no focus event
  announces. 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) holds every
  reading, bound pair, verdict and interval.
- Motion: the only animation is the loading skeleton's pulse, carrying
  motion-reduce:animate-none. Nothing else moves, so the chart is complete and
  readable with animation off.

Customization levers
- band: omit it entirely for a bare flagged line; one-sided envelopes work too
  (send null for the missing side and the readout switches to "at most /
  at least"). The band label feeds the legend and the table header.
- intervals vs point flags: they are independent channels — use intervals for
  verdicts about stretches (a bad deploy, an illness week) and point flags for
  verdicts about single readings; the mock shows both at once, with only the
  worst reading inside an interval also point-flagged.
- The audit ledger: "unflagged readings outside the range" is the lever for
  tuning trust in the detector — keep it when the band IS the detector's
  envelope, remove that one line if your band is unrelated to the flags (e.g.
  a contractual bound) and the mismatch is expected.
- height / maxTicks / the 88px tick pitch: the density knobs. 140-160 makes a
  dashboard row; 320+ makes the timeline the hero of an incident review.
- Marker weight: FLAG_RADIUS / HALO_RADIUS scale the emphasis; shrink both for
  dense feeds with many flags, or drop the halo and keep only the diamond when
  flags cluster tightly.
- onPointSelect carries the PREPARED sample — sorted, holes stated, bounds
  attached — wire it to a drill-down, a trace search or an annotation form.
- Severity tiers: if the feed grades anomalies, split the single anomaly flag
  into an enum in the contract and map tiers to marker size (shape and text,
  not a second hue) — the destructive token stays the only verdict colour.

Concepts

  • Verdicts are rendered, never re-derived — the anomaly flags and the intervals are the detection pipeline's output, and the chart's whole job is to show them faithfully. A chart that re-runs its own detection can silently disagree with the alerts it sits next to, and then nobody knows which one paged.
  • The audit channel — the one thing the chart computes itself is the mismatch: finite readings outside the stated expected range that carry no flag. They are counted in the ledger, not promoted to flags, because "the detector missed this" is a finding about the detector, not a verdict about the data.
  • Two kinds of verdict, two encodings — a point flag is a diamond with a halo at the reading; an interval is a hatched column behind the data plus a ribbon under the axis. They are independent channels in the contract, so an illness week can be one interval instead of seven separate point flags — and the worst day inside it can still carry its own diamond.
  • One hue for the phenomenon, one for the judgement — the line and its expected band share var(--chart-1) because they are one metric and its envelope; everything the detector said wears var(--destructive). A reader can strip either layer mentally because no colour is doing two jobs.
  • Bounds ride with their point — the band arrives index-aligned with the points array, so preparation attaches each bound pair to its reading before sorting; sorting first would tear the envelope off the line it bounds.
  • Holes break everything honestly — a non-finite reading becomes a stated gap: the line lifts its pen, the band splits into runs, a lone survivor is drawn as a dot, and a flagged reading with no value is counted in the notes instead of vanishing.
  • One tab stop, roving index — the plot is a listbox of readings rather than dozens of tab stops, and Page Up / Page Down jump between flagged readings, so nobody has to arrow through a whole season to reach the finding.

On This Page