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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-control.jsonPrompt
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.
Cumulative Flow Diagram
A four-state cumulative flow diagram that reads band thickness as work in progress and the horizontal gap between the arrival and delivery curves as approximate lead time, annotated on the plot and reachable by keyboard.
Waffle Chart
A four-state waffle (square pie) that apportions categories onto a countable grid with largest-remainder rounding, reserves a cell for shares worth less than one, and keeps the palette readable past five categories.