{"spec_id":"heatmap-adjacency","library":"d3","language":"javascript","code":"// anyplot.ai\n// heatmap-adjacency: Network Adjacency Matrix Heatmap\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 88/100 | Created: 2026-09-05\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Deterministic PRNG (LCG, no seeded Math.random in the browser) --------\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\n\n// --- Data: workplace collaboration network, grouped by department ----------\n// Nodes are ordered by department so the block-diagonal structure (dense\n// within-team collaboration, sparse cross-team ties) is visible without any\n// reordering step.\nconst departments = [\n  { name: \"Product\", size: 8 },\n  { name: \"Engineering\", size: 14 },\n  { name: \"Marketing\", size: 9 },\n  { name: \"Sales\", size: 9 },\n];\nconst nodes = [];\ndepartments.forEach((dept) => {\n  for (let i = 0; i < dept.size; i++) nodes.push({ department: dept.name });\n});\nconst n = nodes.length;\n\n// Symmetric weighted adjacency matrix; null = no collaboration recorded.\n// Diagonal stays null (self-loops carry no information here).\nconst matrix = Array.from({ length: n }, () => new Array(n).fill(null));\nfor (let i = 0; i < n; i++) {\n  for (let j = i + 1; j < n; j++) {\n    const sameDept = nodes[i].department === nodes[j].department;\n    let weight = null;\n    if (sameDept) {\n      if (rand() < 0.72) weight = 0.35 + rand() * 0.6;\n    } else if (rand() < 0.05) {\n      weight = 0.1 + rand() * 0.3;\n    }\n    matrix[i][j] = weight;\n    matrix[j][i] = weight;\n  }\n}\nconst maxWeight = d3.max(matrix.flat().filter((v) => v !== null));\n\n// Department block boundaries (node index ranges) for separators + labels.\nlet cursor = 0;\nconst blocks = departments.map((dept) => {\n  const start = cursor;\n  cursor += dept.size;\n  return { name: dept.name, start, end: cursor, mid: start + dept.size / 2 };\n});\n\n// --- Insight: densest internal cluster + sparsest cross-team pair -----------\n// A short reinforcing takeaway beyond \"block-diagonal is visible\".\nconst deptAvg = blocks.map((b) => {\n  let sum = 0;\n  let count = 0;\n  for (let i = b.start; i < b.end; i++) {\n    for (let j = b.start; j < b.end; j++) {\n      if (i !== j && matrix[i][j] !== null) {\n        sum += matrix[i][j];\n        count++;\n      }\n    }\n  }\n  return { name: b.name, avg: count ? sum / count : 0 };\n});\nconst densest = deptAvg.reduce((best, d) => (d.avg > best.avg ? d : best));\n\nlet sparsestPair = null;\nfor (let a = 0; a < blocks.length; a++) {\n  for (let b = a + 1; b < blocks.length; b++) {\n    const ba = blocks[a];\n    const bb = blocks[b];\n    let count = 0;\n    for (let i = ba.start; i < ba.end; i++) {\n      for (let j = bb.start; j < bb.end; j++) {\n        if (matrix[i][j] !== null) count++;\n      }\n    }\n    const density = count / ((ba.end - ba.start) * (bb.end - bb.start));\n    if (!sparsestPair || density < sparsestPair.density) {\n      sparsestPair = { a: ba.name, b: bb.name, density };\n    }\n  }\n}\n\n// --- SVG mount ---------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// --- Layout -------------------------------------------------------------------\nconst margin = { top: 150, right: 220, bottom: 40, left: 110 };\nconst availW = width - margin.left - margin.right;\nconst availH = height - margin.top - margin.bottom;\nconst gridSize = Math.min(availW, availH);\nconst cell = gridSize / n;\nconst gridX = margin.left;\nconst gridY = margin.top;\n\n// --- Color scale (Imprint sequential: brand green -> blue) ------------------\nconst color = d3.scaleSequential(d3.interpolateRgbBasis(t.seq)).domain([0, maxWeight]);\n\n// --- Cells --------------------------------------------------------------------\nconst cellData = [];\nfor (let i = 0; i < n; i++) {\n  for (let j = 0; j < n; j++) {\n    cellData.push({ i, j, value: matrix[i][j] });\n  }\n}\n\nsvg\n  .append(\"g\")\n  .selectAll(\"rect\")\n  .data(cellData)\n  .join(\"rect\")\n  .attr(\"x\", (d) => gridX + d.j * cell)\n  .attr(\"y\", (d) => gridY + d.i * cell)\n  .attr(\"width\", cell)\n  .attr(\"height\", cell)\n  .attr(\"fill\", (d) => (d.value === null ? t.elevatedBg : color(d.value)));\n\n// --- Block separators (mark cluster / department boundaries) ----------------\n// Kept subtle (t.grid, the 15%-alpha ink rule token) so only the outer frame\n// reads at full ink weight, per the Imprint \"subtle structural line\" convention.\nconst separators = svg.append(\"g\");\nblocks.forEach((b) => {\n  if (b.start === 0) return;\n  const pos = gridX + b.start * cell;\n  separators\n    .append(\"line\")\n    .attr(\"x1\", pos)\n    .attr(\"x2\", pos)\n    .attr(\"y1\", gridY)\n    .attr(\"y2\", gridY + gridSize)\n    .attr(\"stroke\", t.grid)\n    .attr(\"stroke-width\", 1.5);\n  separators\n    .append(\"line\")\n    .attr(\"x1\", gridX)\n    .attr(\"x2\", gridX + gridSize)\n    .attr(\"y1\", gridY + b.start * cell)\n    .attr(\"y2\", gridY + b.start * cell)\n    .attr(\"stroke\", t.grid)\n    .attr(\"stroke-width\", 1.5);\n});\n// Outer frame around the full matrix.\nseparators\n  .append(\"rect\")\n  .attr(\"x\", gridX)\n  .attr(\"y\", gridY)\n  .attr(\"width\", gridSize)\n  .attr(\"height\", gridSize)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-width\", 2);\n\n// --- Group boundary labels (node count too large for per-node ticks) -------\nsvg\n  .append(\"g\")\n  .selectAll(\"text\")\n  .data(blocks)\n  .join(\"text\")\n  .attr(\"x\", (d) => gridX + d.mid * cell)\n  .attr(\"y\", gridY - 16)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"16px\")\n  .text((d) => d.name);\n\nsvg\n  .append(\"g\")\n  .selectAll(\"text\")\n  .data(blocks)\n  .join(\"text\")\n  .attr(\"transform\", (d) => `translate(${gridX - 16},${gridY + d.mid * cell}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"16px\")\n  .text((d) => d.name);\n\n// --- Colorbar legend (weight scale) ------------------------------------------\nconst legendX = gridX + gridSize + 60;\nconst legendW = 26;\nconst legendH = gridSize * 0.6;\nconst legendY = gridY + (gridSize - legendH) / 2;\n\nconst defs = svg.append(\"defs\");\nconst gradient = defs\n  .append(\"linearGradient\")\n  .attr(\"id\", \"weight-gradient\")\n  .attr(\"x1\", \"0%\")\n  .attr(\"x2\", \"0%\")\n  .attr(\"y1\", \"100%\")\n  .attr(\"y2\", \"0%\");\n// d3.quantize samples the interpolator at N evenly-spaced points in one call,\n// avoiding a hand-rolled d3.range/offset loop for the gradient stops.\nconst stopColors = d3.quantize((tt) => color(tt * maxWeight), 11);\ngradient\n  .selectAll(\"stop\")\n  .data(stopColors)\n  .join(\"stop\")\n  .attr(\"offset\", (d, i) => `${(i / (stopColors.length - 1)) * 100}%`)\n  .attr(\"stop-color\", (d) => d);\n\nsvg\n  .append(\"rect\")\n  .attr(\"x\", legendX)\n  .attr(\"y\", legendY)\n  .attr(\"width\", legendW)\n  .attr(\"height\", legendH)\n  .attr(\"fill\", \"url(#weight-gradient)\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1);\n\nconst legendScale = d3.scaleLinear().domain([0, maxWeight]).range([legendY + legendH, legendY]);\n// Force a tick at the true data ceiling so the legend never appears to stop\n// short of the actual max weight.\nconst legendTickValues = legendScale.ticks(4);\nif (legendTickValues[legendTickValues.length - 1] < maxWeight * 0.97) {\n  legendTickValues.push(maxWeight);\n}\nconst legendAxis = d3.axisRight(legendScale).tickValues(legendTickValues).tickFormat(d3.format(\".2f\"));\nconst legendAxisG = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${legendX + legendW},0)`)\n  .call(legendAxis);\nlegendAxisG.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\");\nlegendAxisG.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\nlegendAxisG.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\nsvg\n  .append(\"text\")\n  .attr(\"transform\", `translate(${legendX - 24},${legendY + legendH / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(\"Collaboration strength\");\n\n// No-data swatch, to disambiguate the near-background fill from a low weight.\nconst swatchY = legendY + legendH + 40;\nsvg\n  .append(\"rect\")\n  .attr(\"x\", legendX)\n  .attr(\"y\", swatchY)\n  .attr(\"width\", legendW)\n  .attr(\"height\", legendW)\n  .attr(\"fill\", t.elevatedBg)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1);\nsvg\n  .append(\"text\")\n  .attr(\"x\", legendX + legendW + 10)\n  .attr(\"y\", swatchY + legendW / 2 + 5)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\"No link\");\n\n// --- Title --------------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 54)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"heatmap-adjacency · javascript · d3 · anyplot.ai\");\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 84)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(`${n}-person collaboration network across 4 departments, reordered to reveal team clusters`);\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 112)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-style\", \"italic\")\n  .text(\n    `Densest cluster: ${densest.name} (avg weight ${densest.avg.toFixed(2)}) · sparsest cross-team ties: ${sparsestPair.a}–${sparsestPair.b}`,\n  );\n"}