{"spec_id":"biplot-pca","library":"muix","language":"javascript","code":"// anyplot.ai\n// biplot-pca: PCA Biplot with Scores and Loading Vectors\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 88/100 | Created: 2026-09-01\n//# anyplot-orientation: landscape\n// anyplot.ai\n// biplot-pca: PCA Biplot with Scores and Loading Vectors\n// Library: MUI X Charts | React | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-09-01\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { ScatterPlot } from \"@mui/x-charts/ScatterChart\";\nimport { ChartsXAxis } from \"@mui/x-charts/ChartsXAxis\";\nimport { ChartsYAxis } from \"@mui/x-charts/ChartsYAxis\";\nimport { ChartsGrid } from \"@mui/x-charts/ChartsGrid\";\nimport { ChartsLegend } from \"@mui/x-charts/ChartsLegend\";\nimport { ChartsTooltip } from \"@mui/x-charts/ChartsTooltip\";\nimport { useXScale, useYScale } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst size = window.ANYPLOT_SIZE;\n\n// --- Deterministic PRNG (LCG + Box-Muller, no seeded RNG in the browser) ----\nfunction createLcg(seed) {\n  let state = seed;\n  return function nextUniform() {\n    state = (state * 16807) % 2147483647;\n    return (state - 1) / 2147483646;\n  };\n}\nconst nextUniform = createLcg(42);\nfunction nextGaussian() {\n  const u1 = Math.max(nextUniform(), 1e-9);\n  const u2 = nextUniform();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\n// --- Data: simulated production-line QC sensor readings (4 correlated vars) -\nconst PRODUCTION_LINES = [\"Line A\", \"Line B\", \"Line C\"];\nconst LINE_BATCH_OFFSETS = [-1.4, 0, 1.4];\nconst OBSERVATIONS_PER_LINE = 20;\nconst VARIABLE_NAMES = [\"Temperature\", \"Pressure\", \"Vibration\", \"Throughput\"];\nconst FEATURE_KEYS = [\"temperature\", \"pressure\", \"vibration\", \"throughput\"];\n\nconst observations = [];\nPRODUCTION_LINES.forEach((line, lineIndex) => {\n  for (let i = 0; i < OBSERVATIONS_PER_LINE; i += 1) {\n    // Two independent latent drivers behind the four sensor readings: process\n    // intensity separates the lines, calibration drift varies within a line.\n    const processIntensity = LINE_BATCH_OFFSETS[lineIndex] + nextGaussian() * 0.5;\n    const calibrationDrift = nextGaussian();\n    observations.push({\n      line,\n      temperature:\n        68 + 4.2 * processIntensity + 1.0 * calibrationDrift + nextGaussian() * 1.4, // deg C\n      pressure:\n        120 + 5.5 * processIntensity - 2.4 * calibrationDrift + nextGaussian() * 1.8, // kPa\n      vibration:\n        3.2 - 1.2 * processIntensity + 2.6 * calibrationDrift + nextGaussian() * 0.35, // mm/s\n      throughput:\n        480 + 6.0 * processIntensity + 0.6 * calibrationDrift + nextGaussian() * 4.5, // units/min\n    });\n  }\n});\nconst sampleCount = observations.length;\nconst featureCount = FEATURE_KEYS.length;\n\n// --- Standardize each variable to mean 0 / unit variance ---------------------\nconst featureMeans = FEATURE_KEYS.map(\n  (key) => observations.reduce((sum, row) => sum + row[key], 0) / sampleCount\n);\nconst featureStds = FEATURE_KEYS.map((key, j) =>\n  Math.sqrt(\n    observations.reduce((sum, row) => sum + (row[key] - featureMeans[j]) ** 2, 0) /\n      sampleCount\n  )\n);\nconst standardized = observations.map((row) =>\n  FEATURE_KEYS.map((key, j) => (row[key] - featureMeans[j]) / featureStds[j])\n);\n\n// --- Correlation matrix (covariance of standardized data) -------------------\nconst correlationMatrix = Array.from({ length: featureCount }, (_, rowIdx) =>\n  Array.from({ length: featureCount }, (_, colIdx) => {\n    let sum = 0;\n    for (let obs = 0; obs < sampleCount; obs += 1) {\n      sum += standardized[obs][rowIdx] * standardized[obs][colIdx];\n    }\n    return sum / sampleCount;\n  })\n);\n\n// --- Jacobi eigenvalue decomposition (symmetric matrices only) --------------\nfunction jacobiEigen(inputMatrix) {\n  const matrixSize = inputMatrix.length;\n  const workingMatrix = inputMatrix.map((row) => row.slice());\n  const eigenvectorMatrix = Array.from({ length: matrixSize }, (_, rowIdx) =>\n    Array.from({ length: matrixSize }, (_, colIdx) => (rowIdx === colIdx ? 1 : 0))\n  );\n\n  for (let sweep = 0; sweep < 100; sweep += 1) {\n    let offDiagonalMagnitude = 0;\n    for (let rowIdx = 0; rowIdx < matrixSize; rowIdx += 1) {\n      for (let colIdx = rowIdx + 1; colIdx < matrixSize; colIdx += 1) {\n        offDiagonalMagnitude += workingMatrix[rowIdx][colIdx] ** 2;\n      }\n    }\n    if (offDiagonalMagnitude < 1e-12) break;\n\n    for (let p = 0; p < matrixSize; p += 1) {\n      for (let q = p + 1; q < matrixSize; q += 1) {\n        if (Math.abs(workingMatrix[p][q]) < 1e-12) continue;\n        const theta = (workingMatrix[q][q] - workingMatrix[p][p]) / (2 * workingMatrix[p][q]);\n        const tan = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));\n        const cos = 1 / Math.sqrt(tan * tan + 1);\n        const sin = tan * cos;\n        const app = workingMatrix[p][p];\n        const aqq = workingMatrix[q][q];\n        const apq = workingMatrix[p][q];\n        workingMatrix[p][p] = cos * cos * app - 2 * sin * cos * apq + sin * sin * aqq;\n        workingMatrix[q][q] = sin * sin * app + 2 * sin * cos * apq + cos * cos * aqq;\n        workingMatrix[p][q] = 0;\n        workingMatrix[q][p] = 0;\n        for (let k = 0; k < matrixSize; k += 1) {\n          if (k !== p && k !== q) {\n            const akp = workingMatrix[k][p];\n            const akq = workingMatrix[k][q];\n            workingMatrix[k][p] = cos * akp - sin * akq;\n            workingMatrix[p][k] = workingMatrix[k][p];\n            workingMatrix[k][q] = sin * akp + cos * akq;\n            workingMatrix[q][k] = workingMatrix[k][q];\n          }\n        }\n        for (let k = 0; k < matrixSize; k += 1) {\n          const vkp = eigenvectorMatrix[k][p];\n          const vkq = eigenvectorMatrix[k][q];\n          eigenvectorMatrix[k][p] = cos * vkp - sin * vkq;\n          eigenvectorMatrix[k][q] = sin * vkp + cos * vkq;\n        }\n      }\n    }\n  }\n\n  const eigenvalues = workingMatrix.map((row, i) => row[i]);\n  return { eigenvalues, eigenvectors: eigenvectorMatrix };\n}\n\nconst { eigenvalues, eigenvectors } = jacobiEigen(correlationMatrix);\nconst componentOrder = eigenvalues\n  .map((value, index) => ({ value, index }))\n  .sort((a, b) => b.value - a.value);\nconst pc1Index = componentOrder[0].index;\nconst pc2Index = componentOrder[1].index;\nconst totalVariance = eigenvalues.reduce((sum, value) => sum + value, 0);\nconst pc1VarianceShare = (eigenvalues[pc1Index] / totalVariance) * 100;\nconst pc2VarianceShare = (eigenvalues[pc2Index] / totalVariance) * 100;\n\n// --- Scores (component coordinates) and loadings (variable correlations) ----\nconst scores = standardized.map((row) => ({\n  x: row.reduce((sum, value, j) => sum + value * eigenvectors[j][pc1Index], 0),\n  y: row.reduce((sum, value, j) => sum + value * eigenvectors[j][pc2Index], 0),\n}));\n\nconst rawLoadings = FEATURE_KEYS.map((_, j) => ({\n  x: eigenvectors[j][pc1Index] * Math.sqrt(eigenvalues[pc1Index]),\n  y: eigenvectors[j][pc2Index] * Math.sqrt(eigenvalues[pc2Index]),\n}));\n\nconst maxScoreExtent = Math.max(\n  ...scores.map((s) => Math.max(Math.abs(s.x), Math.abs(s.y)))\n);\nconst maxLoadingExtent = Math.max(\n  ...rawLoadings.map((l) => Math.max(Math.abs(l.x), Math.abs(l.y)))\n);\nconst loadingDisplayScale = (maxScoreExtent * 0.85) / maxLoadingExtent;\nconst loadingVectors = rawLoadings.map((loading, j) => ({\n  variable: VARIABLE_NAMES[j],\n  x: loading.x * loadingDisplayScale,\n  y: loading.y * loadingDisplayScale,\n}));\n\nconst axisPadding = maxScoreExtent * 0.25;\nconst axisMin = -(maxScoreExtent + axisPadding);\nconst axisMax = maxScoreExtent + axisPadding;\n\n// --- One scatter series per production line, Imprint categorical colors -----\nconst series = PRODUCTION_LINES.map((line, lineIndex) => ({\n  type: \"scatter\",\n  id: line,\n  label: line,\n  color: t.palette[lineIndex],\n  markerSize: 7,\n  data: observations\n    .map((row, i) => ({ row, i }))\n    .filter(({ row }) => row.line === line)\n    .map(({ i }) => ({ x: scores[i].x, y: scores[i].y, id: `${line}-${i}` })),\n}));\n\n// --- Push label y-positions apart on each side so nearly-parallel loading\n// arrows (e.g. Throughput/Temperature above) don't print overlapping text ---\nconst MIN_LABEL_GAP = 34;\nfunction declutterLabels(items) {\n  const sides = [true, false].map((pointsRight) =>\n    items\n      .filter((item) => item.pointsRight === pointsRight)\n      .sort((a, b) => a.tipY - b.tipY)\n  );\n  sides.forEach((side) => {\n    side.forEach((item, i) => {\n      const minY = i === 0 ? -Infinity : side[i - 1].labelY + MIN_LABEL_GAP;\n      item.labelY = Math.max(item.tipY, minY);\n    });\n  });\n  return sides.flat();\n}\n\n// --- Unit-circle reference for correlation-biplot scaling: a loading vector\n// reaching this radius represents a variable perfectly captured by PC1+PC2 ---\nconst UNIT_CIRCLE_STEPS = 72;\nconst unitCirclePoints = Array.from({ length: UNIT_CIRCLE_STEPS + 1 }, (_, i) => {\n  const angle = (i / UNIT_CIRCLE_STEPS) * 2 * Math.PI;\n  return { x: Math.cos(angle) * loadingDisplayScale, y: Math.sin(angle) * loadingDisplayScale };\n});\n\n// --- Loading-vector overlay, drawn in data space via the chart scale hooks --\nfunction LoadingArrows() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  const originX = xScale(0);\n  const originY = yScale(0);\n\n  return (\n    <g>\n      <defs>\n        <marker\n          id=\"biplot-arrowhead\"\n          markerWidth={8}\n          markerHeight={8}\n          refX={6}\n          refY={4}\n          orient=\"auto\"\n        >\n          <path d=\"M0,0 L8,4 L0,8 Z\" fill={t.ink} />\n        </marker>\n      </defs>\n      <polyline\n        points={unitCirclePoints.map((p) => `${xScale(p.x)},${yScale(p.y)}`).join(\" \")}\n        fill=\"none\"\n        stroke={t.inkSoft}\n        strokeWidth={1}\n        strokeDasharray=\"4 4\"\n        opacity={0.6}\n      />\n      <line\n        x1={xScale(axisMin)}\n        x2={xScale(axisMax)}\n        y1={originY}\n        y2={originY}\n        stroke={t.grid}\n        strokeWidth={1}\n      />\n      <line\n        x1={originX}\n        x2={originX}\n        y1={yScale(axisMin)}\n        y2={yScale(axisMax)}\n        stroke={t.grid}\n        strokeWidth={1}\n      />\n      {declutterLabels(\n        loadingVectors.map((loading) => ({\n          variable: loading.variable,\n          tipX: xScale(loading.x),\n          tipY: yScale(loading.y),\n          pointsRight: loading.x >= 0,\n        }))\n      ).map((loading) => (\n        <g key={loading.variable}>\n          <line\n            x1={originX}\n            y1={originY}\n            x2={loading.tipX}\n            y2={loading.tipY}\n            stroke={t.ink}\n            strokeWidth={2.5}\n            markerEnd=\"url(#biplot-arrowhead)\"\n          />\n          <text\n            x={loading.tipX + (loading.pointsRight ? 10 : -10)}\n            y={loading.labelY}\n            fill={t.ink}\n            fontSize={15}\n            fontWeight={600}\n            fontFamily=\"system-ui, sans-serif\"\n            textAnchor={loading.pointsRight ? \"start\" : \"end\"}\n            dominantBaseline=\"middle\"\n          >\n            {loading.variable}\n          </text>\n        </g>\n      ))}\n    </g>\n  );\n}\n\nconst chartTitle = \"biplot-pca · javascript · muix · anyplot.ai\";\n\n// --- Chart (default-exported component — the harness mounts it) ------------\nexport default function Chart() {\n  return (\n    <ChartContainer\n      width={size.width}\n      height={size.height}\n      series={series}\n      xAxis={[\n        {\n          id: \"pc1\",\n          scaleType: \"linear\",\n          min: axisMin,\n          max: axisMax,\n          label: `PC1 (${pc1VarianceShare.toFixed(1)}%)`,\n          labelStyle: { fontSize: 16, fill: t.ink },\n          tickLabelStyle: { fontSize: 13, fill: t.inkSoft },\n        },\n      ]}\n      yAxis={[\n        {\n          id: \"pc2\",\n          scaleType: \"linear\",\n          min: axisMin,\n          max: axisMax,\n          label: `PC2 (${pc2VarianceShare.toFixed(1)}%)`,\n          labelStyle: { fontSize: 16, fill: t.ink },\n          tickLabelStyle: { fontSize: 13, fill: t.inkSoft },\n        },\n      ]}\n      margin={{ top: 72, right: 56, bottom: 110, left: 92 }}\n      disableVoronoi\n      skipAnimation\n    >\n      <text\n        x={size.width / 2}\n        y={40}\n        textAnchor=\"middle\"\n        fontSize={22}\n        fontWeight={600}\n        fill={t.ink}\n        fontFamily=\"system-ui, sans-serif\"\n      >\n        {chartTitle}\n      </text>\n      <ChartsGrid horizontal vertical />\n      <LoadingArrows />\n      <ScatterPlot />\n      <ChartsXAxis />\n      <ChartsYAxis />\n      <ChartsLegend\n        direction=\"row\"\n        position={{ vertical: \"bottom\", horizontal: \"middle\" }}\n        labelStyle={{ fontSize: 14, fill: t.inkSoft }}\n      />\n      <ChartsTooltip trigger=\"item\" />\n    </ChartContainer>\n  );\n}\n"}