{"spec_id":"heatmap-clustered","library":"d3","language":"javascript","code":"// anyplot.ai\n// heatmap-clustered: Clustered Heatmap\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 87/100 | Created: 2026-09-05\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Deterministic PRNG (browser has no seeded RNG) -------------------------\nfunction lcg(seed) {\n  let state = seed % 2147483647;\n  if (state <= 0) state += 2147483646;\n  return function () {\n    state = (state * 16807) % 2147483647;\n    return (state - 1) / 2147483646;\n  };\n}\nconst rng = lcg(42);\nfunction randomNormal() {\n  const u1 = rng();\n  const u2 = rng();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\n// --- Data: gene-expression z-scores across dosage groups --------------------\n// Rows = genes grouped into 4 co-expression modules, columns = dosage-group\n// replicates. Values are already centered on zero, matching the diverging cmap.\nconst groups = [\"Control\", \"Low-Dose\", \"High-Dose\"];\nconst replicatesPerGroup = 4;\nconst columnLabels = [];\ngroups.forEach((g) => {\n  for (let r = 1; r <= replicatesPerGroup; r++) columnLabels.push(`${g}-${r}`);\n});\n\nconst modules = [\n  { size: 5, effect: [1.8, -1.5, 0.2] },\n  { size: 5, effect: [-1.6, 1.7, 0.3] },\n  { size: 4, effect: [0.1, -1.8, 1.6] },\n  { size: 4, effect: [-1.9, -0.2, 1.8] },\n];\n\nconst rowLabels = [];\nconst matrix = [];\nlet geneIndex = 1;\nmodules.forEach((mod) => {\n  for (let i = 0; i < mod.size; i++) {\n    rowLabels.push(`Gene-${String(geneIndex).padStart(2, \"0\")}`);\n    geneIndex++;\n    const row = [];\n    groups.forEach((_, gi) => {\n      for (let r = 0; r < replicatesPerGroup; r++) {\n        row.push(mod.effect[gi] + randomNormal() * 0.4);\n      }\n    });\n    matrix.push(row);\n  }\n});\n\nfunction transpose(m) {\n  return m[0].map((_, j) => m.map((row) => row[j]));\n}\n\n// --- Hierarchical clustering (UPGMA / average linkage, Euclidean distance) --\nfunction euclideanDistance(a, b) {\n  let sum = 0;\n  for (let i = 0; i < a.length; i++) sum += (a[i] - b[i]) ** 2;\n  return Math.sqrt(sum);\n}\n\nfunction averageLinkageCluster(vectors) {\n  let active = vectors.map((v, i) => ({ indices: [i], height: 0, children: null }));\n  while (active.length > 1) {\n    let bestI = 0;\n    let bestJ = 1;\n    let bestD = Infinity;\n    for (let i = 0; i < active.length; i++) {\n      for (let j = i + 1; j < active.length; j++) {\n        let total = 0;\n        let count = 0;\n        for (const ai of active[i].indices) {\n          for (const aj of active[j].indices) {\n            total += euclideanDistance(vectors[ai], vectors[aj]);\n            count++;\n          }\n        }\n        const d = total / count;\n        if (d < bestD) {\n          bestD = d;\n          bestI = i;\n          bestJ = j;\n        }\n      }\n    }\n    const a = active[bestI];\n    const b = active[bestJ];\n    const merged = { indices: a.indices.concat(b.indices), height: bestD, children: [a, b] };\n    active = active.filter((_, k) => k !== bestI && k !== bestJ);\n    active.push(merged);\n  }\n  return active[0];\n}\n\n// Assigns each node a leaf-order position `u` and a merge-height `v`, and\n// records the left-to-right leaf visitation order (no branch crossings).\nfunction assignPositions(node, leafOrder) {\n  if (!node.children) {\n    node.u = leafOrder.length;\n    node.v = 0;\n    leafOrder.push(node.indices[0]);\n    return;\n  }\n  assignPositions(node.children[0], leafOrder);\n  assignPositions(node.children[1], leafOrder);\n  node.u = (node.children[0].u + node.children[1].u) / 2;\n  node.v = node.height;\n}\n\n// Elbow-style dendrogram links in abstract (u = leaf position, v = height) space.\nfunction collectSegments(node, segments) {\n  if (!node.children) return;\n  const [c0, c1] = node.children;\n  segments.push({ u1: c0.u, v1: c0.v, u2: c0.u, v2: node.v });\n  segments.push({ u1: c1.u, v1: c1.v, u2: c1.u, v2: node.v });\n  segments.push({ u1: c0.u, v1: node.v, u2: c1.u, v2: node.v });\n  collectSegments(c0, segments);\n  collectSegments(c1, segments);\n}\n\nconst rowTree = averageLinkageCluster(matrix);\nconst rowOrder = [];\nassignPositions(rowTree, rowOrder);\nconst rowSegments = [];\ncollectSegments(rowTree, rowSegments);\nconst rowMaxHeight = rowTree.v;\n\nconst colTree = averageLinkageCluster(transpose(matrix));\nconst colOrder = [];\nassignPositions(colTree, colOrder);\nconst colSegments = [];\ncollectSegments(colTree, colSegments);\nconst colMaxHeight = colTree.v;\n\nconst orderedRowLabels = rowOrder.map((i) => rowLabels[i]);\nconst orderedColLabels = colOrder.map((i) => columnLabels[i]);\nconst orderedMatrix = rowOrder.map((ri) => colOrder.map((ci) => matrix[ri][ci]));\n\n// --- Layout -------------------------------------------------------------\nconst marginLeft = 20;\nconst rowDendroWidth = 130;\nconst rowLabelWidth = 120;\nconst gapLeft = 8;\nconst gapRight = 20;\nconst colorbarWidth = 34;\nconst colorbarAxisWidth = 60;\nconst marginRight = 26;\n\nconst marginTop = 74;\nconst colDendroHeight = 130;\nconst gapTop = 6;\nconst colLabelHeight = 110;\nconst marginBottom = 26;\n\nconst heatmapX = marginLeft + rowDendroWidth + rowLabelWidth + gapLeft;\nconst heatmapWidth =\n  width - heatmapX - gapRight - colorbarWidth - colorbarAxisWidth - marginRight;\nconst heatmapY = marginTop + colDendroHeight + gapTop;\nconst heatmapHeight = height - heatmapY - colLabelHeight - marginBottom;\n\nconst xCell = d3.scaleBand().domain(d3.range(colOrder.length)).range([0, heatmapWidth]);\nconst yCell = d3.scaleBand().domain(d3.range(rowOrder.length)).range([0, heatmapHeight]);\n\nconst rowLeafScale = d3\n  .scaleLinear()\n  .domain([0, rowOrder.length - 1])\n  .range([yCell.bandwidth() / 2, heatmapHeight - yCell.bandwidth() / 2]);\nconst colLeafScale = d3\n  .scaleLinear()\n  .domain([0, colOrder.length - 1])\n  .range([xCell.bandwidth() / 2, heatmapWidth - xCell.bandwidth() / 2]);\nconst rowHeightScale = d3.scaleLinear().domain([0, rowMaxHeight]).range([rowDendroWidth, 0]);\nconst colHeightScale = d3.scaleLinear().domain([0, colMaxHeight]).range([colDendroHeight, 0]);\n\nconst maxAbs = d3.max(matrix.flat().map(Math.abs));\nconst colorScale = d3.scaleSequential(d3.interpolateRgbBasis(t.div)).domain([-maxAbs, maxAbs]);\n\n// --- SVG mount ----------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 44)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"26px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"heatmap-clustered · javascript · d3 · anyplot.ai\");\n\n// --- Row dendrogram (left of the heatmap) --------------------------------\nconst rowDendro = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${marginLeft},${heatmapY})`);\nrowDendro\n  .selectAll(\"line\")\n  .data(rowSegments)\n  .join(\"line\")\n  .attr(\"x1\", (d) => rowHeightScale(d.v1))\n  .attr(\"y1\", (d) => rowLeafScale(d.u1))\n  .attr(\"x2\", (d) => rowHeightScale(d.v2))\n  .attr(\"y2\", (d) => rowLeafScale(d.u2))\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"fill\", \"none\");\n\n// --- Column dendrogram (above the heatmap) -------------------------------\nconst colDendro = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${heatmapX},${marginTop})`);\ncolDendro\n  .selectAll(\"line\")\n  .data(colSegments)\n  .join(\"line\")\n  .attr(\"x1\", (d) => colLeafScale(d.u1))\n  .attr(\"y1\", (d) => colHeightScale(d.v1))\n  .attr(\"x2\", (d) => colLeafScale(d.u2))\n  .attr(\"y2\", (d) => colHeightScale(d.v2))\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"fill\", \"none\");\n\n// --- Row labels -----------------------------------------------------------\nconst rowLabelG = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${marginLeft + rowDendroWidth},${heatmapY})`);\nrowLabelG\n  .selectAll(\"text\")\n  .data(orderedRowLabels)\n  .join(\"text\")\n  .attr(\"x\", rowLabelWidth - 10)\n  .attr(\"y\", (_, i) => yCell(i) + yCell.bandwidth() / 2)\n  .attr(\"dy\", \"0.32em\")\n  .attr(\"text-anchor\", \"end\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text((d) => d);\n\n// --- Column labels ----------------------------------------------------------\nconst colLabelG = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${heatmapX},${heatmapY + heatmapHeight + 10})`);\ncolLabelG\n  .selectAll(\"text\")\n  .data(orderedColLabels)\n  .join(\"text\")\n  .attr(\n    \"transform\",\n    (_, j) => `translate(${xCell(j) + xCell.bandwidth() / 2},0) rotate(-40)`\n  )\n  .attr(\"text-anchor\", \"end\")\n  .attr(\"dy\", \"0.32em\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text((d) => d);\n\n// --- Heatmap cells ----------------------------------------------------------\nconst heatmapG = svg.append(\"g\").attr(\"transform\", `translate(${heatmapX},${heatmapY})`);\nconst cells = [];\norderedMatrix.forEach((row, i) => {\n  row.forEach((value, j) => cells.push({ i, j, value }));\n});\nheatmapG\n  .selectAll(\"rect\")\n  .data(cells)\n  .join(\"rect\")\n  .attr(\"x\", (d) => xCell(d.j))\n  .attr(\"y\", (d) => yCell(d.i))\n  .attr(\"width\", xCell.bandwidth())\n  .attr(\"height\", yCell.bandwidth())\n  .attr(\"fill\", (d) => colorScale(d.value));\n\nheatmapG\n  .append(\"rect\")\n  .attr(\"x\", 0)\n  .attr(\"y\", 0)\n  .attr(\"width\", heatmapWidth)\n  .attr(\"height\", heatmapHeight)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1);\n\n// --- Colorbar legend --------------------------------------------------------\nconst colorbarX = heatmapX + heatmapWidth + gapRight;\nconst colorbarSteps = d3.range(0, 1.001, 0.1);\nsvg\n  .append(\"linearGradient\")\n  .attr(\"id\", \"imprint-div-gradient\")\n  .attr(\"x1\", \"0\")\n  .attr(\"x2\", \"0\")\n  .attr(\"y1\", \"1\")\n  .attr(\"y2\", \"0\")\n  .selectAll(\"stop\")\n  .data(colorbarSteps)\n  .join(\"stop\")\n  .attr(\"offset\", (d) => `${d * 100}%`)\n  .attr(\"stop-color\", (d) => colorScale(-maxAbs + d * 2 * maxAbs));\n\nsvg\n  .append(\"rect\")\n  .attr(\"x\", colorbarX)\n  .attr(\"y\", heatmapY)\n  .attr(\"width\", colorbarWidth)\n  .attr(\"height\", heatmapHeight)\n  .attr(\"fill\", \"url(#imprint-div-gradient)\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1);\n\nconst colorbarScale = d3.scaleLinear().domain([-maxAbs, maxAbs]).range([heatmapHeight, 0]);\nconst colorbarAxis = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${colorbarX + colorbarWidth},${heatmapY})`)\n  .call(d3.axisRight(colorbarScale).ticks(5).tickSize(6));\ncolorbarAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\");\ncolorbarAxis.selectAll(\"line\").attr(\"stroke\", t.grid);\ncolorbarAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\nsvg\n  .append(\"text\")\n  .attr(\n    \"transform\",\n    `translate(${colorbarX + colorbarWidth + colorbarAxisWidth - 6},${heatmapY + heatmapHeight / 2}) rotate(90)`\n  )\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(\"Expression (z-score)\");\n"}