Skip to content

Commit 29f870d

Browse files
feat(muix): implement roc-curve (#11614)
## Implementation: `roc-curve` - javascript/muix Implements the **javascript/muix** version of `roc-curve`. **File:** `plots/roc-curve/implementations/javascript/muix.tsx` **Parent Issue:** #2273 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/33967725414)* --------- 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 b089f5d commit 29f870d

2 files changed

Lines changed: 503 additions & 0 deletions

File tree

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
// anyplot.ai
2+
// roc-curve: ROC Curve with AUC
3+
// Library: muix 7.29.1 | JavaScript 22.23.2
4+
// Quality: 91/100 | Created: 2026-09-05
5+
//# anyplot-orientation: square
6+
// anyplot.ai
7+
// roc-curve: ROC Curve with AUC
8+
// Library: muix 7.29.1 | JavaScript 22.23.2
9+
// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.
10+
// Quality: pending | Created: 2026-09-05
11+
import { LineChart } from "@mui/x-charts/LineChart";
12+
import { ChartsReferenceLine } from "@mui/x-charts/ChartsReferenceLine";
13+
import Box from "@mui/material/Box";
14+
import Typography from "@mui/material/Typography";
15+
16+
const t = window.ANYPLOT_TOKENS;
17+
18+
// --- Data (in-memory, deterministic) ----------------------------------------
19+
// The ROC pipeline: lcg/randNormal synthesize classifier scores,
20+
// rocFromScores sweeps every threshold into an empirical (fpr, tpr, auc)
21+
// curve, and onGrid resamples that step function onto a shared FPR grid so
22+
// both models plot against one xAxis.
23+
24+
// Tiny fixed-seed LCG — the browser has no seeded RNG
25+
function lcg(seed: number) {
26+
let s = seed >>> 0;
27+
return () => {
28+
s = (Math.imul(1664525, s) + 1013904223) >>> 0;
29+
return s / 4294967295;
30+
};
31+
}
32+
const rand = lcg(42);
33+
34+
// Standard normal deviate via Box-Muller, driven by the LCG above.
35+
function randNormal(mean: number, std: number) {
36+
const u1 = Math.max(rand(), 1e-9);
37+
const u2 = rand();
38+
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
39+
return mean + z * std;
40+
}
41+
42+
// Simulate classifier scores for malignant (positive) vs. benign (negative)
43+
// biopsy samples, then sweep every threshold to trace the empirical ROC
44+
// curve — mirrors what sklearn.metrics.roc_curve produces from real
45+
// predictions. AUC follows from the trapezoidal rule over the curve.
46+
function rocFromScores(
47+
nPos: number,
48+
nNeg: number,
49+
meanPos: number,
50+
meanNeg: number,
51+
std: number,
52+
) {
53+
const scored = [
54+
...Array.from({ length: nPos }, () => ({
55+
s: randNormal(meanPos, std),
56+
label: 1,
57+
})),
58+
...Array.from({ length: nNeg }, () => ({
59+
s: randNormal(meanNeg, std),
60+
label: 0,
61+
})),
62+
].sort((a, b) => b.s - a.s);
63+
64+
const fpr = [0];
65+
const tpr = [0];
66+
let tp = 0;
67+
let fp = 0;
68+
for (const { label } of scored) {
69+
if (label === 1) tp += 1;
70+
else fp += 1;
71+
fpr.push(fp / nNeg);
72+
tpr.push(tp / nPos);
73+
}
74+
75+
let auc = 0;
76+
for (let i = 1; i < fpr.length; i++) {
77+
auc += ((fpr[i] - fpr[i - 1]) * (tpr[i] + tpr[i - 1])) / 2;
78+
}
79+
return { fpr, tpr, auc };
80+
}
81+
82+
// Resample a step-function ROC curve onto a shared FPR grid so every series
83+
// (both models plus the diagonal) can be plotted against one xAxis.
84+
function onGrid(fpr: number[], tpr: number[], grid: number[]) {
85+
return grid.map((x) => {
86+
let i = 0;
87+
while (i < fpr.length - 1 && fpr[i + 1] < x) i += 1;
88+
const j = Math.min(i + 1, fpr.length - 1);
89+
if (fpr[j] === fpr[i]) return tpr[j];
90+
const frac = (x - fpr[i]) / (fpr[j] - fpr[i]);
91+
return tpr[i] + frac * (tpr[j] - tpr[i]);
92+
});
93+
}
94+
95+
const N_SAMPLES = 500;
96+
const GRID = Array.from({ length: 101 }, (_, i) => i / 100);
97+
98+
const forest = rocFromScores(N_SAMPLES, N_SAMPLES, 2.3, 0, 1);
99+
const logistic = rocFromScores(N_SAMPLES, N_SAMPLES, 1.15, 0, 1);
100+
const forestTpr = onGrid(forest.fpr, forest.tpr, GRID);
101+
const logisticTpr = onGrid(logistic.fpr, logistic.tpr, GRID);
102+
103+
const TITLE = "roc-curve · javascript · muix · anyplot.ai";
104+
const TITLE_H = 56;
105+
106+
// --- Chart (default-exported component — the harness mounts it) -----------
107+
108+
export default function Chart() {
109+
const { width, height } = window.ANYPLOT_SIZE;
110+
111+
return (
112+
<Box
113+
sx={{
114+
width,
115+
height,
116+
bgcolor: t.pageBg,
117+
display: "flex",
118+
flexDirection: "column",
119+
}}
120+
>
121+
<Box
122+
sx={{
123+
height: TITLE_H,
124+
display: "flex",
125+
alignItems: "center",
126+
px: "40px",
127+
pt: "10px",
128+
}}
129+
>
130+
<Typography
131+
sx={{
132+
color: t.ink,
133+
fontSize: "25px",
134+
fontWeight: 600,
135+
lineHeight: 1,
136+
}}
137+
>
138+
{TITLE}
139+
</Typography>
140+
</Box>
141+
142+
<LineChart
143+
width={width}
144+
height={height - TITLE_H}
145+
skipAnimation
146+
grid={{ horizontal: true }}
147+
xAxis={[
148+
{
149+
data: GRID,
150+
scaleType: "linear",
151+
min: 0,
152+
max: 1,
153+
label: "False Positive Rate",
154+
tickLabelStyle: { fontSize: 14 },
155+
labelStyle: { fontSize: 16 },
156+
},
157+
]}
158+
yAxis={[
159+
{
160+
min: 0,
161+
max: 1,
162+
label: "True Positive Rate",
163+
// tickFontSize drives the auto-computed label offset (see MUI X
164+
// ChartsYAxis: labelRefPoint.x = -(tickFontSize + tickSize + 10));
165+
// set it wide enough to clear the "0.XX"-style tick text, while
166+
// tickLabelStyle.fontSize keeps the rendered tick size correct.
167+
tickFontSize: 40,
168+
tickLabelStyle: { fontSize: 14 },
169+
labelStyle: { fontSize: 16 },
170+
},
171+
]}
172+
series={[
173+
{
174+
id: "forest",
175+
data: forestTpr,
176+
label: `Random Forest (AUC = ${forest.auc.toFixed(2)})`,
177+
color: t.palette[0],
178+
showMark: false,
179+
curve: "linear",
180+
},
181+
{
182+
id: "logistic",
183+
data: logisticTpr,
184+
label: `Logistic Regression (AUC = ${logistic.auc.toFixed(2)})`,
185+
color: t.palette[1],
186+
showMark: false,
187+
curve: "linear",
188+
},
189+
{
190+
// No `label`: this is the y=x reference, not a fitted model, so
191+
// it's excluded from the legend (see ChartsReferenceLine below,
192+
// which annotates it directly on the chart instead).
193+
id: "baseline",
194+
data: GRID,
195+
color: t.inkSoft,
196+
showMark: false,
197+
curve: "linear",
198+
},
199+
]}
200+
margin={{ top: 20, bottom: 90, left: 130, right: 40 }}
201+
sx={{
202+
"& .MuiLineElement-series-forest": { strokeWidth: 3.5 },
203+
"& .MuiLineElement-series-logistic": { strokeWidth: 3 },
204+
"& .MuiLineElement-series-baseline": {
205+
strokeDasharray: "10 6",
206+
strokeWidth: 2,
207+
strokeOpacity: 0.6,
208+
},
209+
"& .MuiChartsGrid-line": { stroke: t.grid, strokeWidth: 1 },
210+
}}
211+
slotProps={{
212+
legend: {
213+
direction: "row",
214+
position: { vertical: "bottom", horizontal: "middle" },
215+
},
216+
}}
217+
>
218+
{/* Annotates the dashed "baseline" series in place of a legend
219+
entry — the reference line's own stroke is hidden (it would
220+
otherwise duplicate the horizontal gridline); only its label
221+
renders, horizontally centered above (FPR=0.5, TPR=0.5) where the
222+
diagonal data series crosses, clear of the line itself. */}
223+
<ChartsReferenceLine
224+
y={0.6}
225+
label="Random guess (AUC = 0.50)"
226+
lineStyle={{ stroke: "none" }}
227+
labelStyle={{ fill: t.inkSoft, fontSize: 13 }}
228+
/>
229+
</LineChart>
230+
</Box>
231+
);
232+
}

0 commit comments

Comments
 (0)