{"spec_id":"heatmap-cohort-retention","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// heatmap-cohort-retention: Cohort Retention Heatmap\n// Library: chartjs 4.4.7 | JavaScript 22.22.3\n// Quality: 91/100 | Created: 2026-06-20\n\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data ---\nconst COHORTS = [\n  \"Jan 2024\", \"Feb 2024\", \"Mar 2024\", \"Apr 2024\", \"May 2024\",\n  \"Jun 2024\", \"Jul 2024\", \"Aug 2024\", \"Sep 2024\", \"Oct 2024\"\n];\nconst COHORT_SIZES = [4821, 5234, 4987, 5612, 6103, 5847, 5290, 5671, 6234, 5988];\nconst NUM_COHORTS = COHORTS.length;\nconst NUM_PERIODS = 10;\n\n// Retention decay — later cohorts show measurable product improvement (+2% per cohort)\nconst BASE_DECAY = [100, 62, 45, 38, 32, 28, 24, 21, 19, 17];\n\nconst cells = [];\nfor (let c = 0; c < NUM_COHORTS; c++) {\n  // Triangular shape: Jan cohort (c=0) has all 10 periods, Oct (c=9) only has period 0\n  const maxPeriod = NUM_COHORTS - 1 - c;\n  for (let p = 0; p <= maxPeriod; p++) {\n    const value = p === 0 ? 100 : Math.round(Math.min(99, BASE_DECAY[p] + c * 2.0));\n    cells.push({ cohort: c, period: p, value });\n  }\n}\n\n// --- Color helpers (imprint_seq: #009E73 → #4467A3) ---\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.replace('#', ''), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nfunction interpColor(c1, c2, frac) {\n  const a = hexToRgb(c1), b = hexToRgb(c2);\n  return `rgb(${Math.round(a[0] + (b[0] - a[0]) * frac)},` +\n    `${Math.round(a[1] + (b[1] - a[1]) * frac)},` +\n    `${Math.round(a[2] + (b[2] - a[2]) * frac)})`;\n}\n\n// 100% → #009E73 (brand green), 0% → #4467A3 (blue)\nfunction cellColor(value) {\n  return interpColor(t.seq[0], t.seq[1], (100 - value) / 100);\n}\n\nfunction fmtNum(n) {\n  return n.toString().replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',');\n}\n\n// --- Mount ---\nconst canvas = document.createElement('canvas');\ndocument.getElementById('container').appendChild(canvas);\n\n// --- Plugin: draws cells + all labels manually ---\nconst heatmapPlugin = {\n  id: 'cohortHeatmap',\n  afterDraw(chart) {\n    const ctx = chart.ctx;\n    const xs = chart.scales.x;\n    const ys = chart.scales.y;\n    const { top, bottom, left, right } = chart.chartArea;\n\n    const cellW = xs.getPixelForValue(1) - xs.getPixelForValue(0);\n    const cellH = ys.getPixelForValue(1) - ys.getPixelForValue(0); // positive with reverse:true\n\n    // --- Cells ---\n    cells.forEach(({ cohort, period, value }) => {\n      const cx = xs.getPixelForValue(period);\n      const cy = ys.getPixelForValue(cohort);\n      const x = cx - cellW / 2;\n      const y = cy - cellH / 2;\n\n      ctx.fillStyle = cellColor(value);\n      ctx.fillRect(x, y, cellW, cellH);\n\n      ctx.strokeStyle = t.pageBg;\n      ctx.lineWidth = 2;\n      ctx.strokeRect(x, y, cellW, cellH);\n\n      const fs = Math.round(Math.min(cellW * 0.22, cellH * 0.28));\n      ctx.font = `bold ${fs}px sans-serif`;\n      ctx.textAlign = 'center';\n      ctx.textBaseline = 'middle';\n      ctx.fillStyle = value < 38 ? '#F0EFE8' : '#1A1A17';\n      ctx.fillText(`${value}%`, cx, cy);\n    });\n\n    // --- Y-axis labels (cohort name + size) ---\n    ctx.textAlign = 'right';\n    ctx.textBaseline = 'middle';\n    ctx.font = '14px sans-serif';\n    ctx.fillStyle = t.inkSoft;\n    for (let c = 0; c < NUM_COHORTS; c++) {\n      const cy = ys.getPixelForValue(c);\n      ctx.fillText(`${COHORTS[c]} (n=${fmtNum(COHORT_SIZES[c])})`, left - 10, cy);\n    }\n\n    // --- Y-axis title (rotated, far left) ---\n    ctx.save();\n    ctx.translate(14, (top + bottom) / 2);\n    ctx.rotate(-Math.PI / 2);\n    ctx.font = '16px sans-serif';\n    ctx.fillStyle = t.ink;\n    ctx.textAlign = 'center';\n    ctx.textBaseline = 'middle';\n    ctx.fillText('Signup Cohort', 0, 0);\n    ctx.restore();\n\n    // --- X-axis labels (week numbers) ---\n    ctx.textAlign = 'center';\n    ctx.textBaseline = 'top';\n    ctx.font = '13px sans-serif';\n    ctx.fillStyle = t.inkSoft;\n    for (let p = 0; p < NUM_PERIODS; p++) {\n      ctx.fillText(`Wk ${p}`, xs.getPixelForValue(p), bottom + 8);\n    }\n\n    // --- X-axis title ---\n    ctx.font = '16px sans-serif';\n    ctx.fillStyle = t.ink;\n    ctx.textAlign = 'center';\n    ctx.textBaseline = 'top';\n    ctx.fillText('Weeks Since Signup', (left + right) / 2, bottom + 44);\n\n    // --- Colorbar ---\n    const barX = right + 22;\n    const barW = 16;\n    const barH = bottom - top;\n\n    const grad = ctx.createLinearGradient(0, top, 0, bottom);\n    grad.addColorStop(0, t.seq[0]);  // top = 100% green\n    grad.addColorStop(1, t.seq[1]);  // bottom = 0% blue\n    ctx.fillStyle = grad;\n    ctx.fillRect(barX, top, barW, barH);\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 1;\n    ctx.strokeRect(barX, top, barW, barH);\n\n    const lx = barX + barW + 6;\n    ctx.font = '14px sans-serif';\n    ctx.fillStyle = t.inkSoft;\n    ctx.textAlign = 'left';\n    ctx.textBaseline = 'middle';\n    ctx.fillText('100%', lx, top);\n    ctx.fillText('50%', lx, top + barH / 2);\n    ctx.fillText('0%', lx, bottom);\n  }\n};\n\n// --- Chart ---\nnew Chart(canvas, {\n  type: 'scatter',\n  data: { datasets: [{ data: [], pointRadius: 0 }] },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: {\n      // Reserve space for manual labels: 210px left (cohort labels), 80px bottom (week labels + title)\n      padding: { top: 20, right: 90, bottom: 80, left: 210 }\n    },\n    plugins: {\n      title: {\n        display: true,\n        text: 'heatmap-cohort-retention · javascript · chartjs · anyplot.ai',\n        color: t.ink,\n        font: { size: 22, weight: 'bold' },\n        padding: { bottom: 4 }\n      },\n      subtitle: {\n        display: true,\n        text: 'Monthly SaaS cohorts Jan–Oct 2024 · later cohorts retain measurably more users',\n        color: t.inkSoft,\n        font: { size: 14 },\n        padding: { bottom: 16 }\n      },\n      legend: { display: false },\n      tooltip: { enabled: false }\n    },\n    scales: {\n      x: {\n        type: 'linear',\n        min: -0.5,\n        max: NUM_PERIODS - 0.5,\n        border: { display: false },\n        ticks: { display: false },\n        grid: { display: false }\n      },\n      y: {\n        type: 'linear',\n        min: -0.5,\n        max: NUM_COHORTS - 0.5,\n        reverse: true,\n        border: { display: false },\n        ticks: { display: false },\n        grid: { display: false }\n      }\n    }\n  },\n  plugins: [heatmapPlugin]\n});\n"}