Charts

Control Chart

A four-state SPC control chart that estimates its own limits from the moving range and flags every rule violation with the rule it broke.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  analyseControlChart,
  CONTROL_RULES,
  CONTROL_RULE_IDS,
  niceTicks,
  tickDecimals,
  type ChartControlData,
  type ChartControlRuleId,
  type ChartControlSigmaMethod,

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartControl" card — a Shewhart
individuals control chart — in plain SVG (no chart library) with zod.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    caption?: string; unit?: string; valueLabel?: string;
    subjectLabel?: string;
    points: { label: string; value: number; note?: string }[];
    limits?: { center: number; sigma: number } }.
  points are in time order, OLDEST FIRST. Order is the identity: two batches
  may legitimately share a label and the chart is meaningless re-sorted, so
  nothing keys on label. A ready payload must carry at least one point.
- limits.sigma is ONE sigma unit, not the distance to the limit. The runs
  rules need the 1σ and 2σ zones, and a chart handed only UCL/LCL cannot
  reconstruct them; from published limits use sigma = (UCL − centre) / 3.
  These are CONTROL limits (the voice of the process), never specification
  limits — a process can sit inside spec while being wildly out of control.
- Component props = z.infer of the schema plus rules (default all five),
  runLength (default 8, clamped 5-20), trendLength (default 6, clamped
  4-20), sigmaMethod: "moving-range" | "stdev" (default "moving-range"),
  baselineCount, height (default 220, clamped 140-480), showZones (default
  true), maxAxisLabels (default 8, clamped 2-24), formatValue, onRetry and
  className. No hand-written parallel interface.
- Ship the statistics as a pure module beside the schema:
  analyseControlChart(points, options) returning per-point { z, side, zone,
  triggers, members, baseline } plus centre, sigma, upper, lower, source,
  movingRangeBar, baselineCount, frozen, signals, degenerate and a padded
  domain. Everything below is computed there and merely painted by the
  component.

Behavior
- SIGMA COMES FROM THE MEAN MOVING RANGE, never from the standard deviation
  of the series. MR(i) = |x(i) − x(i−1)|; sigma = mean(MR) / 1.128, where
  1.128 is Hartley's d2 for subgroups of two. This is the load-bearing
  decision of the whole component: the standard deviation is inflated by
  exactly the shifts, spikes and drifts the chart exists to detect, so
  estimating limits with it widens them until the signal fits comfortably
  inside. Measured on the shipped mock: moving range gives sigma 0.755 and
  four signals, the standard deviation of the same 30 points gives 1.069 and
  only three — the early-warning rule vanishes. Offer sigmaMethod: "stdev"
  anyway, because being able to see that happen is worth more than hiding
  the choice.
- CENTRE and LIMITS: centre = mean of the baseline slice, UCL = centre + 3σ,
  LCL = centre − 3σ. baselineCount estimates from the first N points only
  and judges everything after against those limits — how a chart is meant to
  be used once a process is known-good, since limits that keep absorbing new
  data quietly widen to accommodate the very drift you are watching for.
  Mark the baseline boundary with a dotted vertical rule.
- ZONES: C = within 1σ, B = 1-2σ, A = 2-3σ, on both sides. Every rule is
  written against these boundaries, so paint them rather than implying them.
- RULES (Nelson numbering, so the chart speaks the language of the standard):
    1 beyond-limit — |z| > 3.
    2 run — runLength consecutive points on the same side of the centre.
    3 trend — trendLength consecutive points strictly rising or falling.
    5 two-of-three — 2 of 3 consecutive beyond 2σ ON THE SAME SIDE.
    6 four-of-five — 4 of 5 consecutive beyond 1σ ON THE SAME SIDE.
  Rules 4, 7 and 8 are deliberately absent: they need much longer histories
  and are the three everybody argues about. Adding one is another span
  detector with the same signature.
- A SIGNAL IS A SPAN, not a point: { rule, from, at, to, direction, message }
  where `at` is the index at which the pattern completes — the moment an
  operator would have been told — and from..to is the whole pattern. Emit ONE
  signal per maximal run and per maximal trend, not one per point past the
  threshold: a run of twelve is one thing that happened, and five overlapping
  signals for it bury the other rules. For the window rules, merge windows
  that overlap an already-open span of the same rule and side.
- Sides and ties: side is the STRICT sign of (value − centre) with a relative
  epsilon of 1e-9·max(1,|centre|); a point exactly on the centre line has no
  side and ENDS a run without starting one. Trends are strictly monotone, so
  a repeated reading breaks a trend instead of extending it. Both choices are
  what stop a coarse gauge from manufacturing signals out of ties.
- DEGENERATE INPUTS, each handled deliberately:
    zero points — the empty branch, with a different second line when points
      did arrive but every one of them was non-finite;
    one point — no moving range exists, so sigma is 0: draw the point and the
      centre line, print no limits, and say limits need at least two;
    every value identical — mean moving range 0, so sigma is 0 again. Say the
      limits have collapsed onto the centre line and that nothing can signal
      until the process (or the gauge) moves. This is the most common way a
      control chart quietly stops working: a gauge too coarse to resolve the
      variation;
    negative values — a deviation from set point is as bad at −3 as at +3;
      nothing may assume a positive quantity;
    labels wider than the axis — thin the x labels to a budget, truncate with
      an ellipsis, and keep the full string in the point's aria-label, in the
      title attribute and in the table;
    an extreme outlier — it stretches the value domain until UCL, CL and LCL
      would print on top of each other in the right-hand lane. Place CL
      first, then each limit only if it clears the last tag by 13px.
- Non-finite values are dropped BEFORE anything is indexed, so every index in
  every signal points into the array that is actually drawn; report how many
  were dropped.
- CLEANUP: this component owns no timers, no requestAnimationFrame, no
  observers and no listeners outside React's synthetic events, so there is
  nothing to tear down. If you add a hover delay or a polling refresh, cancel
  it in the effect teardown AND on every dependency change, not only on
  unmount.
- The four states are first-class branches of one bg-card panel: a skeleton
  that keeps the shape of a centre line and two dashed limits (aria-hidden,
  with an sr-only role="status"), an empty state, an error state with a "Try
  again" button only when onRetry exists, and ready.

Rendering & styling
- Geometry in one SVG with a constant viewBox and preserveAspectRatio="none",
  so the plot stretches to any width with no ResizeObserver, no first-frame
  reflow and no SSR mismatch. ONLY x is stretched: vertical geometry stays in
  px, which is what keeps the HTML markers, tick labels and limit tags on the
  lines the SVG painted. x(i) = pad + i/(n−1) × (100 − 2·pad) percent with
  pad 3.5, and n = 1 sits at 50%. y(v) = height − (v − lo)/(hi − lo) × height.
- EVERY GLYPH IS HTML, not SVG: a <circle> inside a non-uniformly scaled
  viewBox is an ellipse whose eccentricity depends on the container width.
  Absolutely position one <button> per point at (x%, y px), with the marker
  inside it. Give the button width: clamp(10px, share%, 26px) so hit targets
  shrink with density instead of overlapping — each point still owns its own
  centre, so no point is ever unhoverable. Past 64 points, stop drawing the
  in-control markers entirely and let the line carry the series: at that
  spacing a row of dots fuses into a band that hides the very line it sits on.
  The buttons stay, so every point remains hoverable, focusable, in the
  readout and in the table.
- COLOUR IS NEVER THE ONLY ENCODING. One series, one token: var(--chart-1)
  for the line, the in-control dots and the zone bands (mixed into
  transparent at 5 / 9 / 14% so the zones deepen outwards). Limits and
  signals use --destructive. What actually distinguishes a signal is its
  SHAPE, one per rule: diamond (beyond limits), square (run), triangle up or
  down (trend, pointing the way it drifts), bullseye (2 of 3), cross (4 of
  5). Points inside a flagged span but not triggering it are hollow rings.
  The same glyph is repeated beside the rule name in the legend and beside
  every row of the signal list, so the mapping is learnable and the chart
  survives greyscale and colour blindness.
- Value axis on the left, limit tags (CL / UCL / LCL) on the right where a
  control chart reader expects them, both in lanes sized min(58px, 24%) and
  min(46px, 18%) so a narrow card gives the plot the room instead of the
  chrome. Ticks on 1/2/5 × 10^k steps, re-rounded to the step's precision —
  0.1 + 0.2 is 0.30000000000000004 and an axis label is the one place that
  shows up as text.
- ACCESSIBILITY: the plot is a <figure> (not role="img" — it contains focusable
  children) whose aria-label states the finding: count, centre, limits, how
  sigma was obtained, whether the limits are frozen, and either "no rule
  signalled" or the first three signals named with their point labels. Each
  point button's aria-label is the same sentence the visual readout shows:
  label, value, sigma distance, zone, the rules it triggers, the spans it
  belongs to, and its note. Below, an sr-only WRAPPER DIV holds a real table
  with one row per point (value, signed sigma, zone, baseline, signal, note).
  Put sr-only on the wrapper, never on the table: CSS width is only a lower
  bound for a table box, so width:1px does not hold one back and a 375px
  viewport picks up hundreds of px of horizontal scroll.
- KEYBOARD: one roving tab stop, so one Tab gets into the series and one Tab
  gets out whether it holds 12 points or 200. Left/Right walk points,
  Up/Down jump to the previous/next point where a signal appears, Home/End
  go to the ends, Enter or Space pins the readout (aria-pressed) because
  touch has no hover, Escape clears the pin. Move focus with .focus() on the
  target rather than by re-rendering, so the browser keeps ownership of the
  focus ring. Rows of the signal list are buttons that move focus to their
  trigger point — the list and the plot are the same widget seen twice.
- Hovering or focusing a point brackets every span it belongs to and drops a
  dashed crosshair at its x. The readout paragraph is aria-hidden on purpose:
  the focused button already announces that sentence, and a live region would
  say all of it twice.
- Motion: the loading skeleton pulses and stops at motion-reduce; nothing
  else animates, so the chart is fully functional with motion off.

Customization levers
- rules: every rule you add raises the false-alarm rate — about 0.27% per
  point for rule 1 alone, near 1% per point with all five, so a stable
  100-point chart is expected to cry wolf about once. Run the full set on a
  process you are investigating; cut to ["beyond-limit"] on a dashboard
  nobody is watching closely. runLength 8 is Western Electric, 9 is Nelson;
  trendLength 6 is Nelson, 7 is common in the ISO literature.
- sigmaMethod and baselineCount are the two levers that decide what the
  limits MEAN: moving range over the whole series ("what has this process
  been doing"), moving range over a frozen baseline ("is it still what it
  was"), supplied limits ("does it match the study we signed off").
- height and showZones set the density: drop the zones for a compact
  monitoring tile where only rule 1 matters, keep them wherever the runs
  rules are on, since they are the reference those rules are written against.
- maxAxisLabels trades label legibility for landmark density — drop it to 3-4
  when the labels are long. The density ladder (11px markers under 33 points,
  9px under 65, and above 65 only signals are marked at all) is where to start
  if your series is denser than the mock: past that the dots fuse into a band
  that hides the line they sit on.
- formatValue reaches the axis, the readout, the signals and the table at
  once — swap in currency, compact notation or a locale of your choosing.
- Palette: re-point var(--chart-1) for the series and the zones; keep the
  alarm on --destructive, or move it to another semantic token if your
  product reserves red. Do not encode a rule in colour alone — change the
  shape map instead.
- Interaction: the pin is the only state the chart owns. Wire onClick on a
  point to open the batch record, or lift `pinned` to compare two charts on
  the same index.

Concepts

  • Moving-range sigma — the sigma unit comes from the average jump between neighbours divided by 1.128, not from the spread of the series. A sustained shift costs the moving range two jumps but costs the standard deviation every point, so limits built on the standard deviation widen until the shift fits inside them. Measured on this page's data: 0.755 versus 1.069, and one of the four signals disappears.
  • Zones and runs rules — 3σ is a coarse alarm: a one-sigma shift takes about 44 points to trip it. The 1σ and 2σ zone boundaries exist so the cheaper patterns — 8 on one side, 2 of 3 out past 2σ, 4 of 5 out past 1σ — can catch the same shift in single digits, which is why they are drawn rather than implied.
  • Signal as a span — a signal has three indices: where the pattern starts, where it completes (at, the moment you would have been told), and how far it ran. The glyph sits at the completion point, the bracket covers the whole pattern, and one maximal run yields exactly one signal instead of one per point past the threshold.
  • Frozen baseline — limits that keep absorbing new data quietly widen to accommodate the drift you are watching for. Estimating from the first N points and holding is what turns the chart from a description of the data into a test against a known-good process.
  • Collapsed limits — identical readings mean a mean moving range of zero, so sigma is zero and UCL, CL and LCL land on one line. Almost always this is a gauge too coarse to resolve the variation, not a perfect process; the chart has to admit it cannot signal rather than draw a reassuringly clean picture.
  • Shape-coded signals — each rule has its own marker shape (diamond, square, triangle, bullseye, cross) repeated in the legend and in the signal list, with hollow rings for the points that take part without triggering. Colour marks severity, never identity, so the chart still reads in greyscale, at 3% zoom on a projector, and for a reader who cannot see red.

On This Page