Skip to content

Commit 45bb601

Browse files
feat(letsplot): implement heatmap-risk-matrix (#4952)
## Implementation: `heatmap-risk-matrix` - letsplot Implements the **letsplot** version of `heatmap-risk-matrix`. **File:** `plots/heatmap-risk-matrix/implementations/letsplot.py` **Parent Issue:** #4567 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23217984766)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 386ad53 commit 45bb601

2 files changed

Lines changed: 399 additions & 0 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""pyplots.ai
2+
heatmap-risk-matrix: Risk Assessment Matrix (Probability vs Impact)
3+
Library: letsplot 4.9.0 | Python 3.14.3
4+
Quality: 80/100 | Created: 2026-03-17
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from lets_plot import *
10+
11+
12+
LetsPlot.setup_html()
13+
14+
# Data
15+
np.random.seed(42)
16+
17+
likelihood_labels = ["Rare", "Unlikely", "Possible", "Likely", "Almost\nCertain"]
18+
impact_labels = ["Negligible", "Minor", "Moderate", "Major", "Catastrophic"]
19+
20+
# Build background grid with risk zones
21+
grid_rows = []
22+
for li in range(1, 6):
23+
for im in range(1, 6):
24+
score = li * im
25+
if score <= 4:
26+
zone = "Low"
27+
elif score <= 9:
28+
zone = "Medium"
29+
elif score <= 16:
30+
zone = "High"
31+
else:
32+
zone = "Critical"
33+
grid_rows.append({"likelihood": li, "impact": im, "score": score, "zone": zone, "score_label": str(score)})
34+
grid_df = pd.DataFrame(grid_rows)
35+
grid_df["zone"] = pd.Categorical(grid_df["zone"], categories=["Low", "Medium", "High", "Critical"], ordered=True)
36+
37+
# Risk items with shorter names to avoid label overlap
38+
risks = pd.DataFrame(
39+
{
40+
"risk_name": [
41+
"Server Outage",
42+
"Data Breach",
43+
"Budget Overrun",
44+
"Staff Loss",
45+
"Vendor Fail",
46+
"Scope Creep",
47+
"Reg. Change",
48+
"Tech Debt",
49+
"Integ. Bug",
50+
"Supply Delay",
51+
"Currency",
52+
"PR Crisis",
53+
"Patent Issue",
54+
"Power Outage",
55+
"Cyber Attack",
56+
],
57+
"likelihood": [4, 3, 4, 2, 2, 5, 3, 4, 3, 1, 3, 1, 1, 2, 5],
58+
"impact": [5, 5, 3, 4, 3, 2, 3, 2, 4, 3, 2, 5, 4, 1, 5],
59+
"category": [
60+
"Technical",
61+
"Technical",
62+
"Financial",
63+
"Operational",
64+
"Operational",
65+
"Operational",
66+
"Financial",
67+
"Technical",
68+
"Technical",
69+
"Operational",
70+
"Financial",
71+
"Operational",
72+
"Financial",
73+
"Technical",
74+
"Technical",
75+
],
76+
}
77+
)
78+
79+
# Compute risk score for size mapping (visual hierarchy)
80+
risks["risk_score"] = risks["likelihood"] * risks["impact"]
81+
82+
# Smart jitter: offset risks sharing same cell
83+
cell_counts = {}
84+
offsets_x = []
85+
offsets_y = []
86+
for _, row in risks.iterrows():
87+
cell = (row["likelihood"], row["impact"])
88+
idx = cell_counts.get(cell, 0)
89+
cell_counts[cell] = idx + 1
90+
offset_patterns = [(0, 0.15), (0, -0.15), (-0.2, 0), (0.2, 0)]
91+
ox, oy = offset_patterns[idx % len(offset_patterns)]
92+
offsets_x.append(ox)
93+
offsets_y.append(oy)
94+
95+
risks["lk_jitter"] = risks["likelihood"] + np.array(offsets_x)
96+
risks["im_jitter"] = risks["impact"] + np.array(offsets_y)
97+
98+
# Assign label nudge using checkerboard pattern based on grid position
99+
# This ensures adjacent cells always have opposite nudge directions
100+
nudge_vals = []
101+
for _, row in risks.iterrows():
102+
li, imp = int(row["likelihood"]), int(row["impact"])
103+
if (li + imp) % 2 == 0:
104+
nudge_vals.append(0.33)
105+
else:
106+
nudge_vals.append(-0.33)
107+
risks["label_nudge_y"] = nudge_vals
108+
109+
# Tooltips for lets-plot distinctive interactivity
110+
risk_tooltips = (
111+
layer_tooltips()
112+
.line("@risk_name")
113+
.line("Category: @category")
114+
.line("Likelihood: @likelihood")
115+
.line("Impact: @impact")
116+
.line("Risk Score: @risk_score")
117+
)
118+
119+
# Zone palette: green-yellow-orange-red as specified
120+
zone_colors = {"Low": "#2A9D8F", "Medium": "#E9C46A", "High": "#F4A261", "Critical": "#C1121F"}
121+
122+
# Category colors
123+
cat_colors = {"Technical": "#306998", "Financial": "#7B2D8E", "Operational": "#D35400"}
124+
125+
# Split risks for alternating label nudge
126+
risks_up = risks[risks["label_nudge_y"] > 0].copy()
127+
risks_down = risks[risks["label_nudge_y"] < 0].copy()
128+
129+
# Plot
130+
plot = (
131+
ggplot()
132+
+ geom_tile(aes(x="likelihood", y="impact", fill="zone"), data=grid_df, color="white", size=2, tooltips="none")
133+
+ geom_text(aes(x="likelihood", y="impact", label="score_label"), data=grid_df, size=13, color="rgba(0,0,0,0.12)")
134+
+ geom_point(
135+
aes(x="lk_jitter", y="im_jitter", color="category", size="risk_score"),
136+
data=risks,
137+
alpha=0.92,
138+
tooltips=risk_tooltips,
139+
)
140+
+ scale_size(range=[4, 12], name="Risk Score", guide="none")
141+
+ scale_fill_manual(values=zone_colors, name="Risk Level")
142+
+ scale_color_manual(values=cat_colors, name="Category")
143+
+ scale_x_continuous(breaks=[1, 2, 3, 4, 5], labels=likelihood_labels, limits=[0.4, 5.8])
144+
+ scale_y_continuous(breaks=[1, 2, 3, 4, 5], labels=impact_labels, limits=[0.4, 5.6])
145+
+ coord_fixed(ratio=1)
146+
+ labs(
147+
x="Likelihood",
148+
y="Impact",
149+
title="heatmap-risk-matrix · lets-plot · pyplots.ai",
150+
subtitle="Risk score = Likelihood × Impact | Marker size scales with severity",
151+
)
152+
+ theme_minimal()
153+
+ theme(
154+
plot_title=element_text(size=24, face="bold"),
155+
plot_subtitle=element_text(size=15, color="#555555"),
156+
axis_title=element_text(size=20, face="bold"),
157+
axis_text=element_text(size=15),
158+
legend_title=element_text(size=17, face="bold"),
159+
legend_text=element_text(size=14),
160+
panel_grid=element_blank(),
161+
)
162+
+ ggsize(1600, 900)
163+
)
164+
165+
# Add labels with alternating nudge directions to reduce overlap
166+
if len(risks_up) > 0:
167+
plot = plot + geom_text(
168+
aes(x="lk_jitter", y="im_jitter", label="risk_name"), data=risks_up, size=9, nudge_y=0.33, fontface="bold"
169+
)
170+
171+
if len(risks_down) > 0:
172+
plot = plot + geom_text(
173+
aes(x="lk_jitter", y="im_jitter", label="risk_name"), data=risks_down, size=9, nudge_y=-0.33, fontface="bold"
174+
)
175+
176+
# Save
177+
ggsave(plot, "plot.png", path=".", scale=3)
178+
ggsave(plot, "plot.html", path=".")

0 commit comments

Comments
 (0)