{"spec_id":"heatmap-clustered","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// heatmap-clustered: Clustered Heatmap\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 95/100 | Created: 2026-09-05\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: simulated qPCR expression (z-score) across stress-response genes -\n// Rows = biological samples under 3 conditions, columns = 12 genes from 3\n// functional modules. Both axes are given in SCRAMBLED order on purpose —\n// the whole point of a clustermap is that Ward's-linkage clustering below\n// recovers the hidden condition/module structure from the values alone.\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\nfunction randNormal(mean, sd) {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + sd * z;\n}\n\nconst CONDITION_NAMES = ['Control', 'Heat Shock', 'Oxidative Stress'];\nconst CONDITION_ABBR = ['Ctrl', 'Heat', 'Oxid'];\n// Scrambled condition assignment, 16 samples, balanced 6/5/5.\nconst ROW_CONDITION = [2, 0, 1, 0, 2, 1, 0, 1, 2, 0, 2, 1, 0, 1, 2, 0];\nconst N_ROWS = ROW_CONDITION.length;\n\nconst MODULE_NAMES = ['Heat-Shock Response', 'Immediate-Early', 'Inflammatory / Redox'];\n// Gene symbols and their true functional module, in scrambled column order.\nconst COL_GENE = ['IL6', 'HSP90AA1', 'FOS', 'NFKB1', 'DNAJB1', 'ATF3', 'TNF', 'HSPB1', 'EGR1', 'SOD1', 'HSPA1A', 'JUN'];\nconst COL_MODULE = [2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1];\nconst N_COLS = COL_GENE.length;\n\n// Typical z-scored response per (condition, module) pair.\nconst MODULE_EFFECT = [\n  [-1.0, -0.5, -0.3],\n  [2.2, 0.8, -0.4],\n  [0.6, -0.2, 2.0],\n];\n\nconst replicateCount = [0, 0, 0];\nconst ROW_LABEL = ROW_CONDITION.map((cond) => {\n  replicateCount[cond] += 1;\n  return `${CONDITION_ABBR[cond]}-${replicateCount[cond]}`;\n});\n\nconst matrix = ROW_CONDITION.map((cond) => COL_MODULE.map((mod) => MODULE_EFFECT[cond][mod] + randNormal(0, 0.45)));\n\nlet minVal = Infinity;\nlet maxVal = -Infinity;\nmatrix.forEach((row) =>\n  row.forEach((v) => {\n    if (v < minVal) minVal = v;\n    if (v > maxVal) maxVal = v;\n  })\n);\nconst DOMAIN = Math.max(Math.abs(minVal), Math.abs(maxVal));\n\n// --- Hierarchical clustering (Ward's method, Euclidean distance) -----------\n// Agglomerative clustering via the centroid form of Ward's criterion:\n// merging clusters a, b costs (|a||b| / (|a|+|b|)) * ||centroid_a - centroid_b||^2.\n// Returns the leaf order plus the dendrogram link list (heights normalized\n// to [0, 1] so the same drawing code works for rows and columns).\nfunction buildDendrogram(vectors) {\n  let clusters = vectors.map((v, i) => ({\n    members: [i],\n    minMember: i,\n    size: 1,\n    centroid: v.slice(),\n    height: 0,\n    left: null,\n    right: null,\n  }));\n\n  while (clusters.length > 1) {\n    let bi = -1;\n    let bj = -1;\n    let bestD = Infinity;\n    for (let i = 0; i < clusters.length; i++) {\n      for (let j = i + 1; j < clusters.length; j++) {\n        const a = clusters[i];\n        const b = clusters[j];\n        let sq = 0;\n        for (let k = 0; k < a.centroid.length; k++) {\n          const d = a.centroid[k] - b.centroid[k];\n          sq += d * d;\n        }\n        const wardD = ((a.size * b.size) / (a.size + b.size)) * sq;\n        if (wardD < bestD) {\n          bestD = wardD;\n          bi = i;\n          bj = j;\n        }\n      }\n    }\n    const a = clusters[bi];\n    const b = clusters[bj];\n    const left = a.minMember <= b.minMember ? a : b;\n    const right = left === a ? b : a;\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    // Clamp to the children's heights so the dendrogram never draws a merge\n    // \"lower\" than either child (Ward's centroid form isn't always monotonic).\n    const height = Math.max(bestD, a.height, b.height);\n    clusters = clusters.filter((_, idx) => idx !== bi && idx !== bj);\n    clusters.push({ members: [...left.members, ...right.members], minMember: left.minMember, size, centroid, height, left, right });\n  }\n\n  const root = clusters[0];\n  const posOf = new Map();\n  root.members.forEach((leafIdx, pos) => posOf.set(leafIdx, pos));\n  const maxHeight = root.height || 1;\n  const links = [];\n\n  function walk(node) {\n    if (!node.left) return { pos: posOf.get(node.members[0]), h: 0 };\n    const l = walk(node.left);\n    const r = walk(node.right);\n    const hMerge = node.height / maxHeight;\n    links.push({ pos1: l.pos, h1: l.h, pos2: r.pos, h2: r.h, hMerge });\n    return { pos: (l.pos + r.pos) / 2, h: hMerge };\n  }\n  walk(root);\n\n  return { order: root.members, links };\n}\n\nconst rowDendro = buildDendrogram(matrix);\nconst colVectors = Array.from({ length: N_COLS }, (_, c) => matrix.map((row) => row[c]));\nconst colDendro = buildDendrogram(colVectors);\nconst ROW_ORDER = rowDendro.order;\nconst COL_ORDER = colDendro.order;\n\n// Reorder everything into clustered order.\nconst M = ROW_ORDER.map((r) => COL_ORDER.map((c) => matrix[r][c]));\nconst rowLabels = ROW_ORDER.map((r) => ROW_LABEL[r]);\nconst rowCondition = ROW_ORDER.map((r) => ROW_CONDITION[r]);\nconst colLabels = COL_ORDER.map((c) => COL_GENE[c]);\nconst colModule = COL_ORDER.map((c) => COL_MODULE[c]);\n\nfunction runsOf(arr) {\n  const runs = [];\n  let start = 0;\n  for (let i = 1; i <= arr.length; i++) {\n    if (i === arr.length || arr[i] !== arr[start]) {\n      runs.push({ value: arr[start], start, end: i - 1 });\n      start = i;\n    }\n  }\n  return runs;\n}\nconst rowRuns = runsOf(rowCondition);\nconst colRuns = runsOf(colModule);\n\n// --- Color: imprint_div — expression data is z-scored, centered on zero ----\nfunction hexToRgb(hex) {\n  return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];\n}\nfunction lerp(a, b, f) {\n  return a + (b - a) * f;\n}\nfunction lerpRgb(a, b, f) {\n  return [Math.round(lerp(a[0], b[0], f)), Math.round(lerp(a[1], b[1], f)), Math.round(lerp(a[2], b[2], f))];\n}\nfunction rgbToCss([r, g, b]) {\n  return `rgb(${r},${g},${b})`;\n}\nconst DIV_LO = hexToRgb(t.div[0]);\nconst DIV_MID = hexToRgb(t.div[1]);\nconst DIV_HI = hexToRgb(t.div[2]);\nfunction valueFill(v) {\n  const frac = Math.min(1, Math.max(0, (v + DOMAIN) / (2 * DOMAIN)));\n  const rgb = frac <= 0.5 ? lerpRgb(DIV_LO, DIV_MID, frac / 0.5) : lerpRgb(DIV_MID, DIV_HI, (frac - 0.5) / 0.5);\n  return rgbToCss(rgb);\n}\n\n// Group-bar colors — chosen away from the diverging colormap's red/blue\n// endpoints so the annotation strips never get mistaken for heatmap data.\nconst ROW_GROUP_COLOR = [t.palette[0], t.palette[1], t.palette[3]]; // green, purple, ochre — condition\nconst COL_GROUP_COLOR = [t.palette[5], t.palette[6], t.palette[7]]; // cyan, rose, lime — gene module\nfunction textColorFor(hex) {\n  const [r, g, b] = hexToRgb(hex);\n  const luminance = 0.299 * r + 0.587 * g + 0.114 * b;\n  return luminance > 150 ? '#1A1A17' : '#F0EFE8';\n}\n\n// --- Title (fontsize scaled off the 67-char baseline) -----------------------\nconst TITLE_TEXT = 'Gene Expression Clustering · heatmap-clustered · javascript · highcharts · anyplot.ai';\nconst TITLE_FS = Math.max(Math.round(22 * Math.min(1, 67 / TITLE_TEXT.length)), 14);\n\n// --- Fixed chart geometry (landscape canvas, harness-guaranteed 1600x900) ---\n// Top margin: title/subtitle baseline (130, proven layout) + column dendrogram\n// band (90) + column group-bar (16) + gaps. Left margin mirrors this for the\n// row dendrogram + row group-bar + row labels.\nconst CHART_MARGIN = [255, 200, 100, 220]; // [top, right, bottom, left]\nconst cellW = (window.ANYPLOT_SIZE.width - CHART_MARGIN[1] - CHART_MARGIN[3]) / N_COLS;\nconst cellH = (window.ANYPLOT_SIZE.height - CHART_MARGIN[0] - CHART_MARGIN[2]) / N_ROWS;\nconst MARKER_RADIUS = Math.max(Math.min(cellW, cellH) / 2 - 2, 3);\n\nconst ROW_DENDRO_ROOT_X = 8;\nconst ROW_DENDRO_LEAF_X = 98; // touches the row group-bar\nconst ROW_GROUPBAR_X0 = ROW_DENDRO_LEAF_X + 4;\nconst ROW_GROUPBAR_W = 20;\nconst ROW_LABEL_X = CHART_MARGIN[3] - 10;\n\nconst COL_DENDRO_ROOT_Y = 136;\nconst COL_DENDRO_LEAF_Y = 226; // touches the column group-bar\nconst COL_GROUPBAR_Y0 = COL_DENDRO_LEAF_Y + 4;\nconst COL_GROUPBAR_H = 16;\n\nfunction rowHeightX(h) {\n  return ROW_DENDRO_LEAF_X - h * (ROW_DENDRO_LEAF_X - ROW_DENDRO_ROOT_X);\n}\nfunction colHeightY(h) {\n  return COL_DENDRO_LEAF_Y - h * (COL_DENDRO_LEAF_Y - COL_DENDRO_ROOT_Y);\n}\n\nconst drawn = [];\nfunction clearDrawn() {\n  drawn.forEach((el) => {\n    try {\n      el.destroy();\n    } catch (_err) {\n      // already removed\n    }\n  });\n  drawn.length = 0;\n}\n\nfunction drawAll() {\n  const chart = this;\n  clearDrawn();\n  const r = chart.renderer;\n  const cw = chart.plotWidth / N_COLS;\n  const ch = chart.plotHeight / N_ROWS;\n\n  // Heatmap cells.\n  for (let row = 0; row < N_ROWS; row++) {\n    for (let col = 0; col < N_COLS; col++) {\n      const x = chart.plotLeft + col * cw;\n      const y = chart.plotTop + row * ch;\n      drawn.push(\n        r\n          .rect(x + 0.5, y + 0.5, cw - 1, ch - 1, 1)\n          .attr({ fill: valueFill(M[row][col]), stroke: 'none', zIndex: 2 })\n          .add()\n      );\n    }\n  }\n\n  // Row labels.\n  rowLabels.forEach((lbl, row) => {\n    const cy = chart.plotTop + (row + 0.5) * ch + 5;\n    drawn.push(\n      r\n        .text(lbl, ROW_LABEL_X, cy)\n        .attr({ align: 'right', zIndex: 2 })\n        .css({ color: t.inkSoft, fontSize: '13px' })\n        .add()\n    );\n  });\n\n  // Column labels.\n  colLabels.forEach((lbl, col) => {\n    const cx = chart.plotLeft + (col + 0.5) * cw;\n    drawn.push(\n      r\n        .text(lbl, cx, chart.plotTop + chart.plotHeight + 22)\n        .attr({ align: 'center', rotation: -35, zIndex: 2 })\n        .css({ color: t.inkSoft, fontSize: '13px' })\n        .add()\n    );\n  });\n\n  // Row group-bar (sample condition) + inline run labels, rotated to fit the\n  // narrow strip.\n  rowRuns.forEach((run) => {\n    const y0 = chart.plotTop + run.start * ch;\n    const h = (run.end - run.start + 1) * ch;\n    const color = ROW_GROUP_COLOR[run.value];\n    drawn.push(r.rect(ROW_GROUPBAR_X0, y0 + 1, ROW_GROUPBAR_W, h - 2, 1).attr({ fill: color, zIndex: 2 }).add());\n    drawn.push(\n      r\n        .text(CONDITION_ABBR[run.value], ROW_GROUPBAR_X0 + ROW_GROUPBAR_W / 2, y0 + h / 2 + 3)\n        .attr({ align: 'center', rotation: -90, zIndex: 3 })\n        .css({ color: textColorFor(color), fontSize: '10px', fontWeight: '600' })\n        .add()\n    );\n  });\n\n  // Column group-bar (gene module) + inline run labels.\n  colRuns.forEach((run) => {\n    const x0 = chart.plotLeft + run.start * cw;\n    const w = (run.end - run.start + 1) * cw;\n    const color = COL_GROUP_COLOR[run.value];\n    drawn.push(r.rect(x0 + 1, COL_GROUPBAR_Y0, w - 2, COL_GROUPBAR_H, 1).attr({ fill: color, zIndex: 2 }).add());\n    if (w > 40) {\n      drawn.push(\n        r\n          .text(`M${run.value + 1}`, x0 + w / 2, COL_GROUPBAR_Y0 + COL_GROUPBAR_H - 4)\n          .attr({ align: 'center', zIndex: 3 })\n          .css({ color: textColorFor(color), fontSize: '11px', fontWeight: '600' })\n          .add()\n      );\n    }\n  });\n\n  // Row dendrogram (height runs horizontally, leaves stacked vertically).\n  rowDendro.links.forEach((link) => {\n    const y1 = chart.plotTop + (link.pos1 + 0.5) * ch;\n    const y2 = chart.plotTop + (link.pos2 + 0.5) * ch;\n    const xMerge = rowHeightX(link.hMerge);\n    const x1 = rowHeightX(link.h1);\n    const x2 = rowHeightX(link.h2);\n    drawn.push(\n      r\n        .path([\n          ['M', x1, y1],\n          ['L', xMerge, y1],\n          ['L', xMerge, y2],\n          ['L', x2, y2],\n        ])\n        .attr({ stroke: t.inkSoft, 'stroke-width': 1.4, fill: 'none', zIndex: 2 })\n        .add()\n    );\n  });\n\n  // Column dendrogram (height runs vertically, leaves spread horizontally).\n  colDendro.links.forEach((link) => {\n    const x1 = chart.plotLeft + (link.pos1 + 0.5) * cw;\n    const x2 = chart.plotLeft + (link.pos2 + 0.5) * cw;\n    const yMerge = colHeightY(link.hMerge);\n    const y1 = colHeightY(link.h1);\n    const y2 = colHeightY(link.h2);\n    drawn.push(\n      r\n        .path([\n          ['M', x1, y1],\n          ['L', x1, yMerge],\n          ['L', x2, yMerge],\n          ['L', x2, y2],\n        ])\n        .attr({ stroke: t.inkSoft, 'stroke-width': 1.4, fill: 'none', zIndex: 2 })\n        .add()\n    );\n  });\n\n  // Diverging colorbar in the freed right margin.\n  const barLeft = chart.plotLeft + chart.plotWidth + 55;\n  const barTop = chart.plotTop + 10;\n  const barWidth = 26;\n  const barHeight = chart.plotHeight - 20;\n  const segments = 60;\n  const segH = barHeight / segments;\n  for (let i = 0; i < segments; i++) {\n    const value = DOMAIN - (2 * DOMAIN * i) / (segments - 1);\n    drawn.push(r.rect(barLeft, barTop + i * segH, barWidth, segH + 0.5).attr({ fill: valueFill(value), zIndex: 2 }).add());\n  }\n  drawn.push(r.rect(barLeft, barTop, barWidth, barHeight).attr({ fill: 'none', stroke: t.inkSoft, 'stroke-width': 1, zIndex: 2 }).add());\n  [\n    [DOMAIN, 0],\n    [0, 0.5],\n    [-DOMAIN, 1],\n  ].forEach(([value, frac]) => {\n    drawn.push(\n      r\n        .text(value.toFixed(1), barLeft + barWidth + 10, barTop + frac * barHeight + 5)\n        .attr({ align: 'left', zIndex: 2 })\n        .css({ color: t.inkSoft, fontSize: '13px' })\n        .add()\n    );\n  });\n  drawn.push(\n    r\n      .text('Expression (z)', barLeft, barTop - 16)\n      .attr({ align: 'left', zIndex: 2 })\n      .css({ color: t.inkSoft, fontSize: '14px', fontWeight: '500' })\n      .add()\n  );\n}\n\n// Invisible scatter layer aligned to each cell so hovering exposes a native\n// Highcharts tooltip — the core bundle has no heatmap/colorAxis module, but a\n// matched-axis scatter series recovers interactivity for the hand-drawn grid.\nconst cellPoints = [];\nfor (let row = 0; row < N_ROWS; row++) {\n  for (let col = 0; col < N_COLS; col++) {\n    cellPoints.push({\n      x: col,\n      y: row,\n      value: M[row][col],\n      sample: rowLabels[row],\n      condition: CONDITION_NAMES[rowCondition[row]],\n      gene: colLabels[col],\n      module: MODULE_NAMES[colModule[col]],\n    });\n  }\n}\n\nHighcharts.chart('container', {\n  chart: {\n    backgroundColor: 'transparent',\n    animation: false,\n    style: { fontFamily: 'inherit' },\n    margin: CHART_MARGIN,\n    events: { load: drawAll, redraw: drawAll },\n  },\n  credits: { enabled: false },\n  title: {\n    text: TITLE_TEXT,\n    style: { color: t.ink, fontSize: TITLE_FS + 'px', fontWeight: '600' },\n  },\n  subtitle: {\n    text: \"Ward's-method clustering reorders 16 samples × 12 genes from scrambled input; strips mark true condition / module groups\",\n    style: { color: t.inkSoft, fontSize: '13px' },\n  },\n  xAxis: { visible: false, min: -0.5, max: N_COLS - 0.5 },\n  yAxis: { visible: false, gridLineWidth: 0, min: -0.5, max: N_ROWS - 0.5, reversed: true },\n  legend: { enabled: false },\n  tooltip: {\n    enabled: true,\n    backgroundColor: t.elevatedBg,\n    borderColor: t.inkSoft,\n    borderRadius: 6,\n    style: { color: t.ink, fontSize: '13px' },\n    formatter: function () {\n      const p = this.point;\n      return `<b>${p.sample}</b> (${p.condition})<br/><b>${p.gene}</b> (${p.module})<br/>z = ${p.value.toFixed(2)}`;\n    },\n  },\n  plotOptions: {\n    series: { animation: false },\n    scatter: {\n      enableMouseTracking: true,\n      stickyTracking: false,\n      marker: {\n        enabled: true,\n        symbol: 'circle',\n        radius: MARKER_RADIUS,\n        fillColor: 'rgba(0,0,0,0.001)',\n        lineWidth: 0,\n        states: { hover: { enabled: false } },\n      },\n    },\n  },\n  series: [\n    {\n      type: 'scatter',\n      name: 'Expression',\n      data: cellPoints,\n    },\n  ],\n});\n"}