fix: heatmap linear colorscale, cell annotations, autosize (Task A.2)

- Replace non-linear 7-stop colorscale with linear 5-stop in both
  create_heatmap_figure() and create_trust_heatmap_figure()
- Add cell text annotations formatted per metric (patients: N, cost: £Nk, cost_pp_pa: £N)
- Set zmin=0 explicitly for correct color mapping
- Remove fixed width, use autosize=True via _base_layout()
- Replace l=200 fixed margin with l=8 + yaxis automargin=True
- Add subtitle annotation when 25-drug cap is reached
- Reduce xgap/ygap from 2 to 1 when >15 drug columns
- Apply _base_layout() to both heatmap functions for consistent styling
This commit is contained in:
Andrew Charlwood
2026-02-07 02:36:02 +00:00
parent 8b980a755f
commit 56ca11ea30
2 changed files with 91 additions and 52 deletions
+3 -3
View File
@@ -53,7 +53,7 @@ Comprehensive review and improvement of all Plotly charts in the Dash dashboard.
- **Checkpoint**: `python run_dash.py` starts, icicle chart unchanged visually - **Checkpoint**: `python run_dash.py` starts, icicle chart unchanged visually
### A.2 Fix heatmap colorscale + cell annotations (Patient Pathways) ### A.2 Fix heatmap colorscale + cell annotations (Patient Pathways)
- [ ] In `create_heatmap_figure()` (~L1189): - [x] In `create_heatmap_figure()` (~L1189):
1. Replace non-linear colorscale with linear 5-stop: `[0.0 #E3F2FD, 0.25 #90CAF9, 0.5 #42A5F5, 0.75 #1E88E5, 1.0 #003087]` 1. Replace non-linear colorscale with linear 5-stop: `[0.0 #E3F2FD, 0.25 #90CAF9, 0.5 #42A5F5, 0.75 #1E88E5, 1.0 #003087]`
2. Add `text=text_values, texttemplate="%{text}"` with formatted values per metric (patients: `"N"`, cost: `"£Nk"`, cost_pp_pa: `"£N"`) 2. Add `text=text_values, texttemplate="%{text}"` with formatted values per metric (patients: `"N"`, cost: `"£Nk"`, cost_pp_pa: `"£N"`)
3. Set `zmin=0` explicitly 3. Set `zmin=0` explicitly
@@ -61,8 +61,8 @@ Comprehensive review and improvement of all Plotly charts in the Dash dashboard.
5. Replace `l=200` with `l=8` + `yaxis automargin=True` 5. Replace `l=200` with `l=8` + `yaxis automargin=True`
6. Add subtitle annotation when 25-drug cap is hit: `"Showing top 25 of N drugs"` 6. Add subtitle annotation when 25-drug cap is hit: `"Showing top 25 of N drugs"`
7. Reduce `xgap/ygap` from 2→1 when >15 columns 7. Reduce `xgap/ygap` from 2→1 when >15 columns
- [ ] Apply same fixes to `create_trust_heatmap_figure()` (~L1582) - [x] Apply same fixes to `create_trust_heatmap_figure()` (~L1582)
- [ ] Apply `_base_layout()` to both heatmap functions - [x] Apply `_base_layout()` to both heatmap functions
- **Checkpoint**: Heatmaps show linear color gradient, cell text visible, no fixed width overflow - **Checkpoint**: Heatmaps show linear color gradient, cell text visible, no fixed width overflow
### A.3 Fix legend overflow in 4 charts ### A.3 Fix legend overflow in 4 charts
+88 -49
View File
@@ -1273,7 +1273,9 @@ def create_heatmap_figure(
# Cap columns to top 25 drugs for readability # Cap columns to top 25 drugs for readability
max_drugs = 25 max_drugs = 25
total_drug_count = len(drugs)
drugs = drugs[:max_drugs] drugs = drugs[:max_drugs]
capped = total_drug_count > max_drugs
metric_labels = { metric_labels = {
"patients": "Patients", "patients": "Patients",
@@ -1282,13 +1284,15 @@ def create_heatmap_figure(
} }
metric_label = metric_labels.get(metric, "Patients") metric_label = metric_labels.get(metric, "Patients")
# Build 2D arrays for z-values and hover text # Build 2D arrays for z-values, hover text, and cell annotations
z_values = [] z_values = []
hover_texts = [] hover_texts = []
text_values = []
for d in directories: for d in directories:
row_z = [] row_z = []
row_hover = [] row_hover = []
row_text = []
dir_data = matrix.get(d, {}) dir_data = matrix.get(d, {})
for drug in drugs: for drug in drugs:
cell = dir_data.get(drug) cell = dir_data.get(drug)
@@ -1305,31 +1309,45 @@ def create_heatmap_figure(
f"Total cost: £{cost:,.0f}<br>" f"Total cost: £{cost:,.0f}<br>"
f"Cost p.a.: £{cpp:,.0f}" f"Cost p.a.: £{cpp:,.0f}"
) )
# Cell annotation text, formatted per metric
if metric == "cost":
row_text.append(f"£{cost / 1000:.0f}k" if cost >= 1000 else f"£{cost:.0f}")
elif metric == "cost_pp_pa":
row_text.append(f"£{cpp:,.0f}")
else:
row_text.append(f"{patients:,}")
else: else:
row_z.append(0) row_z.append(0)
row_hover.append( row_hover.append(
f"<b>{drug}</b><br>{d}<br>No patients" f"<b>{drug}</b><br>{d}<br>No patients"
) )
row_text.append("")
z_values.append(row_z) z_values.append(row_z)
hover_texts.append(row_hover) hover_texts.append(row_hover)
text_values.append(row_text)
# NHS blue colorscale for the heatmap # Linear 5-stop NHS blue colorscale
colorscale = [ colorscale = [
[0.0, "#F0F4F8"], [0.0, "#E3F2FD"],
[0.01, "#E3F2FD"], [0.25, "#90CAF9"],
[0.1, "#90CAF9"], [0.5, "#42A5F5"],
[0.3, "#42A5F5"], [0.75, "#1E88E5"],
[0.5, "#1E88E5"],
[0.7, "#0066CC"],
[1.0, "#003087"], [1.0, "#003087"],
] ]
n_drugs = len(drugs)
gap = 1 if n_drugs > 15 else 2
fig = go.Figure( fig = go.Figure(
data=go.Heatmap( data=go.Heatmap(
z=z_values, z=z_values,
x=drugs, x=drugs,
y=directories, y=directories,
colorscale=colorscale, colorscale=colorscale,
zmin=0,
text=text_values,
texttemplate="%{text}",
textfont=dict(size=10),
hovertext=hover_texts, hovertext=hover_texts,
hovertemplate="%{hovertext}<extra></extra>", hovertemplate="%{hovertext}<extra></extra>",
colorbar=dict( colorbar=dict(
@@ -1340,8 +1358,8 @@ def create_heatmap_figure(
thickness=15, thickness=15,
len=0.8, len=0.8,
), ),
xgap=2, xgap=gap,
ygap=2, ygap=gap,
) )
) )
@@ -1349,22 +1367,11 @@ def create_heatmap_figure(
if title: if title:
chart_title = f"{chart_title}{title}" chart_title = f"{chart_title}{title}"
n_drugs = len(drugs)
n_dirs = len(directories) n_dirs = len(directories)
fig_width = max(700, 80 + n_drugs * 55)
fig_height = max(400, 80 + n_dirs * 40) fig_height = max(400, 80 + n_dirs * 40)
fig.update_layout( layout = _base_layout(chart_title)
title=dict( layout.update(
text=chart_title,
font=dict(
family="Source Sans 3, system-ui, sans-serif",
size=18,
color="#003087",
),
x=0.5,
xanchor="center",
),
xaxis=dict( xaxis=dict(
title="", title="",
tickfont=dict(size=11, color="#425563"), tickfont=dict(size=11, color="#425563"),
@@ -1375,14 +1382,22 @@ def create_heatmap_figure(
title="", title="",
tickfont=dict(size=12, color="#425563"), tickfont=dict(size=12, color="#425563"),
autorange="reversed", autorange="reversed",
automargin=True,
), ),
plot_bgcolor="rgba(0,0,0,0)", margin=dict(t=60, l=8, r=80, b=120),
paper_bgcolor="rgba(0,0,0,0)",
font=dict(family="Source Sans 3, system-ui, sans-serif"),
margin=dict(t=60, l=200, r=80, b=120),
width=fig_width,
height=fig_height, height=fig_height,
) )
fig.update_layout(**layout)
# Add subtitle when drug cap is reached
if capped:
fig.add_annotation(
text=f"Showing top {max_drugs} of {total_drug_count} drugs",
xref="paper", yref="paper",
x=0.5, y=1.02,
showarrow=False,
font=dict(size=12, color=ANNOTATION_COLOR),
)
return fig return fig
@@ -1661,12 +1676,14 @@ def create_trust_heatmap_figure(
if not trusts or not drugs: if not trusts or not drugs:
return go.Figure() return go.Figure()
total_drug_count = len(drugs)
drugs = drugs[:25] drugs = drugs[:25]
capped = total_drug_count > 25
metric_labels = { metric_labels = {
"patients": "Patients", "patients": "Patients",
"cost": "Total Cost (\u00a3)", "cost": "Total Cost (£)",
"cost_pp_pa": "Cost per Patient p.a. (\u00a3)", "cost_pp_pa": "Cost per Patient p.a. (£)",
} }
metric_label = metric_labels.get(metric, "Patients") metric_label = metric_labels.get(metric, "Patients")
@@ -1675,10 +1692,12 @@ def create_trust_heatmap_figure(
z_values = [] z_values = []
hover_texts = [] hover_texts = []
text_values = []
for t in trusts: for t in trusts:
row_z = [] row_z = []
row_hover = [] row_hover = []
row_text = []
trust_data = matrix.get(t, {}) trust_data = matrix.get(t, {})
for drug in drugs: for drug in drugs:
cell = trust_data.get(drug) cell = trust_data.get(drug)
@@ -1692,57 +1711,77 @@ def create_trust_heatmap_figure(
f"<b>{drug}</b><br>" f"<b>{drug}</b><br>"
f"{short_trust(t)}<br>" f"{short_trust(t)}<br>"
f"Patients: {patients:,}<br>" f"Patients: {patients:,}<br>"
f"Total cost: \u00a3{cost:,.0f}<br>" f"Total cost: £{cost:,.0f}<br>"
f"Cost p.a.: \u00a3{cpp:,.0f}" f"Cost p.a.: £{cpp:,.0f}"
) )
if metric == "cost":
row_text.append(f"£{cost / 1000:.0f}k" if cost >= 1000 else f"£{cost:.0f}")
elif metric == "cost_pp_pa":
row_text.append(f"£{cpp:,.0f}")
else:
row_text.append(f"{patients:,}")
else: else:
row_z.append(0) row_z.append(0)
row_hover.append(f"<b>{drug}</b><br>{short_trust(t)}<br>No patients") row_hover.append(f"<b>{drug}</b><br>{short_trust(t)}<br>No patients")
row_text.append("")
z_values.append(row_z) z_values.append(row_z)
hover_texts.append(row_hover) hover_texts.append(row_hover)
text_values.append(row_text)
# Linear 5-stop NHS blue colorscale
colorscale = [ colorscale = [
[0.0, "#F0F4F8"], [0.01, "#E3F2FD"], [0.1, "#90CAF9"], [0.0, "#E3F2FD"],
[0.3, "#42A5F5"], [0.5, "#1E88E5"], [0.7, "#0066CC"], [1.0, "#003087"], [0.25, "#90CAF9"],
[0.5, "#42A5F5"],
[0.75, "#1E88E5"],
[1.0, "#003087"],
] ]
display_trusts = [short_trust(t) for t in trusts] display_trusts = [short_trust(t) for t in trusts]
n_drugs = len(drugs)
gap = 1 if n_drugs > 15 else 2
fig = go.Figure( fig = go.Figure(
data=go.Heatmap( data=go.Heatmap(
z=z_values, x=drugs, y=display_trusts, z=z_values, x=drugs, y=display_trusts,
colorscale=colorscale, colorscale=colorscale,
zmin=0,
text=text_values,
texttemplate="%{text}",
textfont=dict(size=10),
hovertext=hover_texts, hovertext=hover_texts,
hovertemplate="%{hovertext}<extra></extra>", hovertemplate="%{hovertext}<extra></extra>",
colorbar=dict( colorbar=dict(
title=dict(text=metric_label, font=dict(size=12, color="#425563")), title=dict(text=metric_label, font=dict(size=12, color="#425563")),
thickness=15, len=0.8, thickness=15, len=0.8,
), ),
xgap=2, ygap=2, xgap=gap, ygap=gap,
) )
) )
chart_title = f"Trust \u00d7 Drug \u2014 {metric_label}" chart_title = f"Trust × Drug — {metric_label}"
if title: if title:
chart_title = f"{chart_title} \u2014 {title}" chart_title = f"{chart_title} {title}"
n_drugs = len(drugs)
n_trusts = len(trusts) n_trusts = len(trusts)
fig.update_layout( layout = _base_layout(chart_title)
title=dict( layout.update(
text=chart_title,
font=dict(family="Source Sans 3, system-ui, sans-serif", size=16, color="#003087"),
x=0.5, xanchor="center",
),
xaxis=dict(title="", tickfont=dict(size=11, color="#425563"), tickangle=-45, side="bottom"), xaxis=dict(title="", tickfont=dict(size=11, color="#425563"), tickangle=-45, side="bottom"),
yaxis=dict(title="", tickfont=dict(size=12, color="#425563"), autorange="reversed"), yaxis=dict(title="", tickfont=dict(size=12, color="#425563"), autorange="reversed", automargin=True),
plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", margin=dict(t=60, l=8, r=80, b=120),
font=dict(family="Source Sans 3, system-ui, sans-serif"),
margin=dict(t=60, l=200, r=80, b=120),
width=max(700, 80 + n_drugs * 55),
height=max(300, 80 + n_trusts * 50), height=max(300, 80 + n_trusts * 50),
) )
fig.update_layout(**layout)
if capped:
fig.add_annotation(
text=f"Showing top 25 of {total_drug_count} drugs",
xref="paper", yref="paper",
x=0.5, y=1.02,
showarrow=False,
font=dict(size=12, color=ANNOTATION_COLOR),
)
return fig return fig