Statistical Process Control Chart (X-bar/R) — D3.js

A Statistical Process Control (SPC) chart displaying sample means (X-bar) and ranges (R) plotted over time against control limits. The chart includes a center line representing the process mean, Upper Control Limit (UCL) and Lower Control Limit (LCL) at ±3 sigma, and optional warning limits at ±2 sigma. Out-of-control points are highlighted to signal process instability. This is a fundamental tool in manufacturing quality control and Six Sigma methodology for monitoring process stability.

Statistical Process Control Chart (X-bar/R) rendered with D3.js

JavaScript source (D3.js)

// anyplot.ai
// spc-xbar-r: Statistical Process Control Chart (X-bar/R)
// Library: d3 7.9.0 | JavaScript 22.22.3
// Quality: 90/100 | Created: 2026-06-20

const t = window.ANYPLOT_TOKENS;
const { width, height } = window.ANYPLOT_SIZE;

// SPC constants for subgroup size n=5; D3=0 so LCL_R excluded
const A2 = 0.577, D4 = 2.114;

// Park-Miller LCG for deterministic, reproducible data
let _s = 42;
function rng() {
  _s = (_s * 16807) % 2147483647;
  return _s / 2147483647;
}
function randn() {
  return Math.sqrt(-2 * Math.log(rng())) * Math.cos(2 * Math.PI * rng());
}

// Generate 30 subgroups of n=5 shaft diameter measurements (mm)
const PROC_MEAN = 25.0, SIGMA_W = 0.012, N_SUB = 5, N_SAMP = 30;

const rawSamples = Array.from({ length: N_SAMP }, (_, i) => {
  const vals = Array.from({ length: N_SUB }, () => PROC_MEAN + SIGMA_W * randn());
  return {
    id: i + 1,
    mean: vals.reduce((a, b) => a + b) / N_SUB,
    range: Math.max(...vals) - Math.min(...vals),
  };
});

// Baseline control limits (computed from all in-control samples)
const xbarBar = rawSamples.reduce((a, d) => a + d.mean, 0) / N_SAMP;
const rBar    = rawSamples.reduce((a, d) => a + d.range, 0) / N_SAMP;
const xbarUCL = xbarBar + A2 * rBar;
const xbarLCL = xbarBar - A2 * rBar;
const xbarUWL = xbarBar + A2 * rBar * (2 / 3);
const xbarLWL = xbarBar - A2 * rBar * (2 / 3);
const rUCL    = D4 * rBar;
const rUWL    = rBar + (D4 - 1) * rBar * (2 / 3);

// Clone and inject 3 out-of-control signals
const samples = rawSamples.map(d => ({ ...d }));
samples[7].mean   = xbarUCL + 0.003;  // sample 8  — X̄ above UCL
samples[18].mean  = xbarLCL - 0.004;  // sample 19 — X̄ below LCL
samples[24].range = rUCL    + 0.002;  // sample 25 — R above UCL

samples.forEach(d => {
  d.xOOC = d.mean  > xbarUCL || d.mean  < xbarLCL;
  d.rOOC = d.range > rUCL;
});

// Layout — increased top margin for proper legend-to-panel breathing room
const margin = { top: 108, right: 90, bottom: 52, left: 90 };
const gap    = 32;
const panelH = Math.floor((height - margin.top - margin.bottom - gap) / 2);
const iw     = width - margin.left - margin.right;

// Imprint palette colors
const DATA_COLOR  = t.palette[0];  // #009E73 brand green — in-control data
const OOC_COLOR   = t.palette[4];  // #AE3030 matte red  — out-of-control
const LIMIT_COLOR = t.palette[2];  // #4467A3 blue       — UCL/LCL lines
const WARN_COLOR  = t.palette[3];  // #BD8233 ochre      — ±2σ warning limits

// SVG root
const svg = d3.select("#container").append("svg")
  .attr("width", width).attr("height", height);

// Chart title
svg.append("text")
  .attr("x", width / 2).attr("y", 46)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink).style("font-size", "22px").style("font-weight", "600")
  .text("spc-xbar-r · javascript · d3 · anyplot.ai");

// Shared x-scale across both panels (discrete sample IDs)
const xScale = d3.scalePoint()
  .domain(samples.map(d => d.id))
  .range([0, iw])
  .padding(0.5);

// Horizontal legend row between title and panels
const legendItems = [
  { color: DATA_COLOR,  type: "circle",    label: "In-control" },
  { color: OOC_COLOR,   type: "circle-lg", label: "Out-of-control" },
  { color: t.ink,       type: "solid",     label: "Center line" },
  { color: LIMIT_COLOR, type: "dash",      label: "Control limits (±3σ)" },
  { color: WARN_COLOR,  type: "warn",      label: "Warning limits (±2σ)" },
];
const legStep  = 195;
const legStart = (width - legendItems.length * legStep) / 2;
const legY     = 70;

// Elevated background behind legend row
svg.append("rect")
  .attr("x", legStart - 14).attr("y", legY - 18)
  .attr("width", legendItems.length * legStep + 28).attr("height", 36)
  .attr("fill", t.elevatedBg).attr("rx", 4);

legendItems.forEach((item, i) => {
  const lx = legStart + i * legStep;
  if (item.type === "circle") {
    svg.append("circle").attr("cx", lx + 8).attr("cy", legY)
      .attr("r", 5).attr("fill", item.color).attr("stroke", t.pageBg).attr("stroke-width", 1.5);
  } else if (item.type === "circle-lg") {
    svg.append("circle").attr("cx", lx + 8).attr("cy", legY)
      .attr("r", 7.5).attr("fill", item.color).attr("stroke", t.pageBg).attr("stroke-width", 2);
  } else if (item.type === "solid") {
    svg.append("line").attr("x1", lx).attr("x2", lx + 22)
      .attr("y1", legY).attr("y2", legY)
      .attr("stroke", item.color).attr("stroke-width", 2).attr("opacity", 0.8);
  } else if (item.type === "dash") {
    svg.append("line").attr("x1", lx).attr("x2", lx + 22)
      .attr("y1", legY).attr("y2", legY)
      .attr("stroke", item.color).attr("stroke-width", 2.2).attr("stroke-dasharray", "7,4");
  } else if (item.type === "warn") {
    svg.append("line").attr("x1", lx).attr("x2", lx + 22)
      .attr("y1", legY).attr("y2", legY)
      .attr("stroke", item.color).attr("stroke-width", 1.5)
      .attr("stroke-dasharray", "5,3").attr("opacity", 0.65);
  }
  svg.append("text").attr("x", lx + 28).attr("y", legY + 5)
    .attr("fill", t.inkSoft).style("font-size", "13px").text(item.label);
});

// Draw a single SPC panel (X̄ or R)
function drawPanel(panelTop, yScale, getVal, isOOC, opts) {
  const g = svg.append("g")
    .attr("transform", `translate(${margin.left},${panelTop})`);

  // Y gridlines
  yScale.ticks(5).forEach(tick => {
    g.append("line")
      .attr("x1", 0).attr("x2", iw)
      .attr("y1", yScale(tick)).attr("y2", yScale(tick))
      .attr("stroke", t.grid).attr("stroke-width", 1);
  });

  // Upper warning limit
  if (opts.uwl !== undefined) {
    const wy = yScale(opts.uwl);
    g.append("line").attr("x1", 0).attr("x2", iw).attr("y1", wy).attr("y2", wy)
      .attr("stroke", WARN_COLOR).attr("stroke-width", 1.5)
      .attr("stroke-dasharray", "5,4").attr("opacity", 0.65);
    g.append("text").attr("x", iw + 6).attr("y", wy + 4)
      .attr("fill", WARN_COLOR).style("font-size", "13px").text("+2σ");
  }

  // Lower warning limit
  if (opts.lwl !== undefined) {
    const ly = yScale(opts.lwl);
    g.append("line").attr("x1", 0).attr("x2", iw).attr("y1", ly).attr("y2", ly)
      .attr("stroke", WARN_COLOR).attr("stroke-width", 1.5)
      .attr("stroke-dasharray", "5,4").attr("opacity", 0.65);
    g.append("text").attr("x", iw + 6).attr("y", ly + 4)
      .attr("fill", WARN_COLOR).style("font-size", "13px").text("-2σ");
  }

  // UCL
  const uy = yScale(opts.ucl);
  g.append("line").attr("x1", 0).attr("x2", iw).attr("y1", uy).attr("y2", uy)
    .attr("stroke", LIMIT_COLOR).attr("stroke-width", 2.2).attr("stroke-dasharray", "8,5");
  g.append("text").attr("x", iw + 6).attr("y", uy + 4)
    .attr("fill", LIMIT_COLOR).style("font-size", "12px").style("font-weight", "600").text("UCL");

  // LCL (X̄ chart only — R chart LCL = 0 for n=5, not plotted)
  if (opts.showLcl) {
    const lcy = yScale(opts.lcl);
    g.append("line").attr("x1", 0).attr("x2", iw).attr("y1", lcy).attr("y2", lcy)
      .attr("stroke", LIMIT_COLOR).attr("stroke-width", 2.2).attr("stroke-dasharray", "8,5");
    g.append("text").attr("x", iw + 6).attr("y", lcy + 4)
      .attr("fill", LIMIT_COLOR).style("font-size", "12px").style("font-weight", "600").text("LCL");
  }

  // Center line — increased opacity for legibility in dark theme
  const cy = yScale(opts.cl);
  g.append("line").attr("x1", 0).attr("x2", iw).attr("y1", cy).attr("y2", cy)
    .attr("stroke", t.ink).attr("stroke-width", 2).attr("opacity", 0.8);
  g.append("text").attr("x", iw + 6).attr("y", cy + 4)
    .attr("fill", t.inkSoft).style("font-size", "12px").text("CL");

  // Data line connecting all sample points
  g.append("path")
    .datum(samples)
    .attr("fill", "none").attr("stroke", DATA_COLOR).attr("stroke-width", 2.5)
    .attr("d", d3.line().x(d => xScale(d.id)).y(d => yScale(getVal(d))));

  // In-control points
  g.selectAll(".ic").data(samples.filter(d => !isOOC(d))).join("circle")
    .attr("class", "ic")
    .attr("cx", d => xScale(d.id)).attr("cy", d => yScale(getVal(d)))
    .attr("r", 5).attr("fill", DATA_COLOR)
    .attr("stroke", t.pageBg).attr("stroke-width", 1.5);

  // Out-of-control points (larger, red)
  g.selectAll(".oc").data(samples.filter(d => isOOC(d))).join("circle")
    .attr("class", "oc")
    .attr("cx", d => xScale(d.id)).attr("cy", d => yScale(getVal(d)))
    .attr("r", 7.5).attr("fill", OOC_COLOR)
    .attr("stroke", t.pageBg).attr("stroke-width", 2);

  // OOC event annotations — label each violation with which limit was breached
  samples.filter(d => isOOC(d)).forEach(d => {
    const px = xScale(d.id);
    const py = yScale(getVal(d));
    const above = getVal(d) > opts.cl;
    g.append("text")
      .attr("x", px).attr("y", above ? py - 14 : py + 22)
      .attr("text-anchor", "middle")
      .attr("fill", OOC_COLOR).style("font-size", "12px").style("font-weight", "700")
      .text(above ? "▲ UCL" : "▼ LCL");
  });

  // Y axis
  const ax = g.append("g").call(
    d3.axisLeft(yScale).ticks(5).tickFormat(d3.format(".4f"))
  );
  ax.selectAll("text").attr("fill", t.inkSoft).style("font-size", "13px");
  ax.selectAll("line").attr("stroke", t.grid);
  ax.select(".domain").attr("stroke", t.inkSoft);

  // Y axis label (rotated)
  g.append("text")
    .attr("transform", "rotate(-90)")
    .attr("x", -panelH / 2).attr("y", -72)
    .attr("text-anchor", "middle")
    .attr("fill", t.ink).style("font-size", "14px").style("font-weight", "500")
    .text(opts.yLabel);

  // Panel header label
  g.append("text")
    .attr("x", 10).attr("y", -12)
    .attr("fill", t.inkSoft).style("font-size", "14px").style("font-weight", "600")
    .text(opts.title);
}

// X̄ panel (top)
const xPad  = (xbarUCL - xbarLCL) * 0.35;
const yXbar = d3.scaleLinear()
  .domain([xbarLCL - xPad, xbarUCL + xPad]).nice()
  .range([panelH, 0]);

drawPanel(
  margin.top, yXbar,
  d => d.mean, d => d.xOOC,
  { cl: xbarBar, ucl: xbarUCL, lcl: xbarLCL, showLcl: true,
    uwl: xbarUWL, lwl: xbarLWL, yLabel: "X̄ (mm)", title: "X̄ Chart" }
);

// R panel (bottom)
const rPad      = rUCL * 0.28;
const rPanelTop = margin.top + panelH + gap;
const yR        = d3.scaleLinear()
  .domain([0, rUCL + rPad]).nice()
  .range([panelH, 0]);

drawPanel(
  rPanelTop, yR,
  d => d.range, d => d.rOOC,
  { cl: rBar, ucl: rUCL, lcl: 0, showLcl: false,
    uwl: rUWL, lwl: undefined, yLabel: "R (mm)", title: "R Chart" }
);

// Shared X axis at the bottom of the R panel
const xTickVals = samples.filter((_, i) => i % 3 === 0 || i === N_SAMP - 1).map(d => d.id);
const gXAxis = svg.append("g")
  .attr("transform", `translate(${margin.left},${rPanelTop + panelH})`);
const xAxisG = gXAxis.call(d3.axisBottom(xScale).tickValues(xTickVals));
xAxisG.selectAll("text").attr("fill", t.inkSoft).style("font-size", "13px");
xAxisG.selectAll("line").attr("stroke", t.grid);
xAxisG.select(".domain").attr("stroke", t.inkSoft);

// X axis label
svg.append("text")
  .attr("x", margin.left + iw / 2)
  .attr("y", rPanelTop + panelH + 46)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink).style("font-size", "14px").style("font-weight", "500")
  .text("Sample Number");

Part of Statistical Process Control Chart (X-bar/R) on anyplot.ai.

Other implementations