{"spec_id":"roc-curve","library":"muix","language":"javascript","code":"// anyplot.ai\n// roc-curve: ROC Curve with AUC\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-05\n//# anyplot-orientation: square\n// anyplot.ai\n// roc-curve: ROC Curve with AUC\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-09-05\nimport { LineChart } from \"@mui/x-charts/LineChart\";\nimport { ChartsReferenceLine } from \"@mui/x-charts/ChartsReferenceLine\";\nimport Box from \"@mui/material/Box\";\nimport Typography from \"@mui/material/Typography\";\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// The ROC pipeline: lcg/randNormal synthesize classifier scores,\n// rocFromScores sweeps every threshold into an empirical (fpr, tpr, auc)\n// curve, and onGrid resamples that step function onto a shared FPR grid so\n// both models plot against one xAxis.\n\n// Tiny fixed-seed LCG — the browser has no seeded RNG\nfunction lcg(seed: number) {\n  let s = seed >>> 0;\n  return () => {\n    s = (Math.imul(1664525, s) + 1013904223) >>> 0;\n    return s / 4294967295;\n  };\n}\nconst rand = lcg(42);\n\n// Standard normal deviate via Box-Muller, driven by the LCG above.\nfunction randNormal(mean: number, std: number) {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * std;\n}\n\n// Simulate classifier scores for malignant (positive) vs. benign (negative)\n// biopsy samples, then sweep every threshold to trace the empirical ROC\n// curve — mirrors what sklearn.metrics.roc_curve produces from real\n// predictions. AUC follows from the trapezoidal rule over the curve.\nfunction rocFromScores(\n  nPos: number,\n  nNeg: number,\n  meanPos: number,\n  meanNeg: number,\n  std: number,\n) {\n  const scored = [\n    ...Array.from({ length: nPos }, () => ({\n      s: randNormal(meanPos, std),\n      label: 1,\n    })),\n    ...Array.from({ length: nNeg }, () => ({\n      s: randNormal(meanNeg, std),\n      label: 0,\n    })),\n  ].sort((a, b) => b.s - a.s);\n\n  const fpr = [0];\n  const tpr = [0];\n  let tp = 0;\n  let fp = 0;\n  for (const { label } of scored) {\n    if (label === 1) tp += 1;\n    else fp += 1;\n    fpr.push(fp / nNeg);\n    tpr.push(tp / nPos);\n  }\n\n  let auc = 0;\n  for (let i = 1; i < fpr.length; i++) {\n    auc += ((fpr[i] - fpr[i - 1]) * (tpr[i] + tpr[i - 1])) / 2;\n  }\n  return { fpr, tpr, auc };\n}\n\n// Resample a step-function ROC curve onto a shared FPR grid so every series\n// (both models plus the diagonal) can be plotted against one xAxis.\nfunction onGrid(fpr: number[], tpr: number[], grid: number[]) {\n  return grid.map((x) => {\n    let i = 0;\n    while (i < fpr.length - 1 && fpr[i + 1] < x) i += 1;\n    const j = Math.min(i + 1, fpr.length - 1);\n    if (fpr[j] === fpr[i]) return tpr[j];\n    const frac = (x - fpr[i]) / (fpr[j] - fpr[i]);\n    return tpr[i] + frac * (tpr[j] - tpr[i]);\n  });\n}\n\nconst N_SAMPLES = 500;\nconst GRID = Array.from({ length: 101 }, (_, i) => i / 100);\n\nconst forest = rocFromScores(N_SAMPLES, N_SAMPLES, 2.3, 0, 1);\nconst logistic = rocFromScores(N_SAMPLES, N_SAMPLES, 1.15, 0, 1);\nconst forestTpr = onGrid(forest.fpr, forest.tpr, GRID);\nconst logisticTpr = onGrid(logistic.fpr, logistic.tpr, GRID);\n\nconst TITLE = \"roc-curve · javascript · muix · anyplot.ai\";\nconst TITLE_H = 56;\n\n// --- Chart (default-exported component — the harness mounts it) -----------\n\nexport default function Chart() {\n  const { width, height } = window.ANYPLOT_SIZE;\n\n  return (\n    <Box\n      sx={{\n        width,\n        height,\n        bgcolor: t.pageBg,\n        display: \"flex\",\n        flexDirection: \"column\",\n      }}\n    >\n      <Box\n        sx={{\n          height: TITLE_H,\n          display: \"flex\",\n          alignItems: \"center\",\n          px: \"40px\",\n          pt: \"10px\",\n        }}\n      >\n        <Typography\n          sx={{\n            color: t.ink,\n            fontSize: \"25px\",\n            fontWeight: 600,\n            lineHeight: 1,\n          }}\n        >\n          {TITLE}\n        </Typography>\n      </Box>\n\n      <LineChart\n        width={width}\n        height={height - TITLE_H}\n        skipAnimation\n        grid={{ horizontal: true }}\n        xAxis={[\n          {\n            data: GRID,\n            scaleType: \"linear\",\n            min: 0,\n            max: 1,\n            label: \"False Positive Rate\",\n            tickLabelStyle: { fontSize: 14 },\n            labelStyle: { fontSize: 16 },\n          },\n        ]}\n        yAxis={[\n          {\n            min: 0,\n            max: 1,\n            label: \"True Positive Rate\",\n            // tickFontSize drives the auto-computed label offset (see MUI X\n            // ChartsYAxis: labelRefPoint.x = -(tickFontSize + tickSize + 10));\n            // set it wide enough to clear the \"0.XX\"-style tick text, while\n            // tickLabelStyle.fontSize keeps the rendered tick size correct.\n            tickFontSize: 40,\n            tickLabelStyle: { fontSize: 14 },\n            labelStyle: { fontSize: 16 },\n          },\n        ]}\n        series={[\n          {\n            id: \"forest\",\n            data: forestTpr,\n            label: `Random Forest (AUC = ${forest.auc.toFixed(2)})`,\n            color: t.palette[0],\n            showMark: false,\n            curve: \"linear\",\n          },\n          {\n            id: \"logistic\",\n            data: logisticTpr,\n            label: `Logistic Regression (AUC = ${logistic.auc.toFixed(2)})`,\n            color: t.palette[1],\n            showMark: false,\n            curve: \"linear\",\n          },\n          {\n            // No `label`: this is the y=x reference, not a fitted model, so\n            // it's excluded from the legend (see ChartsReferenceLine below,\n            // which annotates it directly on the chart instead).\n            id: \"baseline\",\n            data: GRID,\n            color: t.inkSoft,\n            showMark: false,\n            curve: \"linear\",\n          },\n        ]}\n        margin={{ top: 20, bottom: 90, left: 130, right: 40 }}\n        sx={{\n          \"& .MuiLineElement-series-forest\": { strokeWidth: 3.5 },\n          \"& .MuiLineElement-series-logistic\": { strokeWidth: 3 },\n          \"& .MuiLineElement-series-baseline\": {\n            strokeDasharray: \"10 6\",\n            strokeWidth: 2,\n            strokeOpacity: 0.6,\n          },\n          \"& .MuiChartsGrid-line\": { stroke: t.grid, strokeWidth: 1 },\n        }}\n        slotProps={{\n          legend: {\n            direction: \"row\",\n            position: { vertical: \"bottom\", horizontal: \"middle\" },\n          },\n        }}\n      >\n        {/* Annotates the dashed \"baseline\" series in place of a legend\n            entry — the reference line's own stroke is hidden (it would\n            otherwise duplicate the horizontal gridline); only its label\n            renders, horizontally centered above (FPR=0.5, TPR=0.5) where the\n            diagonal data series crosses, clear of the line itself. */}\n        <ChartsReferenceLine\n          y={0.6}\n          label=\"Random guess (AUC = 0.50)\"\n          lineStyle={{ stroke: \"none\" }}\n          labelStyle={{ fill: t.inkSoft, fontSize: 13 }}\n        />\n      </LineChart>\n    </Box>\n  );\n}\n"}