{"spec_id":"heatmap-risk-matrix","library":"d3","language":"javascript","code":"// anyplot.ai\n// heatmap-risk-matrix: Risk Assessment Matrix (Probability vs Impact)\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 87/100 | Created: 2026-06-20\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst isDark = window.ANYPLOT_THEME === \"dark\";\n\nconst margin = { top: 100, right: 40, bottom: 260, left: 175 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\nconst cellSize = Math.floor(Math.min(iw, ih) / 5);\nconst gridSide = cellSize * 5;\n\n// Semantic risk zone colors — traffic-light domain convention (semantic exception)\nconst ZONE = [\n  { color: \"#009E73\", label: \"Low (1–4)\",        opacity: 0.32, opacityDark: 0.50 },\n  { color: \"#DDCC77\", label: \"Medium (5–9)\",     opacity: 0.62, opacityDark: 0.62 },\n  { color: \"#BD8233\", label: \"High (10–16)\",     opacity: 0.80, opacityDark: 0.80 },\n  { color: \"#AE3030\", label: \"Critical (20–25)\", opacity: 0.90, opacityDark: 0.90 },\n];\n\nfunction zoneIdx(l, i) {\n  const s = l * i;\n  return s <= 4 ? 0 : s <= 9 ? 1 : s <= 16 ? 2 : 3;\n}\n\n// Project management risk register — 12 items, all in distinct cells\nconst risks = [\n  { name: \"Budget Overrun\",     l: 4, i: 4 },\n  { name: \"Scope Creep\",        l: 5, i: 3 },\n  { name: \"Staff Turnover\",     l: 2, i: 5 },\n  { name: \"Tech Failure\",       l: 2, i: 4 },\n  { name: \"Regulatory Change\",  l: 2, i: 3 },\n  { name: \"Vendor Delay\",       l: 4, i: 2 },\n  { name: \"Data Breach\",        l: 1, i: 5 },\n  { name: \"Market Shift\",       l: 3, i: 3 },\n  { name: \"Integration Issues\", l: 3, i: 2 },\n  { name: \"Comms Gap\",          l: 5, i: 1 },\n  { name: \"Resource Shortage\",  l: 3, i: 4 },\n  { name: \"Compliance Fail\",    l: 1, i: 4 },\n];\n\nconst xLabels = [\"Rare\", \"Unlikely\", \"Possible\", \"Likely\", \"Almost Certain\"];\nconst yLabels = [\"Negligible\", \"Minor\", \"Moderate\", \"Major\", \"Catastrophic\"];\n\n// SVG mount\nconst svg = d3.select(\"#container\").append(\"svg\")\n  .attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\")\n  .attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// D3 scaleBand for grid positioning — band scale centers ticks automatically\nconst x = d3.scaleBand()\n  .domain(d3.range(1, 6))\n  .range([0, gridSide])\n  .paddingInner(0);\n\n// Descending domain so impact=5 (Catastrophic) maps to top of grid\nconst y = d3.scaleBand()\n  .domain(d3.range(5, 0, -1))\n  .range([0, gridSide])\n  .paddingInner(0);\n\n// Generate all 25 grid cells with d3.cross (idiomatic D3)\nconst cells = d3.cross(d3.range(1, 6), d3.range(1, 6), (l, i) => ({\n  l, i, score: l * i, zi: zoneIdx(l, i),\n}));\n\n// Draw cells with .data().join()\ng.selectAll(\"rect.cell\")\n  .data(cells)\n  .join(\"rect\")\n  .attr(\"class\", \"cell\")\n  .attr(\"x\", d => x(d.l))\n  .attr(\"y\", d => y(d.i))\n  .attr(\"width\", x.bandwidth())\n  .attr(\"height\", y.bandwidth())\n  .attr(\"fill\", d => ZONE[d.zi].color)\n  .attr(\"fill-opacity\", d => isDark ? ZONE[d.zi].opacityDark : ZONE[d.zi].opacity)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-opacity\", 0.45);\n\n// Score watermarks (top-right corner of each cell)\ng.selectAll(\"text.score\")\n  .data(cells)\n  .join(\"text\")\n  .attr(\"class\", \"score\")\n  .attr(\"x\", d => x(d.l) + x.bandwidth() - 9)\n  .attr(\"y\", d => y(d.i) + 20)\n  .attr(\"text-anchor\", \"end\")\n  .attr(\"fill\", t.ink)\n  .attr(\"fill-opacity\", 0.28)\n  .style(\"font-size\", \"13px\")\n  .text(d => d.score);\n\n// Outer grid border\ng.append(\"rect\")\n  .attr(\"width\", gridSide).attr(\"height\", gridSide)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 2)\n  .attr(\"stroke-opacity\", 0.55);\n\n// X-axis — d3.axisBottom with scaleBand (auto-centers ticks at band midpoints)\nconst xAxisG = g.append(\"g\")\n  .attr(\"transform\", `translate(0,${gridSide})`)\n  .call(\n    d3.axisBottom(x)\n      .tickFormat(d => xLabels[d - 1])\n      .tickSize(0)\n      .tickPadding(12)\n  );\n\n// \"Almost Certain\" (d=5): split into two tspan lines\nxAxisG.selectAll(\".tick\")\n  .filter(d => d === 5)\n  .select(\"text\")\n  .each(function() {\n    const el = d3.select(this);\n    el.text(\"\");\n    el.append(\"tspan\").attr(\"x\", 0).attr(\"dy\", 0).text(\"Almost\");\n    el.append(\"tspan\").attr(\"x\", 0).attr(\"dy\", 18).text(\"Certain\");\n  });\n\nxAxisG.selectAll(\".tick text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\nxAxisG.select(\".domain\").remove();\n\n// X-axis title\ng.append(\"text\")\n  .attr(\"x\", gridSide / 2).attr(\"y\", gridSide + 90)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"15px\").style(\"font-weight\", \"600\")\n  .text(\"Likelihood →\");\n\n// Y-axis — d3.axisLeft with scaleBand (auto-centers ticks at band midpoints)\nconst yAxisG = g.append(\"g\").call(\n  d3.axisLeft(y)\n    .tickFormat(d => yLabels[d - 1])\n    .tickSize(0)\n    .tickPadding(10)\n);\n\nyAxisG.selectAll(\".tick text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\nyAxisG.select(\".domain\").remove();\n\n// Y-axis title\ng.append(\"text\")\n  .attr(\"transform\", `translate(${-148},${gridSide / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"15px\").style(\"font-weight\", \"600\")\n  .text(\"Impact →\");\n\n// Risk markers — Critical zone (score ≥ 20) gets larger circles for visual emphasis\nconst markerData = risks.map((r, idx) => ({ ...r, idx, score: r.l * r.i }));\n\ng.selectAll(\"circle.marker\")\n  .data(markerData)\n  .join(\"circle\")\n  .attr(\"class\", \"marker\")\n  .attr(\"cx\", d => x(d.l) + x.bandwidth() / 2)\n  .attr(\"cy\", d => y(d.i) + y.bandwidth() / 2)\n  .attr(\"r\", d => d.score >= 20 ? 17 : 14)\n  .attr(\"fill\", t.ink)\n  .attr(\"fill-opacity\", 0.84)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 2.5);\n\ng.selectAll(\"text.marker-num\")\n  .data(markerData)\n  .join(\"text\")\n  .attr(\"class\", \"marker-num\")\n  .attr(\"x\", d => x(d.l) + x.bandwidth() / 2)\n  .attr(\"y\", d => y(d.i) + y.bandwidth() / 2)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"dominant-baseline\", \"central\")\n  .attr(\"fill\", t.pageBg)\n  .style(\"font-size\", d => d.score >= 20 ? \"12px\" : \"11px\")\n  .style(\"font-weight\", \"700\")\n  .text(d => d.idx + 1);\n\n// Zone legend strip\nconst zoneY = gridSide + 120;\nconst zoneBoxW = Math.floor((gridSide - 9) / 4);\n\ng.append(\"text\")\n  .attr(\"x\", 0).attr(\"y\", zoneY - 9)\n  .attr(\"fill\", t.ink).style(\"font-size\", \"13px\").style(\"font-weight\", \"600\")\n  .text(\"Risk Zones\");\n\ng.selectAll(\"rect.zone-box\")\n  .data(ZONE)\n  .join(\"rect\")\n  .attr(\"class\", \"zone-box\")\n  .attr(\"x\", (d, i) => i * (zoneBoxW + 3))\n  .attr(\"y\", zoneY)\n  .attr(\"width\", zoneBoxW)\n  .attr(\"height\", 16)\n  .attr(\"fill\", d => d.color)\n  .attr(\"fill-opacity\", d => isDark ? d.opacityDark : d.opacity)\n  .attr(\"rx\", 3);\n\ng.selectAll(\"text.zone-label\")\n  .data(ZONE)\n  .join(\"text\")\n  .attr(\"class\", \"zone-label\")\n  .attr(\"x\", (d, i) => i * (zoneBoxW + 3) + zoneBoxW / 2)\n  .attr(\"y\", zoneY + 31)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"12px\")\n  .text(d => d.label);\n\n// Risk item index (3-column layout)\nconst itemY = zoneY + 56;\n\ng.append(\"text\")\n  .attr(\"x\", 0).attr(\"y\", itemY - 9)\n  .attr(\"fill\", t.ink).style(\"font-size\", \"13px\").style(\"font-weight\", \"600\")\n  .text(\"Risk Items\");\n\nconst colW = Math.floor(gridSide / 3);\n\ng.selectAll(\"text.risk-item\")\n  .data(risks)\n  .join(\"text\")\n  .attr(\"class\", \"risk-item\")\n  .attr(\"x\", (d, i) => (i % 3) * colW)\n  .attr(\"y\", (d, i) => itemY + Math.floor(i / 3) * 21)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"12px\")\n  .text((d, i) => `${i + 1}. ${d.name}`);\n\n// Title\nsvg.append(\"text\")\n  .attr(\"x\", margin.left + gridSide / 2).attr(\"y\", 54)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"22px\").style(\"font-weight\", \"600\")\n  .text(\"heatmap-risk-matrix · javascript · d3 · anyplot.ai\");\n"}