{"spec_id":"heatmap-cohort-retention","library":"muix","language":"javascript","code":"// anyplot.ai\n// heatmap-cohort-retention: Cohort Retention Heatmap\n// Library: muix 7.29.1 | JavaScript 22.22.3\n// Quality: 90/100 | Created: 2026-06-20\n//# anyplot-orientation: square\n// anyplot.ai\n// heatmap-cohort-retention: Cohort Retention Heatmap\n// Library: MUI X Charts | React | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-06-20\n\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { ChartsXAxis } from \"@mui/x-charts/ChartsXAxis\";\nimport { useDrawingArea } from \"@mui/x-charts/hooks\";\nimport { useXScale, useYScale } from \"@mui/x-charts/hooks\";\n\nconst tok = window.ANYPLOT_TOKENS;\nconst W = window.ANYPLOT_SIZE.width;\nconst H = window.ANYPLOT_SIZE.height;\n\nconst COHORTS = [\n  { label: \"Jan 2024\", size: 4821 },\n  { label: \"Feb 2024\", size: 3956 },\n  { label: \"Mar 2024\", size: 5234 },\n  { label: \"Apr 2024\", size: 4102 },\n  { label: \"May 2024\", size: 6187 },\n  { label: \"Jun 2024\", size: 5543 },\n  { label: \"Jul 2024\", size: 4798 },\n  { label: \"Aug 2024\", size: 5912 },\n  { label: \"Sep 2024\", size: 4445 },\n  { label: \"Oct 2024\", size: 3821 },\n];\n\nconst N_PERIODS = 10;\nconst PERIOD_LABELS = Array.from({ length: N_PERIODS }, (_, i) => String(i));\nconst COHORT_LABELS = COHORTS.map(c => c.label);\n\nfunction makeLcg(seed) {\n  let s = seed >>> 0;\n  return () => {\n    s = (s * 1664525 + 1013904223) >>> 0;\n    return s / 4294967296;\n  };\n}\nconst rng = makeLcg(42);\n\nconst MATRIX = COHORTS.map((_, ci) => {\n  const nP = N_PERIODS - ci;\n  return Array.from({ length: nP }, (_, pi) => {\n    if (pi === 0) return 100;\n    const base = 100 * Math.pow(0.80, pi) + 5 / (1 + 0.4 * pi);\n    const noise = (rng() - 0.5) * 10;\n    return Math.max(2, Math.min(97, Math.round(base + noise)));\n  });\n});\n\nfunction hexToRgb(hex) {\n  const v = parseInt(hex.replace(\"#\", \"\"), 16);\n  return [(v >> 16) & 255, (v >> 8) & 255, v & 255];\n}\n\nfunction seqColor(pct) {\n  const t = Math.max(0, Math.min(1, pct / 100));\n  const [r1, g1, b1] = hexToRgb(tok.seq[0]);\n  const [r2, g2, b2] = hexToRgb(tok.seq[1]);\n  return `rgb(${Math.round(r1 + t * (r2 - r1))},${Math.round(g1 + t * (g2 - g1))},${Math.round(b1 + t * (b2 - b1))})`;\n}\n\n// Heatmap cells drawn via MUI X band-scale hooks\nfunction HeatmapCells() {\n  const xScale = useXScale(\"periods\");\n  const yScale = useYScale(\"cohorts\");\n  if (!xScale || !yScale || !xScale.bandwidth) return null;\n  const bw = xScale.bandwidth();\n  const bh = yScale.bandwidth();\n  return (\n    <g>\n      {MATRIX.map((row, ci) => {\n        const y0 = yScale(COHORTS[ci].label);\n        if (y0 === undefined) return null;\n        return row.map((val, pi) => {\n          const x0 = xScale(String(pi));\n          if (x0 === undefined) return null;\n          return (\n            <g key={`${ci}-${pi}`}>\n              <rect\n                x={x0 + 1} y={y0 + 1}\n                width={bw - 2} height={bh - 2}\n                fill={seqColor(val)} rx={3}\n              />\n              <text\n                x={x0 + bw / 2} y={y0 + bh / 2 + 5}\n                textAnchor=\"middle\" fontSize={12}\n                fill=\"#FFFDF6\" fontWeight=\"600\"\n              >\n                {val}%\n              </text>\n            </g>\n          );\n        });\n      })}\n    </g>\n  );\n}\n\n// Cohort labels + cohort sizes on left, positioned via MUI X y-scale hook\nfunction CohortLabels() {\n  const { left } = useDrawingArea();\n  const yScale = useYScale(\"cohorts\");\n  if (!yScale || !yScale.bandwidth) return null;\n  const bh = yScale.bandwidth();\n  return (\n    <g>\n      {COHORTS.map((cohort, ci) => {\n        const y0 = yScale(cohort.label);\n        if (y0 === undefined) return null;\n        const mid = y0 + bh / 2;\n        return (\n          <g key={ci}>\n            <text\n              x={left - 8} y={mid - 5}\n              textAnchor=\"end\" fontSize={13}\n              fill={tok.ink} fontWeight=\"500\"\n            >\n              {cohort.label}\n            </text>\n            <text\n              x={left - 8} y={mid + 10}\n              textAnchor=\"end\" fontSize={11}\n              fill={tok.inkSoft}\n            >\n              {`n=${cohort.size.toLocaleString()}`}\n            </text>\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\n// Sequential colorbar positioned in right margin via useDrawingArea\nfunction Colorbar() {\n  const { left, top, width, height } = useDrawingArea();\n  const barX = left + width + 18;\n  return (\n    <g>\n      <defs>\n        <linearGradient id=\"cbGrad\" 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      <text\n        x={barX + 8} y={top - 10}\n        textAnchor=\"middle\" fontSize={12}\n        fill={tok.inkSoft}\n      >\n        Retention\n      </text>\n      <rect x={barX} y={top} width={16} height={height} fill=\"url(#cbGrad)\" rx={3} />\n      {[0, 25, 50, 75, 100].map(pct => {\n        const ty = top + height * (1 - pct / 100);\n        return (\n          <g key={pct}>\n            <line\n              x1={barX + 16} y1={ty} x2={barX + 22} y2={ty}\n              stroke={tok.inkSoft} strokeWidth={1}\n            />\n            <text x={barX + 26} y={ty + 4} fontSize={11} fill={tok.inkSoft}>\n              {pct}%\n            </text>\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\nfunction ChartTitle() {\n  const TITLE = \"heatmap-cohort-retention · javascript · muix · anyplot.ai\";\n  const fs = Math.max(13, Math.round(20 * Math.min(1, 60 / TITLE.length)));\n  return (\n    <text\n      x={W / 2} y={32}\n      textAnchor=\"middle\" fontSize={fs}\n      fill={tok.ink} fontWeight=\"500\"\n    >\n      {TITLE}\n    </text>\n  );\n}\n\nexport default function Chart() {\n  return (\n    <ChartContainer\n      width={W}\n      height={H}\n      margin={{ left: 155, top: 55, right: 85, bottom: 50 }}\n      xAxis={[{\n        id: \"periods\",\n        scaleType: \"band\",\n        data: PERIOD_LABELS,\n        categoryGapRatio: 0.02,\n        label: \"Months Since Signup\",\n      }]}\n      yAxis={[{\n        id: \"cohorts\",\n        scaleType: \"band\",\n        data: COHORT_LABELS,\n        categoryGapRatio: 0.02,\n      }]}\n      series={[]}\n      skipAnimation\n    >\n      <ChartTitle />\n      <HeatmapCells />\n      <CohortLabels />\n      <Colorbar />\n      <ChartsXAxis axisId=\"periods\" />\n    </ChartContainer>\n  );\n}\n"}