{"spec_id":"heatmap-clustered","library":"muix","language":"javascript","code":"// anyplot.ai\n// heatmap-clustered: Clustered Heatmap\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 87/100 | Created: 2026-09-05\n//# anyplot-orientation: square\n// anyplot.ai\n// heatmap-clustered: Clustered 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-09-05\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { ChartsXAxis } from \"@mui/x-charts/ChartsXAxis\";\nimport { ChartsYAxis } from \"@mui/x-charts/ChartsYAxis\";\nimport { ChartsText } from \"@mui/x-charts/ChartsText\";\nimport { ContinuousColorLegend } from \"@mui/x-charts/ChartsLegend\";\nimport { useXScale, useYScale, useZColorScale, useDrawingArea } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst SIZE = window.ANYPLOT_SIZE;\n\n// --- Data: synthetic gene-expression matrix (in-memory, deterministic LCG) ---------\nfunction makeLcg(seed) {\n  let state = seed >>> 0;\n  return () => {\n    state = (state * 1664525 + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rand = makeLcg(42);\nconst noise = (range) => (rand() * 2 - 1) * range;\n\nconst GENES = [\n  \"IL6\", \"TNF\", \"IFNG\", \"IL1B\", \"CXCL10\", \"STAT1\", // inflammatory response\n  \"COL1A1\", \"COL3A1\", \"ACTA2\", \"FN1\", \"VIM\", // fibrosis markers\n  \"MKI67\", \"PCNA\", \"TOP2A\", \"CCND1\", \"CDK4\", // proliferation markers\n];\nconst geneClusterOf = (i) => (i < 6 ? 0 : i < 11 ? 1 : 2);\nconst GENE_CLUSTER_LABELS = [\"Inflammatory\", \"Fibrosis\", \"Proliferation\"];\n// Palette positions distinct from the Control/Treated strip (0, 1) and from the\n// diverging heatmap's red/blue endpoints (4, 2), so the row groups read as their own signal.\nconst GENE_CLUSTER_PALETTE_IDX = [3, 5, 6];\n\nconst SAMPLES = [\n  \"Control-01\", \"Control-02\", \"Control-03\", \"Control-04\", \"Control-05\", \"Control-06\",\n  \"Treated-01\", \"Treated-02\", \"Treated-03\", \"Treated-04\", \"Treated-05\", \"Treated-06\",\n];\nconst conditionOf = (j) => (j < 6 ? 0 : 1);\n\n// Representative log2 fold-change per (gene cluster, condition)\nconst CLUSTER_BASE = [\n  [-0.3, 2.4], // inflammatory: flat in control, up in treated\n  [0.3, -2.2], // fibrosis: flat in control, down in treated\n  [0.6, 1.4], // proliferation: mild rise under treatment\n];\n\nconst geneOffset = GENES.map(() => noise(0.25));\nconst sampleBatch = SAMPLES.map(() => noise(0.3));\nconst matrix = GENES.map((_, i) =>\n  SAMPLES.map((_, j) => {\n    const base = CLUSTER_BASE[geneClusterOf(i)][conditionOf(j)];\n    return base + geneOffset[i] + sampleBatch[j] + noise(0.35);\n  }),\n);\nconst maxAbsValue = Math.max(...matrix.flat().map(Math.abs));\nconst COLOR_DOMAIN = Math.ceil(maxAbsValue * 10) / 10;\n\n// --- Hierarchical clustering (Ward's minimum-variance linkage, Euclidean distance) -\nfunction squaredEuclidean(a, b) {\n  let sum = 0;\n  for (let k = 0; k < a.length; k += 1) sum += (a[k] - b[k]) ** 2;\n  return sum;\n}\n\nfunction buildTree(vectors) {\n  let nodes = vectors.map((v, i) => ({\n    height: 0,\n    leaves: [i],\n    children: null,\n    pos: 0,\n    centroid: v.slice(),\n    size: 1,\n  }));\n  while (nodes.length > 1) {\n    let minCost = Infinity;\n    let mi = 0;\n    let mj = 1;\n    for (let i = 0; i < nodes.length; i += 1) {\n      for (let j = i + 1; j < nodes.length; j += 1) {\n        const a = nodes[i];\n        const b = nodes[j];\n        // Ward's criterion: increase in within-cluster sum of squares from merging a, b.\n        const cost = ((a.size * b.size) / (a.size + b.size)) * squaredEuclidean(a.centroid, b.centroid);\n        if (cost < minCost) {\n          minCost = cost;\n          mi = i;\n          mj = j;\n        }\n      }\n    }\n    const a = nodes[mi];\n    const b = nodes[mj];\n    const size = a.size + b.size;\n    const centroid = a.centroid.map((v, k) => (v * a.size + b.centroid[k] * b.size) / size);\n    const merged = {\n      height: Math.sqrt(minCost),\n      leaves: [...a.leaves, ...b.leaves],\n      children: [a, b],\n      pos: 0,\n      centroid,\n      size,\n    };\n    nodes.splice(mj, 1);\n    nodes.splice(mi, 1);\n    nodes.push(merged);\n  }\n  return nodes[0];\n}\n\nfunction leafOrder(node) {\n  if (!node.children) return [node.leaves[0]];\n  return [...leafOrder(node.children[0]), ...leafOrder(node.children[1])];\n}\n\nfunction assignPos(node, posMap) {\n  if (!node.children) {\n    node.pos = posMap[node.leaves[0]];\n    return;\n  }\n  assignPos(node.children[0], posMap);\n  assignPos(node.children[1], posMap);\n  node.pos = (node.children[0].pos + node.children[1].pos) / 2;\n}\n\nfunction getSegments(node) {\n  if (!node.children) return [];\n  const [l, r] = node.children;\n  return [\n    { p1: l.pos, d1: node.height, p2: r.pos, d2: node.height },\n    { p1: l.pos, d1: l.height, p2: l.pos, d2: node.height },\n    { p1: r.pos, d1: r.height, p2: r.pos, d2: node.height },\n    ...getSegments(l),\n    ...getSegments(r),\n  ];\n}\n\nconst rowTree = buildTree(matrix);\nconst colTree = buildTree(SAMPLES.map((_, j) => GENES.map((_, i) => matrix[i][j])));\nconst rowOrder = leafOrder(rowTree);\nconst colOrder = leafOrder(colTree);\nconst rowPosMap = {};\nrowOrder.forEach((gi, pos) => { rowPosMap[gi] = pos; });\nconst colPosMap = {};\ncolOrder.forEach((sj, pos) => { colPosMap[sj] = pos; });\nassignPos(rowTree, rowPosMap);\nassignPos(colTree, colPosMap);\nconst rowSegments = getSegments(rowTree);\nconst colSegments = getSegments(colTree);\n\nconst orderedGeneLabels = rowOrder.map((i) => GENES[i]);\nconst orderedSampleLabels = colOrder.map((j) => SAMPLES[j]);\n\nconst cells = [];\nfor (let pr = 0; pr < orderedGeneLabels.length; pr += 1) {\n  for (let pc = 0; pc < orderedSampleLabels.length; pc += 1) {\n    const gi = rowOrder[pr];\n    const sj = colOrder[pc];\n    cells.push({\n      id: `${gi}-${sj}`,\n      x: orderedSampleLabels[pc],\n      y: orderedGeneLabels[pr],\n      value: matrix[gi][sj],\n    });\n  }\n}\n\n// --- Colour: diverging Imprint colormap (imprint_div) ------------------------------\nfunction hexToRgb(hex) {\n  const int = parseInt(hex.slice(1), 16);\n  return [(int >> 16) & 255, (int >> 8) & 255, int & 255];\n}\nfunction lerp(a, b, ratio) {\n  return Math.round(a + (b - a) * ratio);\n}\nfunction imprintDivInterpolator(stops) {\n  const [low, mid, high] = stops.map(hexToRgb);\n  return (position) => {\n    const [start, end, localRatio] =\n      position < 0.5 ? [low, mid, position / 0.5] : [mid, high, (position - 0.5) / 0.5];\n    const [r, g, b] = [0, 1, 2].map((c) => lerp(start[c], end[c], localRatio));\n    return `rgb(${r}, ${g}, ${b})`;\n  };\n}\n\n// --- Layout constants (square 1200x1200 CSS mount) ---------------------------------\nconst TITLE_H = 60;\nconst COL_DENDRO_H = 100;\nconst ANNOT_H = 16;\nconst ROW_DENDRO_W = 110;\nconst LABEL_RESERVE = 88;\nconst ROW_ANNOT_W = 16;\nconst ROW_ANNOT_GAP = 6;\nconst MARGIN = {\n  top: TITLE_H + 14 + COL_DENDRO_H + 6 + ANNOT_H + 6,\n  right: 190,\n  bottom: 130,\n  left: ROW_DENDRO_W + 8 + LABEL_RESERVE + ROW_ANNOT_GAP + ROW_ANNOT_W + ROW_ANNOT_GAP,\n};\n\n// --- Overlay: dendrograms, condition strip, and heatmap cells drawn in one pass ----\nfunction ClusteredOverlay() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  const colorScale = useZColorScale();\n  const drawingArea = useDrawingArea();\n\n  const colCenterX = (pos) => drawingArea.left + (drawingArea.width / orderedSampleLabels.length) * (pos + 0.5);\n  const rowCenterY = (pos) => drawingArea.top + (drawingArea.height / orderedGeneLabels.length) * (pos + 0.5);\n\n  const colDendroYBottom = drawingArea.top - ANNOT_H - 12;\n  const colDendroYTop = TITLE_H + 14;\n  const colDistY = (dist) =>\n    colDendroYBottom - (dist / colTree.height) * (colDendroYBottom - colDendroYTop);\n\n  const rowDendroXRight = drawingArea.left - ROW_ANNOT_GAP - ROW_ANNOT_W - ROW_ANNOT_GAP - LABEL_RESERVE - 8;\n  const rowDendroXLeft = 12;\n  const rowDistX = (dist) =>\n    rowDendroXRight - (dist / rowTree.height) * (rowDendroXRight - rowDendroXLeft);\n\n  const stripTop = drawingArea.top - ANNOT_H - 4;\n  const rowStripRight = drawingArea.left - ROW_ANNOT_GAP;\n  const rowStripLeft = rowStripRight - ROW_ANNOT_W;\n  const rowHeight = drawingArea.height / orderedGeneLabels.length;\n\n  return (\n    <g>\n      {/* Heatmap cells */}\n      {cells.map((cell) => (\n        <rect\n          key={cell.id}\n          x={xScale(cell.x) ?? 0}\n          y={yScale(cell.y) ?? 0}\n          width={xScale.bandwidth()}\n          height={yScale.bandwidth()}\n          fill={colorScale(cell.value)}\n        />\n      ))}\n\n      {/* Condition annotation strip (Control vs. Treated) */}\n      {colOrder.map((sj, pos) => (\n        <rect\n          key={`strip-${sj}`}\n          x={colCenterX(pos) - drawingArea.width / orderedSampleLabels.length / 2}\n          y={stripTop}\n          width={drawingArea.width / orderedSampleLabels.length}\n          height={ANNOT_H}\n          fill={t.palette[conditionOf(sj)]}\n        />\n      ))}\n\n      {/* Gene-cluster annotation strip (Inflammatory / Fibrosis / Proliferation) */}\n      {rowOrder.map((gi, pos) => (\n        <rect\n          key={`row-strip-${gi}`}\n          x={rowStripLeft}\n          y={rowCenterY(pos) - rowHeight / 2}\n          width={ROW_ANNOT_W}\n          height={rowHeight}\n          fill={t.palette[GENE_CLUSTER_PALETTE_IDX[geneClusterOf(gi)]]}\n        />\n      ))}\n\n      {/* Column dendrogram (samples) */}\n      {colSegments.map((s, i) => (\n        <line\n          key={`col-seg-${i}`}\n          x1={colCenterX(s.p1)}\n          y1={colDistY(s.d1)}\n          x2={colCenterX(s.p2)}\n          y2={colDistY(s.d2)}\n          stroke={t.ink}\n          strokeWidth={1.5}\n          strokeLinecap=\"round\"\n        />\n      ))}\n\n      {/* Row dendrogram (genes) */}\n      {rowSegments.map((s, i) => (\n        <line\n          key={`row-seg-${i}`}\n          x1={rowDistX(s.d1)}\n          y1={rowCenterY(s.p1)}\n          x2={rowDistX(s.d2)}\n          y2={rowCenterY(s.p2)}\n          stroke={t.ink}\n          strokeWidth={1.5}\n          strokeLinecap=\"round\"\n        />\n      ))}\n\n      {/* Condition legend swatches */}\n      <circle cx={SIZE.width - 168} cy={30} r={6} fill={t.palette[0]} />\n      <ChartsText\n        text=\"Control\"\n        x={SIZE.width - 154}\n        y={30}\n        style={{ fontSize: 13, fill: t.inkSoft, textAnchor: \"start\", dominantBaseline: \"central\" }}\n      />\n      <circle cx={SIZE.width - 168} cy={50} r={6} fill={t.palette[1]} />\n      <ChartsText\n        text=\"Treated\"\n        x={SIZE.width - 154}\n        y={50}\n        style={{ fontSize: 13, fill: t.inkSoft, textAnchor: \"start\", dominantBaseline: \"central\" }}\n      />\n\n      {/* Gene-cluster legend swatches */}\n      {GENE_CLUSTER_LABELS.map((label, idx) => (\n        <g key={`gc-legend-${label}`}>\n          <circle cx={SIZE.width - 168} cy={76 + idx * 20} r={6} fill={t.palette[GENE_CLUSTER_PALETTE_IDX[idx]]} />\n          <ChartsText\n            text={label}\n            x={SIZE.width - 154}\n            y={76 + idx * 20}\n            style={{ fontSize: 13, fill: t.inkSoft, textAnchor: \"start\", dominantBaseline: \"central\" }}\n          />\n        </g>\n      ))}\n\n      <ChartsText\n        text=\"Log2 fold change\"\n        x={SIZE.width - 22}\n        y={SIZE.height / 2}\n        style={{ fontSize: 12, fill: t.inkSoft, textAnchor: \"middle\", angle: -90 }}\n      />\n    </g>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) -------------------\nconst TITLE = \"heatmap-clustered · javascript · muix · anyplot.ai\";\n\nexport default function Chart() {\n  return (\n    <ChartContainer\n      width={SIZE.width}\n      height={SIZE.height}\n      series={[]}\n      margin={MARGIN}\n      skipAnimation\n      xAxis={[\n        {\n          scaleType: \"band\",\n          data: orderedSampleLabels,\n          categoryGapRatio: 0.1,\n          disableLine: true,\n          disableTicks: true,\n          tickLabelStyle: { fontSize: 13, fill: t.inkSoft, angle: -45, textAnchor: \"end\" },\n        },\n      ]}\n      yAxis={[\n        {\n          scaleType: \"band\",\n          data: orderedGeneLabels,\n          categoryGapRatio: 0.1,\n          disableLine: true,\n          disableTicks: true,\n          tickLabelStyle: { fontSize: 13, fill: t.inkSoft },\n        },\n      ]}\n      zAxis={[\n        {\n          colorMap: {\n            type: \"continuous\",\n            min: -COLOR_DOMAIN,\n            max: COLOR_DOMAIN,\n            color: imprintDivInterpolator(t.div),\n          },\n        },\n      ]}\n    >\n      <ClusteredOverlay />\n      <ChartsXAxis />\n      <ChartsYAxis />\n      <ContinuousColorLegend\n        position={{ horizontal: \"right\", vertical: \"middle\" }}\n        direction=\"column\"\n        length=\"45%\"\n        thickness={18}\n        labelStyle={{ fontSize: 12, fill: t.inkSoft }}\n      />\n      <ChartsText\n        text={TITLE}\n        x={SIZE.width / 2}\n        y={32}\n        style={{ fontSize: 22, fontWeight: 600, fill: t.ink, textAnchor: \"middle\" }}\n      />\n    </ChartContainer>\n  );\n}\n"}