{"spec_id":"line-pca-variance-cumulative","library":"d3","language":"javascript","code":"// anyplot.ai\n// line-pca-variance-cumulative: Cumulative Explained Variance for PCA Component Selection\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-08-26\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 110, right: 90, bottom: 90, left: 120 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Synthetic PCA spectrum for a 22-channel industrial sensor array: variance\n// concentrates in the first few components (correlated sensor groups), then\n// decays with diminishing returns per added component — the classic PCA\n// \"elbow\" shape.\nlet seed = 20260826;\nfunction lcg() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\n\nconst nComponents = 22;\nconst rawEigenvalues = Array.from({ length: nComponents }, (_, i) => {\n  const decay = Math.exp(-i / 5.2);\n  const noise = 1 + (lcg() - 0.5) * 0.12;\n  return Math.max(decay * noise, 0.002);\n});\nconst eigenvalueSum = d3.sum(rawEigenvalues);\nconst individualRatio = rawEigenvalues.map((v) => (v / eigenvalueSum) * 100);\nconst cumulativeRatio = individualRatio.map((_, i) => d3.sum(individualRatio.slice(0, i + 1)));\nconst componentCounts = d3.range(1, nComponents + 1);\n\n// --- Threshold crossings (first component count reaching each target) --------\nconst thresholds = [90, 95];\nconst crossings = thresholds.map((pct) => ({\n  pct,\n  n: componentCounts[cumulativeRatio.findIndex((v) => v >= pct)],\n}));\n\n// --- Elbow detection ------------------------------------------------------------\n// Kneedle heuristic: the scree point with the largest perpendicular distance\n// from the chord connecting the first and last individual-variance values.\nconst p1 = { x: 1, y: individualRatio[0] };\nconst p2 = { x: nComponents, y: individualRatio[nComponents - 1] };\nconst chordLen = Math.hypot(p2.x - p1.x, p2.y - p1.y);\nlet elbowIdx = 0;\nlet maxDist = -Infinity;\nindividualRatio.forEach((v, i) => {\n  const x0 = i + 1;\n  const dist = Math.abs((p2.y - p1.y) * x0 - (p2.x - p1.x) * v + p2.x * p1.y - p2.y * p1.x) / chordLen;\n  if (dist > maxDist) {\n    maxDist = dist;\n    elbowIdx = i;\n  }\n});\nconst elbowN = elbowIdx + 1;\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Scales ---------------------------------------------------------------------\n// Half-unit padding on the x domain keeps the scree bars off the axis edges.\nconst x = d3.scaleLinear().domain([0.5, nComponents + 0.5]).range([0, iw]);\nconst y = d3.scaleLinear().domain([0, 100]).range([ih, 0]);\n\n// --- Gridlines (y-axis only) ----------------------------------------------------\ng.append(\"g\")\n  .attr(\"class\", \"grid\")\n  .call(d3.axisLeft(y).ticks(6).tickSize(-iw).tickFormat(\"\"))\n  .call((sel) => sel.select(\".domain\").remove())\n  .selectAll(\"line\")\n  .attr(\"stroke\", t.grid);\n\n// --- Individual variance (scree) bars — muted secondary encoding ---------------\nconst barWidth = Math.min(28, (iw / nComponents) * 0.55);\ng.selectAll(\"rect.scree\")\n  .data(individualRatio)\n  .join(\"rect\")\n  .attr(\"class\", \"scree\")\n  .attr(\"x\", (d, i) => x(i + 1) - barWidth / 2)\n  .attr(\"y\", (d) => y(d))\n  .attr(\"width\", barWidth)\n  .attr(\"height\", (d) => ih - y(d))\n  .attr(\"fill\", t.inkSoft)\n  .attr(\"opacity\", 0.25);\n\n// --- Threshold reference lines (label folds in the crossing count so it\n// doesn't need a second floating label that could collide with the curve) --\nthresholds.forEach((pct) => {\n  const crossN = crossings.find((c) => c.pct === pct).n;\n\n  g.append(\"line\")\n    .attr(\"x1\", 0)\n    .attr(\"x2\", iw)\n    .attr(\"y1\", y(pct))\n    .attr(\"y2\", y(pct))\n    .attr(\"stroke\", t.amber)\n    .attr(\"stroke-width\", 2)\n    .attr(\"stroke-dasharray\", \"8,6\");\n\n  g.append(\"text\")\n    .attr(\"x\", iw - 6)\n    .attr(\"y\", y(pct) - 8)\n    .attr(\"text-anchor\", \"end\")\n    .attr(\"fill\", t.amber)\n    .style(\"font-size\", \"14px\")\n    .style(\"font-weight\", \"600\")\n    .text(`${pct}% threshold (n=${crossN})`);\n});\n\n// --- Cumulative variance line -----------------------------------------------------\nconst line = d3\n  .line()\n  .x((d, i) => x(componentCounts[i]))\n  .y((d) => y(d));\n\ng.append(\"path\")\n  .datum(cumulativeRatio)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[0])\n  .attr(\"stroke-width\", 3.5)\n  .attr(\"d\", line);\n\n// --- Markers at each discrete component count -------------------------------------\ng.selectAll(\"circle.point\")\n  .data(cumulativeRatio)\n  .join(\"circle\")\n  .attr(\"class\", \"point\")\n  .attr(\"cx\", (d, i) => x(componentCounts[i]))\n  .attr(\"cy\", (d) => y(d))\n  .attr(\"r\", 6)\n  .attr(\"fill\", t.palette[0])\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.5);\n\n// --- Threshold-crossing callouts (ring marker; the count itself is labeled\n// on the threshold line above, avoiding a second label that could collide\n// with the closely-spaced 90%/95% lines) -------------------------------------\ncrossings.forEach(({ n }) => {\n  g.append(\"circle\")\n    .attr(\"cx\", x(n))\n    .attr(\"cy\", y(cumulativeRatio[n - 1]))\n    .attr(\"r\", 10)\n    .attr(\"fill\", \"none\")\n    .attr(\"stroke\", t.ink)\n    .attr(\"stroke-width\", 2);\n});\n\n// --- Elbow annotation -----------------------------------------------------------------\ng.append(\"text\")\n  .attr(\"x\", x(elbowN) + 16)\n  .attr(\"y\", y(cumulativeRatio[elbowIdx]) + 32)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .style(\"font-style\", \"italic\")\n  .text(`elbow ≈ ${elbowN} components`);\n\n// --- Axes -------------------------------------------------------------------------\nconst xTickStep = nComponents > 16 ? 2 : 1;\nconst xAxis = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(\n    d3\n      .axisBottom(x)\n      .tickValues(componentCounts.filter((n) => n === 1 || n % xTickStep === 0))\n      .tickFormat(d3.format(\"d\")),\n  );\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).ticks(6).tickFormat((d) => `${d}%`));\n\nfor (const ax of [xAxis, yAxis]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  ax.selectAll(\"line\").attr(\"stroke\", t.grid);\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\n\n// --- Axis labels -------------------------------------------------------------------\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 60)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"16px\")\n  .text(\"Number of Principal Components\");\n\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -86)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"16px\")\n  .text(\"Cumulative Explained Variance (%)\");\n\n// --- Title ---------------------------------------------------------------------------\nconst title = \"Sensor Array PCA · line-pca-variance-cumulative · javascript · d3 · anyplot.ai\";\nconst titleFontSize = Math.max(16, Math.round(22 * Math.min(1, 67 / title.length)));\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 50)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(title);\n"}