{"spec_id":"heatmap-adjacency","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// heatmap-adjacency: Network Adjacency Matrix Heatmap\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-05\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: co-authorship network across five university research labs ------\n// The core Highcharts bundle has no heatmap/colorAxis module loaded, so the\n// matrix is drawn cell-by-cell with the SVG renderer, same as any other\n// vector shape Highcharts can draw natively.\nconst LABS = ['Neuroscience', 'Robotics', 'Genomics', 'Climate Science', 'Materials Science'];\nconst LAB_SIZE = 8;\nconst N = LABS.length * LAB_SIZE; // 40 researchers total across five labs\nconst labOf = (i) => Math.floor(i / LAB_SIZE);\nconst NODE_NAMES = Array.from({ length: N }, (_, i) => `${LABS[labOf(i)].slice(0, 2).toUpperCase()}${(i % LAB_SIZE) + 1}`);\n\n// Deterministic LCG — the browser has no seeded RNG.\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\n\n// Nodes are already ordered by lab (cluster) so the block-diagonal structure\n// is visible without a separate reordering step. Intra-lab pairs collaborate\n// far more often and more deeply than cross-lab pairs, which is what produces\n// the dense diagonal blocks against a sparse off-diagonal background.\nconst WEIGHT = Array.from({ length: N }, () => new Array(N).fill(0));\nfor (let i = 0; i < N; i++) {\n  for (let j = i + 1; j < N; j++) {\n    const sameLab = labOf(i) === labOf(j);\n    const edgeProbability = sameLab ? 0.6 : 0.07;\n    if (rand() < edgeProbability) {\n      const maxPapers = sameLab ? 12 : 4;\n      const papers = 1 + Math.round(rand() * (maxPapers - 1));\n      WEIGHT[i][j] = papers;\n      WEIGHT[j][i] = papers; // undirected graph — fill both triangles\n    }\n  }\n}\n// Diagonal (self-pairs) carries no meaning; it is hatched (see drawAll) to\n// disambiguate \"not applicable\" from a genuine zero-weight absent edge.\n\nlet maxWeight = 0;\nWEIGHT.forEach((row) => row.forEach((w) => { if (w > maxWeight) maxWeight = w; }));\n\n// --- Color: imprint_seq — single-polarity data (joint-paper count >= 0) ----\nfunction hexToRgb(hex) {\n  return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];\n}\nconst SEQ_LO = hexToRgb(t.seq[0]); // #009E73\nconst SEQ_HI = hexToRgb(t.seq[1]); // #4467A3\nfunction lerp(a, b, f) {\n  return a + (b - a) * f;\n}\nfunction weightFill(w) {\n  if (w === 0) return t.elevatedBg; // absent edge — distinct from the color scale\n  const f = w / maxWeight;\n  const [red, green, blue] = [lerp(SEQ_LO[0], SEQ_HI[0], f), lerp(SEQ_LO[1], SEQ_HI[1], f), lerp(SEQ_LO[2], SEQ_HI[2], f)];\n  return `rgb(${Math.round(red)},${Math.round(green)},${Math.round(blue)})`;\n}\n\n// --- Title (fontsize scaled off the 67-char baseline) -----------------------\nconst TITLE_TEXT = 'Co-authorship Network by Lab · heatmap-adjacency · 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 (square canvas, harness-guaranteed 1200x1200 CSS px) —\n// a single source of truth for the margin, the grid, and the invisible hover\n// layer below, so everything lines up without a runtime resync. A fixed\n// column is reserved to the right of the grid for the colorbar + its labels\n// so long strings like \"Joint papers\" never run past the canvas edge.\nconst CHART_MARGIN = [130, 10, 175, 150]; // [top, right, bottom, left]\nconst COLORBAR_COLUMN = 200;\nconst size = window.ANYPLOT_SIZE;\nconst gridSpan = Math.min(\n  size.width - CHART_MARGIN[1] - CHART_MARGIN[3] - COLORBAR_COLUMN,\n  size.height - CHART_MARGIN[0] - CHART_MARGIN[2]\n);\nconst CELL = gridSpan / N;\nconst MARKER_RADIUS = Math.max(CELL / 2 - 1, 2);\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 gridLeft = chart.plotLeft + (chart.plotWidth - COLORBAR_COLUMN - gridSpan) / 2;\n  const gridTop = chart.plotTop + (chart.plotHeight - gridSpan) / 2;\n\n  // Matrix cells — full N x N grid, both triangles filled (undirected graph).\n  // Diagonal (self-pair) cells get a hatch overlay so \"not applicable\" reads\n  // as visually distinct from a genuine zero-weight absent edge.\n  for (let row = 0; row < N; row++) {\n    for (let col = 0; col < N; col++) {\n      const w = WEIGHT[row][col];\n      const x = gridLeft + col * CELL;\n      const y = gridTop + row * CELL;\n      drawn.push(\n        r\n          .rect(x + 0.5, y + 0.5, CELL - 1, CELL - 1, 0)\n          .attr({ fill: weightFill(w), stroke: 'none', zIndex: 2 })\n          .add()\n      );\n      if (row === col) {\n        const pad = Math.max(CELL * 0.15, 1);\n        drawn.push(\n          r.path(['M', x + pad, y + pad, 'L', x + CELL - pad, y + CELL - pad]).attr({ stroke: t.inkSoft, 'stroke-width': 1, opacity: 0.4, zIndex: 3 }).add()\n        );\n        drawn.push(\n          r.path(['M', x + CELL - pad, y + pad, 'L', x + pad, y + CELL - pad]).attr({ stroke: t.inkSoft, 'stroke-width': 1, opacity: 0.4, zIndex: 3 }).add()\n        );\n      }\n    }\n  }\n\n  // Block-boundary dividers between labs — thicker lines so the cluster\n  // structure the node ordering encodes is immediately legible.\n  for (let b = 1; b < LABS.length; b++) {\n    const pos = gridLeft + b * LAB_SIZE * CELL;\n    drawn.push(\n      r.path(['M', pos, gridTop, 'L', pos, gridTop + gridSpan]).attr({ stroke: t.inkSoft, 'stroke-width': 1.5, zIndex: 3 }).add()\n    );\n    const posY = gridTop + b * LAB_SIZE * CELL;\n    drawn.push(\n      r.path(['M', gridLeft, posY, 'L', gridLeft + gridSpan, posY]).attr({ stroke: t.inkSoft, 'stroke-width': 1.5, zIndex: 3 }).add()\n    );\n  }\n  // Outer frame around the full matrix.\n  drawn.push(\n    r.rect(gridLeft, gridTop, gridSpan, gridSpan).attr({ fill: 'none', stroke: t.inkSoft, 'stroke-width': 1.5, zIndex: 3 }).add()\n  );\n\n  // Sparse tick marks halfway through each block — full per-node labels would\n  // crowd a 40x40 grid, but a light mid-block tick gives orientation within\n  // each lab's rows/columns without adding text.\n  for (let i = LAB_SIZE / 2; i < N; i += LAB_SIZE) {\n    const x = gridLeft + i * CELL;\n    drawn.push(r.path(['M', x, gridTop + gridSpan, 'L', x, gridTop + gridSpan + 6]).attr({ stroke: t.grid, 'stroke-width': 1, zIndex: 2 }).add());\n    const y = gridTop + i * CELL;\n    drawn.push(r.path(['M', gridLeft - 6, y, 'L', gridLeft, y]).attr({ stroke: t.grid, 'stroke-width': 1, zIndex: 2 }).add());\n  }\n\n  // Lab labels centered on each block — per-node tick labels would crowd a\n  // 40x40 grid, so only the group boundaries are labeled (x below, y left).\n  LABS.forEach((lab, b) => {\n    const center = gridLeft + (b + 0.5) * LAB_SIZE * CELL;\n    drawn.push(\n      r.text(lab, center, gridTop + gridSpan + 34).attr({ align: 'center', zIndex: 2 }).css({ color: t.inkSoft, fontSize: '16px', fontWeight: '500' }).add()\n    );\n    const centerY = gridTop + (b + 0.5) * LAB_SIZE * CELL;\n    drawn.push(\n      r\n        .text(lab, gridLeft - 14, centerY + 5)\n        .attr({ align: 'right', zIndex: 2 })\n        .css({ color: t.inkSoft, fontSize: '16px', fontWeight: '500' })\n        .add()\n    );\n  });\n\n  // Vertical colorbar in the freed right margin.\n  const barLeft = gridLeft + gridSpan + 34;\n  const barTop = gridTop;\n  const barWidth = 22;\n  const barHeight = gridSpan;\n  const segments = 50;\n  const segH = barHeight / segments;\n  for (let i = 0; i < segments; i++) {\n    const w = maxWeight - ((maxWeight * i) / (segments - 1));\n    drawn.push(r.rect(barLeft, barTop + i * segH, barWidth, segH + 0.5).attr({ fill: weightFill(Math.max(w, 0.01)), zIndex: 2 }).add());\n  }\n  drawn.push(r.rect(barLeft, barTop, barWidth, barHeight).attr({ fill: 'none', stroke: t.inkSoft, 'stroke-width': 1, zIndex: 3 }).add());\n  [\n    [maxWeight, 0],\n    [1, 1],\n  ].forEach(([w, frac]) => {\n    drawn.push(\n      r.text(String(w), barLeft + barWidth + 10, barTop + frac * barHeight + 5).attr({ align: 'left', zIndex: 2 }).css({ color: t.inkSoft, fontSize: '13px' }).add()\n    );\n  });\n  drawn.push(r.text('Joint papers', barLeft, barTop - 16).attr({ align: 'left', zIndex: 2 }).css({ color: t.inkSoft, fontSize: '14px', fontWeight: '500' }).add());\n  // \"No collaboration\" swatch below the colorbar for the absent-edge fill.\n  const swatchTop = barTop + barHeight + 22;\n  drawn.push(r.rect(barLeft, swatchTop, barWidth, barWidth).attr({ fill: t.elevatedBg, stroke: t.inkSoft, 'stroke-width': 1, zIndex: 3 }).add());\n  drawn.push(\n    r.text('No papers', barLeft + barWidth + 10, swatchTop + barWidth / 2 + 5).attr({ align: 'left', zIndex: 2 }).css({ color: t.inkSoft, fontSize: '13px' }).add()\n  );\n  // \"Self-pair\" swatch — same hatch pattern drawn on the matrix diagonal, so\n  // the legend disambiguates \"not applicable\" from a genuine absent edge.\n  const swatch2Top = swatchTop + barWidth + 14;\n  drawn.push(r.rect(barLeft, swatch2Top, barWidth, barWidth).attr({ fill: t.elevatedBg, stroke: t.inkSoft, 'stroke-width': 1, zIndex: 3 }).add());\n  const hp = barWidth * 0.15;\n  drawn.push(\n    r.path(['M', barLeft + hp, swatch2Top + hp, 'L', barLeft + barWidth - hp, swatch2Top + barWidth - hp]).attr({ stroke: t.inkSoft, 'stroke-width': 1, opacity: 0.4, zIndex: 3 }).add()\n  );\n  drawn.push(\n    r.path(['M', barLeft + barWidth - hp, swatch2Top + hp, 'L', barLeft + hp, swatch2Top + barWidth - hp]).attr({ stroke: t.inkSoft, 'stroke-width': 1, opacity: 0.4, zIndex: 3 }).add()\n  );\n  drawn.push(\n    r.text('Self-pair (n/a)', barLeft + barWidth + 10, swatch2Top + barWidth / 2 + 5).attr({ align: 'left', zIndex: 2 }).css({ color: t.inkSoft, fontSize: '13px' }).add()\n  );\n}\n\n// Invisible scatter layer aligned to each cell so hovering exposes a real\n// Highcharts tooltip — the core bundle has no heatmap/colorAxis module, but a\n// matched-axis scatter series recovers native hover interactivity without\n// disturbing the hand-drawn matrix above it.\nconst cellPoints = [];\nfor (let row = 0; row < N; row++) {\n  for (let col = 0; col < N; col++) {\n    cellPoints.push({ x: col, y: row, papers: WEIGHT[row][col], from: NODE_NAMES[row], to: NODE_NAMES[col] });\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: '40 researchers across five labs, ordered by lab to expose block-diagonal collaboration clusters',\n    style: { color: t.inkSoft, fontSize: '14px' },\n  },\n  xAxis: { visible: false, min: -0.5, max: N - 0.5 },\n  yAxis: { visible: false, gridLineWidth: 0, min: -0.5, max: N - 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      if (p.from === p.to) return `<b>${p.from}</b><br/>Self-pair — not applicable`;\n      return p.papers > 0 ? `<b>${p.from} ↔ ${p.to}</b><br/>${p.papers} joint paper${p.papers === 1 ? '' : 's'}` : `<b>${p.from} ↔ ${p.to}</b><br/>No collaboration`;\n    },\n  },\n  plotOptions: {\n    series: { animation: false },\n    scatter: {\n      enableMouseTracking: true,\n      stickyTracking: false,\n      marker: {\n        enabled: true,\n        symbol: 'square',\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: 'Collaboration',\n      data: cellPoints,\n    },\n  ],\n});\n"}