{"spec_id":"heatmap-risk-matrix","library":"muix","language":"javascript","code":"// anyplot.ai\n// heatmap-risk-matrix: Risk Assessment Matrix (Probability vs Impact)\n// Library: muix 9.16.0 | JavaScript 22.23.3\n// Quality: 87/100 | Updated: 2026-10-24\n//# anyplot-orientation: square\nimport { ChartsContainer } from \"@mui/x-charts/ChartsContainer\";\nimport { ScatterPlot } from \"@mui/x-charts/ScatterChart\";\nimport { ChartsXAxis } from \"@mui/x-charts/ChartsXAxis\";\nimport { ChartsYAxis } from \"@mui/x-charts/ChartsYAxis\";\nimport { useDrawingArea } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\n\n// Constants\nconst W = window.ANYPLOT_SIZE.width;\nconst H = window.ANYPLOT_SIZE.height;\nconst FONT = \"system-ui, -apple-system, sans-serif\";\nconst TITLE = \"heatmap-risk-matrix · javascript · muix · anyplot.ai\";\n\nconst RATING_MIN = 1;\nconst RATING_MAX = 5;\nconst CELL_FILL_OPACITY = 0.62;\nconst DARK_INK = \"#1A1A17\";\nconst LIGHT_INK = \"#FAF8F1\";\n\nconst LIKELIHOOD_LABELS = [\n  \"Rare (1)\",\n  \"Unlikely (2)\",\n  \"Possible (3)\",\n  \"Likely (4)\",\n  \"Almost Certain (5)\",\n];\nconst IMPACT_LABELS = [\n  \"Negligible (1)\",\n  \"Minor (2)\",\n  \"Moderate (3)\",\n  \"Major (4)\",\n  \"Catastrophic (5)\",\n];\n\nconst ZONE_LEGEND = [\n  { name: \"Low\", label: \"Low (1–4)\", maxScore: 4, color: t.palette[0] },\n  { name: \"Medium\", label: \"Medium (5–9)\", maxScore: 9, color: t.amber },\n  { name: \"High\", label: \"High (10–16)\", maxScore: 16, color: t.palette[3] },\n  { name: \"Critical\", label: \"Critical (20–25)\", maxScore: 25, color: t.palette[4] },\n];\n\n// Columns: set these to your data's column names\nconst RISK_NAME = \"Risk\"; // role: risk_name\nconst LIKELIHOOD = \"Likelihood\"; // role: likelihood\nconst IMPACT = \"Consequence Impact\"; // role: impact\n\n// Data: replace this block with your own records\nconst data = [\n  { [RISK_NAME]: \"Data Breach\", [LIKELIHOOD]: 3, [IMPACT]: 5 },\n  { [RISK_NAME]: \"Ransomware\", [LIKELIHOOD]: 4, [IMPACT]: 5 },\n  { [RISK_NAME]: \"DDoS Attack\", [LIKELIHOOD]: 4, [IMPACT]: 3 },\n  { [RISK_NAME]: \"Supply Chain\", [LIKELIHOOD]: 2, [IMPACT]: 4 },\n  { [RISK_NAME]: \"Insider Threat\", [LIKELIHOOD]: 2, [IMPACT]: 5 },\n  { [RISK_NAME]: \"API Failure\", [LIKELIHOOD]: 3, [IMPACT]: 3 },\n  { [RISK_NAME]: \"Key Staff Loss\", [LIKELIHOOD]: 3, [IMPACT]: 4 },\n  { [RISK_NAME]: \"Budget Cut\", [LIKELIHOOD]: 4, [IMPACT]: 2 },\n  { [RISK_NAME]: \"Compliance\", [LIKELIHOOD]: 2, [IMPACT]: 3 },\n  { [RISK_NAME]: \"Cloud Outage\", [LIKELIHOOD]: 2, [IMPACT]: 4 },\n  { [RISK_NAME]: \"Phishing\", [LIKELIHOOD]: 5, [IMPACT]: 2 },\n  { [RISK_NAME]: \"HW Failure\", [LIKELIHOOD]: 1, [IMPACT]: 3 },\n];\n\n// Plot: reads only data and the constants above\nconst gridCount = RATING_MAX - RATING_MIN + 1;\nconst axisMin = RATING_MIN - 0.5;\nconst axisMax = RATING_MAX + 0.5;\nconst tickRatings = Array.from({ length: gridCount }, (_, i) => RATING_MIN + i);\n\nconst bgInt = parseInt(t.pageBg.slice(1), 16);\nconst bgR = (bgInt >> 16) & 255;\nconst bgG = (bgInt >> 8) & 255;\nconst bgB = bgInt & 255;\n\nconst gridCells: Array<{\n  lh: number;\n  imp: number;\n  score: number;\n  color: string;\n  scoreTextFill: string;\n}> = [];\n\nfor (let lh = RATING_MIN; lh <= RATING_MAX; lh++) {\n  for (let imp = RATING_MIN; imp <= RATING_MAX; imp++) {\n    const score = lh * imp;\n    const zone = ZONE_LEGEND.find((z) => score <= z.maxScore) ?? ZONE_LEGEND[ZONE_LEGEND.length - 1];\n    const zInt = parseInt(zone.color.slice(1), 16);\n    const zR = (zInt >> 16) & 255;\n    const zG = (zInt >> 8) & 255;\n    const zB = zInt & 255;\n    const effR = bgR * (1 - CELL_FILL_OPACITY) + zR * CELL_FILL_OPACITY;\n    const effG = bgG * (1 - CELL_FILL_OPACITY) + zG * CELL_FILL_OPACITY;\n    const effB = bgB * (1 - CELL_FILL_OPACITY) + zB * CELL_FILL_OPACITY;\n    const lum = (0.2126 * effR + 0.7152 * effG + 0.0722 * effB) / 255;\n    const scoreTextFill = lum > 0.52 ? DARK_INK : LIGHT_INK;\n    gridCells.push({ lh, imp, score, color: zone.color, scoreTextFill });\n  }\n}\n\nconst cellCounts = new Map<string, number>();\nfor (const r of data) {\n  const k = `${r[LIKELIHOOD]}-${r[IMPACT]}`;\n  cellCounts.set(k, (cellCounts.get(k) ?? 0) + 1);\n}\n\nconst cellSeen = new Map<string, number>();\nconst risksPlaced = data.map((r, id) => {\n  const lh = r[LIKELIHOOD];\n  const imp = r[IMPACT];\n  const k = `${lh}-${imp}`;\n  const idx = cellSeen.get(k) ?? 0;\n  cellSeen.set(k, idx + 1);\n  const siblings = cellCounts.get(k)!;\n  if (siblings === 1) {\n    return {\n      id,\n      name: r[RISK_NAME],\n      lh,\n      imp,\n      jx: imp,\n      jy: lh,\n      labelBelow: false,\n    };\n  }\n  const angle = (2 * Math.PI * idx) / siblings - Math.PI / 2;\n  return {\n    id,\n    name: r[RISK_NAME],\n    lh,\n    imp,\n    jx: imp + 0.14 * Math.sin(angle),\n    jy: lh - 0.18 * Math.cos(angle),\n    labelBelow: idx % 2 === 1,\n  };\n});\n\nconst scatterData = risksPlaced.map((r) => ({ id: r.id, x: r.jx, y: r.jy }));\n\nconst zoneCountMap = new Map<string, number>();\nfor (const z of ZONE_LEGEND) zoneCountMap.set(z.name, 0);\nfor (const r of data) {\n  const score = r[LIKELIHOOD] * r[IMPACT];\n  const zone = ZONE_LEGEND.find((z) => score <= z.maxScore) ?? ZONE_LEGEND[ZONE_LEGEND.length - 1];\n  zoneCountMap.set(zone.name, (zoneCountMap.get(zone.name) ?? 0) + 1);\n}\nconst topTwoSummary = ZONE_LEGEND.slice(-2)\n  .reverse()\n  .map((z) => `${zoneCountMap.get(z.name)} ${z.name}`)\n  .join(\", \");\n\nconst SUBTITLE = `${data.length} ${RISK_NAME}s across ${gridCount}×${gridCount} ${LIKELIHOOD} × ${IMPACT} matrix (${topTwoSummary})`;\n\nfunction RiskMatrixLayer() {\n  const { left, top, width, height } = useDrawingArea();\n  const cW = width / gridCount;\n  const cH = height / gridCount;\n  const legendY = top + height + 104;\n  const colW = width / ZONE_LEGEND.length;\n\n  return (\n    <g>\n      <text\n        x={W / 2}\n        y={42}\n        textAnchor=\"middle\"\n        fontSize={22}\n        fontWeight=\"600\"\n        fill={t.ink}\n        fontFamily={FONT}\n      >\n        {TITLE}\n      </text>\n      <text\n        x={W / 2}\n        y={70}\n        textAnchor=\"middle\"\n        fontSize={15}\n        fill={t.inkSoft}\n        fontFamily={FONT}\n      >\n        {SUBTITLE}\n      </text>\n\n      {gridCells.map((cell) => {\n        const cx = left + (cell.imp - RATING_MIN) * cW;\n        const cy = top + (RATING_MAX - cell.lh) * cH;\n        return (\n          <g key={`cell-${cell.lh}-${cell.imp}`}>\n            <rect\n              x={cx}\n              y={cy}\n              width={cW}\n              height={cH}\n              fill={cell.color}\n              fillOpacity={CELL_FILL_OPACITY}\n              stroke={t.ink}\n              strokeOpacity={0.24}\n              strokeWidth={1.5}\n            />\n            <text\n              x={cx + cW - 10}\n              y={cy + cH - 10}\n              textAnchor=\"end\"\n              fontSize={14}\n              fontWeight=\"600\"\n              fill={cell.scoreTextFill}\n              fontFamily={FONT}\n            >\n              {cell.score}\n            </text>\n          </g>\n        );\n      })}\n\n      {ZONE_LEGEND.map((z, i) => {\n        const lx = left + colW * i + colW / 2 - 72;\n        return (\n          <g key={z.label}>\n            <rect\n              x={lx}\n              y={legendY - 10}\n              width={20}\n              height={20}\n              fill={z.color}\n              fillOpacity={CELL_FILL_OPACITY}\n              stroke={t.ink}\n              strokeOpacity={0.25}\n              rx={3}\n            />\n            <text\n              x={lx + 28}\n              y={legendY + 1}\n              dominantBaseline=\"central\"\n              fontSize={14}\n              fontWeight=\"600\"\n              fill={t.ink}\n              fontFamily={FONT}\n            >\n              {z.label}\n            </text>\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\nfunction RiskLabelsLayer() {\n  const { left, top, width, height } = useDrawingArea();\n\n  return (\n    <g>\n      {risksPlaced.map((r) => {\n        const sx = left + ((r.jx - axisMin) / gridCount) * width;\n        const sy = top + ((axisMax - r.jy) / gridCount) * height;\n        const labelY = r.labelBelow ? sy + 26 : sy - 20;\n        const pillW = r.name.length * 8.2 + 14;\n        const pillH = 22;\n        return (\n          <g key={`risk-${r.id}`}>\n            <rect\n              x={sx - pillW / 2}\n              y={labelY - pillH / 2 - 4}\n              width={pillW}\n              height={pillH}\n              rx={4}\n              fill={t.pageBg}\n              fillOpacity={0.92}\n              stroke={t.grid}\n              strokeWidth={1}\n            />\n            <text\n              x={sx}\n              y={labelY}\n              textAnchor=\"middle\"\n              fontSize={14}\n              fontWeight=\"600\"\n              fill={t.ink}\n              fontFamily={FONT}\n            >\n              {r.name}\n            </text>\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\nexport default function Chart() {\n  return (\n    <ChartsContainer\n      width={W}\n      height={H}\n      margin={{ left: 32, top: 96, right: 44, bottom: 84 }}\n      series={[\n        {\n          type: \"scatter\",\n          id: \"risks\",\n          data: scatterData,\n          color: t.ink,\n          markerSize: 9,\n        },\n      ]}\n      xAxis={[\n        {\n          id: \"impact-axis\",\n          min: axisMin,\n          max: axisMax,\n          tickInterval: tickRatings,\n          height: 62,\n          label: IMPACT,\n          valueFormatter: (v: number) => IMPACT_LABELS[Math.round(v) - RATING_MIN] ?? \"\",\n          tickLabelStyle: { fontSize: 14, fill: t.inkSoft, fontFamily: FONT },\n          labelStyle: {\n            fontSize: 15,\n            fontWeight: 600,\n            fill: t.ink,\n            fontFamily: FONT,\n          },\n        },\n      ]}\n      yAxis={[\n        {\n          id: \"likelihood-axis\",\n          min: axisMin,\n          max: axisMax,\n          tickInterval: tickRatings,\n          width: 158,\n          label: LIKELIHOOD,\n          valueFormatter: (v: number) =>\n            LIKELIHOOD_LABELS[Math.round(v) - RATING_MIN] ?? \"\",\n          tickLabelStyle: { fontSize: 14, fill: t.inkSoft, fontFamily: FONT },\n          labelStyle: {\n            fontSize: 15,\n            fontWeight: 600,\n            fill: t.ink,\n            fontFamily: FONT,\n          },\n        },\n      ]}\n      skipAnimation\n    >\n      <RiskMatrixLayer />\n      <ScatterPlot />\n      <RiskLabelsLayer />\n      <ChartsXAxis axisId=\"impact-axis\" />\n      <ChartsYAxis axisId=\"likelihood-axis\" />\n    </ChartsContainer>\n  );\n}\n"}