From 56ca11ea305112e717e78f599b6d210c6bb67e9c Mon Sep 17 00:00:00 2001 From: Andrew Charlwood Date: Sat, 7 Feb 2026 02:36:02 +0000 Subject: [PATCH] fix: heatmap linear colorscale, cell annotations, autosize (Task A.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- IMPLEMENTATION_PLAN.md | 6 +- src/visualization/plotly_generator.py | 137 +++++++++++++++++--------- 2 files changed, 91 insertions(+), 52 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index e1d4522..3d5fa89 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -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 ### 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]` 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 @@ -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` 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 -- [ ] Apply same fixes to `create_trust_heatmap_figure()` (~L1582) -- [ ] Apply `_base_layout()` to both heatmap functions +- [x] Apply same fixes to `create_trust_heatmap_figure()` (~L1582) +- [x] Apply `_base_layout()` to both heatmap functions - **Checkpoint**: Heatmaps show linear color gradient, cell text visible, no fixed width overflow ### A.3 Fix legend overflow in 4 charts diff --git a/src/visualization/plotly_generator.py b/src/visualization/plotly_generator.py index d2b4d38..ef3fb22 100644 --- a/src/visualization/plotly_generator.py +++ b/src/visualization/plotly_generator.py @@ -1273,7 +1273,9 @@ def create_heatmap_figure( # Cap columns to top 25 drugs for readability max_drugs = 25 + total_drug_count = len(drugs) drugs = drugs[:max_drugs] + capped = total_drug_count > max_drugs metric_labels = { "patients": "Patients", @@ -1282,13 +1284,15 @@ def create_heatmap_figure( } 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 = [] hover_texts = [] + text_values = [] for d in directories: row_z = [] row_hover = [] + row_text = [] dir_data = matrix.get(d, {}) for drug in drugs: cell = dir_data.get(drug) @@ -1305,31 +1309,45 @@ def create_heatmap_figure( f"Total cost: £{cost:,.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: row_z.append(0) row_hover.append( f"{drug}
{d}
No patients" ) + row_text.append("") z_values.append(row_z) hover_texts.append(row_hover) + text_values.append(row_text) - # NHS blue colorscale for the heatmap + # Linear 5-stop NHS blue colorscale colorscale = [ - [0.0, "#F0F4F8"], - [0.01, "#E3F2FD"], - [0.1, "#90CAF9"], - [0.3, "#42A5F5"], - [0.5, "#1E88E5"], - [0.7, "#0066CC"], + [0.0, "#E3F2FD"], + [0.25, "#90CAF9"], + [0.5, "#42A5F5"], + [0.75, "#1E88E5"], [1.0, "#003087"], ] + n_drugs = len(drugs) + gap = 1 if n_drugs > 15 else 2 + fig = go.Figure( data=go.Heatmap( z=z_values, x=drugs, y=directories, colorscale=colorscale, + zmin=0, + text=text_values, + texttemplate="%{text}", + textfont=dict(size=10), hovertext=hover_texts, hovertemplate="%{hovertext}", colorbar=dict( @@ -1340,8 +1358,8 @@ def create_heatmap_figure( thickness=15, len=0.8, ), - xgap=2, - ygap=2, + xgap=gap, + ygap=gap, ) ) @@ -1349,22 +1367,11 @@ def create_heatmap_figure( if title: chart_title = f"{chart_title} — {title}" - n_drugs = len(drugs) n_dirs = len(directories) - fig_width = max(700, 80 + n_drugs * 55) fig_height = max(400, 80 + n_dirs * 40) - fig.update_layout( - title=dict( - text=chart_title, - font=dict( - family="Source Sans 3, system-ui, sans-serif", - size=18, - color="#003087", - ), - x=0.5, - xanchor="center", - ), + layout = _base_layout(chart_title) + layout.update( xaxis=dict( title="", tickfont=dict(size=11, color="#425563"), @@ -1375,14 +1382,22 @@ def create_heatmap_figure( 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)", - font=dict(family="Source Sans 3, system-ui, sans-serif"), - margin=dict(t=60, l=200, r=80, b=120), - width=fig_width, + margin=dict(t=60, l=8, r=80, b=120), 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 @@ -1661,12 +1676,14 @@ def create_trust_heatmap_figure( if not trusts or not drugs: return go.Figure() + total_drug_count = len(drugs) drugs = drugs[:25] + capped = total_drug_count > 25 metric_labels = { "patients": "Patients", - "cost": "Total Cost (\u00a3)", - "cost_pp_pa": "Cost per Patient p.a. (\u00a3)", + "cost": "Total Cost (£)", + "cost_pp_pa": "Cost per Patient p.a. (£)", } metric_label = metric_labels.get(metric, "Patients") @@ -1675,10 +1692,12 @@ def create_trust_heatmap_figure( z_values = [] hover_texts = [] + text_values = [] for t in trusts: row_z = [] row_hover = [] + row_text = [] trust_data = matrix.get(t, {}) for drug in drugs: cell = trust_data.get(drug) @@ -1692,57 +1711,77 @@ def create_trust_heatmap_figure( f"{drug}
" f"{short_trust(t)}
" f"Patients: {patients:,}
" - f"Total cost: \u00a3{cost:,.0f}
" - f"Cost p.a.: \u00a3{cpp:,.0f}" + f"Total cost: £{cost:,.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: row_z.append(0) row_hover.append(f"{drug}
{short_trust(t)}
No patients") + row_text.append("") z_values.append(row_z) hover_texts.append(row_hover) + text_values.append(row_text) + # Linear 5-stop NHS blue colorscale colorscale = [ - [0.0, "#F0F4F8"], [0.01, "#E3F2FD"], [0.1, "#90CAF9"], - [0.3, "#42A5F5"], [0.5, "#1E88E5"], [0.7, "#0066CC"], [1.0, "#003087"], + [0.0, "#E3F2FD"], + [0.25, "#90CAF9"], + [0.5, "#42A5F5"], + [0.75, "#1E88E5"], + [1.0, "#003087"], ] display_trusts = [short_trust(t) for t in trusts] + n_drugs = len(drugs) + gap = 1 if n_drugs > 15 else 2 fig = go.Figure( data=go.Heatmap( z=z_values, x=drugs, y=display_trusts, colorscale=colorscale, + zmin=0, + text=text_values, + texttemplate="%{text}", + textfont=dict(size=10), hovertext=hover_texts, hovertemplate="%{hovertext}", colorbar=dict( title=dict(text=metric_label, font=dict(size=12, color="#425563")), 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: - chart_title = f"{chart_title} \u2014 {title}" + chart_title = f"{chart_title} — {title}" - n_drugs = len(drugs) n_trusts = len(trusts) - fig.update_layout( - title=dict( - text=chart_title, - font=dict(family="Source Sans 3, system-ui, sans-serif", size=16, color="#003087"), - x=0.5, xanchor="center", - ), + layout = _base_layout(chart_title) + layout.update( xaxis=dict(title="", tickfont=dict(size=11, color="#425563"), tickangle=-45, side="bottom"), - yaxis=dict(title="", tickfont=dict(size=12, color="#425563"), autorange="reversed"), - plot_bgcolor="rgba(0,0,0,0)", 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=max(700, 80 + n_drugs * 55), + yaxis=dict(title="", tickfont=dict(size=12, color="#425563"), autorange="reversed", automargin=True), + margin=dict(t=60, l=8, r=80, b=120), 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