{"spec_id":"gain-curve","library":"muix","language":"javascript","code":"// anyplot.ai\n// gain-curve: Cumulative Gains Chart\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-05\n//# anyplot-orientation: landscape\n// anyplot.ai\n// gain-curve: Cumulative Gains Chart\n// Library: MUI X Charts | React | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-09-05\n\nimport { LineChart } from \"@mui/x-charts/LineChart\";\nimport Box from \"@mui/material/Box\";\nimport Typography from \"@mui/material/Typography\";\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: fraud-investigation scoring model (in-memory, deterministic) -----\n// LCG PRNG — the browser has no seeded RNG.\nlet seed = 42;\nfunction nextRandom() {\n  seed = (1664525 * seed + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\nfunction nextGaussian() {\n  const u1 = Math.max(nextRandom(), 1e-12);\n  const u2 = nextRandom();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\nfunction sigmoid(x) {\n  return 1 / (1 + Math.exp(-x));\n}\n\nconst TRANSACTIONS = 3000;\nconst yTrue = [];\nconst yScore = [];\nfor (let i = 0; i < TRANSACTIONS; i++) {\n  const risk = nextGaussian();\n  const fraudProbability = sigmoid(1.4 * risk - 2.3); // ~9% base fraud rate\n  yTrue.push(nextRandom() < fraudProbability ? 1 : 0);\n  yScore.push(risk + nextGaussian() * 0.9); // model score: imperfect proxy for true risk\n}\n\n// Rank by predicted score (descending) and accumulate captured fraud cases.\nconst order = yTrue.map((_, i) => i).sort((a, b) => yScore[b] - yScore[a]);\nconst totalFraud = yTrue.reduce((sum, v) => sum + v, 0);\nconst fraudRate = totalFraud / TRANSACTIONS;\n\nconst cumulativeGainAt = new Array(TRANSACTIONS);\nlet cumulativeFraud = 0;\nfor (let i = 0; i < TRANSACTIONS; i++) {\n  cumulativeFraud += yTrue[order[i]];\n  cumulativeGainAt[i] = (cumulativeFraud / totalFraud) * 100;\n}\n\n// Sample at every integer percent of population targeted.\nconst PERCENTAGES = Array.from({ length: 101 }, (_, i) => i);\nconst modelGain = PERCENTAGES.map((pct) => {\n  if (pct === 0) return 0;\n  const idx = Math.min(Math.round((pct / 100) * TRANSACTIONS), TRANSACTIONS) - 1;\n  return cumulativeGainAt[idx];\n});\nconst randomGain = PERCENTAGES.map((pct) => pct);\nconst perfectCapPct = fraudRate * 100;\nconst perfectGain = PERCENTAGES.map((pct) => (pct <= perfectCapPct ? pct / fraudRate : 100));\n\n// --- Chart -------------------------------------------------------------------\nconst TITLE = \"Fraud Investigation Targeting · gain-curve · javascript · muix · anyplot.ai\";\nconst TITLE_FONT_SIZE = Math.round(22 * Math.min(1, 67 / TITLE.length));\nconst TITLE_HEIGHT = 64;\n\nexport default function Chart() {\n  const chartWidth = window.ANYPLOT_SIZE.width;\n  const chartHeight = window.ANYPLOT_SIZE.height - TITLE_HEIGHT;\n\n  return (\n    <Box sx={{ width: chartWidth, height: window.ANYPLOT_SIZE.height, display: \"flex\", flexDirection: \"column\" }}>\n      <Box sx={{ height: TITLE_HEIGHT, display: \"flex\", alignItems: \"center\", justifyContent: \"center\", flexShrink: 0 }}>\n        <Typography sx={{ color: t.ink, fontSize: TITLE_FONT_SIZE, fontWeight: 500 }}>{TITLE}</Typography>\n      </Box>\n      <LineChart\n        skipAnimation\n        width={chartWidth}\n        height={chartHeight}\n        margin={{ top: 50, right: 40, bottom: 70, left: 105 }}\n        grid={{ horizontal: true }}\n        xAxis={[\n          {\n            data: PERCENTAGES,\n            scaleType: \"linear\",\n            min: 0,\n            max: 100,\n            label: \"Population Targeted (%)\",\n            labelStyle: { fontSize: 16, fill: t.ink },\n            tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n            valueFormatter: (v) => `${v}%`,\n          },\n        ]}\n        yAxis={[\n          {\n            min: 0,\n            max: 100,\n            label: \"Fraud Cases Captured (%)\",\n            labelStyle: { fontSize: 16, fill: t.ink },\n            tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n            valueFormatter: (v) => `${v}%`,\n          },\n        ]}\n        // Push the rotated y-axis title clear of the \"100%\"-wide tick labels —\n        // MUI X's default label offset assumes narrower numeric ticks. Set via\n        // `leftAxis` (not the yAxis config) so only the left axis is affected.\n        leftAxis={{ slotProps: { axisLabel: { x: -78 } } }}\n        series={[\n          {\n            id: \"model\",\n            data: modelGain,\n            label: \"Model\",\n            color: t.palette[0],\n            curve: \"monotoneX\",\n            area: true,\n            showMark: false,\n          },\n          {\n            id: \"perfect\",\n            data: perfectGain,\n            label: \"Perfect Model\",\n            color: t.inkSoft,\n            curve: \"linear\",\n            showMark: false,\n          },\n          {\n            id: \"random\",\n            data: randomGain,\n            label: \"Random Selection\",\n            color: t.ink,\n            curve: \"linear\",\n            showMark: false,\n          },\n        ]}\n        slotProps={{\n          legend: {\n            direction: \"row\",\n            position: { vertical: \"top\", horizontal: \"right\" },\n            labelStyle: { fontSize: 14, fill: t.inkSoft },\n          },\n        }}\n        sx={{\n          \"& .MuiChartsAxis-line, & .MuiChartsAxis-tick\": { stroke: t.inkSoft },\n          \"& .MuiChartsGrid-line\": { stroke: t.grid },\n          \"& .MuiLineElement-series-model\": { strokeWidth: 3 },\n          \"& .MuiLineElement-series-perfect\": { strokeWidth: 1.5, strokeDasharray: \"2 4\" },\n          \"& .MuiLineElement-series-random\": { strokeWidth: 1.5, strokeDasharray: \"8 6\" },\n          \"& .MuiAreaElement-series-model\": { fillOpacity: 0.12 },\n        }}\n      />\n    </Box>\n  );\n}\n"}