Cumulative Explained Variance for PCA Component Selection — D3.js

A line plot showing the cumulative proportion of explained variance as a function of the number of Principal Component Analysis (PCA) components. This visualization helps determine the optimal number of components to retain by displaying the trade-off between dimensionality reduction and information preservation. The cumulative curve typically exhibits an elbow pattern where additional components yield diminishing returns, and horizontal threshold lines (e.g., 90%, 95%) guide component selection decisions.

Cumulative Explained Variance for PCA Component Selection rendered with D3.js

Renders

JavaScript source (D3.js)

// anyplot.ai
// line-pca-variance-cumulative: Cumulative Explained Variance for PCA Component Selection
// Library: d3 7.9.0 | JavaScript 22.23.2
// Quality: 93/100 | Created: 2026-08-26

const t = window.ANYPLOT_TOKENS;
const { width, height } = window.ANYPLOT_SIZE;
const margin = { top: 110, right: 90, bottom: 90, left: 120 };
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;

// --- Data (in-memory, deterministic) ----------------------------------------
// Synthetic PCA spectrum for a 22-channel industrial sensor array: variance
// concentrates in the first few components (correlated sensor groups), then
// decays with diminishing returns per added component — the classic PCA
// "elbow" shape.
let seed = 20260826;
function lcg() {
  seed = (seed * 1664525 + 1013904223) % 4294967296;
  return seed / 4294967296;
}

const nComponents = 22;
const rawEigenvalues = Array.from({ length: nComponents }, (_, i) => {
  const decay = Math.exp(-i / 5.2);
  const noise = 1 + (lcg() - 0.5) * 0.12;
  return Math.max(decay * noise, 0.002);
});
const eigenvalueSum = d3.sum(rawEigenvalues);
const individualRatio = rawEigenvalues.map((v) => (v / eigenvalueSum) * 100);
const cumulativeRatio = individualRatio.map((_, i) => d3.sum(individualRatio.slice(0, i + 1)));
const componentCounts = d3.range(1, nComponents + 1);

// --- Threshold crossings (first component count reaching each target) --------
const thresholds = [90, 95];
const crossings = thresholds.map((pct) => ({
  pct,
  n: componentCounts[cumulativeRatio.findIndex((v) => v >= pct)],
}));

// --- Elbow detection ------------------------------------------------------------
// Kneedle heuristic: the scree point with the largest perpendicular distance
// from the chord connecting the first and last individual-variance values.
const p1 = { x: 1, y: individualRatio[0] };
const p2 = { x: nComponents, y: individualRatio[nComponents - 1] };
const chordLen = Math.hypot(p2.x - p1.x, p2.y - p1.y);
let elbowIdx = 0;
let maxDist = -Infinity;
individualRatio.forEach((v, i) => {
  const x0 = i + 1;
  const dist = Math.abs((p2.y - p1.y) * x0 - (p2.x - p1.x) * v + p2.x * p1.y - p2.y * p1.x) / chordLen;
  if (dist > maxDist) {
    maxDist = dist;
    elbowIdx = i;
  }
});
const elbowN = elbowIdx + 1;

// --- SVG mount ----------------------------------------------------------------
const svg = d3.select("#container").append("svg").attr("width", width).attr("height", height);
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

// --- Scales ---------------------------------------------------------------------
// Half-unit padding on the x domain keeps the scree bars off the axis edges.
const x = d3.scaleLinear().domain([0.5, nComponents + 0.5]).range([0, iw]);
const y = d3.scaleLinear().domain([0, 100]).range([ih, 0]);

// --- Gridlines (y-axis only) ----------------------------------------------------
g.append("g")
  .attr("class", "grid")
  .call(d3.axisLeft(y).ticks(6).tickSize(-iw).tickFormat(""))
  .call((sel) => sel.select(".domain").remove())
  .selectAll("line")
  .attr("stroke", t.grid);

// --- Individual variance (scree) bars — muted secondary encoding ---------------
const barWidth = Math.min(28, (iw / nComponents) * 0.55);
g.selectAll("rect.scree")
  .data(individualRatio)
  .join("rect")
  .attr("class", "scree")
  .attr("x", (d, i) => x(i + 1) - barWidth / 2)
  .attr("y", (d) => y(d))
  .attr("width", barWidth)
  .attr("height", (d) => ih - y(d))
  .attr("fill", t.inkSoft)
  .attr("opacity", 0.25);

// --- Threshold reference lines (label folds in the crossing count so it
// doesn't need a second floating label that could collide with the curve) --
thresholds.forEach((pct) => {
  const crossN = crossings.find((c) => c.pct === pct).n;

  g.append("line")
    .attr("x1", 0)
    .attr("x2", iw)
    .attr("y1", y(pct))
    .attr("y2", y(pct))
    .attr("stroke", t.amber)
    .attr("stroke-width", 2)
    .attr("stroke-dasharray", "8,6");

  g.append("text")
    .attr("x", iw - 6)
    .attr("y", y(pct) - 8)
    .attr("text-anchor", "end")
    .attr("fill", t.amber)
    .style("font-size", "14px")
    .style("font-weight", "600")
    .text(`${pct}% threshold (n=${crossN})`);
});

// --- Cumulative variance line -----------------------------------------------------
const line = d3
  .line()
  .x((d, i) => x(componentCounts[i]))
  .y((d) => y(d));

g.append("path")
  .datum(cumulativeRatio)
  .attr("fill", "none")
  .attr("stroke", t.palette[0])
  .attr("stroke-width", 3.5)
  .attr("d", line);

// --- Markers at each discrete component count -------------------------------------
g.selectAll("circle.point")
  .data(cumulativeRatio)
  .join("circle")
  .attr("class", "point")
  .attr("cx", (d, i) => x(componentCounts[i]))
  .attr("cy", (d) => y(d))
  .attr("r", 6)
  .attr("fill", t.palette[0])
  .attr("stroke", t.pageBg)
  .attr("stroke-width", 1.5);

// --- Threshold-crossing callouts (ring marker; the count itself is labeled
// on the threshold line above, avoiding a second label that could collide
// with the closely-spaced 90%/95% lines) -------------------------------------
crossings.forEach(({ n }) => {
  g.append("circle")
    .attr("cx", x(n))
    .attr("cy", y(cumulativeRatio[n - 1]))
    .attr("r", 10)
    .attr("fill", "none")
    .attr("stroke", t.ink)
    .attr("stroke-width", 2);
});

// --- Elbow annotation -----------------------------------------------------------------
g.append("text")
  .attr("x", x(elbowN) + 16)
  .attr("y", y(cumulativeRatio[elbowIdx]) + 32)
  .attr("fill", t.inkSoft)
  .style("font-size", "13px")
  .style("font-style", "italic")
  .text(`elbow ≈ ${elbowN} components`);

// --- Axes -------------------------------------------------------------------------
const xTickStep = nComponents > 16 ? 2 : 1;
const xAxis = g
  .append("g")
  .attr("transform", `translate(0,${ih})`)
  .call(
    d3
      .axisBottom(x)
      .tickValues(componentCounts.filter((n) => n === 1 || n % xTickStep === 0))
      .tickFormat(d3.format("d")),
  );
const yAxis = g.append("g").call(d3.axisLeft(y).ticks(6).tickFormat((d) => `${d}%`));

for (const ax of [xAxis, yAxis]) {
  ax.selectAll("text").attr("fill", t.inkSoft).style("font-size", "14px");
  ax.selectAll("line").attr("stroke", t.grid);
  ax.select(".domain").attr("stroke", t.inkSoft);
}

// --- Axis labels -------------------------------------------------------------------
g.append("text")
  .attr("x", iw / 2)
  .attr("y", ih + 60)
  .attr("text-anchor", "middle")
  .attr("fill", t.inkSoft)
  .style("font-size", "16px")
  .text("Number of Principal Components");

g.append("text")
  .attr("transform", "rotate(-90)")
  .attr("x", -ih / 2)
  .attr("y", -86)
  .attr("text-anchor", "middle")
  .attr("fill", t.inkSoft)
  .style("font-size", "16px")
  .text("Cumulative Explained Variance (%)");

// --- Title ---------------------------------------------------------------------------
const title = "Sensor Array PCA · line-pca-variance-cumulative · javascript · d3 · anyplot.ai";
const titleFontSize = Math.max(16, Math.round(22 * Math.min(1, 67 / title.length)));
svg
  .append("text")
  .attr("x", width / 2)
  .attr("y", 50)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", `${titleFontSize}px`)
  .style("font-weight", "600")
  .text(title);

Part of Cumulative Explained Variance for PCA Component Selection on anyplot.ai.

Other implementations