{"spec_id":"heatmap-risk-matrix","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nheatmap-risk-matrix: Risk Assessment Matrix (Probability vs Impact)\nLibrary: altair 6.2.1 | Python 3.13.14\nQuality: 85/100 | Updated: 2026-06-20\n\"\"\"\n\nimport importlib\nimport os\nimport sys\n\n\n# Drop script directory from sys.path so `altair` resolves to the installed package, not this file\nsys.path[:] = [p for p in sys.path if os.path.abspath(p or \".\") != os.path.dirname(os.path.abspath(__file__))]\nalt = importlib.import_module(\"altair\")\nnp = importlib.import_module(\"numpy\")\npd = importlib.import_module(\"pandas\")\nImage = importlib.import_module(\"PIL.Image\")\n\n# Theme tokens — Imprint palette, theme-adaptive chrome\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\"\n\n# Imprint categorical palette — first 3 positions for risk categories\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Data\nnp.random.seed(42)\n\nlikelihood_labels = [\"Rare\", \"Unlikely\", \"Possible\", \"Likely\", \"Almost Certain\"]\nimpact_labels = [\"Negligible\", \"Minor\", \"Moderate\", \"Major\", \"Catastrophic\"]\n\nlikelihood_map = {lbl: i + 1 for i, lbl in enumerate(likelihood_labels)}\nimpact_map = {lbl: i + 1 for i, lbl in enumerate(impact_labels)}\n\n# Background grid — 25 cells with risk scores (likelihood × impact)\ngrid_rows = []\nfor li in range(1, 6):\n    for im in range(1, 6):\n        grid_rows.append(\n            {\"li\": li, \"im\": im, \"risk_score\": li * im, \"x1\": im - 0.5, \"x2\": im + 0.5, \"y1\": li - 0.5, \"y2\": li + 0.5}\n        )\n\ngrid_df = pd.DataFrame(grid_rows)\n\n# Risk items — project risk management scenario\nrisk_items = [\n    {\"risk_name\": \"Server Outage\", \"likelihood\": \"Unlikely\", \"impact\": \"Catastrophic\", \"category\": \"Technical\"},\n    {\"risk_name\": \"Budget Overrun\", \"likelihood\": \"Likely\", \"impact\": \"Major\", \"category\": \"Financial\"},\n    {\"risk_name\": \"Key Staff Loss\", \"likelihood\": \"Possible\", \"impact\": \"Major\", \"category\": \"Operational\"},\n    {\"risk_name\": \"Scope Creep\", \"likelihood\": \"Almost Certain\", \"impact\": \"Moderate\", \"category\": \"Operational\"},\n    {\"risk_name\": \"Data Breach\", \"likelihood\": \"Unlikely\", \"impact\": \"Catastrophic\", \"category\": \"Technical\"},\n    {\"risk_name\": \"Vendor Delay\", \"likelihood\": \"Possible\", \"impact\": \"Moderate\", \"category\": \"Operational\"},\n    {\"risk_name\": \"Reg. Change\", \"likelihood\": \"Unlikely\", \"impact\": \"Major\", \"category\": \"Financial\"},\n    {\"risk_name\": \"Req. Gap\", \"likelihood\": \"Likely\", \"impact\": \"Moderate\", \"category\": \"Technical\"},\n    {\"risk_name\": \"Currency Risk\", \"likelihood\": \"Possible\", \"impact\": \"Minor\", \"category\": \"Financial\"},\n    {\"risk_name\": \"Power Failure\", \"likelihood\": \"Rare\", \"impact\": \"Moderate\", \"category\": \"Technical\"},\n    {\"risk_name\": \"Supply Issue\", \"likelihood\": \"Possible\", \"impact\": \"Major\", \"category\": \"Operational\"},\n    {\"risk_name\": \"Testing Delay\", \"likelihood\": \"Likely\", \"impact\": \"Minor\", \"category\": \"Technical\"},\n    {\"risk_name\": \"Legal Dispute\", \"likelihood\": \"Rare\", \"impact\": \"Catastrophic\", \"category\": \"Financial\"},\n    {\"risk_name\": \"Team Conflict\", \"likelihood\": \"Unlikely\", \"impact\": \"Minor\", \"category\": \"Operational\"},\n    {\"risk_name\": \"Tech Debt\", \"likelihood\": \"Almost Certain\", \"impact\": \"Minor\", \"category\": \"Technical\"},\n]\n\nrisk_df = pd.DataFrame(risk_items)\nrisk_df[\"li\"] = risk_df[\"likelihood\"].map(likelihood_map)\nrisk_df[\"im\"] = risk_df[\"impact\"].map(impact_map)\n\n# Smart jitter — spread items sharing the same cell to avoid overlap\ncell_key = risk_df[\"likelihood\"] + \"|\" + risk_df[\"impact\"]\ncell_counts = cell_key.map(cell_key.value_counts())\ncell_idx = cell_key.groupby(cell_key).cumcount()\n\nrisk_df[\"x\"] = (\n    risk_df[\"im\"]\n    + np.where(cell_counts > 1, (cell_idx - (cell_counts - 1) / 2) * 0.30, 0)\n    + np.random.uniform(-0.04, 0.04, len(risk_df))\n)\nrisk_df[\"y\"] = (\n    risk_df[\"li\"]\n    + np.where(cell_counts > 1, (cell_idx - (cell_counts - 1) / 2) * 0.20, 0)\n    + np.random.uniform(-0.04, 0.04, len(risk_df))\n)\n\n# Label y-positions: alternate above/below for same-cell items to prevent overlap\n# Chart height 460px for 5 data units → 0.16 units ≈ 15px above, -0.22 ≈ 20px below\nrisk_df[\"label_y\"] = risk_df[\"y\"] + np.where(cell_counts == 1, 0.16, np.where(cell_idx % 2 == 0, 0.16, -0.22))\n\n# Color scales\n# Heatmap background: spec-mandated green→yellow→orange→red risk gradient\ncolor_scale = alt.Scale(\n    domain=[1, 5, 10, 16, 25], range=[\"#4caf50\", \"#c6d93e\", \"#ff9800\", \"#f44336\", \"#b71c1c\"], interpolate=\"lab\"\n)\n\n# Category markers: Imprint palette positions 1–3\ncategory_scale = alt.Scale(\n    domain=[\"Technical\", \"Financial\", \"Operational\"], range=[IMPRINT_PALETTE[0], IMPRINT_PALETTE[1], IMPRINT_PALETTE[2]]\n)\n\n# Axis label expressions — map numeric ticks to descriptive domain terms\nx_label_expr = \" : \".join(f\"datum.value === {i + 1} ? '{lbl}'\" for i, lbl in enumerate(impact_labels)) + \" : ''\"\ny_label_expr = \" : \".join(f\"datum.value === {i + 1} ? '{lbl}'\" for i, lbl in enumerate(likelihood_labels)) + \" : ''\"\n\nx_axis = alt.Axis(\n    values=[1, 2, 3, 4, 5],\n    labelExpr=x_label_expr,\n    labelFontSize=11,\n    titleFontSize=14,\n    titleFontWeight=\"bold\",\n    labelAngle=0,\n    domainWidth=0,\n    tickWidth=0,\n    titlePadding=12,\n    labelPadding=8,\n    labelColor=INK_SOFT,\n    titleColor=INK,\n)\ny_axis = alt.Axis(\n    values=[1, 2, 3, 4, 5],\n    labelExpr=y_label_expr,\n    labelFontSize=11,\n    titleFontSize=14,\n    titleFontWeight=\"bold\",\n    domainWidth=0,\n    tickWidth=0,\n    titlePadding=12,\n    labelPadding=8,\n    labelColor=INK_SOFT,\n    titleColor=INK,\n)\n\nx_scale = alt.Scale(domain=[0.5, 5.5])\ny_scale = alt.Scale(domain=[0.5, 5.5])\n\n# Layer 1: Heatmap background cells\nheatmap = (\n    alt.Chart(grid_df)\n    .mark_rect(stroke=PAGE_BG, strokeWidth=3, cornerRadius=4)\n    .encode(\n        x=alt.X(\"x1:Q\", scale=x_scale, axis=None),\n        x2=\"x2:Q\",\n        y=alt.Y(\"y1:Q\", scale=y_scale, axis=None),\n        y2=\"y2:Q\",\n        color=alt.Color(\"risk_score:Q\", scale=color_scale, legend=None),\n    )\n)\n\n# Layer 2: Risk score watermarks (subtle numbers in each cell)\nscore_text = (\n    alt.Chart(grid_df)\n    .mark_text(fontSize=22, fontWeight=\"bold\", opacity=0.18)\n    .encode(\n        x=alt.X(\"im:Q\", scale=x_scale, axis=None),\n        y=alt.Y(\"li:Q\", scale=y_scale, axis=None),\n        text=alt.Text(\"risk_score:Q\"),\n        color=alt.condition(\n            alt.datum.risk_score > 12, alt.value(\"rgba(255,255,255,0.7)\"), alt.value(\"rgba(0,0,0,0.35)\")\n        ),\n    )\n)\n\n# Layer 3: Risk item markers — Imprint palette positions 1–3 for categories\nmarkers = (\n    alt.Chart(risk_df)\n    .mark_circle(size=200, stroke=PAGE_BG, strokeWidth=2.5, opacity=0.92)\n    .encode(\n        x=alt.X(\"x:Q\", scale=x_scale, title=\"Impact\", axis=x_axis),\n        y=alt.Y(\"y:Q\", scale=y_scale, title=\"Likelihood\", axis=y_axis),\n        color=alt.Color(\"category:N\", scale=category_scale, legend=None),\n        tooltip=[\n            alt.Tooltip(\"risk_name:N\", title=\"Risk\"),\n            alt.Tooltip(\"category:N\", title=\"Category\"),\n            alt.Tooltip(\"likelihood:N\", title=\"Likelihood\"),\n            alt.Tooltip(\"impact:N\", title=\"Impact\"),\n        ],\n    )\n)\n\n# Layer 4: Risk item labels — 11px normal weight, positioned above/below per item\nlabels = (\n    alt.Chart(risk_df)\n    .mark_text(fontSize=11, align=\"center\", baseline=\"middle\")\n    .encode(\n        x=alt.X(\"x:Q\", scale=x_scale, axis=None),\n        y=alt.Y(\"label_y:Q\", scale=y_scale, axis=None),\n        text=alt.Text(\"risk_name:N\"),\n        color=alt.value(INK),\n    )\n)\n\n# Layer 5: Invisible marks to carry the category legend\nlegend_source = pd.DataFrame({\"category\": [\"Technical\", \"Financial\", \"Operational\"], \"x\": [1] * 3, \"y\": [1] * 3})\nlegend_layer = (\n    alt.Chart(legend_source)\n    .mark_circle(size=0, opacity=0)\n    .encode(\n        x=alt.X(\"x:Q\", scale=x_scale, axis=None),\n        y=alt.Y(\"y:Q\", scale=y_scale, axis=None),\n        color=alt.Color(\n            \"category:N\",\n            scale=category_scale,\n            legend=alt.Legend(\n                title=\"Risk Category\",\n                titleFontSize=13,\n                titleFontWeight=\"bold\",\n                labelFontSize=11,\n                symbolSize=180,\n                orient=\"none\",\n                legendX=5,\n                legendY=372,\n                direction=\"vertical\",\n                fillColor=ELEVATED_BG,\n                strokeColor=INK_SOFT,\n                padding=7,\n                cornerRadius=5,\n                titleColor=INK,\n                labelColor=INK_SOFT,\n            ),\n        ),\n    )\n)\n\n# Compose chart — square canvas: width=500, height=460 → pads to 2400×2400\nchart = (\n    alt.layer(heatmap, score_text, markers, labels, legend_layer)\n    .properties(\n        width=465,\n        height=460,\n        background=PAGE_BG,\n        title=alt.Title(\n            \"heatmap-risk-matrix · python · altair · anyplot.ai\",\n            fontSize=16,\n            fontWeight=\"bold\",\n            anchor=\"middle\",\n            color=INK,\n            subtitle=[\n                \"Project risk assessment — 15 risks plotted by likelihood and impact severity\",\n                \"Risk Zones:  Low (1–4)  ·  Medium (5–9)  ·  High (10–16)  ·  Critical (20–25)\",\n            ],\n            subtitleFontSize=13,\n            subtitleColor=INK_SOFT,\n            subtitlePadding=8,\n        ),\n    )\n    .resolve_axis(x=\"independent\", y=\"independent\")\n    .configure_view(strokeWidth=0, fill=PAGE_BG)\n    .configure_axis(labelColor=INK_SOFT, titleColor=INK, domainColor=INK_SOFT, tickColor=INK_SOFT, gridOpacity=0)\n    .configure_title(color=INK)\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n)\n\n# Save PNG then pad to exact 2400×2400 square target\nTW, TH = 2400, 2400\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        \"Shrink chart .properties(width=, height=) and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\n# Save interactive HTML\nchart.save(f\"plot-{THEME}.html\")\n"}