Skip to content

Commit 972b586

Browse files
feat(d3): implement roc-curve (#11604)
## Implementation: `roc-curve` - javascript/d3 Implements the **javascript/d3** version of `roc-curve`. **File:** `plots/roc-curve/implementations/javascript/d3.js` **Parent Issue:** #2273 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/33967135923)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent 3c72cef commit 972b586

2 files changed

Lines changed: 444 additions & 0 deletions

File tree

  • plots/roc-curve
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// anyplot.ai
2+
// roc-curve: ROC Curve with AUC
3+
// Library: d3 7.9.0 | JavaScript 22.23.2
4+
// Quality: 91/100 | Created: 2026-09-05
5+
//# anyplot-orientation: square
6+
7+
const t = window.ANYPLOT_TOKENS;
8+
const isDark = window.ANYPLOT_THEME === "dark";
9+
const muted = isDark ? "#A8A79F" : "#6B6A63"; // Imprint semantic anchor: muted
10+
const { width, height } = window.ANYPLOT_SIZE;
11+
const margin = { top: 110, right: 90, bottom: 100, left: 120 };
12+
const iw = width - margin.left - margin.right;
13+
const ih = height - margin.top - margin.bottom;
14+
15+
// --- Data: three synthetic diagnostic-test classifiers of varying skill, each
16+
// built from a deterministic LCG (own seed per model, so runs stay reproducible
17+
// and independent of each other) ---------------------------------------------
18+
function makeLcg(seed) {
19+
let state = seed >>> 0;
20+
return () => {
21+
state = (state * 1664525 + 1013904223) >>> 0;
22+
return state / 4294967296;
23+
};
24+
}
25+
const clamp01 = (v) => Math.min(1, Math.max(0, v));
26+
27+
function buildRoc({ seed, muDiseased, muHealthy, sd }) {
28+
const rand = makeLcg(seed);
29+
function randNormal() {
30+
const u1 = Math.max(rand(), 1e-9);
31+
const u2 = rand();
32+
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
33+
}
34+
35+
const nDiseased = 150;
36+
const nHealthy = 150;
37+
const diseasedScores = Array.from({ length: nDiseased }, () => clamp01(muDiseased + sd * randNormal()));
38+
const healthyScores = Array.from({ length: nHealthy }, () => clamp01(muHealthy + sd * randNormal()));
39+
40+
const labeledScores = [
41+
...diseasedScores.map((score) => ({ score, isDiseased: true })),
42+
...healthyScores.map((score) => ({ score, isDiseased: false })),
43+
].sort((a, b) => b.score - a.score);
44+
45+
// Sweep the decision threshold from high to low, accumulating hits/misses —
46+
// the same construction sklearn.metrics.roc_curve uses on predicted scores.
47+
let truePositives = 0;
48+
let falsePositives = 0;
49+
const points = [{ fpr: 0, tpr: 0 }];
50+
for (const { isDiseased } of labeledScores) {
51+
if (isDiseased) truePositives += 1;
52+
else falsePositives += 1;
53+
points.push({ fpr: falsePositives / nHealthy, tpr: truePositives / nDiseased });
54+
}
55+
56+
let auc = 0;
57+
for (let i = 1; i < points.length; i++) {
58+
const a = points[i - 1];
59+
const b = points[i];
60+
auc += ((b.fpr - a.fpr) * (a.tpr + b.tpr)) / 2;
61+
}
62+
return { points, auc };
63+
}
64+
65+
const models = [
66+
{ name: "Strong classifier", seed: 42, muDiseased: 0.66, muHealthy: 0.34, sd: 0.16 },
67+
{ name: "Moderate classifier", seed: 7, muDiseased: 0.6, muHealthy: 0.4, sd: 0.2 },
68+
{ name: "Weak classifier", seed: 99, muDiseased: 0.56, muHealthy: 0.44, sd: 0.24 },
69+
].map((spec) => ({ ...spec, ...buildRoc(spec) }));
70+
71+
const color = d3
72+
.scaleOrdinal()
73+
.domain(models.map((m) => m.name))
74+
.range(t.palette);
75+
76+
// --- SVG mount ---------------------------------------------------------------
77+
const svg = d3.select("#container").append("svg").attr("width", width).attr("height", height);
78+
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
79+
80+
// --- Scales — equal-aspect: iw === ih so an FPR unit spans the same pixels
81+
// as a TPR unit, per the spec's "equal aspect ratio preferred" note ---------
82+
const x = d3.scaleLinear().domain([0, 1]).range([0, iw]);
83+
const y = d3.scaleLinear().domain([0, 1]).range([ih, 0]);
84+
85+
// --- Gridlines -----------------------------------------------------------
86+
g.append("g")
87+
.selectAll("line")
88+
.data(x.ticks(5))
89+
.join("line")
90+
.attr("x1", (d) => x(d))
91+
.attr("x2", (d) => x(d))
92+
.attr("y1", 0)
93+
.attr("y2", ih)
94+
.attr("stroke", t.grid);
95+
g.append("g")
96+
.selectAll("line")
97+
.data(y.ticks(5))
98+
.join("line")
99+
.attr("x1", 0)
100+
.attr("x2", iw)
101+
.attr("y1", (d) => y(d))
102+
.attr("y2", (d) => y(d))
103+
.attr("stroke", t.grid);
104+
105+
// --- Diagonal reference line (random classifier, y = x) ---------------------
106+
g.append("line")
107+
.attr("x1", x(0))
108+
.attr("y1", y(0))
109+
.attr("x2", x(1))
110+
.attr("y2", y(1))
111+
.attr("stroke", muted)
112+
.attr("stroke-width", 2.5)
113+
.attr("stroke-dasharray", "10,8");
114+
115+
// --- Area fill under the strongest curve only, to keep a single focal point,
116+
// then the ROC curve for each model in its own Imprint color -----------------
117+
const area = d3
118+
.area()
119+
.x((d) => x(d.fpr))
120+
.y0(ih)
121+
.y1((d) => y(d.tpr));
122+
g.append("path").datum(models[0].points).attr("fill", color(models[0].name)).attr("opacity", 0.1).attr("d", area);
123+
124+
const line = d3
125+
.line()
126+
.x((d) => x(d.fpr))
127+
.y((d) => y(d.tpr));
128+
g.selectAll(".roc-line")
129+
.data(models)
130+
.join("path")
131+
.attr("class", "roc-line")
132+
.attr("fill", "none")
133+
.attr("stroke", (d) => color(d.name))
134+
.attr("stroke-width", 4)
135+
.attr("stroke-linejoin", "round")
136+
.attr("stroke-linecap", "round")
137+
.attr("d", (d) => line(d.points));
138+
139+
// --- Axes -------------------------------------------------------------------
140+
const xAxis = g
141+
.append("g")
142+
.attr("transform", `translate(0,${ih})`)
143+
.call(d3.axisBottom(x).ticks(5).tickFormat(d3.format(".1f")));
144+
const yAxis = g.append("g").call(d3.axisLeft(y).ticks(5).tickFormat(d3.format(".1f")));
145+
for (const ax of [xAxis, yAxis]) {
146+
ax.selectAll("text").attr("fill", t.inkSoft).style("font-size", "14px");
147+
ax.selectAll("line").attr("stroke", t.inkSoft);
148+
ax.select(".domain").attr("stroke", t.inkSoft);
149+
}
150+
151+
// --- Axis labels --------------------------------------------------------------
152+
g.append("text")
153+
.attr("x", iw / 2)
154+
.attr("y", ih + 65)
155+
.attr("text-anchor", "middle")
156+
.attr("fill", t.ink)
157+
.style("font-size", "18px")
158+
.text("False Positive Rate");
159+
g.append("text")
160+
.attr("transform", "rotate(-90)")
161+
.attr("x", -ih / 2)
162+
.attr("y", -90)
163+
.attr("text-anchor", "middle")
164+
.attr("fill", t.ink)
165+
.style("font-size", "18px")
166+
.text("True Positive Rate");
167+
168+
// --- Legend (bottom-right — every ROC curve stays at/above the diagonal, so
169+
// the low-TPR/high-FPR corner below it stays clear of the data) --------------
170+
const legendEntries = [
171+
...models.map((m) => ({ label: `${m.name} (AUC = ${m.auc.toFixed(2)})`, stroke: color(m.name), dash: null })),
172+
{ label: "Random classifier (AUC = 0.50)", stroke: muted, dash: "8,6" },
173+
];
174+
const legend = g.append("g").attr("transform", `translate(${iw - 460}, ${ih - 160})`);
175+
const rows = legend
176+
.selectAll(".legend-row")
177+
.data(legendEntries)
178+
.join("g")
179+
.attr("class", "legend-row")
180+
.attr("transform", (_, i) => `translate(0, ${i * 34})`);
181+
rows
182+
.append("line")
183+
.attr("x1", 0)
184+
.attr("x2", 36)
185+
.attr("y1", 0)
186+
.attr("y2", 0)
187+
.attr("stroke", (d) => d.stroke)
188+
.attr("stroke-width", (d) => (d.dash ? 2.5 : 4))
189+
.attr("stroke-dasharray", (d) => d.dash);
190+
rows
191+
.append("text")
192+
.attr("x", 48)
193+
.attr("y", 5)
194+
.attr("fill", (d, i) => (i === legendEntries.length - 1 ? t.inkSoft : t.ink))
195+
.style("font-size", "15px")
196+
.text((d) => d.label);
197+
198+
// --- Title --------------------------------------------------------------------
199+
svg
200+
.append("text")
201+
.attr("x", width / 2)
202+
.attr("y", 55)
203+
.attr("text-anchor", "middle")
204+
.attr("fill", t.ink)
205+
.style("font-size", "24px")
206+
.style("font-weight", "600")
207+
.text("roc-curve · javascript · d3 · anyplot.ai");

0 commit comments

Comments
 (0)