{"spec_id":"gain-curve","library":"d3","language":"javascript","code":"// anyplot.ai\n// gain-curve: Cumulative Gains Chart\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 94/100 | Created: 2026-09-05\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 100, right: 60, bottom: 90, left: 110 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data: simulated marketing-campaign response model ----------------------\n// Deterministic LCG — the browser has no seeded RNG.\nfunction lcg(seed) {\n  let state = seed >>> 0;\n  return function () {\n    state = (state * 1664525 + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rand = lcg(42);\n\nfunction centeredNoise() {\n  return (rand() + rand() + rand() - 1.5) / 1.5; // ~[-1, 1], symmetric around 0\n}\n\nconst nCustomers = 800;\nconst customers = [];\nfor (let i = 0; i < nCustomers; i++) {\n  const propensity = Math.pow(rand(), 2.2); // skewed: most customers unlikely to respond\n  const responded = rand() < propensity * 1.4 ? 1 : 0;\n  const score = Math.min(1, Math.max(0, propensity + centeredNoise() * 0.18));\n  customers.push({ responded, score });\n}\n\nconst rankedByScore = customers.slice().sort((a, b) => b.score - a.score);\nconst totalResponders = rankedByScore.reduce((sum, c) => sum + c.responded, 0);\n\nconst gainCurve = [{ population: 0, captured: 0 }];\nlet cumulativeResponders = 0;\nrankedByScore.forEach((c, i) => {\n  cumulativeResponders += c.responded;\n  gainCurve.push({\n    population: ((i + 1) / nCustomers) * 100,\n    captured: (cumulativeResponders / totalResponders) * 100,\n  });\n});\nconst baseline = [\n  { population: 0, captured: 0 },\n  { population: 100, captured: 100 },\n];\n\n// Peak-lift point: population % where the model's advantage over random\n// selection (captured - population) is greatest.\nconst peakLift = gainCurve.reduce((best, d) =>\n  d.captured - d.population > best.captured - best.population ? d : best\n);\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Scales ---------------------------------------------------------------\nconst x = d3.scaleLinear().domain([0, 100]).range([0, iw]);\nconst y = d3.scaleLinear().domain([0, 100]).range([ih, 0]);\n\n// --- Gridlines (y-axis only, subtle) ---------------------------------------\ng.append(\"g\")\n  .selectAll(\"line\")\n  .data(y.ticks(5))\n  .join(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", iw)\n  .attr(\"y1\", (d) => y(d))\n  .attr(\"y2\", (d) => y(d))\n  .attr(\"stroke\", t.grid);\n\n// --- Gain area (shaded lift over random selection) ---------------------------\n// The baseline is the diagonal captured == population, so it can be reused\n// directly as the area's lower edge without resampling a second series.\nconst areaGen = d3\n  .area()\n  .curve(d3.curveMonotoneX)\n  .x((d) => x(d.population))\n  .y0((d) => y(d.population))\n  .y1((d) => y(d.captured));\n\ng.append(\"path\")\n  .datum(gainCurve)\n  .attr(\"fill\", t.palette[0])\n  .attr(\"fill-opacity\", 0.12)\n  .attr(\"d\", areaGen);\n\n// --- Baseline (random-selection reference) ----------------------------------\nconst lineGen = d3\n  .line()\n  .curve(d3.curveMonotoneX)\n  .x((d) => x(d.population))\n  .y((d) => y(d.captured));\n\ng.append(\"path\")\n  .datum(baseline)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 2)\n  .attr(\"stroke-dasharray\", \"8,6\")\n  .attr(\"d\", lineGen);\n\n// --- Model gain curve --------------------------------------------------------\ng.append(\"path\")\n  .datum(gainCurve)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[0])\n  .attr(\"stroke-width\", 4)\n  .attr(\"stroke-linejoin\", \"round\")\n  .attr(\"d\", lineGen);\n\n// --- Peak-lift annotation -----------------------------------------------------\ng.append(\"circle\")\n  .attr(\"cx\", x(peakLift.population))\n  .attr(\"cy\", y(peakLift.captured))\n  .attr(\"r\", 6)\n  .attr(\"fill\", t.palette[0])\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 2);\n\nconst callout = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(${x(peakLift.population) + 16}, ${y(peakLift.captured) - 24})`);\ncallout\n  .append(\"text\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-weight\", \"600\")\n  .text(`${peakLift.population.toFixed(0)}% targeted → ${peakLift.captured.toFixed(0)}% captured`);\n\n// --- Axes -------------------------------------------------------------------\nconst xAxis = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).ticks(10).tickFormat((d) => `${d}%`));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).ticks(5).tickFormat((d) => `${d}%`));\n\nfor (const axis of [xAxis, yAxis]) {\n  axis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  axis.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\n  axis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\ng.selectAll(\".tick line\").attr(\"stroke\", t.inkSoft);\n\n// --- Axis labels --------------------------------------------------------------\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 60)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Population Targeted (%)\");\n\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -80)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Responders Captured (%)\");\n\n// --- Legend -------------------------------------------------------------------\nconst legend = g.append(\"g\").attr(\"transform\", `translate(${iw - 340}, 20)`);\nconst legendItems = [\n  { label: \"Model (ranked by score)\", color: t.palette[0], dashed: false },\n  { label: \"Random selection (baseline)\", color: t.inkSoft, dashed: true },\n];\nlegendItems.forEach((item, i) => {\n  const row = legend.append(\"g\").attr(\"transform\", `translate(0, ${i * 32})`);\n  row\n    .append(\"line\")\n    .attr(\"x1\", 0)\n    .attr(\"x2\", 36)\n    .attr(\"y1\", 0)\n    .attr(\"y2\", 0)\n    .attr(\"stroke\", item.color)\n    .attr(\"stroke-width\", 4)\n    .attr(\"stroke-dasharray\", item.dashed ? \"8,6\" : null);\n  row\n    .append(\"text\")\n    .attr(\"x\", 46)\n    .attr(\"y\", 5)\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"14px\")\n    .text(item.label);\n});\n\n// --- Title --------------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 50)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"gain-curve · javascript · d3 · anyplot.ai\");\n"}