{"spec_id":"heatmap-cohort-retention","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// heatmap-cohort-retention: Cohort Retention Heatmap\n// Library: highcharts 12.6.0 | JavaScript 22.22.3\n// Quality: 89/100 | Created: 2026-06-20\n//# anyplot-orientation: square\n// anyplot.ai\n// heatmap-cohort-retention: Cohort Retention Heatmap\n// Library: Highcharts 12.6.0 | Node 22\n// License: Highcharts — commercial license, free for non-commercial use (highcharts.com/license)\n// Quality: pending | Created: 2026-06-20\n\nconst t = window.ANYPLOT_TOKENS;\n\nconst COHORT_LABELS = [\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 = ['1,842', '1,654', '2,103', '1,798', '2,247', '1,923', '1,756', '2,089', '1,945', '2,312'];\nconst N_COHORTS = COHORT_LABELS.length;  // 10\nconst N_PERIODS = N_COHORTS;             // 10 periods: Wk 0 … Wk 9\n\n// Deterministic week-1 retention rate per cohort (power-decay model)\nconst BASE_RETENTION = [0.72, 0.68, 0.75, 0.70, 0.78, 0.65, 0.71, 0.74, 0.69, 0.76];\n\nfunction getRetention(cohort, period) {\n  if (period === 0) return 100;\n  // Triangular: cohort c has data only up to period (N_COHORTS - c - 1)\n  if (period > N_COHORTS - cohort - 1) return null;\n  return Math.round(100 * Math.pow(BASE_RETENTION[cohort], period));\n}\n\nconst heatData = [];\nfor (let c = 0; c < N_COHORTS; c++) {\n  for (let p = 0; p < N_PERIODS; p++) {\n    const v = getRetention(c, p);\n    if (v !== null) heatData.push({ cohort: c, period: p, value: v });\n  }\n}\n\n// Imprint sequential: #009E73 (low retention) → #4467A3 (high retention)\nconst SEQ0 = { r: 0, g: 158, b: 115 };   // #009E73\nconst SEQ1 = { r: 68, g: 103, b: 163 };  // #4467A3\n\nfunction cellRgb(value) {\n  const f = value / 100;\n  return {\n    r: Math.round(SEQ0.r + f * (SEQ1.r - SEQ0.r)),\n    g: Math.round(SEQ0.g + f * (SEQ1.g - SEQ0.g)),\n    b: Math.round(SEQ0.b + f * (SEQ1.b - SEQ0.b))\n  };\n}\n\nfunction cellBg(value) {\n  const { r, g, b } = cellRgb(value);\n  return `rgb(${r},${g},${b})`;\n}\n\n// Relative luminance — pick text color for maximum contrast\nfunction cellTextColor(value) {\n  const { r, g, b } = cellRgb(value);\n  const lin = x => x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);\n  const L = 0.2126 * lin(r / 255) + 0.7152 * lin(g / 255) + 0.0722 * lin(b / 255);\n  return L < 0.22 ? '#FAF8F1' : t.ink;\n}\n\nconst periodLabels = Array.from({ length: N_PERIODS }, (_, i) => `Wk ${i}`);\n\nconst drawn = [];\n\nfunction clearDrawn() {\n  drawn.forEach(el => { try { el.destroy(); } catch (_) {} });\n  drawn.length = 0;\n}\n\nfunction drawAll() {\n  const ch = this;\n  clearDrawn();\n  const r = ch.renderer;\n\n  // Cell dimensions tiling the full plot area\n  const cW = ch.plotWidth / N_PERIODS;\n  const cH = ch.plotHeight / N_COHORTS;\n\n  // Heatmap cells (triangular: older cohorts have more columns)\n  heatData.forEach(d => {\n    const x = ch.plotLeft + d.period * cW;\n    const y = ch.plotTop + d.cohort * cH;\n    const bg = cellBg(d.value);\n\n    drawn.push(\n      r.rect(x + 1, y + 1, cW - 2, cH - 2, 3)\n        .attr({ fill: bg, zIndex: 3 })\n        .add()\n    );\n\n    const fs = cH < 50 ? '9px' : '11px';\n    drawn.push(\n      r.text(d.value + '%', x + cW / 2, y + cH / 2 + 4)\n        .attr({ align: 'center', zIndex: 4 })\n        .css({ color: cellTextColor(d.value), fontSize: fs, fontWeight: '700' })\n        .add()\n    );\n  });\n\n  // Y-axis labels: cohort name + size, right-aligned, vertically centered in each row\n  COHORT_LABELS.forEach((lbl, c) => {\n    const py = ch.plotTop + (c + 0.5) * cH + 4;\n    drawn.push(\n      r.text(`${lbl}  (n = ${COHORT_SIZES[c]})`, ch.plotLeft - 10, py)\n        .attr({ align: 'right', zIndex: 3 })\n        .css({ color: t.inkSoft, fontSize: '12px' })\n        .add()\n    );\n  });\n\n  // X-axis labels: \"Wk 0\" … \"Wk 9\", centered in each column; bold Wk 0 to anchor the 100% baseline\n  periodLabels.forEach((lbl, p) => {\n    const px = ch.plotLeft + (p + 0.5) * cW;\n    drawn.push(\n      r.text(lbl, px, ch.plotTop + ch.plotHeight + 18)\n        .attr({ align: 'center', zIndex: 3 })\n        .css({ color: t.inkSoft, fontSize: '13px', fontWeight: p === 0 ? '700' : '400' })\n        .add()\n    );\n  });\n\n  // Axis titles\n  drawn.push(\n    r.text('Weeks Since Signup',\n           ch.plotLeft + ch.plotWidth / 2,\n           ch.plotTop + ch.plotHeight + 44)\n      .attr({ align: 'center', zIndex: 3 })\n      .css({ color: t.inkSoft, fontSize: '15px' })\n      .add()\n  );\n\n  drawn.push(\n    r.text('Signup Cohort',\n           ch.plotLeft - 168,\n           ch.plotTop + ch.plotHeight / 2)\n      .attr({ align: 'center', rotation: -90, zIndex: 3 })\n      .css({ color: t.inkSoft, fontSize: '15px' })\n      .add()\n  );\n\n  // Sequential color legend bar (right of plot, high retention at top)\n  const bX = ch.plotLeft + ch.plotWidth + 18;\n  const bY = ch.plotTop;\n  const bH = ch.plotHeight;\n  const bW = 14;\n  const N_SEG = 30;\n  const sH = bH / N_SEG;\n\n  for (let i = 0; i < N_SEG; i++) {\n    const f = 1 - i / N_SEG;  // top = 100%, bottom = ~0%\n    drawn.push(\n      r.rect(bX, bY + i * sH, bW, sH + 0.5)\n        .attr({ fill: cellBg(f * 100), zIndex: 3 })\n        .add()\n    );\n  }\n\n  [['100%', bY + 10], ['50%', bY + bH / 2 + 4], ['0%', bY + bH + 2]].forEach(([txt, cy]) => {\n    drawn.push(\n      r.text(txt, bX + bW + 4, cy)\n        .css({ color: t.inkSoft, fontSize: '13px' })\n        .add()\n    );\n  });\n\n  drawn.push(\n    r.text('Retention', bX + bW / 2, bY - 6)\n      .attr({ align: 'center', zIndex: 3 })\n      .css({ color: t.inkSoft, fontSize: '13px' })\n      .add()\n  );\n}\n\nHighcharts.chart('container', {\n  chart: {\n    backgroundColor: 'transparent',\n    animation: false,\n    style: { fontFamily: 'inherit' },\n    margin: [90, 72, 58, 200],\n    events: { load: drawAll, redraw: drawAll }\n  },\n  credits: { enabled: false },\n  title: {\n    text: 'heatmap-cohort-retention · javascript · highcharts · anyplot.ai',\n    style: { color: t.ink, fontSize: '22px', fontWeight: '600' }\n  },\n  subtitle: {\n    text: 'May 2024 leads all cohorts with 78% week-1 retention — average across cohorts: 72%',\n    style: { color: t.inkSoft, fontSize: '13px' }\n  },\n  xAxis: { visible: false },\n  yAxis: { visible: false, gridLineWidth: 0 },\n  legend: { enabled: false },\n  tooltip: { enabled: false },\n  plotOptions: { series: { animation: false } },\n  series: []\n});\n"}