tutorial
Build a manufacturing SPC dashboard: control charts and process capability
Last updated 5 September 2026
Quality control on a line is not a table of numbers you eyeball; it is statistical process control. You need to know whether the process has moved, whether it has become less repeatable, whether it can hold the tolerance at all, and which specific parts broke a rule. This tutorial builds that dashboard from readings off the line: control charts, process capability, out-of-spec formatting and rule-based anomaly flags, with no backend to run.
Open the finished dashboard in the sandbox
The problem: control, not just a table
A run of measurements hides its own story until you put limits on it. The hard part is that two different limits matter and are easy to confuse: the control limits are the process talking, computed from its own variation, while the specification is the customer talking, the tolerance a part must meet. A process can be in perfect control and still make scrap, or drift out of control while every part is still in spec. The dashboard below keeps the two apart and reads every measure from one declared tolerance, so it is configuration rather than a statistics library you write yourself.
Set up the page
Load the grid and the charts module from the CDN with ordinary script tags.
No build step and no import: the grid is on the global
LatticeGrid, and the charts module adds
createChart to it. On localhost the grid is free to use; a
deployed site is licensed per domain, which the sandbox already carries for
you.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.32.0/lattice-grid.min.css">
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.32.0/lattice-grid.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.32.0/modules/charts.min.js"></script>
Lay out a strip of tiles, the charts and an element for the grid.
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; background: #f6f7f9; color: #1b2430; }
.tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 12px; }
.tile { background: #fff; border: 1px solid #e3e6ea; border-radius: 11px; padding: 12px 14px; min-width: 0; }
#control { height: 240px; background: #fff; border: 1px solid #e3e6ea; border-radius: 10px; margin-bottom: 12px; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
.pair > div { height: 210px; background: #fff; border: 1px solid #e3e6ea; border-radius: 10px; min-width: 0; }
#grid { height: 360px; background: #fff; border: 1px solid #e3e6ea; border-radius: 10px; overflow: hidden; }
@media (max-width: 820px) { .tiles, .pair { grid-template-columns: 1fr; } }
</style>
<div class="tiles">
<div class="tile" id="cpk"></div>
<div class="tile" id="mean"></div>
<div class="tile" id="defect"></div>
</div>
<div id="control"></div>
<div class="pair"><div id="mr"></div><div id="cap"></div></div>
<div id="grid"></div>
The production data model
A seeded generator stands in for readings coming off the line: one bore diameter per part, in millimetres, normally distributed around the target. The process runs on target for a stable stretch and then drifts up, so the study has both a healthy run and a real fault to catch rather than a flat line that shows nothing.
// A seeded generator standing in for readings off the line: one bore diameter
// per part, in millimetres. The process runs on target for a stable stretch,
// then drifts up, so the study has both a healthy run and a real fault to catch.
function rng(seed) {
var a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Box-Muller, so the readings are normally distributed around the target.
function gauss(rand) {
return Math.sqrt(-2 * Math.log(Math.max(rand(), 1e-9))) * Math.cos(2 * Math.PI * rand());
}
function generateReadings(count) {
var rand = rng(907);
var MACHINES = ['Cell A', 'Cell B', 'Cell C'];
var rows = [];
for (var i = 0; i < count; i++) {
// Stable for the first eighty parts, then a slow upward drift.
var drift = i < 80 ? 0 : (i - 80) * 0.0011;
var bore = Math.round((10.0 + drift + gauss(rand) * 0.013) * 1000) / 1000;
rows.push({ id: i + 1, n: i + 1, machine: MACHINES[i % 3], bore: bore });
}
return rows;
}
The tolerance on the column
Declare the tolerance once, as the spec on the bore column: the target and the two limits the customer will accept. Everything downstream reads it. Cp and Cpk measure the process against it, the control chart draws it apart from the process's own limits, and the out-of-spec formatting keys off it. Beside the reading is a rolling mean, computed in one ordered pass, which smooths the run so a drift is easier to see.
// The tolerance lives on the column as spec: the target and the two limits the
// customer will accept. Cp and Cpk read it, the control chart draws it apart
// from the process's own limits, and the out-of-spec formatting below uses it.
var SPEC = { lower: 9.95, upper: 10.05, target: 10 };
var columns = [
{ field: 'n', title: '#', type: 'number', layout: { width: 80, pin: 'start' } },
{ field: 'machine', title: 'Machine', filter: { type: 'set' }, layout: { width: 120 } },
{
field: 'bore', title: 'Bore', type: 'number',
format: { suffix: ' mm', decimals: 3 },
spec: SPEC,
},
// A rolling mean over the last eight parts, computed in one ordered pass. The
// leading parts have no full window and read blank rather than pretending.
{
id: 'trend', title: 'Rolling mean', type: 'number', format: { suffix: ' mm', decimals: 3 },
shadow: { kind: 'rollingAvg', of: 'bore', orderBy: 'n', window: { kind: 'count', span: 8 } },
},
];
Out-of-spec formatting and the grid
Any part outside the tolerance is marked in the table, so a scrapped part is visible where you read the numbers, not only on a chart. The statistics panel is docked and open, so the figures are the first thing you see and they follow the filters: narrow to one machine and every measure recomputes for it.
var grid = LatticeGrid.createGrid(document.getElementById('grid'), {
rowKey: 'id',
theme: 'light',
rows: generateReadings(160),
columns: columns,
selection: 'multiple',
statusBar: true,
// Out-of-spec conditional formatting: any bore outside the tolerance is marked
// in red, so a scrapped part is visible in the table as well as on the chart.
formatting: {
bore: [
{ id: 'below', when: { op: 'lt', value: SPEC.lower }, style: { color: '#c92a2a', fontWeight: '600' } },
{ id: 'above', when: { op: 'gt', value: SPEC.upper }, style: { color: '#c92a2a', fontWeight: '600' } },
],
},
// The statistics panel, docked and open, so the figures are the first thing
// seen and follow the filters.
toolPanel: {
side: 'left',
panels: ['columns', 'filters', 'statistics'],
openPanel: 'statistics',
actions: ['excel', 'restore'],
exportName: 'readings',
},
});
Process capability: Cp and Cpk
Three tiles read from the capability report. Cpk says whether the process, where it actually sits, can hold the tolerance, and the usual floor is 1.33. The defect rate is the share of parts outside the spec. Fixing the limits on the first thirty parts means the later drift is measured against a healthy process rather than being absorbed into a widening band, which is the whole point of a baseline.
// Three tiles read from the capability report. baseline fixes the limits on the
// first thirty parts, so the drift is measured against a healthy process rather
// than absorbed into a widening band. Each tile follows the filters.
var cap = function () { return grid.statistics.capability('bore', { rules: 'nelson', baseline: 30 }); };
LatticeGrid.createStat({ grid: grid, container: document.getElementById('cpk'), title: 'Cpk', value: function () { return cap().cpk; }, goodWhen: 'up', baseline: 1.33, decimals: 2, footer: '1.33 is the usual floor' });
LatticeGrid.createStat({ grid: grid, container: document.getElementById('mean'), title: 'Mean bore', of: 'bore', fn: 'avg', decimals: 3 });
LatticeGrid.createStat({ grid: grid, container: document.getElementById('defect'), title: 'Defect rate', value: function () { return cap().defectRate; }, goodWhen: 'down', format: { style: 'percent', decimals: 2 }, footer: 'share outside the tolerance' });
Control charts
The charts are the SPC study, all from the one declared tolerance. The individuals chart asks whether the process has moved, with its control limits drawn separately from the specification. The moving-range chart is its partner, asking whether the process has become less repeatable, because a process can fail either without failing the other. The capability chart draws the spread against the tolerance. Nelson's rules judge the points, so the drift trips a rule rather than passing unnoticed.
// The SPC study, all from the one declared tolerance. The individuals chart
// asks whether the process has moved; the moving-range chart asks whether it has
// become less repeatable; the capability chart draws the spread against the
// tolerance. Nelson's rules judge the points, and the limits are fixed on the
// first thirty parts.
var control = LatticeGrid.createChart({ grid: grid, container: '#control', type: 'control', y: 'bore', baseline: 30, rules: 'nelson', title: 'Individuals chart, limits fixed on the first 30 parts' });
var movingRange = LatticeGrid.createChart({ grid: grid, container: '#mr', type: 'movingRange', y: 'bore', title: 'Moving range' });
var capability = LatticeGrid.createChart({ grid: grid, container: '#cap', type: 'capability', y: 'bore', title: 'Capability against the tolerance' });
That is the whole dashboard: capability tiles, a control and moving-range pair, a capability chart, and an out-of-spec table beneath. Run it in the sandbox and filter to one machine to watch every measure follow.
Anomaly flags
Seeing the violations on the chart is one thing; acting on them is another. The capability report lists every point that broke a rule, with the rule number and the rule set that judged it, so the parts to pull and inspect are a list you can hand to the floor rather than a chart someone has to read.
// Anomaly flags in code, if you want to act on them rather than only see them.
// The capability report lists every point that broke a rule, with the rule
// number, under the rule set that judged it.
var report = grid.statistics.capability('bore', { rules: 'nelson', baseline: 30 });
console.log(report.ruleSet, 'violations:', report.violations);
// report.violations: [{ index, rule }, ...] -> the parts to pull and inspect
What you built
A run of measurements became a control dashboard: capability that says whether the process can hold the tolerance, control charts that say whether it has moved or become less repeatable, out-of-spec marks where you read the numbers, and a list of exactly which parts broke a rule. Every measure came from one declared tolerance and follows the filters.
Next, see the SPC charts demo as a compact example, or read the statistics overview for the full set of measures the grid computes.