{"spec_id":"lift-curve","library":"muix","language":"javascript","code":"// anyplot.ai\n// lift-curve: Model Lift Chart\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 87/100 | Created: 2026-09-05\n//# anyplot-orientation: landscape\n// anyplot.ai\n// lift-curve: Model Lift 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 { ChartsReferenceLine } from \"@mui/x-charts/ChartsReferenceLine\";\nimport { useXScale, useYScale, useDrawingArea } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Deterministic LCG — the browser has no seeded RNG, and Math.random() isn't reproducible.\nlet seed = 42;\nfunction nextRandom() {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\n\n// Simulated transaction population: fraud flag + model's predicted fraud score.\n// Fraudulent cases skew toward higher scores, but with enough noise that the\n// model isn't perfect — a realistic imperfect classifier. 3% base rate is in\n// line with real-world card-fraud incidence (well under the 8% used earlier).\nconst populationSize = 2000;\nconst fraudRate = 0.03;\nconst trueLabels = [];\nconst modelScores = [];\nfor (let i = 0; i < populationSize; i += 1) {\n  const isFraud = nextRandom() < fraudRate ? 1 : 0;\n  const signal = isFraud ? 0.75 : 0.3;\n  const noise = (nextRandom() - 0.5) * 0.7;\n  modelScores.push(Math.min(1, Math.max(0, signal + noise)));\n  trueLabels.push(isFraud);\n}\n\n// Rank transactions by predicted score, descending — the order the model would target.\nconst ranked = trueLabels\n  .map((label, i) => ({ label, score: modelScores[i] }))\n  .sort((a, b) => b.score - a.score);\n\nlet running = 0;\nconst cumulativeFraud = ranked.map((r) => {\n  running += r.label;\n  return running;\n});\nconst baselineRate = running / populationSize;\n\n// Cumulative lift ratio at each 5% slice of the targeted population.\nconst percentages = Array.from({ length: 20 }, (_, i) => (i + 1) * 5);\nconst liftByPct = percentages.map((pct) => {\n  const cutoff = Math.round((pct / 100) * populationSize);\n  const targetedRate = cumulativeFraud[cutoff - 1] / cutoff;\n  return targetedRate / baselineRate;\n});\n\n// Key deciles called out with their exact lift value, per the spec's\n// suggestion to surface actual values at a few percentiles.\nconst keyPercentiles = [10, 25, 50];\nconst maxLift = liftByPct[0];\n\n// Labels the curve's own points at a few key deciles (must be a LineChart\n// child so useXScale/useYScale resolve against the chart's own axes).\nfunction KeyPercentileLabels() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  if (!xScale || !yScale) return null;\n\n  return (\n    <g>\n      {keyPercentiles.map((pct) => {\n        const idx = percentages.indexOf(pct);\n        const value = liftByPct[idx];\n        const x = xScale(pct);\n        const y = yScale(value);\n        // Points near the curve's peak sit close to the top margin — flip\n        // the label below the point there so it never runs off-canvas.\n        const below = value > maxLift * 0.85;\n        return (\n          <text\n            key={pct}\n            x={x}\n            y={below ? y + 22 : y - 14}\n            textAnchor=\"middle\"\n            fontSize={14}\n            fontWeight={600}\n            fill={t.ink}\n          >\n            {value.toFixed(1)}×\n          </text>\n        );\n      })}\n    </g>\n  );\n}\n\n// A rotated y-axis title, positioned explicitly to clear the \"×\"-suffixed\n// tick labels — drawn by hand instead of leaning on the built-in `yAxis.label`,\n// whose only offset lever (the deprecated `tickFontSize`) also resizes the\n// tick text itself, forcing a fight between the two concerns.\nfunction YAxisTitle() {\n  const { left, top, height } = useDrawingArea();\n  const x = left - 78;\n  const y = top + height / 2;\n  return (\n    <text\n      x={x}\n      y={y}\n      textAnchor=\"middle\"\n      dominantBaseline=\"middle\"\n      fontSize={16}\n      fill={t.ink}\n      transform={`rotate(-90 ${x} ${y})`}\n    >\n      Cumulative lift\n    </text>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) ------------\nexport default function Chart() {\n  return (\n    <div style={{ width: \"100%\", height: \"100%\", position: \"relative\" }}>\n      {/* Title rendered in the chart's top margin space */}\n      <div\n        style={{\n          position: \"absolute\",\n          top: 14,\n          left: 0,\n          right: 0,\n          textAlign: \"center\",\n          zIndex: 1,\n          fontSize: 22,\n          fontWeight: 500,\n          color: t.ink,\n          pointerEvents: \"none\",\n          fontFamily: \"'Roboto', 'Helvetica', 'Arial', sans-serif\",\n        }}\n      >\n        Fraud Detection Lift · lift-curve · javascript · muix · anyplot.ai\n      </div>\n\n      <LineChart\n        width={window.ANYPLOT_SIZE.width}\n        height={window.ANYPLOT_SIZE.height}\n        skipAnimation\n        series={[\n          {\n            data: liftByPct,\n            color: t.palette[0],\n            curve: \"monotoneX\",\n            showMark: true,\n          },\n        ]}\n        xAxis={[\n          {\n            data: percentages,\n            scaleType: \"point\",\n            label: \"Population targeted (%)\",\n            valueFormatter: (v) => `${v}%`,\n          },\n        ]}\n        yAxis={[\n          {\n            min: 0,\n            valueFormatter: (v) => `${v.toFixed(1)}×`,\n          },\n        ]}\n        grid={{ horizontal: true }}\n        sx={{\n          \"& .MuiChartsAxis-label\": {\n            fontSize: \"16px !important\",\n          },\n          \"& .MuiChartsAxis-tickLabel\": {\n            fontSize: \"14px !important\",\n          },\n          \"& .MuiLineElement-root\": {\n            strokeWidth: \"3.5px\",\n          },\n          \"& .MuiMarkElement-root\": {\n            strokeWidth: \"2px\",\n          },\n        }}\n        slotProps={{\n          legend: { hidden: true },\n        }}\n        margin={{ top: 70, right: 60, bottom: 70, left: 100 }}\n      >\n        <ChartsReferenceLine\n          y={1}\n          label=\"Random targeting (no lift)\"\n          labelAlign=\"start\"\n          lineStyle={{\n            stroke: t.ink,\n            strokeDasharray: \"6 4\",\n            strokeWidth: 2,\n            strokeOpacity: 0.6,\n          }}\n          labelStyle={{\n            fill: t.inkSoft,\n            fontSize: 14,\n          }}\n        />\n        <KeyPercentileLabels />\n        <YAxisTitle />\n      </LineChart>\n    </div>\n  );\n}\n"}