{"spec_id":"biplot-pca","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// biplot-pca: PCA Biplot with Scores and Loading Vectors\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-01\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Deterministic PRNG (LCG + Box-Muller) ----------------------------------\nconst lcg = (seed) => {\n    let s = seed >>> 0;\n    return () => {\n        s = (Math.imul(1664525, s) + 1013904223) >>> 0;\n        return s / 4294967296;\n    };\n};\nconst rand = lcg(42);\nconst randn = () => {\n    const u1 = Math.max(rand(), 1e-9);\n    const u2 = rand();\n    return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n};\n\n// --- Data: process-monitoring measurements across three production lines ---\n// Each observation is generated from a 2D latent process state (per-line\n// offset + shared noise), so the six correlated measurements carry a real\n// low-rank structure for PCA to recover.\nconst lines = [\"Line A\", \"Line B\", \"Line C\"];\nconst latentOffsets = [\n    [-2.1, 1.1],\n    [2.0, 1.0],\n    [0.0, -2.2],\n];\nconst nPerLine = 30;\nconst variables = [\"Temperature\", \"Pressure\", \"Humidity\", \"FlowRate\", \"Vibration\", \"ToolWear\"];\n// [weight on latent1, weight on latent2] per variable\nconst weights = [\n    [1.0, 0.2],\n    [0.9, -0.3],\n    [-0.8, 0.4],\n    [0.3, 1.0],\n    [-0.2, 0.9],\n    [0.6, 0.6],\n];\nconst noiseSigma = 0.6;\n\nconst groupIndex = [];\nconst X = [];\nlines.forEach((_, g) => {\n    const [m1, m2] = latentOffsets[g];\n    for (let k = 0; k < nPerLine; k++) {\n        const latent1 = m1 + randn();\n        const latent2 = m2 + randn();\n        const row = weights.map(([a, b]) => a * latent1 + b * latent2 + noiseSigma * randn());\n        X.push(row);\n        groupIndex.push(g);\n    }\n});\nconst n = X.length;\nconst p = variables.length;\n\n// --- Standardize columns (mean 0, sd 1) -> correlation-based PCA -----------\nconst means = variables.map((_, j) => X.reduce((s, row) => s + row[j], 0) / n);\nconst sds = variables.map((_, j) =>\n    Math.sqrt(X.reduce((s, row) => s + (row[j] - means[j]) ** 2, 0) / (n - 1))\n);\nconst Xs = X.map((row) => row.map((v, j) => (v - means[j]) / sds[j]));\n\n// --- Correlation matrix (p x p) ---------------------------------------------\nconst corr = Array.from({ length: p }, (_, i) =>\n    Array.from({ length: p }, (_, j) => Xs.reduce((s, row) => s + row[i] * row[j], 0) / (n - 1))\n);\n\n// --- Jacobi eigenvalue algorithm for symmetric matrices ---------------------\nconst jacobiEigen = (matrix, size) => {\n    const a = matrix.map((row) => row.slice());\n    const v = Array.from({ length: size }, (_, i) =>\n        Array.from({ length: size }, (_, j) => (i === j ? 1 : 0))\n    );\n    for (let sweep = 0; sweep < 100; sweep++) {\n        let offDiag = 0;\n        for (let i = 0; i < size; i++) {\n            for (let j = i + 1; j < size; j++) offDiag += a[i][j] * a[i][j];\n        }\n        if (offDiag < 1e-12) break;\n        for (let pi = 0; pi < size - 1; pi++) {\n            for (let qi = pi + 1; qi < size; qi++) {\n                if (Math.abs(a[pi][qi]) < 1e-14) continue;\n                const theta = (a[qi][qi] - a[pi][pi]) / (2 * a[pi][qi]);\n                const sign = theta >= 0 ? 1 : -1;\n                const tVal = sign / (Math.abs(theta) + Math.sqrt(theta * theta + 1));\n                const c = 1 / Math.sqrt(tVal * tVal + 1);\n                const s = tVal * c;\n                const app = a[pi][pi];\n                const aqq = a[qi][qi];\n                const apq = a[pi][qi];\n                a[pi][pi] = c * c * app - 2 * s * c * apq + s * s * aqq;\n                a[qi][qi] = s * s * app + 2 * s * c * apq + c * c * aqq;\n                a[pi][qi] = 0;\n                a[qi][pi] = 0;\n                for (let i = 0; i < size; i++) {\n                    if (i === pi || i === qi) continue;\n                    const aip = a[i][pi];\n                    const aiq = a[i][qi];\n                    a[i][pi] = c * aip - s * aiq;\n                    a[pi][i] = a[i][pi];\n                    a[i][qi] = s * aip + c * aiq;\n                    a[qi][i] = a[i][qi];\n                }\n                for (let i = 0; i < size; i++) {\n                    const vip = v[i][pi];\n                    const viq = v[i][qi];\n                    v[i][pi] = c * vip - s * viq;\n                    v[i][qi] = s * vip + c * viq;\n                }\n            }\n        }\n    }\n    return { values: Array.from({ length: size }, (_, i) => a[i][i]), vectors: v };\n};\n\nconst { values: eigVals, vectors: eigVecs } = jacobiEigen(corr, p);\nconst order = eigVals.map((_, i) => i).sort((i, j) => eigVals[j] - eigVals[i]);\nconst [pc1, pc2] = order;\nconst totalVar = eigVals.reduce((s, v) => s + v, 0);\nconst varExplained1 = (eigVals[pc1] / totalVar) * 100;\nconst varExplained2 = (eigVals[pc2] / totalVar) * 100;\n\n// --- Scores: project standardized data onto the top two eigenvectors -------\nconst scores = Xs.map((row) => [\n    row.reduce((s, v, j) => s + v * eigVecs[j][pc1], 0),\n    row.reduce((s, v, j) => s + v * eigVecs[j][pc2], 0),\n]);\n\n// --- Correlation loadings: eigenvector scaled by sqrt(eigenvalue) ----------\n// Each loading lies within the unit circle, representing the correlation\n// between the original variable and the principal component.\nconst loadingsRaw = variables.map((_, j) => [\n    eigVecs[j][pc1] * Math.sqrt(eigVals[pc1]),\n    eigVecs[j][pc2] * Math.sqrt(eigVals[pc2]),\n]);\n\n// Scale loadings so arrow tips reach a comparable magnitude to the score\n// cloud, per the spec's \"scale loadings appropriately\" guidance.\nconst scoreRadius = Math.max(...scores.map(([x, y]) => Math.sqrt(x * x + y * y)));\nconst loadingRadius = Math.max(...loadingsRaw.map(([x, y]) => Math.sqrt(x * x + y * y)));\nconst loadingScale = (scoreRadius / loadingRadius) * 0.85;\nconst loadings = loadingsRaw.map(([x, y]) => [x * loadingScale, y * loadingScale]);\n\n// --- Title (fontsize scaled to length, baseline 67 chars -> 27px) ----------\n// Short titles get the full baseline size so they still fill a healthy share\n// of the plot width; only titles longer than the 67-char baseline shrink.\nconst titleText = \"biplot-pca · javascript · chartjs · anyplot.ai\";\nconst titleFontSize = titleText.length > 67 ? Math.round((27 * 67) / titleText.length) : 27;\n\n// --- Data storytelling: highlight the most influential loading -------------\n// The variable with the largest correlation-loading magnitude drives PC1/PC2\n// the most; rendering it in full ink (vs. the muted ink-soft of the rest)\n// draws the eye straight to the biplot's key insight.\nconst loadingMagnitudes = loadingsRaw.map(([x, y]) => Math.sqrt(x * x + y * y));\nconst dominantLoading = loadingMagnitudes.indexOf(Math.max(...loadingMagnitudes));\n\n// --- Plugins -----------------------------------------------------------------\n// Background fill + L-shaped spine frame, combined into one draw plugin\n// since both are simple chrome strokes with no shared state.\nconst chromePlugin = {\n    id: \"chrome\",\n    beforeDraw({ ctx, width, height }) {\n        ctx.save();\n        ctx.fillStyle = t.pageBg;\n        ctx.fillRect(0, 0, width, height);\n        ctx.restore();\n    },\n    afterDatasetsDraw({ ctx, chartArea: { top, right, bottom, left } }) {\n        ctx.save();\n        ctx.strokeStyle = t.inkSoft;\n        ctx.lineWidth = 1;\n        ctx.beginPath();\n        ctx.moveTo(left, top);\n        ctx.lineTo(left, bottom);\n        ctx.moveTo(left, bottom);\n        ctx.lineTo(right, bottom);\n        ctx.stroke();\n        ctx.restore();\n    },\n};\n\n// Unit circle (scaled) as a reference for correlation-loading magnitude.\nconst unitCirclePlugin = {\n    id: \"unitCircle\",\n    afterDatasetsDraw({ ctx, scales: { x: xs, y: ys } }) {\n        const cx = xs.getPixelForValue(0);\n        const cy = ys.getPixelForValue(0);\n        const rx = xs.getPixelForValue(loadingScale) - cx;\n        const ry = cy - ys.getPixelForValue(loadingScale);\n        ctx.save();\n        ctx.strokeStyle = t.grid;\n        ctx.setLineDash([5, 5]);\n        ctx.lineWidth = 1.2;\n        ctx.beginPath();\n        ctx.ellipse(cx, cy, Math.abs(rx), Math.abs(ry), 0, 0, Math.PI * 2);\n        ctx.stroke();\n        ctx.restore();\n    },\n};\n\n// Loading arrows + variable labels, drawn from the origin outward. The\n// dominant loading (largest correlation magnitude) renders in full ink with\n// a heavier stroke and bolder label so the eye lands on the variable that\n// drives the components the most, before scanning the rest — a chrome-only\n// emphasis that doesn't reuse a data (group) color.\nconst loadingArrowPlugin = {\n    id: \"loadingArrows\",\n    afterDatasetsDraw({ ctx, scales: { x: xs, y: ys } }) {\n        const originX = xs.getPixelForValue(0);\n        const originY = ys.getPixelForValue(0);\n        ctx.save();\n        loadings.forEach(([lx, ly], i) => {\n            const isDominant = i === dominantLoading;\n            const arrowColor = isDominant ? t.ink : t.inkSoft;\n            const tipX = xs.getPixelForValue(lx);\n            const tipY = ys.getPixelForValue(ly);\n            const angle = Math.atan2(tipY - originY, tipX - originX);\n            const headLen = 12;\n\n            ctx.strokeStyle = arrowColor;\n            ctx.fillStyle = arrowColor;\n            ctx.lineWidth = isDominant ? 3 : 2;\n\n            ctx.beginPath();\n            ctx.moveTo(originX, originY);\n            ctx.lineTo(tipX, tipY);\n            ctx.stroke();\n\n            ctx.beginPath();\n            ctx.moveTo(tipX, tipY);\n            ctx.lineTo(\n                tipX - headLen * Math.cos(angle - Math.PI / 7),\n                tipY - headLen * Math.sin(angle - Math.PI / 7)\n            );\n            ctx.lineTo(\n                tipX - headLen * Math.cos(angle + Math.PI / 7),\n                tipY - headLen * Math.sin(angle + Math.PI / 7)\n            );\n            ctx.closePath();\n            ctx.fill();\n\n            ctx.font = isDominant ? \"700 16px sans-serif\" : \"600 15px sans-serif\";\n            ctx.fillStyle = t.ink;\n            ctx.textAlign = tipX >= originX ? \"left\" : \"right\";\n            ctx.textBaseline = tipY >= originY ? \"top\" : \"bottom\";\n            ctx.fillText(variables[i], tipX + (tipX >= originX ? 6 : -6), tipY + (tipY >= originY ? 6 : -6));\n        });\n        ctx.restore();\n    },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\nnew Chart(canvas, {\n    type: \"scatter\",\n    data: {\n        datasets: lines.map((label, g) => ({\n            label,\n            data: scores\n                .map((s, i) => ({ s, i }))\n                .filter(({ i }) => groupIndex[i] === g)\n                .map(({ s }) => ({ x: s[0], y: s[1] })),\n            backgroundColor: t.palette[g] + \"cc\",\n            borderColor: t.pageBg,\n            borderWidth: 1,\n            pointRadius: 8,\n            pointHoverRadius: 8,\n        })),\n    },\n    options: {\n        responsive: true,\n        maintainAspectRatio: false,\n        animation: false,\n        layout: { padding: { top: 10, right: 95, bottom: 6, left: 6 } },\n        plugins: {\n            title: {\n                display: true,\n                text: titleText,\n                color: t.ink,\n                font: { size: titleFontSize, weight: \"600\" },\n                padding: { top: 8, bottom: 16 },\n            },\n            legend: {\n                position: \"top\",\n                align: \"end\",\n                labels: { color: t.ink, font: { size: 15 }, usePointStyle: true, boxWidth: 8 },\n            },\n            tooltip: { enabled: false },\n        },\n        scales: {\n            x: {\n                title: {\n                    display: true,\n                    text: `PC1 (${varExplained1.toFixed(1)}%)`,\n                    color: t.ink,\n                    font: { size: 16 },\n                },\n                ticks: { color: t.inkSoft, font: { size: 14 } },\n                grid: { color: t.grid },\n                border: { display: false },\n            },\n            y: {\n                title: {\n                    display: true,\n                    text: `PC2 (${varExplained2.toFixed(1)}%)`,\n                    color: t.ink,\n                    font: { size: 16 },\n                },\n                ticks: { color: t.inkSoft, font: { size: 14 } },\n                grid: { color: t.grid },\n                border: { display: false },\n            },\n        },\n    },\n    plugins: [chromePlugin, unitCirclePlugin, loadingArrowPlugin],\n});\n"}