{"spec_id":"heatmap-risk-matrix","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// heatmap-risk-matrix: Risk Assessment Matrix (Probability vs Impact)\n// Library: chartjs 4.4.7 | JavaScript 22.22.3\n// Quality: 88/100 | Created: 2026-06-20\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst THEME = window.ANYPLOT_THEME;\n\n// --- Cell color: Imprint semantic anchors green → amber → ochre → red -------\nfunction cellColor(score) {\n  const stops = [\n    { s: 1,  r: 0,   g: 158, b: 115, a: 0.75 }, // #009E73 low risk\n    { s: 5,  r: 221, g: 204, b: 119, a: 0.80 }, // #DDCC77 medium risk\n    { s: 10, r: 189, g: 130, b: 51,  a: 0.85 }, // #BD8233 high risk\n    { s: 17, r: 174, g: 48,  b: 48,  a: 0.90 }, // #AE3030 critical risk\n    { s: 25, r: 140, g: 20,  b: 20,  a: 0.95 },\n  ];\n  let lo = stops[0], hi = stops[stops.length - 1];\n  for (let i = 0; i < stops.length - 1; i++) {\n    if (score >= stops[i].s && score <= stops[i + 1].s) {\n      lo = stops[i]; hi = stops[i + 1]; break;\n    }\n  }\n  const f = lo.s === hi.s ? 0 : (score - lo.s) / (hi.s - lo.s);\n  const a = +(lo.a + (hi.a - lo.a) * f).toFixed(2);\n  return `rgba(${Math.round(lo.r + (hi.r - lo.r) * f)},${Math.round(lo.g + (hi.g - lo.g) * f)},${Math.round(lo.b + (hi.b - lo.b) * f)},${a})`;\n}\n\n// --- Data: IT security risk register ----------------------------------------\n// 12 risks, all in unique cells — no jitter needed\nconst risks = [\n  { name: \"Data Breach\",      likelihood: 4, impact: 5, cat: 0 },\n  { name: \"Ransomware\",       likelihood: 3, impact: 5, cat: 0 },\n  { name: \"Integration Fail\", likelihood: 2, impact: 5, cat: 0 },\n  { name: \"Phishing Attack\",  likelihood: 5, impact: 3, cat: 0 },\n  { name: \"Network Outage\",   likelihood: 4, impact: 3, cat: 0 },\n  { name: \"Vendor Failure\",   likelihood: 2, impact: 4, cat: 1 },\n  { name: \"Regulatory Fine\",  likelihood: 2, impact: 3, cat: 1 },\n  { name: \"Budget Overrun\",   likelihood: 4, impact: 2, cat: 1 },\n  { name: \"System Downtime\",  likelihood: 3, impact: 4, cat: 2 },\n  { name: \"Key Staff Loss\",   likelihood: 3, impact: 3, cat: 2 },\n  { name: \"Scope Creep\",      likelihood: 5, impact: 2, cat: 2 },\n  { name: \"Hardware Failure\", likelihood: 2, impact: 2, cat: 2 },\n];\n\nconst catConfig = [\n  { label: \"Technical\",   color: t.palette[0] }, // #009E73\n  { label: \"Financial\",   color: t.palette[1] }, // #C475FD\n  { label: \"Operational\", color: t.palette[2] }, // #4467A3\n];\n\nconst datasets = catConfig.map((cat, ci) => ({\n  label: cat.label,\n  data: risks\n    .filter(r => r.cat === ci)\n    .map(r => ({ x: r.impact, y: r.likelihood, name: r.name })),\n  backgroundColor: cat.color,\n  borderColor: THEME === \"light\" ? \"rgba(250,248,241,0.85)\" : \"rgba(26,26,23,0.85)\",\n  borderWidth: 2.5,\n  pointRadius: 12,\n  pointHoverRadius: 14,\n}));\n\n// --- Plugin: draw 5×5 colored risk matrix background -----------------------\nconst matrixBg = {\n  id: \"matrixBg\",\n  beforeDraw(chart) {\n    const { ctx, scales: { x, y }, chartArea: ca } = chart;\n    ctx.save();\n    ctx.beginPath();\n    ctx.rect(ca.left, ca.top, ca.right - ca.left, ca.bottom - ca.top);\n    ctx.clip();\n\n    for (let imp = 1; imp <= 5; imp++) {\n      for (let lik = 1; lik <= 5; lik++) {\n        const score = imp * lik;\n        const x0 = x.getPixelForValue(imp - 0.5);\n        const x1 = x.getPixelForValue(imp + 0.5);\n        const y0 = y.getPixelForValue(lik + 0.5);\n        const y1 = y.getPixelForValue(lik - 0.5);\n        const w = x1 - x0, h = y1 - y0;\n\n        // Colored fill\n        ctx.fillStyle = cellColor(score);\n        ctx.fillRect(x0, y0, w, h);\n\n        // Cell grid border\n        ctx.strokeStyle = THEME === \"light\" ? \"rgba(255,255,255,0.45)\" : \"rgba(0,0,0,0.35)\";\n        ctx.lineWidth = 1.5;\n        ctx.strokeRect(x0, y0, w, h);\n\n        // Risk score in bottom-right corner\n        ctx.font = \"bold 12px sans-serif\";\n        ctx.fillStyle = THEME === \"light\" ? \"rgba(255,255,255,0.70)\" : \"rgba(255,255,255,0.65)\";\n        ctx.textAlign = \"right\";\n        ctx.textBaseline = \"bottom\";\n        ctx.fillText(String(score), x1 - 7, y1 - 5);\n      }\n    }\n\n    // Zone labels in free cells representative of each zone\n    const zones = [\n      { label: \"LOW\",      imp: 1, lik: 1 }, // score 1\n      { label: \"MEDIUM\",   imp: 2, lik: 3 }, // score 6\n      { label: \"HIGH\",     imp: 4, lik: 4 }, // score 16\n      { label: \"CRITICAL\", imp: 5, lik: 5 }, // score 25\n    ];\n    zones.forEach(({ label, imp, lik }) => {\n      const cx = (x.getPixelForValue(imp - 0.5) + x.getPixelForValue(imp + 0.5)) / 2;\n      const cy = (y.getPixelForValue(lik + 0.5) + y.getPixelForValue(lik - 0.5)) / 2;\n      ctx.font = \"bold 13px sans-serif\";\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n      ctx.fillStyle = THEME === \"light\" ? \"rgba(255,255,255,0.90)\" : \"rgba(255,255,255,0.85)\";\n      ctx.fillText(label, cx, cy);\n    });\n\n    ctx.restore();\n  },\n};\n\n// --- Plugin: draw risk item name labels near each marker --------------------\nconst riskLabels = {\n  id: \"riskLabels\",\n  afterDraw(chart) {\n    const { ctx, scales: { x, y } } = chart;\n    ctx.save();\n\n    chart.data.datasets.forEach(ds => {\n      ds.data.forEach(pt => {\n        const px = x.getPixelForValue(pt.x);\n        const py = y.getPixelForValue(pt.y);\n        const label = pt.name;\n        const above = pt.y <= 3; // high likelihood → point near top → label below\n\n        ctx.font = \"600 11px sans-serif\";\n        const tw = ctx.measureText(label).width;\n        const th = 13, pad = 4;\n        const bw = tw + pad * 2, bh = th + pad;\n        const bx = px - bw / 2;\n        const by = above ? py - 18 - bh : py + 18;\n\n        // Background pill for legibility\n        ctx.fillStyle = THEME === \"light\" ? \"rgba(250,248,241,0.92)\" : \"rgba(26,26,23,0.92)\";\n        ctx.beginPath();\n        ctx.roundRect(bx, by, bw, bh, 3);\n        ctx.fill();\n\n        ctx.fillStyle = THEME === \"light\" ? \"#1A1A17\" : \"#F0EFE8\";\n        ctx.textAlign = \"center\";\n        ctx.textBaseline = \"middle\";\n        ctx.fillText(label, px, by + bh / 2);\n      });\n    });\n\n    ctx.restore();\n  },\n};\n\n// --- Plugin: zone severity legend box ---------------------------------------\nconst zoneLegend = {\n  id: \"zoneLegend\",\n  afterDraw(chart) {\n    const { ctx, chartArea: ca } = chart;\n    ctx.save();\n\n    const entries = [\n      { label: \"Low (1–4)\",      score: 2  },\n      { label: \"Medium (5–9)\",   score: 7  },\n      { label: \"High (10–16)\",   score: 13 },\n      { label: \"Critical (17–25)\", score: 22 },\n    ];\n\n    ctx.font = \"12px sans-serif\";\n    const maxW = Math.max(...entries.map(e => ctx.measureText(e.label).width));\n    const swW = 14, swH = 12, gap = 6, lineH = 22, pad = 10;\n    const boxW = pad * 2 + swW + gap + maxW + 4;\n    const boxH = pad * 2 + entries.length * lineH - (lineH - swH);\n\n    const bx = ca.right - boxW - 10;\n    const by = ca.bottom - boxH - 10;\n\n    // Legend box background\n    ctx.fillStyle = t.elevatedBg;\n    ctx.strokeStyle = THEME === \"light\" ? \"rgba(26,26,23,0.15)\" : \"rgba(240,239,232,0.15)\";\n    ctx.lineWidth = 1;\n    ctx.beginPath();\n    ctx.roundRect(bx, by, boxW, boxH, 5);\n    ctx.fill();\n    ctx.stroke();\n\n    entries.forEach((entry, i) => {\n      const rowY = by + pad + i * lineH;\n      // Color swatch\n      ctx.fillStyle = cellColor(entry.score);\n      ctx.beginPath();\n      ctx.roundRect(bx + pad, rowY + (lineH - swH) / 2, swW, swH, 2);\n      ctx.fill();\n      // Zone label text\n      ctx.font = \"12px sans-serif\";\n      ctx.fillStyle = THEME === \"light\" ? \"#4A4A44\" : \"#B8B7B0\";\n      ctx.textAlign = \"left\";\n      ctx.textBaseline = \"middle\";\n      ctx.fillText(entry.label, bx + pad + swW + gap, rowY + lineH / 2);\n    });\n\n    ctx.restore();\n  },\n};\n\n// --- Mount ------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart ------------------------------------------------------------------\nconst IMPACT_LABELS = [\"\", \"Negligible\", \"Minor\", \"Moderate\", \"Major\", \"Catastrophic\"];\nconst LIKELIHOOD_LABELS = [\"\", \"Rare\", \"Unlikely\", \"Possible\", \"Likely\", \"Almost Certain\"];\n\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: {\n      title: {\n        display: true,\n        text: \"heatmap-risk-matrix · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22, weight: \"600\" },\n        padding: { top: 16, bottom: 10 },\n      },\n      legend: {\n        display: true,\n        position: \"bottom\",\n        labels: {\n          color: t.ink,\n          font: { size: 15 },\n          padding: 20,\n          usePointStyle: true,\n          pointStyle: \"circle\",\n        },\n      },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: 0.5,\n        max: 5.5,\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 13 },\n          stepSize: 1,\n          callback(val) {\n            const v = Math.round(val);\n            return v >= 1 && v <= 5 ? IMPACT_LABELS[v] : \"\";\n          },\n          maxRotation: 0,\n        },\n        grid: { display: false },\n        border: { color: t.inkSoft, width: 1.5 },\n        title: {\n          display: true,\n          text: \"Impact\",\n          color: t.ink,\n          font: { size: 15, weight: \"600\" },\n          padding: { top: 8 },\n        },\n      },\n      y: {\n        type: \"linear\",\n        min: 0.5,\n        max: 5.5,\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 13 },\n          stepSize: 1,\n          callback(val) {\n            const v = Math.round(val);\n            return v >= 1 && v <= 5 ? LIKELIHOOD_LABELS[v] : \"\";\n          },\n        },\n        grid: { display: false },\n        border: { color: t.inkSoft, width: 1.5 },\n        title: {\n          display: true,\n          text: \"Likelihood\",\n          color: t.ink,\n          font: { size: 15, weight: \"600\" },\n          padding: { bottom: 8 },\n        },\n      },\n    },\n    layout: {\n      padding: { top: 8, right: 16, bottom: 8, left: 8 },\n    },\n  },\n  plugins: [matrixBg, riskLabels, zoneLegend],\n});\n"}