{"spec_id":"heatmap-cohort-retention","library":"muix","language":"javascript","code":"// anyplot.ai\n// heatmap-cohort-retention: Cohort Retention Heatmap\n// Library: muix 9.16.0 | JavaScript 22.23.3\n// Quality: 88/100 | Updated: 2026-10-24\n//# anyplot-orientation: square\nimport { ChartsContainer } from \"@mui/x-charts/ChartsContainer\";\nimport { ChartsXAxis } from \"@mui/x-charts/ChartsXAxis\";\nimport { useDrawingArea, useXScale, useYScale } from \"@mui/x-charts/hooks\";\n\nconst tok = window.ANYPLOT_TOKENS;\n\n// Constants\nconst W = window.ANYPLOT_SIZE.width;\nconst H = window.ANYPLOT_SIZE.height;\nconst TITLE = \"heatmap-cohort-retention · javascript · muix · anyplot.ai\";\nconst SCALE_MAX = 100;\nconst COLORBAR_TICKS = [0, 25, 50, 75, 100];\nconst LIGHT_TEXT = \"#FAF8F1\";\nconst DARK_TEXT = \"#1A1A17\";\nconst PERIOD_UNIT = \"Month\";\n\n// Columns: set these to your data's column names\nconst COHORT = \"Cohort\"; // role: cohort\nconst COHORT_SIZE = \"Cohort size\"; // role: cohort_size\nconst PERIOD = \"Months since signup\"; // role: period\nconst RETENTION = \"Retention (%)\"; // role: retention_rate\n\n// Data: replace this block with your own records\nconst COHORT_SEEDS = [\n  { cohort: \"Jan 2024\", size: 4821 },\n  { cohort: \"Feb 2024\", size: 3956 },\n  { cohort: \"Mar 2024\", size: 5234 },\n  { cohort: \"Apr 2024\", size: 4102 },\n  { cohort: \"May 2024\", size: 6187 },\n  { cohort: \"Jun 2024\", size: 5543 },\n  { cohort: \"Jul 2024\", size: 4798 },\n  { cohort: \"Aug 2024\", size: 5912 },\n  { cohort: \"Sep 2024\", size: 4445 },\n  { cohort: \"Oct 2024\", size: 3821 },\n];\n\ntype RetentionRow = {\n  [COHORT]: string;\n  [COHORT_SIZE]: number;\n  [PERIOD]: number;\n  [RETENTION]: number;\n};\n\nconst data: RetentionRow[] = [];\nlet lcg = 42 >>> 0;\nconst totalPeriods = COHORT_SEEDS.length;\n\nfor (let ci = 0; ci < COHORT_SEEDS.length; ci++) {\n  const seed = COHORT_SEEDS[ci];\n  const elapsedPeriods = totalPeriods - ci;\n  for (let pi = 0; pi < elapsedPeriods; pi++) {\n    let retention = SCALE_MAX;\n    if (pi > 0) {\n      lcg = (lcg * 1664525 + 1013904223) >>> 0;\n      const noise = (lcg / 4294967296 - 0.5) * 10;\n      const base = SCALE_MAX * Math.pow(0.8, pi) + 5 / (1 + 0.4 * pi);\n      retention = Math.max(2, Math.min(97, Math.round(base + noise)));\n    }\n    data.push({\n      [COHORT]: seed.cohort,\n      [COHORT_SIZE]: seed.size,\n      [PERIOD]: pi,\n      [RETENTION]: retention,\n    });\n  }\n}\n\n// Plot: reads only data and the constants above\nconst cohorts: string[] = [];\nconst cohortSizeByLabel = new Map<string, number>();\nconst periodSet = new Set<number>();\n\nfor (const row of data) {\n  const c = row[COHORT];\n  if (!cohortSizeByLabel.has(c)) {\n    cohorts.push(c);\n    cohortSizeByLabel.set(c, row[COHORT_SIZE]);\n  }\n  periodSet.add(row[PERIOD]);\n}\n\nconst periods = Array.from(periodSet)\n  .sort((a, b) => a - b)\n  .map((p) => String(p));\n\nconst seqStartHex = tok.seq[0].replace(\"#\", \"\");\nconst seqEndHex = tok.seq[1].replace(\"#\", \"\");\nconst seqStartNum = parseInt(seqStartHex, 16);\nconst seqEndNum = parseInt(seqEndHex, 16);\nconst r1 = (seqStartNum >> 16) & 255;\nconst g1 = (seqStartNum >> 8) & 255;\nconst b1 = seqStartNum & 255;\nconst r2 = (seqEndNum >> 16) & 255;\nconst g2 = (seqEndNum >> 8) & 255;\nconst b2 = seqEndNum & 255;\n\nconst cells = data.map((row) => {\n  const val = row[RETENTION];\n  const ratio = val / SCALE_MAX;\n  const r = Math.round(r1 + ratio * (r2 - r1));\n  const g = Math.round(g1 + ratio * (g2 - g1));\n  const b = Math.round(b1 + ratio * (b2 - b1));\n  const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n  return {\n    cohort: row[COHORT],\n    periodKey: String(row[PERIOD]),\n    val,\n    fill: `rgb(${r},${g},${b})`,\n    textFill: lum > 0.54 ? DARK_TEXT : LIGHT_TEXT,\n  };\n});\n\nconst period1Rows = data.filter((r) => r[PERIOD] === 1);\nconst avgPeriod1 =\n  period1Rows.reduce((acc, r) => acc + r[RETENTION], 0) / period1Rows.length;\nconst totalUsers = cohorts.reduce((acc, c) => acc + cohortSizeByLabel.get(c)!, 0);\n\nconst subtitle = `${cohorts.length} ${COHORT}s (${cohorts[0]}–${cohorts[cohorts.length - 1]}, n=${totalUsers.toLocaleString(\"en-US\")}) · ${PERIOD_UNIT} 1 mean ${RETENTION}: ${avgPeriod1.toFixed(0)}%`;\n\nfunction CohortRetentionLayer() {\n  const { left, top, width, height } = useDrawingArea();\n  const xScale = useXScale(\"periods\") as unknown as ((v: string) => number) & { bandwidth: () => number };\n  const yScale = useYScale(\"cohorts\") as unknown as ((v: string) => number) & { bandwidth: () => number };\n\n  const bw = xScale.bandwidth();\n  const bh = yScale.bandwidth();\n  const barX = left + width + 22;\n  const barW = 18;\n\n  return (\n    <g>\n      <defs>\n        <linearGradient id=\"cohortRetentionGrad\" x1=\"0\" y1=\"1\" x2=\"0\" y2=\"0\">\n          <stop offset=\"0%\" stopColor={tok.seq[0]} />\n          <stop offset=\"100%\" stopColor={tok.seq[1]} />\n        </linearGradient>\n      </defs>\n\n      <text\n        x={W / 2}\n        y={36}\n        textAnchor=\"middle\"\n        fontSize={22}\n        fontWeight=\"600\"\n        fill={tok.ink}\n      >\n        {TITLE}\n      </text>\n      <text\n        x={W / 2}\n        y={62}\n        textAnchor=\"middle\"\n        fontSize={15}\n        fill={tok.inkSoft}\n      >\n        {subtitle}\n      </text>\n\n      {cells.map((cell) => {\n        const x0 = xScale(cell.periodKey);\n        const y0 = yScale(cell.cohort);\n        return (\n          <g key={`${cell.cohort}-${cell.periodKey}`}>\n            <rect\n              x={x0 + 1}\n              y={y0 + 1}\n              width={bw - 2}\n              height={bh - 2}\n              fill={cell.fill}\n              rx={4}\n            />\n            <text\n              x={x0 + bw / 2}\n              y={y0 + bh / 2 + 5}\n              textAnchor=\"middle\"\n              fontSize={14}\n              fontWeight=\"600\"\n              fill={cell.textFill}\n            >\n              {cell.val}%\n            </text>\n          </g>\n        );\n      })}\n\n      {cohorts.map((cohort) => {\n        const y0 = yScale(cohort);\n        const mid = y0 + bh / 2;\n        const size = cohortSizeByLabel.get(cohort)!;\n        return (\n          <g key={cohort}>\n            <text\n              x={left - 12}\n              y={mid - 6}\n              textAnchor=\"end\"\n              fontSize={14}\n              fontWeight=\"600\"\n              fill={tok.ink}\n            >\n              {cohort}\n            </text>\n            <text\n              x={left - 12}\n              y={mid + 14}\n              textAnchor=\"end\"\n              fontSize={14}\n              fill={tok.inkSoft}\n            >\n              {`n=${size.toLocaleString(\"en-US\")}`}\n            </text>\n          </g>\n        );\n      })}\n\n      <text\n        x={barX + barW / 2}\n        y={top - 12}\n        textAnchor=\"middle\"\n        fontSize={14}\n        fontWeight=\"600\"\n        fill={tok.inkSoft}\n      >\n        {RETENTION}\n      </text>\n      <rect\n        x={barX}\n        y={top}\n        width={barW}\n        height={height}\n        fill=\"url(#cohortRetentionGrad)\"\n        stroke={tok.grid}\n        strokeWidth={1}\n        rx={3}\n      />\n      {COLORBAR_TICKS.map((pct) => {\n        const ty = top + height * (1 - pct / SCALE_MAX);\n        return (\n          <g key={pct}>\n            <line\n              x1={barX + barW}\n              y1={ty}\n              x2={barX + barW + 6}\n              y2={ty}\n              stroke={tok.inkSoft}\n              strokeWidth={1}\n            />\n            <text\n              x={barX + barW + 10}\n              y={ty + 5}\n              fontSize={14}\n              fill={tok.inkSoft}\n            >\n              {pct}%\n            </text>\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\nexport default function Chart() {\n  return (\n    <ChartsContainer\n      width={W}\n      height={H}\n      margin={{ left: 135, top: 92, right: 110, bottom: 24 }}\n      xAxis={[\n        {\n          id: \"periods\",\n          scaleType: \"band\",\n          data: periods,\n          categoryGapRatio: 0.04,\n          height: 58,\n          label: PERIOD,\n          valueFormatter: (v: string) => `${PERIOD_UNIT} ${v}`,\n          disableTicks: true,\n          tickLabelStyle: { fontSize: 14, fill: tok.inkSoft },\n          labelStyle: { fontSize: 15, fill: tok.ink },\n        },\n      ]}\n      yAxis={[\n        {\n          id: \"cohorts\",\n          scaleType: \"band\",\n          data: cohorts,\n          categoryGapRatio: 0.04,\n          position: \"none\",\n        },\n      ]}\n      series={[]}\n      skipAnimation\n    >\n      <CohortRetentionLayer />\n      <ChartsXAxis axisId=\"periods\" />\n    </ChartsContainer>\n  );\n}\n"}