{"spec_id":"heatmap-cohort-retention","library":"d3","language":"javascript","code":"// anyplot.ai\n// heatmap-cohort-retention: Cohort Retention Heatmap\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 90/100 | Created: 2026-06-20\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data: SaaS product analytics — monthly signup cohorts, weekly retention ---\nconst cohortData = [\n  { label: \"Jan 2024\", size: 2450, rates: [100, 62, 45, 36, 29, 24, 21, 18, 16, 14] },\n  { label: \"Feb 2024\", size: 2810, rates: [100, 59, 43, 34, 27, 23, 19, 17, 15] },\n  { label: \"Mar 2024\", size: 3105, rates: [100, 65, 47, 38, 31, 26, 22, 19] },\n  { label: \"Apr 2024\", size: 2960, rates: [100, 61, 44, 35, 28, 23, 18] },\n  { label: \"May 2024\", size: 3290, rates: [100, 67, 49, 40, 33, 27] },\n  { label: \"Jun 2024\", size: 2730, rates: [100, 58, 42, 33, 26] },\n  { label: \"Jul 2024\", size: 3480, rates: [100, 68, 50, 41] },\n  { label: \"Aug 2024\", size: 2890, rates: [100, 63, 46] },\n  { label: \"Sep 2024\", size: 3210, rates: [100, 60] },\n  { label: \"Oct 2024\", size: 2640, rates: [100] },\n];\n\nconst maxPeriods = 10;\n\nconst cells = [];\ncohortData.forEach((cohort, ci) => {\n  cohort.rates.forEach((rate, pi) => {\n    cells.push({ ci, pi, rate });\n  });\n});\n\n// --- Layout (increased left margin to prevent y-axis label overlap with cohort labels) ---\nconst margin = { top: 90, right: 158, bottom: 76, left: 200 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\nconst cellW = iw / maxPeriods;\nconst cellH = ih / cohortData.length;\n\n// --- SVG root ---\nconst svg = d3\n  .select(\"#container\")\n  .append(\"svg\")\n  .attr(\"width\", width)\n  .attr(\"height\", height);\n\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Sequential color scale: Imprint seq (green→blue), low=green, high=blue ---\nconst colorScale = d3.scaleSequential(d3.interpolateRgbBasis(t.seq)).domain([0, 100]);\n\n// --- Shade empty upper-right triangle to emphasize the cohort format's shape ---\nconst emptyFill =\n  window.ANYPLOT_THEME === \"dark\" ? \"rgba(240,239,232,0.06)\" : \"rgba(26,26,23,0.05)\";\ncohortData.forEach((cohort, ci) => {\n  const emptyStart = cohort.rates.length;\n  if (emptyStart < maxPeriods) {\n    g.append(\"rect\")\n      .attr(\"x\", emptyStart * cellW)\n      .attr(\"y\", ci * cellH)\n      .attr(\"width\", (maxPeriods - emptyStart) * cellW)\n      .attr(\"height\", cellH)\n      .attr(\"fill\", emptyFill);\n  }\n});\n\n// --- Heatmap cells (triangular: older cohorts have more periods) ---\nconst gap = 2;\ng.selectAll(\".cell\")\n  .data(cells)\n  .join(\"rect\")\n  .attr(\"class\", \"cell\")\n  .attr(\"x\", (d) => d.pi * cellW + gap / 2)\n  .attr(\"y\", (d) => d.ci * cellH + gap / 2)\n  .attr(\"width\", cellW - gap)\n  .attr(\"height\", cellH - gap)\n  .attr(\"rx\", 3)\n  .attr(\"fill\", (d) => colorScale(d.rate));\n\n// --- Best cohort highlight: Jul 2024 (68% wk-1, highest initial retention) ---\nconst bestCi = 6;\nconst bestPeriods = cohortData[bestCi].rates.length;\ng.append(\"rect\")\n  .attr(\"x\", -2)\n  .attr(\"y\", bestCi * cellH - 2)\n  .attr(\"width\", bestPeriods * cellW + 4)\n  .attr(\"height\", cellH + 4)\n  .attr(\"rx\", 4)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[0])\n  .attr(\"stroke-width\", 2)\n  .attr(\"opacity\", 0.55);\n\n// --- Worst cohort highlight: Jun 2024 (58% wk-1, lowest initial retention) ---\nconst worstCi = 5;\nconst worstPeriods = cohortData[worstCi].rates.length;\ng.append(\"rect\")\n  .attr(\"x\", -2)\n  .attr(\"y\", worstCi * cellH - 2)\n  .attr(\"width\", worstPeriods * cellW + 4)\n  .attr(\"height\", cellH + 4)\n  .attr(\"rx\", 4)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[4])\n  .attr(\"stroke-width\", 2)\n  .attr(\"opacity\", 0.55);\n\n// --- Retention % labels inside each cell (dark text on lighter cells, light on darker) ---\ng.selectAll(\".cell-label\")\n  .data(cells)\n  .join(\"text\")\n  .attr(\"class\", \"cell-label\")\n  .attr(\"x\", (d) => d.pi * cellW + cellW / 2)\n  .attr(\"y\", (d) => d.ci * cellH + cellH / 2)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"dominant-baseline\", \"central\")\n  .attr(\"fill\", (d) => (d.rate >= 55 ? \"#FAF8F1\" : \"#1A1A17\"))\n  .style(\"font-size\", \"13px\")\n  .style(\"font-weight\", \"600\")\n  .text((d) => `${d.rate}%`);\n\n// --- X axis: week period labels ---\ng.append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .selectAll(\"text\")\n  .data(d3.range(maxPeriods))\n  .join(\"text\")\n  .attr(\"x\", (d) => d * cellW + cellW / 2)\n  .attr(\"y\", 28)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text((d) => `Week ${d}`);\n\n// --- Y axis: cohort labels with cohort sizes ---\ng.selectAll(\".cohort-label\")\n  .data(cohortData)\n  .join(\"text\")\n  .attr(\"class\", \"cohort-label\")\n  .attr(\"x\", -12)\n  .attr(\"y\", (d, i) => i * cellH + cellH / 2)\n  .attr(\"text-anchor\", \"end\")\n  .attr(\"dominant-baseline\", \"central\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text((d) => `${d.label} (n=${d.size.toLocaleString()})`);\n\n// --- Cohort insight annotations (appear in the empty triangle area beside each row) ---\ng.append(\"text\")\n  .attr(\"x\", bestPeriods * cellW + 10)\n  .attr(\"y\", bestCi * cellH + cellH / 2 - 7)\n  .attr(\"dominant-baseline\", \"central\")\n  .attr(\"fill\", t.palette[0])\n  .style(\"font-size\", \"11px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"↑ Best week-1\");\ng.append(\"text\")\n  .attr(\"x\", bestPeriods * cellW + 10)\n  .attr(\"y\", bestCi * cellH + cellH / 2 + 9)\n  .attr(\"dominant-baseline\", \"central\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"10px\")\n  .text(\"68% retention\");\n\ng.append(\"text\")\n  .attr(\"x\", worstPeriods * cellW + 10)\n  .attr(\"y\", worstCi * cellH + cellH / 2 - 7)\n  .attr(\"dominant-baseline\", \"central\")\n  .attr(\"fill\", t.palette[4])\n  .style(\"font-size\", \"11px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"↓ Weakest wk-1\");\ng.append(\"text\")\n  .attr(\"x\", worstPeriods * cellW + 10)\n  .attr(\"y\", worstCi * cellH + cellH / 2 + 9)\n  .attr(\"dominant-baseline\", \"central\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"10px\")\n  .text(\"58% retention\");\n\n// --- Axis descriptor labels ---\nsvg\n  .append(\"text\")\n  .attr(\"x\", margin.left + iw / 2)\n  .attr(\"y\", height - 16)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"Weeks since signup\");\n\n// Y-axis label moved to y=16 to avoid overlap with cohort row labels\nsvg\n  .append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -(margin.top + ih / 2))\n  .attr(\"y\", 16)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"Signup Cohort\");\n\n// --- Color bar legend (Imprint sequential: low → high retention) ---\nconst barW = 16;\nconst barH = ih * 0.65;\nconst barX = iw + 52;\nconst barY = (ih - barH) / 2;\n\nconst defs = svg.append(\"defs\");\nconst grad = defs\n  .append(\"linearGradient\")\n  .attr(\"id\", \"retention-seq\")\n  .attr(\"x1\", \"0%\")\n  .attr(\"y1\", \"100%\")\n  .attr(\"x2\", \"0%\")\n  .attr(\"y2\", \"0%\");\n\n[0, 25, 50, 75, 100].forEach((v) => {\n  grad.append(\"stop\").attr(\"offset\", `${v}%`).attr(\"stop-color\", colorScale(v));\n});\n\ng.append(\"rect\")\n  .attr(\"x\", barX)\n  .attr(\"y\", barY)\n  .attr(\"width\", barW)\n  .attr(\"height\", barH)\n  .attr(\"rx\", 3)\n  .attr(\"fill\", \"url(#retention-seq)\");\n\nconst legendScale = d3.scaleLinear().domain([0, 100]).range([barY + barH, barY]);\n\nconst legendG = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(${barX + barW},0)`)\n  .call(\n    d3\n      .axisRight(legendScale)\n      .tickValues([0, 25, 50, 75, 100])\n      .tickFormat((d) => `${d}%`)\n      .tickSize(5)\n  );\n\nlegendG.select(\".domain\").attr(\"stroke\", t.inkSoft);\nlegendG.selectAll(\".tick line\").attr(\"stroke\", t.inkSoft);\nlegendG.selectAll(\".tick text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\");\n\ng.append(\"text\")\n  .attr(\"x\", barX + barW / 2)\n  .attr(\"y\", barY - 14)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\"Retention\");\n\n// --- Chart title ---\nsvg\n  .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(\"heatmap-cohort-retention · javascript · d3 · anyplot.ai\");\n"}