{"spec_id":"heatmap-risk-matrix","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nheatmap-risk-matrix: Risk Assessment Matrix (Probability vs Impact)\nLibrary: pygal 3.1.3 | Python 3.13.14\nQuality: 84/100 | Updated: 2026-06-20\n\"\"\"\n\nimport importlib\nimport os\nimport sys\n\nimport numpy as np\n\n\n# Remove this script's directory so 'pygal' resolves to the installed package, not this file\n_script_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p) != _script_dir]\nGraph = importlib.import_module(\"pygal.graph.graph\").Graph\nStyle = importlib.import_module(\"pygal.style\").Style\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint categorical palette — 8 hues in hybrid-v3 sort order\nIMPRINT_PALETTE = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\n\n# Zone colors: Imprint-aligned semantic gradient (green → amber → orange → red)\nLOW_COLOR = \"#4DAF8D\"\nMED_COLOR = \"#DDCC77\"\nHIGH_COLOR = \"#CC7830\"\nCRIT_COLOR = \"#AE3030\"\nZONE_COLORS = [(4, LOW_COLOR), (9, MED_COLOR), (16, HIGH_COLOR), (25, CRIT_COLOR)]\n\n# Category colors — Imprint palette positions, avoiding green to prevent clash with Low zone\nCAT_COLORS = {\"Technical\": \"#4467A3\", \"Financial\": \"#C475FD\", \"Operational\": \"#BD8233\", \"External\": \"#2ABCCD\"}\n\n# Data\nlikelihood_labels = [\"Rare\", \"Unlikely\", \"Possible\", \"Likely\", \"Almost Certain\"]\nimpact_labels = [\"Negligible\", \"Minor\", \"Moderate\", \"Major\", \"Catastrophic\"]\n\nrisk_scores = [[li * im for im in range(1, 6)] for li in range(1, 6)]\n\nrisk_items = [\n    {\"name\": \"Server Outage\", \"likelihood\": 3, \"impact\": 4, \"category\": \"Technical\", \"color\": CAT_COLORS[\"Technical\"]},\n    {\"name\": \"Data Breach\", \"likelihood\": 2, \"impact\": 5, \"category\": \"Technical\", \"color\": CAT_COLORS[\"Technical\"]},\n    {\"name\": \"Bgt. Overrun\", \"likelihood\": 4, \"impact\": 3, \"category\": \"Financial\", \"color\": CAT_COLORS[\"Financial\"]},\n    {\"name\": \"Currency Risk\", \"likelihood\": 3, \"impact\": 2, \"category\": \"Financial\", \"color\": CAT_COLORS[\"Financial\"]},\n    {\n        \"name\": \"Vendor Delay\",\n        \"likelihood\": 4,\n        \"impact\": 2,\n        \"category\": \"Operational\",\n        \"color\": CAT_COLORS[\"Operational\"],\n    },\n    {\n        \"name\": \"Staff Turnover\",\n        \"likelihood\": 4,\n        \"impact\": 1,\n        \"category\": \"Operational\",\n        \"color\": CAT_COLORS[\"Operational\"],\n    },\n    {\n        \"name\": \"Scope Creep\",\n        \"likelihood\": 5,\n        \"impact\": 2,\n        \"category\": \"Operational\",\n        \"color\": CAT_COLORS[\"Operational\"],\n    },\n    {\"name\": \"Reg. Change\", \"likelihood\": 2, \"impact\": 4, \"category\": \"External\", \"color\": CAT_COLORS[\"External\"]},\n    {\"name\": \"Supply Chain\", \"likelihood\": 3, \"impact\": 5, \"category\": \"External\", \"color\": CAT_COLORS[\"External\"]},\n    {\"name\": \"Market Shift\", \"likelihood\": 2, \"impact\": 3, \"category\": \"Financial\", \"color\": CAT_COLORS[\"Financial\"]},\n    {\"name\": \"Tech Debt\", \"likelihood\": 4, \"impact\": 4, \"category\": \"Technical\", \"color\": CAT_COLORS[\"Technical\"]},\n    {\"name\": \"Cyber Attack\", \"likelihood\": 1, \"impact\": 4, \"category\": \"Technical\", \"color\": CAT_COLORS[\"Technical\"]},\n    {\"name\": \"Key Person\", \"likelihood\": 4, \"impact\": 5, \"category\": \"Operational\", \"color\": CAT_COLORS[\"Operational\"]},\n    {\"name\": \"Pandemic\", \"likelihood\": 1, \"impact\": 5, \"category\": \"External\", \"color\": CAT_COLORS[\"External\"]},\n]\n\n\nclass RiskMatrixHeatmap(Graph):\n    _series_margin = 0\n\n    def _plot(self):\n        n_rows = len(likelihood_labels)\n        n_cols = len(impact_labels)\n\n        plot_width = self.view.width\n        plot_height = self.view.height\n\n        margin_left = 420\n        margin_bottom = 210\n        margin_top = 20\n        margin_right = 550\n\n        avail_w = plot_width - margin_left - margin_right\n        avail_h = plot_height - margin_bottom - margin_top\n\n        cell_size = min(avail_w / n_cols, avail_h / n_rows) * 0.94\n        gap = cell_size * 0.04\n\n        grid_w = n_cols * (cell_size + gap) - gap\n        grid_h = n_rows * (cell_size + gap) - gap\n\n        x0 = self.view.x(0) + margin_left + (avail_w - grid_w) / 2\n        y0 = self.view.y(n_rows) + margin_top + (avail_h - grid_h) / 2\n\n        plot_node = self.nodes[\"plot\"]\n        group = self.svg.node(plot_node, class_=\"risk-matrix\")\n\n        score_font = int(cell_size * 0.24)\n        zone_font = int(cell_size * 0.15)\n        marker_font = int(cell_size * 0.105)\n        marker_r = int(cell_size * 0.09)\n        label_font = int(cell_size * 0.165)\n        title_font = int(cell_size * 0.22)\n        legend_font = int(cell_size * 0.155)\n        legend_box = int(cell_size * 0.14)\n\n        # --- Draw cells ---\n        for i in range(n_rows):\n            for j in range(n_cols):\n                row_idx = n_rows - 1 - i\n                score = risk_scores[row_idx][j]\n                color = next((c for s, c in ZONE_COLORS if score <= s), ZONE_COLORS[-1][1])\n                r, g, b = int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16)\n                tc = \"#1A1A17\" if (r * 299 + g * 587 + b * 114) / 1000 > 140 else \"#F0EFE8\"\n                zone_label = \"Low\" if score <= 4 else \"Medium\" if score <= 9 else \"High\" if score <= 16 else \"Critical\"\n\n                x = x0 + j * (cell_size + gap)\n                y = y0 + i * (cell_size + gap)\n\n                cell_group = self.svg.node(group, \"g\", class_=\"cell\")\n                rect = self.svg.node(cell_group, \"rect\", x=x, y=y, width=cell_size, height=cell_size, rx=8, ry=8)\n                rect.set(\"fill\", color)\n                rect.set(\"stroke\", PAGE_BG)\n                rect.set(\"stroke-width\", \"4\")\n\n                title_el = self.svg.node(cell_group, \"title\")\n                title_el.text = f\"Risk Score: {score} ({zone_label})\"\n\n                txt = self.svg.node(cell_group, \"text\", x=x + cell_size / 2, y=y + cell_size * 0.38)\n                txt.set(\"text-anchor\", \"middle\")\n                txt.set(\"fill\", tc)\n                txt.set(\"style\", f\"font-size:{score_font}px;font-weight:bold;font-family:sans-serif\")\n                txt.text = str(score)\n\n                zt = self.svg.node(cell_group, \"text\", x=x + cell_size / 2, y=y + cell_size * 0.58)\n                zt.set(\"text-anchor\", \"middle\")\n                zt.set(\"fill\", tc)\n                zt.set(\"opacity\", \"0.75\")\n                zt.set(\"style\", f\"font-size:{zone_font}px;font-weight:500;font-family:sans-serif\")\n                zt.text = zone_label\n\n        # --- Risk item markers ---\n        np.random.seed(42)\n        cell_items = {}\n        for item in risk_items:\n            key = (item[\"likelihood\"], item[\"impact\"])\n            cell_items.setdefault(key, []).append(item)\n\n        for (lik, imp), items in cell_items.items():\n            col_idx = imp - 1\n            row_display = n_rows - lik\n            cell_x = x0 + col_idx * (cell_size + gap)\n            cell_y = y0 + row_display * (cell_size + gap)\n            n_items = len(items)\n\n            for idx, item in enumerate(items):\n                color = item.get(\"color\", IMPRINT_PALETTE[2])\n\n                if n_items == 1:\n                    offset_x = 0\n                else:\n                    spread = cell_size * 0.48\n                    offset_x = -spread / 2 + idx * spread / (n_items - 1)\n\n                jitter_y = np.random.uniform(-cell_size * 0.04, cell_size * 0.04)\n\n                cx = cell_x + cell_size / 2 + offset_x\n                cy = cell_y + cell_size * 0.72 + jitter_y\n                pad = marker_r + 8\n                cx = max(cell_x + pad, min(cx, cell_x + cell_size - pad))\n                cy = max(cell_y + pad, min(cy, cell_y + cell_size - pad))\n\n                mg = self.svg.node(group, \"g\", class_=\"risk-marker\")\n\n                circle = self.svg.node(mg, \"circle\", cx=cx, cy=cy, r=marker_r)\n                circle.set(\"fill\", color)\n                circle.set(\"stroke\", PAGE_BG)\n                circle.set(\"stroke-width\", \"3\")\n                circle.set(\"opacity\", \"0.95\")\n\n                label_y = cy + marker_r + marker_font + 2\n                label_y = min(label_y, cell_y + cell_size - 5)\n\n                lbl = self.svg.node(mg, \"text\", x=cx, y=label_y)\n                lbl.set(\"text-anchor\", \"middle\")\n                lbl.set(\"fill\", INK)\n                lbl.set(\"stroke\", PAGE_BG)\n                lbl.set(\"stroke-width\", \"5\")\n                lbl.set(\"paint-order\", \"stroke fill\")\n                lbl.set(\"style\", f\"font-size:{marker_font}px;font-weight:700;font-family:sans-serif\")\n                lbl.text = item[\"name\"]\n\n                tip = self.svg.node(mg, \"title\")\n                score = item[\"likelihood\"] * item[\"impact\"]\n                tip.text = f\"{item['name']} — L:{lik} × I:{imp} = {score}\"\n\n        # --- Y-axis labels ---\n        for i in range(n_rows):\n            row_idx = n_rows - 1 - i\n            y = y0 + i * (cell_size + gap) + cell_size / 2\n\n            num_node = self.svg.node(group, \"text\", x=x0 - 18, y=y + label_font * 0.15)\n            num_node.set(\"text-anchor\", \"end\")\n            num_node.set(\"fill\", INK_SOFT)\n            num_node.set(\"style\", f\"font-size:{label_font}px;font-weight:600;font-family:sans-serif\")\n            num_node.text = f\"{row_idx + 1}. {likelihood_labels[row_idx]}\"\n\n        # --- X-axis labels (rotated 35° for readability) ---\n        for j in range(n_cols):\n            x = x0 + j * (cell_size + gap) + cell_size / 2\n            y = y0 + grid_h + 42\n\n            num_node = self.svg.node(group, \"text\", x=x, y=y)\n            num_node.set(\"text-anchor\", \"end\")\n            num_node.set(\"fill\", INK_SOFT)\n            num_node.set(\"style\", f\"font-size:{label_font}px;font-weight:600;font-family:sans-serif\")\n            num_node.set(\"transform\", f\"rotate(-35, {x}, {y})\")\n            num_node.text = f\"{j + 1}. {impact_labels[j]}\"\n\n        # --- Axis titles ---\n        mid_y = y0 + grid_h / 2\n        yt = self.svg.node(group, \"text\", x=x0 - 250, y=mid_y)\n        yt.set(\"text-anchor\", \"middle\")\n        yt.set(\"fill\", INK)\n        yt.set(\"style\", f\"font-size:{title_font}px;font-weight:bold;font-family:sans-serif;letter-spacing:3px\")\n        yt.set(\"transform\", f\"rotate(-90, {x0 - 250}, {mid_y})\")\n        yt.text = \"LIKELIHOOD\"\n\n        mid_x = x0 + grid_w / 2\n        xt = self.svg.node(group, \"text\", x=mid_x, y=y0 + grid_h + 185)\n        xt.set(\"text-anchor\", \"middle\")\n        xt.set(\"fill\", INK)\n        xt.set(\"style\", f\"font-size:{title_font}px;font-weight:bold;font-family:sans-serif;letter-spacing:3px\")\n        xt.text = \"IMPACT\"\n\n        # --- Zone legend (right side) ---\n        lx = x0 + grid_w + 65\n        ly = y0 + 10\n        zone_items = [\n            (\"Low (1–4)\", LOW_COLOR),\n            (\"Medium (5–9)\", MED_COLOR),\n            (\"High (10–16)\", HIGH_COLOR),\n            (\"Critical (20–25)\", CRIT_COLOR),\n        ]\n        lt = self.svg.node(group, \"text\", x=lx, y=ly)\n        lt.set(\"fill\", INK)\n        lt.set(\"style\", f\"font-size:{legend_font}px;font-weight:bold;font-family:sans-serif\")\n        lt.text = \"Risk Zones\"\n        ly += legend_font + 14\n\n        for label, color in zone_items:\n            rect = self.svg.node(group, \"rect\", x=lx, y=ly, width=legend_box, height=legend_box, rx=4, ry=4)\n            rect.set(\"fill\", color)\n            rect.set(\"stroke\", PAGE_BG)\n            rect.set(\"stroke-width\", \"2\")\n\n            txt = self.svg.node(group, \"text\", x=lx + legend_box + 12, y=ly + legend_box * 0.76)\n            txt.set(\"fill\", INK_SOFT)\n            txt.set(\"style\", f\"font-size:{legend_font}px;font-weight:500;font-family:sans-serif\")\n            txt.text = label\n            ly += legend_box + 20\n\n        # --- Category legend ---\n        ly += 24\n        ct = self.svg.node(group, \"text\", x=lx, y=ly)\n        ct.set(\"fill\", INK)\n        ct.set(\"style\", f\"font-size:{legend_font}px;font-weight:bold;font-family:sans-serif\")\n        ct.text = \"Categories\"\n        ly += legend_font + 12\n\n        for cat, color in CAT_COLORS.items():\n            circ = self.svg.node(group, \"circle\", cx=lx + legend_box / 2, cy=ly + legend_box / 2 - 2, r=marker_r)\n            circ.set(\"fill\", color)\n            circ.set(\"stroke\", PAGE_BG)\n            circ.set(\"stroke-width\", \"2\")\n\n            txt = self.svg.node(group, \"text\", x=lx + legend_box + 12, y=ly + legend_box * 0.73)\n            txt.set(\"fill\", INK_SOFT)\n            txt.set(\"style\", f\"font-size:{legend_font}px;font-weight:500;font-family:sans-serif\")\n            txt.text = cat\n            ly += legend_box + 18\n\n    def _compute(self):\n        n_rows = len(likelihood_labels)\n        n_cols = len(impact_labels)\n        self._box.xmin = 0\n        self._box.xmax = n_cols\n        self._box.ymin = 0\n        self._box.ymax = n_rows\n\n\n# Style — pygal Style carries all theme-adaptive chrome tokens\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=IMPRINT_PALETTE,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=2.5,\n    font_family=\"sans-serif\",\n)\n\n# Plot — square canvas for symmetric heatmap\nTITLE = \"heatmap-risk-matrix · python · pygal · anyplot.ai\"\nchart = RiskMatrixHeatmap(\n    width=2400,\n    height=2400,\n    style=custom_style,\n    title=TITLE,\n    show_legend=False,\n    margin=40,\n    margin_top=50,\n    margin_bottom=60,\n    margin_left=60,\n    margin_right=60,\n    show_x_labels=False,\n    show_y_labels=False,\n    tooltip_border_radius=6,\n)\n\nchart.add(\"\", [0])\n\n# Save\nchart.render_to_png(f\"plot-{THEME}.png\")\n\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}