{"spec_id":"heatmap-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// heatmap-basic: Basic Heatmap\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 92/100 | Updated: 2026-06-03\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 170, right: 180, bottom: 60, left: 220 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// Data — asset-class correlation matrix (symmetric, diagonal = 1), the canonical\n// diverging-heatmap scenario from the spec's applications. Values are hand-tuned\n// to reflect typical post-2020 regime correlations: equities cluster positive,\n// long-duration bonds anti-correlate with risk assets, gold sits near-zero.\nconst assets = [\n  \"US Equities\",\n  \"EU Equities\",\n  \"EM Equities\",\n  \"REITs\",\n  \"High Yield\",\n  \"IG Credit\",\n  \"Treasuries\",\n  \"Gold\",\n  \"Oil\",\n  \"USD Cash\",\n];\n\nconst corr = [\n  [1.00,  0.84,  0.72,  0.68,  0.71,  0.42, -0.34,  0.08,  0.38, -0.28],\n  [0.84,  1.00,  0.78,  0.62,  0.66,  0.45, -0.30,  0.12,  0.35, -0.31],\n  [0.72,  0.78,  1.00,  0.55,  0.61,  0.38, -0.38,  0.18,  0.44, -0.42],\n  [0.68,  0.62,  0.55,  1.00,  0.58,  0.48, -0.12,  0.10,  0.28, -0.18],\n  [0.71,  0.66,  0.61,  0.58,  1.00,  0.66, -0.22,  0.05,  0.32, -0.20],\n  [0.42,  0.45,  0.38,  0.48,  0.66,  1.00,  0.52,  0.14,  0.08,  0.06],\n  [-0.34, -0.30, -0.38, -0.12, -0.22,  0.52,  1.00,  0.22, -0.18,  0.38],\n  [0.08,  0.12,  0.18,  0.10,  0.05,  0.14,  0.22,  1.00,  0.26,  0.15],\n  [0.38,  0.35,  0.44,  0.28,  0.32,  0.08, -0.18,  0.26,  1.00, -0.22],\n  [-0.28, -0.31, -0.42, -0.18, -0.20,  0.06,  0.38,  0.15, -0.22,  1.00],\n];\n\n// SVG mount\nconst svg = d3.select(\"#container\").append(\"svg\")\n  .attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// Scales\nconst x = d3.scaleBand().domain(assets).range([0, iw]).padding(0.04);\nconst y = d3.scaleBand().domain(assets).range([0, ih]).padding(0.04);\n\n// Imprint diverging colormap — three stops, midpoint already theme-adaptive\nconst color = d3.scaleSequential(d3.interpolateRgbBasis(t.div)).domain([-1, 1]);\n\n// Cells (flattened)\nconst cells = [];\nfor (let i = 0; i < assets.length; i++) {\n  for (let j = 0; j < assets.length; j++) {\n    cells.push({ row: assets[i], col: assets[j], value: corr[i][j] });\n  }\n}\n\n// Per-cell text color picked by WCAG contrast ratio against the actual cell\n// fill — keeps annotation contrast uniform across light/dark themes. The\n// diverging midpoint flips with the theme, so a fixed |r| threshold would\n// under-serve saturated cells in one theme or the other.\nconst relLum = (hex) => {\n  const c = d3.color(hex).rgb();\n  const f = (v) => {\n    const u = v / 255;\n    return u <= 0.03928 ? u / 12.92 : Math.pow((u + 0.055) / 1.055, 2.4);\n  };\n  return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b);\n};\nconst contrast = (a, b) => {\n  const la = relLum(a);\n  const lb = relLum(b);\n  return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);\n};\nconst textOn = (fill) =>\n  contrast(fill, t.ink) >= contrast(fill, t.pageBg) ? t.ink : t.pageBg;\n\n// Cell rectangles — diagonal gets a soft accent stroke so the eye lands on\n// the unit-correlation backbone first (DE-03 storytelling).\ng.selectAll(\"rect.cell\").data(cells).join(\"rect\")\n  .attr(\"class\", \"cell\")\n  .attr(\"x\", (d) => x(d.col))\n  .attr(\"y\", (d) => y(d.row))\n  .attr(\"width\", x.bandwidth())\n  .attr(\"height\", y.bandwidth())\n  .attr(\"fill\", (d) => color(d.value))\n  .attr(\"stroke\", (d) => (d.row === d.col ? t.ink : \"none\"))\n  .attr(\"stroke-width\", (d) => (d.row === d.col ? 1.6 : 0));\n\n// Value annotations — weight bumps to 700 on strong correlations so the\n// equity cluster and treasury anti-correlation block read as figures, not\n// uniform texture.\ng.selectAll(\"text.value\").data(cells).join(\"text\")\n  .attr(\"class\", \"value\")\n  .attr(\"x\", (d) => x(d.col) + x.bandwidth() / 2)\n  .attr(\"y\", (d) => y(d.row) + y.bandwidth() / 2)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"dominant-baseline\", \"central\")\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", (d) => (Math.abs(d.value) > 0.5 ? 700 : 500))\n  .attr(\"fill\", (d) => textOn(color(d.value)))\n  .text((d) => d.value.toFixed(2));\n\n// Column labels — rotated above the matrix\nconst xAxis = g.append(\"g\")\n  .attr(\"transform\", \"translate(0,0)\")\n  .call(d3.axisTop(x).tickSize(0).tickPadding(14));\nxAxis.select(\".domain\").remove();\nxAxis.selectAll(\"text\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"24px\")\n  .style(\"font-weight\", \"500\")\n  .attr(\"transform\", \"rotate(-38)\")\n  .style(\"text-anchor\", \"start\");\n\n// Row labels — left of the matrix\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).tickSize(0).tickPadding(14));\nyAxis.select(\".domain\").remove();\nyAxis.selectAll(\"text\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"24px\")\n  .style(\"font-weight\", \"500\");\n\n// Title\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 60)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"38px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"heatmap-basic · javascript · d3 · anyplot.ai\");\n\n// Colorbar — vertical gradient strip + axis\nconst cbWidth = 38;\nconst cbHeight = ih;\nconst cbX = iw + 70;\n\nconst defs = svg.append(\"defs\");\nconst grad = defs.append(\"linearGradient\")\n  .attr(\"id\", \"cbgrad\")\n  .attr(\"x1\", \"0%\").attr(\"y1\", \"0%\")\n  .attr(\"x2\", \"0%\").attr(\"y2\", \"100%\");\nconst nStops = 32;\nfor (let i = 0; i <= nStops; i++) {\n  const u = i / nStops;\n  grad.append(\"stop\")\n    .attr(\"offset\", `${u * 100}%`)\n    .attr(\"stop-color\", color(1 - 2 * u));\n}\n\ng.append(\"rect\")\n  .attr(\"x\", cbX).attr(\"y\", 0)\n  .attr(\"width\", cbWidth).attr(\"height\", cbHeight)\n  .attr(\"fill\", \"url(#cbgrad)\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 0.6);\n\nconst cbScale = d3.scaleLinear().domain([1, -1]).range([0, cbHeight]);\nconst cbAxis = g.append(\"g\")\n  .attr(\"transform\", `translate(${cbX + cbWidth}, 0)`)\n  .call(\n    d3.axisRight(cbScale)\n      .tickValues([-1, -0.5, 0, 0.5, 1])\n      .tickFormat(d3.format(\"+.1f\"))\n      .tickSize(8),\n  );\ncbAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"22px\");\ncbAxis.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\ncbAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\n// Colorbar caption\ng.append(\"text\")\n  .attr(\"x\", cbX + cbWidth / 2).attr(\"y\", -22)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"500\")\n  .text(\"Pearson r\");\n"}