{"spec_id":"heatmap-risk-matrix","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nheatmap-risk-matrix: Risk Assessment Matrix (Probability vs Impact)\nLibrary: letsplot 4.10.1 | Python 3.13.14\nQuality: 86/100 | Updated: 2026-06-20\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\n\n\nLetsPlot.setup_html()\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\nSCORE_TEXT = \"rgba(0,0,0,0.20)\" if THEME == \"light\" else \"rgba(255,255,255,0.28)\"\n\n# Data\nnp.random.seed(42)\n\nlikelihood_labels = [\"Rare\", \"Unlikely\", \"Possible\", \"Likely\", \"Almost\\nCertain\"]\nimpact_labels = [\"Negligible\", \"Minor\", \"Moderate\", \"Major\", \"Catastrophic\"]\n\n# 5x5 background grid with risk zones\ngrid_rows = []\nfor li in range(1, 6):\n    for im in range(1, 6):\n        score = li * im\n        zone = \"Low\" if score <= 4 else \"Medium\" if score <= 9 else \"High\" if score <= 16 else \"Critical\"\n        grid_rows.append({\"likelihood\": li, \"impact\": im, \"score\": score, \"zone\": zone, \"score_label\": str(score)})\n\ngrid_df = pd.DataFrame(grid_rows)\ngrid_df[\"zone\"] = pd.Categorical(grid_df[\"zone\"], categories=[\"Low\", \"Medium\", \"High\", \"Critical\"], ordered=True)\n\n# Risk register — IT project management scenario\nrisks = pd.DataFrame(\n    {\n        \"risk_name\": [\n            \"Server Outage\",\n            \"Data Breach\",\n            \"Budget Overrun\",\n            \"Staff Loss\",\n            \"Vendor Fail\",\n            \"Scope Creep\",\n            \"Reg. Change\",\n            \"Tech Debt\",\n            \"Integ. Bug\",\n            \"Supply Delay\",\n            \"Currency Risk\",\n            \"PR Crisis\",\n            \"Patent Issue\",\n            \"Power Outage\",\n            \"Cyber Attack\",\n        ],\n        \"likelihood\": [4, 3, 4, 2, 2, 5, 3, 4, 3, 1, 3, 1, 1, 2, 5],\n        \"impact\": [5, 5, 3, 4, 3, 2, 3, 2, 4, 3, 2, 5, 4, 1, 5],\n        \"category\": [\n            \"Technical\",\n            \"Technical\",\n            \"Financial\",\n            \"Operational\",\n            \"Operational\",\n            \"Operational\",\n            \"Financial\",\n            \"Technical\",\n            \"Technical\",\n            \"Operational\",\n            \"Financial\",\n            \"Operational\",\n            \"Financial\",\n            \"Technical\",\n            \"Technical\",\n        ],\n    }\n)\nrisks[\"risk_score\"] = risks[\"likelihood\"] * risks[\"impact\"]\n\n# Per-cell jitter to separate co-located risks\ncell_counts: dict = {}\noffsets_x, offsets_y = [], []\nfor _, row in risks.iterrows():\n    cell = (int(row[\"likelihood\"]), int(row[\"impact\"]))\n    idx = cell_counts.get(cell, 0)\n    cell_counts[cell] = idx + 1\n    patterns = [(0, 0), (0.20, 0.17), (-0.20, 0.17), (0.20, -0.17)]\n    ox, oy = patterns[idx % len(patterns)]\n    offsets_x.append(ox)\n    offsets_y.append(oy)\n\nrisks[\"lk_jitter\"] = risks[\"likelihood\"] + np.array(offsets_x)\nrisks[\"im_jitter\"] = risks[\"impact\"] + np.array(offsets_y)\n\n# Per-impact-row alternating nudge (sorted by likelihood) to reduce horizontal overlap.\n# Even rank within each impact row → nudge below point; odd rank → nudge above.\n# This staggers labels so adjacent x-positions land on alternating y levels.\nrisks[\"_rank\"] = risks.groupby(\"impact\")[\"likelihood\"].transform(lambda s: s.rank(method=\"first\").astype(int) - 1)\nrisks[\"label_y\"] = risks.apply(lambda r: min(r[\"im_jitter\"] + (-0.28 if r[\"_rank\"] % 2 == 0 else 0.28), 5.35), axis=1)\nrisks = risks.drop(columns=\"_rank\")\n\n# Rich tooltips for interactive HTML\nrisk_tooltips = (\n    layer_tooltips().line(\"@risk_name\").line(\"Category: @category\").line(\"Score: @risk_score  (likelihood × impact)\")\n)\n\n# Zone colors — Imprint semantic mapping: green→safe, amber→caution, ochre→elevated, red→critical\nzone_colors = {\n    \"Low\": \"#009E73\",  # Imprint green  — safe\n    \"Medium\": \"#DDCC77\",  # Imprint amber  — caution\n    \"High\": \"#BD8233\",  # Imprint ochre  — elevated risk\n    \"Critical\": \"#AE3030\",  # Imprint red    — critical alert\n}\n\n# Category marker colors — Imprint palette: first series must be #009E73\ncat_colors = {\n    \"Technical\": \"#009E73\",  # Imprint green — first series\n    \"Financial\": \"#C475FD\",  # Imprint lavender\n    \"Operational\": \"#4467A3\",  # Imprint blue\n}\n\nanyplot_theme = theme(\n    plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n    panel_background=element_rect(fill=PAGE_BG),\n    panel_grid=element_blank(),\n    axis_title=element_text(color=INK, size=12, face=\"bold\"),\n    axis_text=element_text(color=INK_SOFT, size=10),\n    axis_line=element_line(color=INK_SOFT, size=0.5),\n    axis_ticks=element_blank(),\n    plot_title=element_text(color=INK, size=16, face=\"bold\"),\n    plot_subtitle=element_text(color=INK_MUTED, size=10),\n    legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n    legend_text=element_text(color=INK_SOFT, size=10),\n    legend_title=element_text(color=INK, size=11, face=\"bold\"),\n)\n\ntitle = \"heatmap-risk-matrix · python · letsplot · anyplot.ai\"\n\n# Plot\nplot = (\n    ggplot()\n    + geom_tile(aes(x=\"likelihood\", y=\"impact\", fill=\"zone\"), data=grid_df, color=\"white\", size=1.5, tooltips=\"none\")\n    + geom_text(\n        aes(x=\"likelihood\", y=\"impact\", label=\"score_label\"), data=grid_df, size=11, color=SCORE_TEXT, fontface=\"bold\"\n    )\n    + geom_point(\n        aes(x=\"lk_jitter\", y=\"im_jitter\", color=\"category\", size=\"risk_score\"),\n        data=risks,\n        alpha=0.92,\n        tooltips=risk_tooltips,\n    )\n    + geom_text(aes(x=\"lk_jitter\", y=\"label_y\", label=\"risk_name\"), data=risks, size=7, fontface=\"bold\", color=INK)\n    + scale_size(range=[4, 12], name=\"Risk Score\", guide=\"none\")\n    + scale_fill_manual(\n        values=zone_colors,\n        name=\"Risk Level\",\n        breaks=[\"Low\", \"Medium\", \"High\", \"Critical\"],\n        labels=[\"Low (1–4)\", \"Medium (5–9)\", \"High (10–16)\", \"Critical (20–25)\"],\n    )\n    + scale_color_manual(values=cat_colors, name=\"Category\")\n    + scale_x_continuous(breaks=[1, 2, 3, 4, 5], labels=likelihood_labels, limits=[0.4, 5.6])\n    + scale_y_continuous(breaks=[1, 2, 3, 4, 5], labels=impact_labels, limits=[0.4, 5.6])\n    + labs(\n        x=\"Likelihood\",\n        y=\"Impact\",\n        title=title,\n        subtitle=\"Marker size scales with risk score  |  Risk Score = Likelihood × Impact\",\n    )\n    + anyplot_theme\n    + ggsize(600, 600)\n)\n\n# Save\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}