{"spec_id":"heatmap-clustered","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// heatmap-clustered: Clustered Heatmap\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-05\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: gene expression matrix (deterministic, fixed-seed LCG) ----------\nconst N_ROWS = 12; // genes\nconst N_COLS = 10; // samples\nconst GENE_GROUPS = [\"A\", \"A\", \"A\", \"A\", \"B\", \"B\", \"B\", \"B\", \"C\", \"C\", \"C\", \"C\"];\nconst SAMPLE_GROUPS = [\"Control\", \"Control\", \"Control\", \"Control\", \"Control\",\n  \"Treatment\", \"Treatment\", \"Treatment\", \"Treatment\", \"Treatment\"];\nconst PATTERN = {\n  A: { Control: -1.1, Treatment: 1.1 },\n  B: { Control: 1.1, Treatment: -1.1 },\n  C: { Control: 0, Treatment: 0 },\n};\n\nlet lcgState = 42;\nfunction rand() {\n  lcgState = (lcgState * 1664525 + 1013904223) >>> 0;\n  return lcgState / 4294967296;\n}\nfunction gauss() {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\nconst rawMatrix = [];\nconst rawRowLabels = [];\nconst groupCounters = { A: 0, B: 0, C: 0 };\nfor (let i = 0; i < N_ROWS; i++) {\n  const group = GENE_GROUPS[i];\n  groupCounters[group] += 1;\n  rawRowLabels.push(`Gene ${group}${groupCounters[group]}`);\n  const row = [];\n  for (let j = 0; j < N_COLS; j++) {\n    const sample = SAMPLE_GROUPS[j];\n    const noiseScale = group === \"C\" ? 0.9 : 0.45;\n    row.push(PATTERN[group][sample] + gauss() * noiseScale);\n  }\n  rawMatrix.push(row);\n}\nconst sampleCounters = { Control: 0, Treatment: 0 };\nconst rawColLabels = SAMPLE_GROUPS.map((s) => {\n  sampleCounters[s] += 1;\n  return `${s} ${sampleCounters[s]}`;\n});\n\n// Per-row z-score standardization (subtract row mean, divide by row std) —\n// the colorbar and tooltips report \"z-score\", so the values actually shown\n// must be standardized, not the raw synthetic pattern.\nfunction standardizeRows(m) {\n  return m.map((row) => {\n    const mean = row.reduce((s, v) => s + v, 0) / row.length;\n    const variance = row.reduce((s, v) => s + (v - mean) ** 2, 0) / row.length;\n    const std = Math.sqrt(variance) || 1;\n    return row.map((v) => (v - mean) / std);\n  });\n}\nconst zMatrix = standardizeRows(rawMatrix);\n\n// --- Hierarchical clustering (average-linkage / UPGMA, Euclidean) ---------\n// UPGMA is used instead of Ward's method (the spec's suggested default): its\n// merge-height update is a simple weighted average, which keeps the from-scratch\n// clustering implementation compact and easy to verify, while still producing\n// well-separated, valid dendrograms for this matrix.\nfunction euclidean(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 hierarchicalClustering(vectors) {\n  const n = vectors.length;\n  const key = (a, b) => (a < b ? `${a},${b}` : `${b},${a}`);\n  const dist = new Map();\n  for (let i = 0; i < n; i++) {\n    for (let j = i + 1; j < n; j++) dist.set(key(i, j), euclidean(vectors[i], vectors[j]));\n  }\n  const size = {};\n  for (let i = 0; i < n; i++) size[i] = 1;\n  const nodes = {};\n  let active = Array.from({ length: n }, (_, i) => i);\n  let nextId = n;\n  while (active.length > 1) {\n    let best = null;\n    for (let i = 0; i < active.length; i++) {\n      for (let j = i + 1; j < active.length; j++) {\n        const a = active[i];\n        const b = active[j];\n        const d = dist.get(key(a, b));\n        if (best === null || d < best.d) best = { a, b, d };\n      }\n    }\n    const { a, b, d } = best;\n    const id = nextId++;\n    nodes[id] = { left: a, right: b, height: d };\n    size[id] = size[a] + size[b];\n    for (const c of active) {\n      if (c === a || c === b) continue;\n      const merged = (size[a] * dist.get(key(a, c)) + size[b] * dist.get(key(b, c))) / (size[a] + size[b]);\n      dist.set(key(id, c), merged);\n    }\n    active = active.filter((c) => c !== a && c !== b);\n    active.push(id);\n  }\n  const root = active[0];\n  const order = [];\n  (function leafOrder(id) {\n    if (id < n) { order.push(id); return; }\n    leafOrder(nodes[id].left);\n    leafOrder(nodes[id].right);\n  })(root);\n  return { n, nodes, root, order };\n}\n\n// Walks a cluster tree into dendrogram line segments (null-separated, ready\n// for a Chart.js `line` dataset). `toPoint(position, normalizedHeight)` maps\n// leaf order + merge height onto plot coordinates.\nfunction dendrogramSegments(clusterResult, toPoint) {\n  const { n, nodes, root } = clusterResult;\n  const maxHeight = nodes[root].height || 1;\n  const orderIndex = new Array(n);\n  clusterResult.order.forEach((leaf, i) => { orderIndex[leaf] = i; });\n  const points = [];\n  function walk(id) {\n    if (id < n) return { pos: orderIndex[id], h: 0 };\n    const node = nodes[id];\n    const left = walk(node.left);\n    const right = walk(node.right);\n    const h = node.height / maxHeight;\n    const gap = (after) => ({ x: after.x, y: null });\n    let p = toPoint(left.pos, left.h);\n    points.push(p, (p = toPoint(left.pos, h)), gap(p));\n    points.push((p = toPoint(right.pos, right.h)), (p = toPoint(right.pos, h)), gap(p));\n    points.push((p = toPoint(left.pos, h)), (p = toPoint(right.pos, h)), gap(p));\n    return { pos: (left.pos + right.pos) / 2, h };\n  }\n  walk(root);\n  return points;\n}\n\nconst rowClusters = hierarchicalClustering(zMatrix);\nconst colClusters = hierarchicalClustering(zMatrix[0].map((_, j) => zMatrix.map((row) => row[j])));\n\nconst rowOrder = rowClusters.order;\nconst colOrder = colClusters.order;\nconst matrix = rowOrder.map((ri) => colOrder.map((ci) => zMatrix[ri][ci]));\nconst rowLabels = rowOrder.map((ri) => rawRowLabels[ri]);\nconst colLabels = colOrder.map((ci) => rawColLabels[ci]);\nconst rowGeneGroup = rowOrder.map((ri) => GENE_GROUPS[ri]);\nconst colSampleGroup = colOrder.map((ci) => SAMPLE_GROUPS[ci]);\n\nlet maxAbs = 0;\nmatrix.forEach((row) => row.forEach((v) => { maxAbs = Math.max(maxAbs, Math.abs(v)); }));\nmaxAbs = Math.ceil(maxAbs * 10) / 10;\n\n// --- Layout (data-unit coordinates: columns 0..N_COLS-1, rows top-to-bottom) --\nconst ANNO_W = 0.4;\nconst ANNO_GAP = 0.15;\nconst DENDRO_GAP = 0.2;\nconst DENDRO_BAND = 3.5;\nconst ROW_LABEL_GAP = 0.3;\nconst ROW_LABEL_W = 2.6;\nconst CBAR_GAP = 0.6;\nconst CBAR_W = 0.6;\nconst CBAR_LABEL_W = 1.4;\nconst COL_LABEL_GAP = 0.3;\nconst COL_LABEL_H = 2.8;\nconst PAD = 0.3;\n\nconst topRowY = N_ROWS - 1; // y-value of row 0 (top row of the heatmap)\nconst rowAnnoLeft = -(ANNO_GAP + ANNO_W);\nconst rowAnnoRight = -ANNO_GAP;\nconst rowDendroLeafX = rowAnnoLeft - DENDRO_GAP;\nconst colAnnoBottom = topRowY + ANNO_GAP;\nconst colAnnoTop = colAnnoBottom + ANNO_W;\nconst colDendroLeafY = colAnnoTop + DENDRO_GAP;\n\nconst xMin = rowDendroLeafX - DENDRO_BAND - PAD;\nconst xMax = N_COLS - 1 + ROW_LABEL_GAP + ROW_LABEL_W + CBAR_GAP + CBAR_W + CBAR_LABEL_W + PAD;\nconst yMin = -(COL_LABEL_GAP + COL_LABEL_H + PAD);\nconst yMax = colDendroLeafY + DENDRO_BAND + PAD;\n\nconst colDendroPoints = dendrogramSegments(colClusters, (pos, h) => ({ x: pos, y: colDendroLeafY + h * DENDRO_BAND }));\nconst rowDendroPoints = dendrogramSegments(rowClusters, (pos, h) => ({ x: rowDendroLeafX - h * DENDRO_BAND, y: topRowY - pos }));\n\nconst cellPoints = [];\nfor (let i = 0; i < N_ROWS; i++) {\n  for (let j = 0; j < N_COLS; j++) {\n    cellPoints.push({ x: j, y: topRowY - i, v: matrix[i][j], row: rowLabels[i], col: colLabels[j] });\n  }\n}\n\nconst geneGroupColor = { A: t.palette[0], B: t.palette[1], C: t.palette[2] };\nconst sampleGroupColor = { Control: t.palette[3], Treatment: t.palette[5] };\n\n// --- Diverging color scale (Imprint imprint_div, theme-adaptive midpoint) --\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\nfunction mixColor(c1, c2, ratio) {\n  const [r1, g1, b1] = hexToRgb(c1);\n  const [r2, g2, b2] = hexToRgb(c2);\n  const mix = (a, b) => Math.round(a + (b - a) * ratio);\n  return `rgb(${mix(r1, r2)}, ${mix(g1, g2)}, ${mix(b1, b2)})`;\n}\nfunction divergingColor(value) {\n  const v = Math.max(-1, Math.min(1, value / maxAbs));\n  return v < 0 ? mixColor(t.div[0], t.div[1], v + 1) : mixColor(t.div[1], t.div[2], v);\n}\n\n// --- Custom draw: heatmap cells, annotation bars, labels, colorbar, legend --\nconst clusteredHeatmapPlugin = {\n  id: \"clusteredHeatmap\",\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea, scales } = chart;\n    const px = (x) => scales.x.getPixelForValue(x);\n    const py = (y) => scales.y.getPixelForValue(y);\n\n    ctx.save();\n\n    // Heatmap cells\n    for (let i = 0; i < N_ROWS; i++) {\n      const yTop = py(topRowY - i + 0.5);\n      const yBottom = py(topRowY - i - 0.5);\n      for (let j = 0; j < N_COLS; j++) {\n        const xLeft = px(j - 0.5);\n        const xRight = px(j + 0.5);\n        ctx.fillStyle = divergingColor(matrix[i][j]);\n        ctx.fillRect(Math.min(xLeft, xRight), Math.min(yTop, yBottom), Math.abs(xRight - xLeft), Math.abs(yBottom - yTop));\n      }\n    }\n\n    // Cell separators (page-background gridlines)\n    ctx.strokeStyle = t.pageBg;\n    ctx.lineWidth = 2;\n    for (let i = 0; i <= N_ROWS; i++) {\n      const y = py(topRowY - i + 0.5);\n      ctx.beginPath();\n      ctx.moveTo(px(-0.5), y);\n      ctx.lineTo(px(N_COLS - 0.5), y);\n      ctx.stroke();\n    }\n    for (let j = 0; j <= N_COLS; j++) {\n      const x = px(j - 0.5);\n      ctx.beginPath();\n      ctx.moveTo(x, py(topRowY + 0.5));\n      ctx.lineTo(x, py(-0.5));\n      ctx.stroke();\n    }\n\n    // Gene-group annotation strip (left of the heatmap)\n    const rowAnnoX1 = px(rowAnnoLeft);\n    const rowAnnoX2 = px(rowAnnoRight);\n    for (let i = 0; i < N_ROWS; i++) {\n      const yTop = py(topRowY - i + 0.5);\n      const yBottom = py(topRowY - i - 0.5);\n      ctx.fillStyle = geneGroupColor[rowGeneGroup[i]];\n      ctx.fillRect(Math.min(rowAnnoX1, rowAnnoX2), Math.min(yTop, yBottom), Math.abs(rowAnnoX2 - rowAnnoX1), Math.abs(yBottom - yTop));\n    }\n\n    // Sample-group annotation strip (above the heatmap)\n    const colAnnoY1 = py(colAnnoBottom);\n    const colAnnoY2 = py(colAnnoTop);\n    for (let j = 0; j < N_COLS; j++) {\n      const xLeft = px(j - 0.5);\n      const xRight = px(j + 0.5);\n      ctx.fillStyle = sampleGroupColor[colSampleGroup[j]];\n      ctx.fillRect(Math.min(xLeft, xRight), Math.min(colAnnoY1, colAnnoY2), Math.abs(xRight - xLeft), Math.abs(colAnnoY2 - colAnnoY1));\n    }\n\n    // Row (gene) labels\n    ctx.fillStyle = t.inkSoft;\n    ctx.font = \"13px sans-serif\";\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"middle\";\n    const rowLabelX = px(N_COLS - 1 + ROW_LABEL_GAP);\n    for (let i = 0; i < N_ROWS; i++) ctx.fillText(rowLabels[i], rowLabelX, py(topRowY - i));\n\n    // Column (sample) labels, rotated\n    ctx.textAlign = \"right\";\n    const colLabelY = py(-COL_LABEL_GAP);\n    for (let j = 0; j < N_COLS; j++) {\n      ctx.save();\n      ctx.translate(px(j), colLabelY);\n      ctx.rotate(-Math.PI / 4);\n      ctx.fillText(colLabels[j], 0, 0);\n      ctx.restore();\n    }\n\n    // Colorbar (z-score scale)\n    const cbarBase = N_COLS - 1 + ROW_LABEL_GAP + ROW_LABEL_W + CBAR_GAP;\n    const cbarX1 = px(cbarBase);\n    const cbarX2 = px(cbarBase + CBAR_W);\n    const cbarYTop = py(topRowY + 0.5);\n    const cbarYBottom = py(-0.5);\n    const gradient = ctx.createLinearGradient(0, cbarYTop, 0, cbarYBottom);\n    gradient.addColorStop(0, t.div[2]);\n    gradient.addColorStop(0.5, t.div[1]);\n    gradient.addColorStop(1, t.div[0]);\n    ctx.fillStyle = gradient;\n    ctx.fillRect(Math.min(cbarX1, cbarX2), cbarYTop, Math.abs(cbarX2 - cbarX1), cbarYBottom - cbarYTop);\n    ctx.strokeStyle = t.ink;\n    ctx.lineWidth = 1;\n    ctx.strokeRect(Math.min(cbarX1, cbarX2), cbarYTop, Math.abs(cbarX2 - cbarX1), cbarYBottom - cbarYTop);\n\n    ctx.fillStyle = t.inkSoft;\n    ctx.font = \"12px sans-serif\";\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"middle\";\n    const cbarLabelX = Math.max(cbarX1, cbarX2) + 8;\n    ctx.fillText(`+${maxAbs.toFixed(1)}`, cbarLabelX, cbarYTop);\n    ctx.fillText(\"0\", cbarLabelX, (cbarYTop + cbarYBottom) / 2);\n    ctx.fillText(`-${maxAbs.toFixed(1)}`, cbarLabelX, cbarYBottom);\n    ctx.textBaseline = \"bottom\";\n    ctx.fillText(\"z-score\", Math.min(cbarX1, cbarX2), cbarYTop - 6);\n\n    // Group legend (top-left corner, outside both dendrograms) — an elevated\n    // panel with aligned swatch/label columns so it reads as one polished block.\n    const swatchSize = 13;\n    const swatchGap = 9;\n    const rowStep = 20;\n    const sectionGap = 12;\n    const panelPad = 12;\n    ctx.font = \"13px sans-serif\";\n    const legendSections = [\n      { header: \"Gene cluster\", rows: [\"A\", \"B\", \"C\"].map((g) => [g, geneGroupColor[g]]) },\n      {\n        header: \"Sample group\",\n        rows: [[\"Control\", sampleGroupColor.Control], [\"Treatment\", sampleGroupColor.Treatment]],\n      },\n    ];\n    let maxTextWidth = 0;\n    legendSections.forEach((section) => {\n      maxTextWidth = Math.max(maxTextWidth, ctx.measureText(section.header).width);\n      section.rows.forEach(([label]) => {\n        maxTextWidth = Math.max(maxTextWidth, ctx.measureText(label).width);\n      });\n    });\n    const panelW = panelPad * 2 + swatchSize + swatchGap + maxTextWidth;\n    const panelH =\n      panelPad * 2 +\n      legendSections.reduce((sum, section) => sum + rowStep * (1 + section.rows.length), 0) +\n      sectionGap * (legendSections.length - 1);\n    const panelX = chartArea.left + 4;\n    const panelY = chartArea.top + 4;\n\n    ctx.fillStyle = t.elevatedBg;\n    ctx.strokeStyle = t.grid;\n    ctx.lineWidth = 1;\n    ctx.beginPath();\n    ctx.roundRect(panelX, panelY, panelW, panelH, 6);\n    ctx.fill();\n    ctx.stroke();\n\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"middle\";\n    const legendX = panelX + panelPad;\n    let legendY = panelY + panelPad + rowStep * 0.7;\n    legendSections.forEach((section, idx) => {\n      ctx.fillStyle = t.ink;\n      ctx.fillText(section.header, legendX, legendY);\n      legendY += rowStep;\n      section.rows.forEach(([label, color]) => {\n        ctx.fillStyle = color;\n        ctx.fillRect(legendX, legendY - swatchSize / 2, swatchSize, swatchSize);\n        ctx.fillStyle = t.inkSoft;\n        ctx.fillText(label, legendX + swatchSize + swatchGap, legendY);\n        legendY += rowStep;\n      });\n      if (idx < legendSections.length - 1) legendY += sectionGap;\n    });\n\n    ctx.restore();\n  },\n};\n\n// --- Mount + chart -----------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\nconst title = \"heatmap-clustered · javascript · chartjs · anyplot.ai\";\nconst titleFontSize = Math.round(22 * Math.min(1, 67 / title.length));\n\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      {\n        label: \"Expression\",\n        data: cellPoints,\n        pointRadius: 22,\n        pointHoverRadius: 22,\n        backgroundColor: \"transparent\",\n        borderWidth: 0,\n      },\n      {\n        type: \"line\",\n        label: \"Sample clustering\",\n        data: colDendroPoints,\n        borderColor: t.inkSoft,\n        borderWidth: 1.5,\n        pointRadius: 0,\n        fill: false,\n        spanGaps: false,\n        tension: 0,\n      },\n      {\n        type: \"line\",\n        label: \"Gene clustering\",\n        data: rowDendroPoints,\n        borderColor: t.inkSoft,\n        borderWidth: 1.5,\n        pointRadius: 0,\n        fill: false,\n        spanGaps: false,\n        tension: 0,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    interaction: { mode: \"nearest\", intersect: true },\n    layout: { padding: 8 },\n    plugins: {\n      title: { display: true, text: title, color: t.ink, font: { size: titleFontSize } },\n      legend: { display: false },\n      tooltip: {\n        filter: (item) => item.datasetIndex === 0,\n        callbacks: {\n          title: (items) => `${items[0].raw.row} × ${items[0].raw.col}`,\n          label: (item) => `z-score: ${item.raw.v.toFixed(2)}`,\n        },\n      },\n    },\n    scales: {\n      x: { type: \"linear\", min: xMin, max: xMax, display: false, grid: { display: false } },\n      y: { type: \"linear\", min: yMin, max: yMax, display: false, grid: { display: false } },\n    },\n  },\n  plugins: [clusteredHeatmapPlugin],\n});\n"}