{"spec_id":"line-retention-cohort","library":"d3","language":"javascript","code":"// anyplot.ai\n// line-retention-cohort: User Retention Curve by Cohort\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 88/100 | Created: 2026-06-20\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 80, right: 310, bottom: 80, left: 90 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// Data: monthly signup cohorts tracked weekly for 12 weeks\n// Retention model: r(t) = plateau + (100 - plateau) * exp(-k * t)\n// Newer cohorts show improving retention (higher long-term plateau, gentler decay)\nconst cohortDefs = [\n  { label: \"May 2025\", sizeStr: \"2,940\", plateau: 22, k: 0.180 },\n  { label: \"Apr 2025\", sizeStr: \"2,680\", plateau: 18, k: 0.195 },\n  { label: \"Mar 2025\", sizeStr: \"2,350\", plateau: 15, k: 0.205 },\n  { label: \"Feb 2025\", sizeStr: \"2,100\", plateau: 12, k: 0.215 },\n  { label: \"Jan 2025\", sizeStr: \"1,850\", plateau: 10, k: 0.220 },\n];\n\nconst weekNums = Array.from({ length: 13 }, (_, i) => i);\n\nconst cohorts = cohortDefs.map((c, ci) => ({\n  ...c,\n  color: t.palette[ci],\n  opacity: 1.0 - ci * 0.1,\n  strokeWidth: 3.5 - ci * 0.45,\n  values: weekNums.map(w => ({\n    week: w,\n    retention: w === 0\n      ? 100\n      : Math.round((c.plateau + (100 - c.plateau) * Math.exp(-c.k * w)) * 10) / 10,\n  })),\n}));\n\n// SVG mount\nconst svg = d3.select(\"#container\")\n  .append(\"svg\")\n  .attr(\"width\", width)\n  .attr(\"height\", height);\n\nconst g = svg.append(\"g\")\n  .attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// Scales\nconst x = d3.scaleLinear().domain([0, 12]).range([0, iw]);\nconst y = d3.scaleLinear().domain([0, 100]).range([ih, 0]);\n\n// Y-axis gridlines (40–100 only; 20% is reserved for the reference line to stand alone)\n[40, 60, 80, 100].forEach(val => {\n  g.append(\"line\")\n    .attr(\"x1\", 0).attr(\"x2\", iw)\n    .attr(\"y1\", y(val)).attr(\"y2\", y(val))\n    .attr(\"stroke\", t.grid)\n    .attr(\"stroke-width\", 1);\n});\n\n// Dashed reference line at 20% retention threshold\ng.append(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", iw)\n  .attr(\"y1\", y(20)).attr(\"y2\", y(20))\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"8,6\")\n  .attr(\"opacity\", 0.65);\n\ng.append(\"text\")\n  .attr(\"x\", 6)\n  .attr(\"y\", y(20) - 10)\n  .attr(\"text-anchor\", \"start\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\"20% target\");\n\n// Line generator with smooth monotone curve\nconst lineGen = d3.line()\n  .x(d => x(d.week))\n  .y(d => y(d.retention))\n  .curve(d3.curveMonotoneX);\n\n// Draw lines oldest-first so newest (May 2025, brand green) renders on top\n[...cohorts].reverse().forEach(c => {\n  g.append(\"path\")\n    .datum(c.values)\n    .attr(\"fill\", \"none\")\n    .attr(\"stroke\", c.color)\n    .attr(\"stroke-width\", c.strokeWidth)\n    .attr(\"stroke-linejoin\", \"round\")\n    .attr(\"stroke-linecap\", \"round\")\n    .attr(\"opacity\", c.opacity)\n    .attr(\"d\", lineGen);\n});\n\n// Circle markers at every data point via D3 join pattern (newest cohort on top)\n[...cohorts].reverse().forEach(c => {\n  g.selectAll(null)\n    .data(c.values)\n    .join(\"circle\")\n    .attr(\"cx\", d => x(d.week))\n    .attr(\"cy\", d => y(d.retention))\n    .attr(\"r\", 3)\n    .attr(\"fill\", c.color)\n    .attr(\"opacity\", c.opacity);\n});\n\n// Inline end-of-line labels at week 12 — direct D3 SVG text placement\ncohorts.forEach(c => {\n  const last = c.values[c.values.length - 1];\n  g.append(\"text\")\n    .attr(\"x\", x(12) + 9)\n    .attr(\"y\", y(last.retention))\n    .attr(\"dominant-baseline\", \"middle\")\n    .attr(\"fill\", c.color)\n    .attr(\"opacity\", Math.min(c.opacity + 0.2, 1.0))\n    .style(\"font-size\", \"11px\")\n    .style(\"font-weight\", \"700\")\n    .text(c.label.replace(\" 20\", \" '\"));\n});\n\n// Axes\nconst xAxis = g.append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x)\n    .tickValues([0, 2, 4, 6, 8, 10, 12])\n    .tickFormat(d => `Wk ${d}`));\n\nconst yAxis = g.append(\"g\")\n  .call(d3.axisLeft(y)\n    .tickValues([0, 20, 40, 60, 80, 100])\n    .tickFormat(d => `${d}%`));\n\n// Remove domain lines and tick marks for clean axis aesthetic\nfor (const ax of [xAxis, yAxis]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  ax.selectAll(\".tick line\").remove();\n  ax.select(\".domain\").remove();\n}\n\n// Axis labels\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 52)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"16px\")\n  .text(\"Weeks Since Signup\");\n\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -(ih / 2))\n  .attr(\"y\", -68)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"16px\")\n  .text(\"Retention Rate (%)\");\n\n// Legend centered vertically within the chart area\nconst legendX = iw + 80;\nconst legendStartY = Math.round((ih - cohorts.length * 46) / 2);\n\ncohorts.forEach((c, i) => {\n  const ly = legendStartY + i * 46;\n  g.append(\"line\")\n    .attr(\"x1\", legendX).attr(\"x2\", legendX + 28)\n    .attr(\"y1\", ly + 10).attr(\"y2\", ly + 10)\n    .attr(\"stroke\", c.color)\n    .attr(\"stroke-width\", c.strokeWidth)\n    .attr(\"opacity\", c.opacity);\n  g.append(\"text\")\n    .attr(\"x\", legendX + 36)\n    .attr(\"y\", ly + 15)\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"13px\")\n    .text(`${c.label} (n=${c.sizeStr})`);\n});\n\n// Title\nsvg.append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 48)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"line-retention-cohort · javascript · d3 · anyplot.ai\");\n"}