{"spec_id":"biplot-pca","library":"echarts","language":"javascript","code":"// anyplot.ai\n// biplot-pca: PCA Biplot with Scores and Loading Vectors\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-01\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Five correlated process-quality measurements from three production lines,\n// generated from two latent factors so PC1/PC2 recover most of the variance.\nfunction makeLcg(seed) {\n  let state = seed % 2147483647;\n  if (state <= 0) state += 2147483646;\n  return function uniform() {\n    state = (state * 16807) % 2147483647;\n    return (state - 1) / 2147483646;\n  };\n}\nconst rand = makeLcg(42);\n\nfunction randNormal() {\n  const u1 = rand();\n  const u2 = rand();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\nconst featureNames = [\"Temperature\", \"Pressure\", \"Vibration\", \"Humidity\", \"Throughput\"];\nconst groups = [\n  { name: \"Line A\", f1: 1.6, f2: 0.0, n: 30 },\n  { name: \"Line B\", f1: -1.1, f2: 1.3, n: 30 },\n  { name: \"Line C\", f1: -0.2, f2: -1.4, n: 30 },\n];\n\nconst rawRows = [];\nconst groupOf = [];\ngroups.forEach((g) => {\n  for (let i = 0; i < g.n; i++) {\n    const f1 = g.f1 + randNormal() * 0.9;\n    const f2 = g.f2 + randNormal() * 0.9;\n    rawRows.push([\n      70 + 2.2 * f1 + 0.5 * randNormal(), // Temperature (°C)\n      120 + 1.6 * f1 + 0.6 * f2 + 0.5 * randNormal(), // Pressure (kPa)\n      3 - 1.8 * f1 + 0.4 * randNormal(), // Vibration (mm/s)\n      45 + 1.7 * f2 + 0.5 * randNormal(), // Humidity (%)\n      200 - 1.3 * f2 + 0.5 * f1 + 0.5 * randNormal(), // Throughput (units/hr)\n    ]);\n    groupOf.push(g.name);\n  }\n});\n\nconst nObs = rawRows.length;\nconst nFeat = featureNames.length;\n\n// --- Standardize (z-score), then correlation matrix -------------------------\nconst means = featureNames.map((_, j) => rawRows.reduce((s, r) => s + r[j], 0) / nObs);\nconst stds = featureNames.map((_, j) => {\n  const variance = rawRows.reduce((s, r) => s + (r[j] - means[j]) ** 2, 0) / (nObs - 1);\n  return Math.sqrt(variance);\n});\nconst z = rawRows.map((r) => r.map((v, j) => (v - means[j]) / stds[j]));\nconst corr = Array.from({ length: nFeat }, (_, i) =>\n  Array.from({ length: nFeat }, (_, j) => z.reduce((s, row) => s + row[i] * row[j], 0) / (nObs - 1))\n);\n\n// --- Top-2 eigenpairs of the correlation matrix (power iteration + Hotelling\n// deflation) — this is the linear algebra behind PCA, done without a library.\nfunction matVecMul(M, v) {\n  return M.map((row) => row.reduce((s, x, j) => s + x * v[j], 0));\n}\nfunction dot(a, b) {\n  return a.reduce((s, x, i) => s + x * b[i], 0);\n}\nfunction powerIteration(M, dim) {\n  let v = Array.from({ length: dim }, (_, i) => 1 / (i + 1));\n  for (let it = 0; it < 500; it++) {\n    const mv = matVecMul(M, v);\n    const n = Math.sqrt(dot(mv, mv));\n    v = mv.map((x) => x / n);\n  }\n  return { vector: v, value: dot(v, matVecMul(M, v)) };\n}\nconst pc1 = powerIteration(corr, nFeat);\nconst deflated = corr.map((row, i) => row.map((x, j) => x - pc1.value * pc1.vector[i] * pc1.vector[j]));\nconst pc2 = powerIteration(deflated, nFeat);\n\n// Scores = standardized data projected onto each eigenvector. Correlation\n// loadings = eigenvector * sqrt(eigenvalue) — the correlation between each\n// original variable and the component, which is why they fit inside the unit\n// circle for a correlation-scaled biplot.\nlet scores1 = z.map((row) => dot(row, pc1.vector));\nlet scores2 = z.map((row) => dot(row, pc2.vector));\nlet loadings1 = pc1.vector.map((v) => v * Math.sqrt(pc1.value));\nlet loadings2 = pc2.vector.map((v) => v * Math.sqrt(pc2.value));\n\n// PCA sign is mathematically arbitrary — orient axes so \"Line A\" reads on the\n// positive PC1 side and \"Line B\" on the positive PC2 side.\nconst lineAIdx = groupOf.flatMap((g, i) => (g === \"Line A\" ? [i] : []));\nconst lineBIdx = groupOf.flatMap((g, i) => (g === \"Line B\" ? [i] : []));\nconst meanLineA1 = lineAIdx.reduce((s, i) => s + scores1[i], 0) / lineAIdx.length;\nconst meanLineB2 = lineBIdx.reduce((s, i) => s + scores2[i], 0) / lineBIdx.length;\nif (meanLineA1 < 0) {\n  scores1 = scores1.map((v) => -v);\n  loadings1 = loadings1.map((v) => -v);\n}\nif (meanLineB2 < 0) {\n  scores2 = scores2.map((v) => -v);\n  loadings2 = loadings2.map((v) => -v);\n}\n\nconst varRatio1 = pc1.value / nFeat;\nconst varRatio2 = pc2.value / nFeat;\n\n// --- Layout: scale loadings to reach into the score cloud; equal PC1/PC2 axis\n// ranges keep vector angles visually meaningful — a core biplot requirement.\nconst maxAbsScore = Math.max(...scores1.map(Math.abs), ...scores2.map(Math.abs));\nconst arrowScale = maxAbsScore * 0.8;\nconst axisLimit = Math.ceil(maxAbsScore * 1.15 * 10) / 10;\n\nconst circlePoints = Array.from({ length: 145 }, (_, i) => {\n  const theta = (i / 144) * 2 * Math.PI;\n  return [Math.cos(theta) * arrowScale, Math.sin(theta) * arrowScale];\n});\n\nconst loadingsData = featureNames.map((name, j) => {\n  const x = loadings1[j] * arrowScale;\n  const y = loadings2[j] * arrowScale;\n  const angle = Math.atan2(y, x);\n  return {\n    name,\n    coords: [\n      [0, 0],\n      [x, y],\n    ],\n    label: {\n      show: true,\n      formatter: () => name,\n      color: t.ink,\n      fontSize: 16,\n      fontWeight: 600,\n      position: \"end\",\n      distance: [Math.cos(angle) * 34, -Math.sin(angle) * 34],\n      backgroundColor: t.pageBg,\n      padding: [3, 6],\n    },\n  };\n});\n\nconst groupSeries = groups.map((g, gi) => ({\n  name: g.name,\n  type: \"scatter\",\n  data: scores1.flatMap((x, i) => (groupOf[i] === g.name ? [[x, scores2[i]]] : [])),\n  symbolSize: 16,\n  itemStyle: { color: t.palette[gi], opacity: 0.75, borderColor: t.pageBg, borderWidth: 1.5 },\n  z: 5,\n}));\n\n// --- Chart --------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\nchart.setOption({\n  animation: false,\n  color: t.palette,\n  backgroundColor: \"transparent\",\n  title: {\n    text: \"biplot-pca · javascript · echarts · anyplot.ai\",\n    left: \"center\",\n    top: 24,\n    textStyle: { color: t.ink, fontSize: 24, fontWeight: 500 },\n  },\n  legend: {\n    top: 84,\n    left: \"center\",\n    // Only the score groups — the loading-vector series already has its own\n    // arrow labels on the plot, and its default legend swatch (a plain\n    // rectangle) doesn't read as a vector, so it's dropped here.\n    data: groups.map((g) => g.name),\n    textStyle: { color: t.ink, fontSize: 15 },\n    itemGap: 28,\n    itemWidth: 22,\n    itemHeight: 14,\n  },\n  grid: { left: 170, right: 110, top: 190, bottom: 90 },\n  xAxis: {\n    type: \"value\",\n    min: -axisLimit,\n    max: axisLimit,\n    name: `PC1 (${(varRatio1 * 100).toFixed(1)}%)`,\n    nameLocation: \"middle\",\n    nameGap: 40,\n    nameTextStyle: { color: t.ink, fontSize: 17 },\n    axisLabel: { color: t.inkSoft, fontSize: 14 },\n    axisLine: { onZero: true, lineStyle: { color: t.inkSoft, width: 1, opacity: 0.35 } },\n    axisTick: { show: false },\n    splitLine: { show: true, lineStyle: { color: t.grid } },\n  },\n  yAxis: {\n    type: \"value\",\n    min: -axisLimit,\n    max: axisLimit,\n    name: `PC2 (${(varRatio2 * 100).toFixed(1)}%)`,\n    nameLocation: \"middle\",\n    nameGap: 50,\n    nameTextStyle: { color: t.ink, fontSize: 17 },\n    axisLabel: { color: t.inkSoft, fontSize: 14 },\n    axisLine: { onZero: true, lineStyle: { color: t.inkSoft, width: 1, opacity: 0.35 } },\n    axisTick: { show: false },\n    splitLine: { show: true, lineStyle: { color: t.grid } },\n  },\n  tooltip: {\n    trigger: \"item\",\n    backgroundColor: t.elevatedBg,\n    borderColor: t.grid,\n    textStyle: { color: t.ink, fontSize: 14 },\n    formatter: (params) => {\n      if (params.seriesType !== \"scatter\") return \"\";\n      const [pc1v, pc2v] = params.value;\n      return `<b>${params.seriesName}</b><br/>PC1 ${pc1v.toFixed(2)}, PC2 ${pc2v.toFixed(2)}`;\n    },\n  },\n  series: [\n    {\n      type: \"line\",\n      data: circlePoints,\n      showSymbol: false,\n      smooth: true,\n      lineStyle: { type: \"dashed\", width: 1.5, color: t.inkSoft, opacity: 0.5 },\n      silent: true,\n      z: 1,\n      tooltip: { show: false },\n    },\n    {\n      name: \"Variable loadings\",\n      type: \"lines\",\n      coordinateSystem: \"cartesian2d\",\n      data: loadingsData,\n      itemStyle: { color: t.ink },\n      lineStyle: { color: t.ink, width: 2.5, opacity: 0.9 },\n      symbol: [\"none\", \"arrow\"],\n      symbolSize: [0, 14],\n      silent: true,\n      z: 10,\n    },\n    ...groupSeries,\n  ],\n});\n"}