{"spec_id":"roc-curve","library":"d3","language":"javascript","code":"// anyplot.ai\n// roc-curve: ROC Curve with AUC\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-05\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst isDark = window.ANYPLOT_THEME === \"dark\";\nconst muted = isDark ? \"#A8A79F\" : \"#6B6A63\"; // Imprint semantic anchor: muted\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 110, right: 90, bottom: 100, left: 120 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data: three synthetic diagnostic-test classifiers of varying skill, each\n// built from a deterministic LCG (own seed per model, so runs stay reproducible\n// and independent of each other) ---------------------------------------------\nfunction makeLcg(seed) {\n  let state = seed >>> 0;\n  return () => {\n    state = (state * 1664525 + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst clamp01 = (v) => Math.min(1, Math.max(0, v));\n\nfunction buildRoc({ seed, muDiseased, muHealthy, sd }) {\n  const rand = makeLcg(seed);\n  function randNormal() {\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  const nDiseased = 150;\n  const nHealthy = 150;\n  const diseasedScores = Array.from({ length: nDiseased }, () => clamp01(muDiseased + sd * randNormal()));\n  const healthyScores = Array.from({ length: nHealthy }, () => clamp01(muHealthy + sd * randNormal()));\n\n  const labeledScores = [\n    ...diseasedScores.map((score) => ({ score, isDiseased: true })),\n    ...healthyScores.map((score) => ({ score, isDiseased: false })),\n  ].sort((a, b) => b.score - a.score);\n\n  // Sweep the decision threshold from high to low, accumulating hits/misses —\n  // the same construction sklearn.metrics.roc_curve uses on predicted scores.\n  let truePositives = 0;\n  let falsePositives = 0;\n  const points = [{ fpr: 0, tpr: 0 }];\n  for (const { isDiseased } of labeledScores) {\n    if (isDiseased) truePositives += 1;\n    else falsePositives += 1;\n    points.push({ fpr: falsePositives / nHealthy, tpr: truePositives / nDiseased });\n  }\n\n  let auc = 0;\n  for (let i = 1; i < points.length; i++) {\n    const a = points[i - 1];\n    const b = points[i];\n    auc += ((b.fpr - a.fpr) * (a.tpr + b.tpr)) / 2;\n  }\n  return { points, auc };\n}\n\nconst models = [\n  { name: \"Strong classifier\", seed: 42, muDiseased: 0.66, muHealthy: 0.34, sd: 0.16 },\n  { name: \"Moderate classifier\", seed: 7, muDiseased: 0.6, muHealthy: 0.4, sd: 0.2 },\n  { name: \"Weak classifier\", seed: 99, muDiseased: 0.56, muHealthy: 0.44, sd: 0.24 },\n].map((spec) => ({ ...spec, ...buildRoc(spec) }));\n\nconst color = d3\n  .scaleOrdinal()\n  .domain(models.map((m) => m.name))\n  .range(t.palette);\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 — equal-aspect: iw === ih so an FPR unit spans the same pixels\n// as a TPR unit, per the spec's \"equal aspect ratio preferred\" note ---------\nconst x = d3.scaleLinear().domain([0, 1]).range([0, iw]);\nconst y = d3.scaleLinear().domain([0, 1]).range([ih, 0]);\n\n// --- Gridlines -----------------------------------------------------------\ng.append(\"g\")\n  .selectAll(\"line\")\n  .data(x.ticks(5))\n  .join(\"line\")\n  .attr(\"x1\", (d) => x(d))\n  .attr(\"x2\", (d) => x(d))\n  .attr(\"y1\", 0)\n  .attr(\"y2\", ih)\n  .attr(\"stroke\", t.grid);\ng.append(\"g\")\n  .selectAll(\"line\")\n  .data(y.ticks(5))\n  .join(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", iw)\n  .attr(\"y1\", (d) => y(d))\n  .attr(\"y2\", (d) => y(d))\n  .attr(\"stroke\", t.grid);\n\n// --- Diagonal reference line (random classifier, y = x) ---------------------\ng.append(\"line\")\n  .attr(\"x1\", x(0))\n  .attr(\"y1\", y(0))\n  .attr(\"x2\", x(1))\n  .attr(\"y2\", y(1))\n  .attr(\"stroke\", muted)\n  .attr(\"stroke-width\", 2.5)\n  .attr(\"stroke-dasharray\", \"10,8\");\n\n// --- Area fill under the strongest curve only, to keep a single focal point,\n// then the ROC curve for each model in its own Imprint color -----------------\nconst area = d3\n  .area()\n  .x((d) => x(d.fpr))\n  .y0(ih)\n  .y1((d) => y(d.tpr));\ng.append(\"path\").datum(models[0].points).attr(\"fill\", color(models[0].name)).attr(\"opacity\", 0.1).attr(\"d\", area);\n\nconst line = d3\n  .line()\n  .x((d) => x(d.fpr))\n  .y((d) => y(d.tpr));\ng.selectAll(\".roc-line\")\n  .data(models)\n  .join(\"path\")\n  .attr(\"class\", \"roc-line\")\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", (d) => color(d.name))\n  .attr(\"stroke-width\", 4)\n  .attr(\"stroke-linejoin\", \"round\")\n  .attr(\"stroke-linecap\", \"round\")\n  .attr(\"d\", (d) => line(d.points));\n\n// --- Axes -------------------------------------------------------------------\nconst xAxis = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).ticks(5).tickFormat(d3.format(\".1f\")));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).ticks(5).tickFormat(d3.format(\".1f\")));\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.inkSoft);\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 + 65)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"18px\")\n  .text(\"False Positive Rate\");\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -90)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"18px\")\n  .text(\"True Positive Rate\");\n\n// --- Legend (bottom-right — every ROC curve stays at/above the diagonal, so\n// the low-TPR/high-FPR corner below it stays clear of the data) --------------\nconst legendEntries = [\n  ...models.map((m) => ({ label: `${m.name} (AUC = ${m.auc.toFixed(2)})`, stroke: color(m.name), dash: null })),\n  { label: \"Random classifier (AUC = 0.50)\", stroke: muted, dash: \"8,6\" },\n];\nconst legend = g.append(\"g\").attr(\"transform\", `translate(${iw - 460}, ${ih - 160})`);\nconst rows = legend\n  .selectAll(\".legend-row\")\n  .data(legendEntries)\n  .join(\"g\")\n  .attr(\"class\", \"legend-row\")\n  .attr(\"transform\", (_, i) => `translate(0, ${i * 34})`);\nrows\n  .append(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", 36)\n  .attr(\"y1\", 0)\n  .attr(\"y2\", 0)\n  .attr(\"stroke\", (d) => d.stroke)\n  .attr(\"stroke-width\", (d) => (d.dash ? 2.5 : 4))\n  .attr(\"stroke-dasharray\", (d) => d.dash);\nrows\n  .append(\"text\")\n  .attr(\"x\", 48)\n  .attr(\"y\", 5)\n  .attr(\"fill\", (d, i) => (i === legendEntries.length - 1 ? t.inkSoft : t.ink))\n  .style(\"font-size\", \"15px\")\n  .text((d) => d.label);\n\n// --- Title --------------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 55)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"24px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"roc-curve · javascript · d3 · anyplot.ai\");\n"}