Charts

Drift Monitor

A four-state drift board: one banded strip per feature on a shared PSI scale with the warn and alert rules drawn across every row, the prediction-drift lane on top, and the worst offender named — pick any window from the tick row to read the whole card at that moment.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartDriftMonitorData,
  ChartDriftMonitorFeature,
  ChartDriftMonitorPredictionPoint,
  ChartDriftMonitorThresholds,
} from "./chart-drift-monitor.contract"

export interface ChartDriftMonitorProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartDriftMonitor" model-monitoring card
in plain HTML/CSS plus one tiny SVG, with zod. Not recharts, and deliberately
not a heatmap: the cell's value must be *positioned*, not just tinted, so a
reader can see a bar cross the alert rule instead of guessing which shade of
orange that was.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    caption?: string; statistic?: "psi" | "kl"; baselineLabel?: string;
    thresholds: { warn: number > 0; alert: number > 0 };
    features: { id; label; reference?: string;
                points: { period: string; psi: number >= 0 }[] }[];
    predictionDrift?: { period: string; value: number >= 0 }[] }.
- `psi` is the drift statistic, not the feature's value: a distance between this
  window's distribution and the frozen baseline's. It keeps the name under
  statistic: "kl" so a payload does not change shape when a team switches
  measure. Non-negative — a negative reading means the producer computed
  something else, and it is dropped and counted rather than drawn.
- thresholds are DATA, not constants: the PSI convention is 0.10 / 0.25, but a
  KL card and a per-team SLO need their own pair, and a card whose bands
  disagree with the alerting pipeline is worse than no card. superRefine:
  alert strictly above warn, unique feature ids, a ready card needs one feature
  with one window. Guard every dereference so a ragged payload yields an issue
  rather than a TypeError out of safeParse.
- `reference` is one line about the BASELINE window ("p50 6.2k · p99 118k tok",
  "1.83 nats/token"). Never plotted, but never invisible either: drift says a
  distribution moved, not what it moved from, and the engineer deciding whether
  0.41 matters needs both — so print it beside the worst offender in the verdict
  line and for every feature in the screen-reader table.
- Props = z.infer plus rowHeight (22, clamped 14..48), laneHeight (56, clamped
  32..160), defaultPeriodIndex, onPeriodChange, onRetry, className and the div's
  native props; forwardRef to the card.
- Export the maths beside the component so a test can print the numbers behind
  the picture: driftBand(), niceCeil(), apportionPercent(), trendSlope(),
  mergePeriodAxis(), predictionLinePath(), rankDriftRows(),
  buildDriftMonitorModel().

Behavior
- ONE MERGED AXIS. A window is a label, not a date — "W9" sorts after "W10" —
  so never sort it. Merge every feature's window list in turn, keeping a cursor
  at the last window already placed and inserting unseen ones in front of it, so
  a feature added mid-quarter can arrive first in the array without dragging the
  windows it missed to the end of the axis. Give every feature one cell per
  window on that axis.
- A HOLE IS NOT A ZERO. A feature that shipped three weeks in gets a dash in the
  windows before it existed, never a zero-height bar: zero drift is the
  strongest claim on the card, and "this column did not exist" is not a claim.
  Count the holes and say how many in view.
- ONE SHARED SCALE, drawn. Top the scale at the two-significant-digit ceiling of
  max(peak * 1.08, alert * 1.15) — relative, because the same component carries
  PSI near 0.3 and KL divergences three orders of magnitude smaller. Every row
  AND the prediction lane use it, and both threshold rules are drawn across all
  of them, so a bar's top crosses the alert rule at exactly the value that
  colours it alert. The mark is positioned by the number that colours and labels
  it, in that order.
- BANDS ARE HALF-OPEN at the lower edge: value >= alert is alert, value >= warn
  is watch, otherwise stable. A threshold its own value does not trip is not a
  threshold.
- RANK, DO NOT JUST LIST. Sort rows by windows over the alert line, then the
  newest reading, then the peak, then payload order. Name the top row in the
  header with its newest value, its alert count, its baseline `reference` line
  and its least-squares trend per window (regressed on the SHARED axis index, so
  a short series does not have its drift rate stretched to the full window). Print the ranking rule under the
  card — a ranking the reader cannot reconstruct is just an order.
- WINDOW SCRUB = THE AXIS. The tick row IS the picker: role="tablist", one
  role="tab" per window, roving tabindex, Left/Right wrapping plus Home/End,
  click to select. Print a label only every stride-th tick, where
  stride = max(1, ceil(n/6), ceil(longestLabelChars/3)) — the second term
  because a long window label such as "2026-07-06" needs about four columns of
  room where "W28" needs one, and measuring the real width would mean the
  ResizeObserver this card otherwise avoids; ceil(n/6) alone prints every tick
  of a six-window axis and the dates collide. Anchor the stride on the NEWEST
  window, so "now" always has a printed tick; the rest keep their label as
  sr-only text, which is both the accessible name and — with the stride — the
  reason two ticks can never overlap. A tab is one column wide and its rule is
  20px tall, so grow the POINTER TARGET with a `before:-inset-y-1`
  pseudo-element rather than with the box: the box is a column of the same flex
  row the strips use, and 20px + 2×4px clears the 24px minimum without touching
  the axis. Selecting a window paints its column through every row and
  re-reads the whole panel: each row's value and band, the prediction reading,
  "k of n measured features over the alert line", and the biggest rise against
  the previous window.
- SHARES THAT ADD UP. Split the measured cells into stable / watch / alert and
  apportion the percentages by LARGEST REMAINDER. Rounding each share on its own
  is the bug: 50.65 / 37.66 / 11.69 rounds to 51 + 38 + 12 = 101, and a card
  publishing 101% of its own cells has told the reader the split is guesswork.
- Four first-class branches of one card: loading (a bar-silhouette skeleton in
  the same grid, aria-hidden, plus an sr-only role=status line), empty (worded
  so it cannot be mistaken for a failed fetch, and it still states the bands),
  error (Try again only when onRetry was passed), ready. status "ready" with
  nothing measured renders the empty branch.
- NO OBSERVERS, NO TIMERS, NO rAF. Every x position is a percentage of the same
  flex row, and the prediction lane is a preserveAspectRatio="none" viewBox with
  vectorEffect="non-scaling-stroke", so the card needs no measurement to stay in
  register — nothing to leak and nothing to tear down.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground panel, border rules and
  separators, bg-muted cell tracks (bg-muted/40 for a hole),
  text-muted-foreground for axis and prose, primary/10 fill with a primary/40
  inset ring for the selected column, border-primary on the selected tick,
  stroke-ring on focus, and the band ramp bg-chart-2 (stable) / bg-chart-3
  (watch) / bg-destructive (alert). Never a hex, never a chart token as small
  text — only destructive is legible at 9-10px in both themes.
- COLOUR IS NEVER THE ONLY CHANNEL: the two threshold rules are drawn across
  every strip so a crossing survives greyscale, each row prints its band as a
  word next to its value, and the sr-only table repeats the band for every cell.
- The tick row and the panel share ONE grid template with no content-driven
  column (fixed label and value columns, minmax(0,1fr) between them): an `auto`
  column resolves differently in two grids holding different text, and every
  tick would then sit a few pixels off the column it labels.
- Cells carry no flex gap — the bars are inset instead — so column i really does
  span [i/n, (i+1)/n] and the lane's dots, its polyline and the strips below
  line up without measuring anything. Nothing may add to a column's box either:
  a 1px border on each side of the selected cell is added to a flex-basis-0
  item's hypothetical size, so it would widen that column by 2px, shrink all the
  others, and slide the strips out of register with the tick row that has no
  such border. Mark the selection with an inset ring or an overlay instead.
- Printed precision follows the scale (one digit finer than its magnitude,
  clamped 2..4), so a KL card does not print a column of "0.00"; every number is
  tabular-nums.
- ACCESSIBILITY: heading + summary line ids wired to the panel, an sr-only
  role=status for loading, and an sr-only WRAPPER DIV (never sr-only on the
  table itself — CSS width is only a lower bound for a table box) holding up to
  12 rank-spaced windows with every feature, its baseline line and the
  prediction lane.
- Motion: the only animation is the skeleton pulse, carrying
  motion-reduce:animate-none; the tick hover/selection transition carries
  motion-reduce:transition-none.

Customization levers
- Density: rowHeight is the whole ladder's weight — 14 for a fifty-feature wall,
  36 for a six-feature card where each bar should read as a bar; laneHeight
  sizes the prediction lane independently.
- Bands: thresholds come from the payload, so point them at the same constants
  your alerting uses. For a three-band KL card try 0.02 / 0.05 and let the
  scale and the printed precision follow by themselves.
- Ranking: rankDriftRows is one comparator — swap the leading key to the newest
  reading for a "what is worst right now" board, or to the trend slope for a
  "what is getting worse fastest" board.
- Statistic: `statistic` only changes wording and precision, so the same card
  serves PSI, KL, JS distance or Wasserstein — rename it in one place.
- Subtract blocks for a tile: drop the prediction lane (omit predictionDrift),
  the legend, or the closing paragraph and keep the strips.
- Interaction: for a static report, render with defaultPeriodIndex and hide the
  tick row's affordance; for a shared cursor across a page of monitors, lift the
  selection into a controlled prop and drive it from onPeriodChange.
- Palette: BAND_FILL is three tokens — re-point them at a host ramp, but keep
  the drawn threshold rules, because they are the channel that survives both
  greyscale and colour blindness.

Concepts

  • Threshold banding, not a colour ramp — a heatmap asks the reader to decode a shade; a drift board only ever has three verdicts, so the cell is a bar whose height is the number and whose fill is the band that same number falls in, with both threshold rules drawn across every row. The crossing is a position you can see, which is why the card still works printed in grey.
  • One scale, or the rows are lying to each other — every feature and the prediction lane share a single ceiling, so a tall bar really is a bigger distance than a short one three rows down. Per-row autoscaling is the classic small-multiples trap: it makes the calmest feature on the board look exactly as alarming as the worst.
  • A hole is not a zero — a feature that shipped mid-quarter gets a dash in the windows before it existed. Zero drift is the strongest claim this card can make, and "this column did not exist yet" is not a claim at all; the count of holes is printed rather than quietly absorbed.
  • The axis is merged, never sorted — a window is a label, so "W9" would sort after "W10". Each feature's list is merged in turn against a cursor, which lets a late-arriving feature be listed first without shoving the windows it missed to the end of the axis.
  • Sustained drift versus one bad night — the shape is the diagnosis. A monotone climb across ten windows is a population that moved and a retraining ticket; a single spike that returns to the stable band is an incident that has already closed. Ranking by alert windows before today's value is what keeps the second from outranking the first.
  • The tick row is the control — the window axis and the window picker are the same widget: role="tablist" with one tab per column, roving tabindex, arrow keys and Home/End. Only every k-th tick prints its label, anchored on the newest window; the rest keep theirs as screen-reader text, so a dense axis can never collide with itself and every column is still reachable by name.
  • Shares that add up — the stable / watch / alert split is apportioned by largest remainder, so the three percentages total exactly 100. Rounding each on its own publishes 101, and a card that cannot add up its own cells has undermined every other number on it.

On This Page