{"spec_id":"gain-curve","library":"echarts","language":"javascript","code":"// anyplot.ai\n// gain-curve: Cumulative Gains Chart\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-05\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) -----------------------------------------\n// Fixed-seed LCG — the browser has no seeded RNG.\nfunction makeLcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1664525 + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rand = makeLcg(42);\n\n// Simulated churn-prediction model: 2000 customers, ~18% actually churned.\n// The model score correlates with the true outcome but overlaps realistically.\nconst sampleCount = 2000;\nconst churnRate = 0.18;\nconst customers = [];\nfor (let i = 0; i < sampleCount; i++) {\n  const churned = rand() < churnRate ? 1 : 0;\n  const score = churned\n    ? 0.35 + 0.65 * rand()\n    : 0.05 + 0.55 * rand();\n  customers.push({ churned, score });\n}\n\ncustomers.sort((a, b) => b.score - a.score);\n\nconst totalPositives = customers.reduce((sum, c) => sum + c.churned, 0);\nconst modelCurve = [[0, 0]];\nlet capturedPositives = 0;\nfor (let i = 0; i < customers.length; i++) {\n  capturedPositives += customers[i].churned;\n  const targetedPct = ((i + 1) / sampleCount) * 100;\n  const capturedPct = (capturedPositives / totalPositives) * 100;\n  modelCurve.push([targetedPct, capturedPct]);\n}\n\n// The random-selection baseline is the identity line y = x, so the gap\n// between it and the model curve at any targeted % is simply (captured - targeted).\n// Stacking an invisible \"lower\" series (= the baseline value) with a\n// transparent-line/filled \"diff\" series on top shades exactly that gap —\n// the chart's core \"gain\" insight — without needing per-point interpolation.\nconst gainBandLower = modelCurve.map(([x]) => [x, x]);\nconst gainBandDiff = modelCurve.map(([x, y]) => [x, Math.max(y - x, 0)]);\n\n// Elbow annotation: first point where the model has captured all positives.\nlet elbowIndex = modelCurve.findIndex(([, y]) => y >= 100);\nif (elbowIndex === -1) elbowIndex = modelCurve.length - 1;\nconst elbowPoint = modelCurve[elbowIndex];\n\n// --- Init ---------------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\n// --- Option ---------------------------------------------------------------------\nchart.setOption({\n  animation: false,\n  color: t.palette,\n  backgroundColor: \"transparent\",\n  title: {\n    text: \"gain-curve · javascript · echarts · anyplot.ai\",\n    left: \"center\",\n    textStyle: { color: t.ink, fontSize: 22 },\n  },\n  legend: {\n    data: [\"Model\", \"Random selection\"],\n    top: 70,\n    itemGap: 24,\n    textStyle: { color: t.ink, fontSize: 16 },\n  },\n  tooltip: {\n    trigger: \"axis\",\n    valueFormatter: (value) => `${value.toFixed(1)}%`,\n  },\n  grid: { left: 90, right: 60, top: 150, bottom: 90 },\n  xAxis: {\n    type: \"value\",\n    name: \"Percentage of customers targeted\",\n    nameLocation: \"middle\",\n    nameGap: 40,\n    nameTextStyle: { color: t.ink, fontSize: 16 },\n    min: 0,\n    max: 100,\n    axisLabel: { color: t.inkSoft, fontSize: 14, formatter: \"{value}%\" },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    splitLine: { lineStyle: { color: t.grid } },\n  },\n  yAxis: {\n    type: \"value\",\n    name: \"Cumulative churners captured\",\n    nameLocation: \"middle\",\n    nameGap: 60,\n    nameTextStyle: { color: t.ink, fontSize: 16 },\n    min: 0,\n    max: 100,\n    axisLabel: { color: t.inkSoft, fontSize: 14, formatter: \"{value}%\" },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    splitLine: { lineStyle: { color: t.grid } },\n  },\n  series: [\n    {\n      name: \"__gain-band-lower\",\n      type: \"line\",\n      data: gainBandLower,\n      symbol: \"none\",\n      silent: true,\n      stack: \"gain-band\",\n      lineStyle: { opacity: 0 },\n      tooltip: { show: false },\n      z: 0,\n    },\n    {\n      name: \"__gain-band-diff\",\n      type: \"line\",\n      data: gainBandDiff,\n      symbol: \"none\",\n      silent: true,\n      stack: \"gain-band\",\n      lineStyle: { opacity: 0 },\n      areaStyle: { color: t.palette[0], opacity: 0.15 },\n      tooltip: { show: false },\n      z: 0,\n    },\n    {\n      // Empty data: the visible diagonal comes from markLine below. Keeping\n      // the series (rather than a plain second line) gives ECharts a named\n      // legend entry while the reference line itself is drawn via markLine.\n      name: \"Random selection\",\n      type: \"line\",\n      data: [],\n      symbol: \"none\",\n      color: t.ink,\n      lineStyle: { color: t.ink, width: 2, type: \"dashed\" },\n      z: 1,\n      markLine: {\n        symbol: \"none\",\n        silent: true,\n        lineStyle: { color: t.ink, width: 2, type: \"dashed\" },\n        label: { show: false },\n        data: [[{ coord: [0, 0] }, { coord: [100, 100] }]],\n      },\n    },\n    {\n      name: \"Model\",\n      type: \"line\",\n      data: modelCurve,\n      symbol: \"none\",\n      color: t.palette[0],\n      lineStyle: { color: t.palette[0], width: 3.5 },\n      z: 2,\n      markPoint: {\n        symbol: \"circle\",\n        symbolSize: 10,\n        itemStyle: { color: t.palette[0], borderColor: t.pageBg, borderWidth: 2 },\n        label: {\n          color: t.ink,\n          fontSize: 13,\n          fontWeight: \"bold\",\n          position: \"top\",\n          formatter: () => `100% captured at ${elbowPoint[0].toFixed(0)}% targeted`,\n        },\n        data: [{ coord: elbowPoint }],\n      },\n    },\n  ],\n});\n"}