diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 3d5fa89..abd3cf5 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -66,23 +66,24 @@ Comprehensive review and improvement of all Plotly charts in the Dash dashboard. - **Checkpoint**: Heatmaps show linear color gradient, cell text visible, no fixed width overflow ### A.3 Fix legend overflow in 4 charts -- [ ] Create `_smart_legend(n_items)` helper that returns legend dict: +- [x] Create `_smart_legend(n_items)` helper that returns legend dict: - When >15 items: vertical legend on right (`orientation="v", x=1.02, y=1, xanchor="left"`) with dynamic right margin - When ≤15: horizontal legend with dynamic bottom margin based on estimated row count -- [ ] Apply to `create_market_share_figure()` (~L350) -- [ ] Apply to `create_trust_market_share_figure()` (~L1564) -- [ ] Apply to `create_dosing_figure()` (~L913) -- [ ] Apply to `create_trust_duration_figure()` (~L1771) -- [ ] Apply `_base_layout()` to all 4 functions +- [x] Also created `_smart_legend_margin(n_items)` helper returning margin dict with dynamic b/r values +- [x] Apply to `create_market_share_figure()` — also replaced local nhs_colours with DRUG_PALETTE +- [x] Apply to `create_trust_market_share_figure()` — also replaced local nhs_colours with DRUG_PALETTE, fixed Unicode escapes to literal chars +- [x] Apply to `create_dosing_figure()` — replaced local nhs_colours with DRUG_PALETTE, legend adapts to trace count +- [x] Apply to `create_trust_duration_figure()` — replaced local nhs_colours with TRUST_PALETTE, fixed l=200→l=8+automargin +- [x] Apply `_base_layout()` to all 4 functions - **Checkpoint**: Legends don't overlap chart content with 42 drugs or 7 trusts ### A.4 Fix trust comparison color differentiation -- [ ] In `create_trust_duration_figure()`: replace `nhs_colours` list with `TRUST_PALETTE` +- [x] In `create_trust_duration_figure()`: replace `nhs_colours` list with `TRUST_PALETTE` (done in A.3) - [ ] Add `is_trust_comparison=False` param to `create_cost_waterfall_figure()` — use `TRUST_PALETTE` when True - [ ] Update `tc_cost_waterfall` callback in `dash_app/callbacks/trust_comparison.py` (~L165) to pass `is_trust_comparison=True` - [ ] Fix `_dosing_by_drug()` blue→blue interpolation: replace with `plotly.colors.sample_colorscale("Viridis", ...)` for meaningful gradient -- [ ] Replace `nhs_colours` in `create_trust_market_share_figure()` with `DRUG_PALETTE` for drug traces -- [ ] Apply `_base_layout()` to all affected functions +- [x] Replace `nhs_colours` in `create_trust_market_share_figure()` with `DRUG_PALETTE` for drug traces (done in A.3) +- [x] Apply `_base_layout()` to all affected functions (done in A.3 for trust_market_share and trust_duration) - **Checkpoint**: Trust Comparison charts have 7 visually distinct trust colors; dosing has meaningful gradient --- diff --git a/src/visualization/plotly_generator.py b/src/visualization/plotly_generator.py index ef3fb22..d2e2bfa 100644 --- a/src/visualization/plotly_generator.py +++ b/src/visualization/plotly_generator.py @@ -46,6 +46,56 @@ DRUG_PALETTE = [ ] +def _smart_legend(n_items: int, legend_title: str = "") -> dict: + """Return a legend dict that adapts to the number of items. + + - >15 items: vertical legend to the right of the chart + - ≤15 items: horizontal legend below the chart with dynamic bottom margin + + Returns a dict suitable for ``legend=...`` inside ``fig.update_layout()``. + The caller should also set bottom margin accordingly — use + ``_smart_legend_margin_b(n_items)`` for that. + """ + base = dict( + font=dict(family=CHART_FONT_FAMILY, size=11), + ) + if legend_title: + base["title"] = legend_title + + if n_items > 15: + base.update( + orientation="v", + x=1.02, + y=1, + xanchor="left", + yanchor="top", + ) + else: + base.update( + orientation="h", + yanchor="top", + y=-0.12, + xanchor="center", + x=0.5, + ) + return base + + +def _smart_legend_margin(n_items: int) -> dict: + """Return margin dict with bottom margin adapted to legend size. + + - >15 items: vertical right legend needs extra right margin (r=140) + but minimal bottom margin (b=40). + - ≤15 items: horizontal legend needs bottom margin scaled to + estimated row count (~6 items per row at font size 11). + """ + if n_items > 15: + return dict(r=140, b=40) + else: + rows = max(1, (n_items + 5) // 6) # ~6 items per row + return dict(b=max(60, rows * 28 + 30), r=24) + + def _base_layout(title: str, **overrides) -> dict: """Return a dict of shared Plotly layout properties. @@ -321,13 +371,6 @@ def create_market_share_figure(data: list[dict], title: str = "") -> go.Figure: if not data: return go.Figure() - # NHS blue palette for different drugs - nhs_colours = [ - "#003087", "#005EB8", "#0072CE", "#1E88E5", "#41B6E6", - "#4FC3F7", "#768692", "#AE2573", "#006747", "#ED8B00", - "#8A1538", "#330072", "#009639", "#DA291C", "#00A499", - ] - # Collect unique directorates (in order — already sorted by total patients desc) seen_dirs = [] for d in data: @@ -341,7 +384,7 @@ def create_market_share_figure(data: list[dict], title: str = "") -> go.Figure: seen_drugs.append(d["drug"]) # Build one trace per drug - drug_colour_map = {drug: nhs_colours[i % len(nhs_colours)] for i, drug in enumerate(seen_drugs)} + drug_colour_map = {drug: DRUG_PALETTE[i % len(DRUG_PALETTE)] for i, drug in enumerate(seen_drugs)} # Build a lookup: (directory, drug) -> row lookup = {(d["directory"], d["drug"]): d for d in data} @@ -384,60 +427,26 @@ def create_market_share_figure(data: list[dict], title: str = "") -> go.Figure: display_title = f"First-Line Drug Market Share — {title}" if title else "First-Line Drug Market Share" + n_drugs = len(seen_drugs) + legend_margins = _smart_legend_margin(n_drugs) + fig = go.Figure(data=traces) - fig.update_layout( + layout = _base_layout(display_title) + layout.update( barmode="stack", - title=dict( - text=display_title, - font=dict( - family="Source Sans 3, system-ui, sans-serif", - size=18, - color="#1E293B", - ), - x=0.5, - xanchor="center", - ), xaxis=dict( title="% of patients", ticksuffix="%", range=[0, 105], - gridcolor="#E2E8F0", + gridcolor=GRID_COLOR, zeroline=False, ), - yaxis=dict( - title="", - automargin=True, - ), - legend=dict( - title="Drug", - orientation="h", - yanchor="top", - y=-0.15, - xanchor="center", - x=0.5, - font=dict( - family="Source Sans 3, system-ui, sans-serif", - size=11, - ), - ), - margin=dict(t=50, l=8, r=24, b=100), - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - autosize=True, - hoverlabel=dict( - bgcolor="#FFFFFF", - bordercolor="#CBD5E1", - font=dict( - family="Source Sans 3, system-ui, sans-serif", - size=13, - color="#1E293B", - ), - ), - font=dict( - family="Source Sans 3, system-ui, sans-serif", - ), + yaxis=dict(title="", automargin=True), + legend=_smart_legend(n_drugs, legend_title="Drug"), + margin=dict(t=50, l=8, **legend_margins), height=max(400, len(seen_dirs) * 60 + 200), ) + fig.update_layout(**layout) return fig @@ -919,36 +928,22 @@ def create_dosing_figure( if not data: return go.Figure() - nhs_colours = [ - "#005EB8", "#003087", "#41B6E6", "#0066CC", "#1E88E5", - "#4FC3F7", "#009639", "#ED8B00", "#768692", "#AE2573", - "#8A1538", "#330072", "#DA291C", "#00A499", "#425563", - ] - if group_by == "trust": - # Single-drug mode: compare trusts, group bars by directory - fig = _dosing_by_trust(data, nhs_colours) - chart_title = f"Dosing Intervals by Trust" + fig = _dosing_by_trust(data, DRUG_PALETTE) + chart_title = "Dosing Intervals by Trust" else: - # Overview mode: weighted average per drug - fig = _dosing_by_drug(data, nhs_colours) + fig = _dosing_by_drug(data, DRUG_PALETTE) chart_title = "Dosing Interval Overview" if title: chart_title = f"{chart_title} — {title}" n_rows = len(fig.data[0].y) if fig.data else 10 - 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", - ), + n_legend = sum(1 for t in fig.data if t.showlegend is not False) + legend_margins = _smart_legend_margin(n_legend) + + layout = _base_layout(chart_title) + layout.update( xaxis=dict( title="Weekly Interval (weeks between doses)", titlefont=dict(size=13, color="#425563"), @@ -956,30 +951,15 @@ def create_dosing_figure( zeroline=True, zerolinecolor="rgba(66,85,99,0.2)", ), - yaxis=dict( - automargin=True, - tickfont=dict(size=11), - ), - font=dict( - family="Source Sans 3, system-ui, sans-serif", - size=12, - ), - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - margin=dict(t=60, l=20, r=40, b=60), + yaxis=dict(automargin=True, tickfont=dict(size=11)), + margin=dict(t=60, l=20, **legend_margins), height=max(450, n_rows * 40 + 150), bargap=0.15, bargroupgap=0.05, showlegend=True, - legend=dict( - orientation="h", - yanchor="top", - y=-0.12, - xanchor="center", - x=0.5, - font=dict(size=11), - ), + legend=_smart_legend(n_legend), ) + fig.update_layout(**layout) return fig @@ -1571,12 +1551,6 @@ def create_trust_market_share_figure( if not data: return go.Figure() - nhs_colours = [ - "#003087", "#005EB8", "#0072CE", "#1E88E5", "#41B6E6", - "#4FC3F7", "#768692", "#AE2573", "#006747", "#ED8B00", - "#8A1538", "#330072", "#009639", "#DA291C", "#00A499", - ] - seen_trusts = [] for d in data: t = d["trust_name"] @@ -1588,7 +1562,7 @@ def create_trust_market_share_figure( if d["drug"] not in seen_drugs: seen_drugs.append(d["drug"]) - drug_colour_map = {drug: nhs_colours[i % len(nhs_colours)] for i, drug in enumerate(seen_drugs)} + drug_colour_map = {drug: DRUG_PALETTE[i % len(DRUG_PALETTE)] for i, drug in enumerate(seen_drugs)} lookup = {(d["trust_name"], d["drug"]): d for d in data} def short_trust(name): @@ -1611,8 +1585,8 @@ def create_trust_market_share_figure( f"{short_trust(trust)}
" f"Patients: {row['patients']:,}
" f"Share: {row['proportion']:.1%}
" - f"Cost: \u00a3{row['cost']:,.0f}
" - f"Cost p.p.p.a: \u00a3{row['cost_pp_pa']:,.0f}" + f"Cost: £{row['cost']:,.0f}
" + f"Cost p.p.p.a: £{row['cost_pp_pa']:,.0f}" ) else: x_vals.append(0) @@ -1625,32 +1599,21 @@ def create_trust_market_share_figure( customdata=hover_texts, )) - display_title = f"Drug Market Share by Trust \u2014 {title}" if title else "Drug Market Share by Trust" + display_title = f"Drug Market Share by Trust — {title}" if title else "Drug Market Share by Trust" + n_drugs = len(seen_drugs) + legend_margins = _smart_legend_margin(n_drugs) fig = go.Figure(data=traces) - fig.update_layout( + layout = _base_layout(display_title) + layout.update( barmode="stack", - title=dict( - text=display_title, - font=dict(family="Source Sans 3, system-ui, sans-serif", size=16, color="#1E293B"), - x=0.5, xanchor="center", - ), - xaxis=dict(title="% of patients", ticksuffix="%", range=[0, 105], gridcolor="#E2E8F0", zeroline=False), + xaxis=dict(title="% of patients", ticksuffix="%", range=[0, 105], gridcolor=GRID_COLOR, zeroline=False), yaxis=dict(title="", automargin=True), - legend=dict( - title="Drug", orientation="h", yanchor="top", y=-0.15, - xanchor="center", x=0.5, font=dict(family="Source Sans 3", size=11), - ), - margin=dict(t=50, l=8, r=24, b=100), - paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", - autosize=True, - hoverlabel=dict( - bgcolor="#FFFFFF", bordercolor="#CBD5E1", - font=dict(family="Source Sans 3, system-ui, sans-serif", size=13, color="#1E293B"), - ), - font=dict(family="Source Sans 3, system-ui, sans-serif"), + legend=_smart_legend(n_drugs, legend_title="Drug"), + margin=dict(t=50, l=8, **legend_margins), height=max(300, len(seen_trusts) * 60 + 200), ) + fig.update_layout(**layout) return fig @@ -1800,11 +1763,6 @@ def create_trust_duration_figure( if not data: return go.Figure() - nhs_colours = [ - "#005EB8", "#003087", "#41B6E6", "#0066CC", "#1E88E5", - "#4FC3F7", "#009639", "#ED8B00", "#768692", "#AE2573", - ] - seen_drugs = [] for d in data: if d["drug"] not in seen_drugs: @@ -1819,7 +1777,7 @@ def create_trust_duration_figure( def short_trust(name): return name.replace(" NHS FOUNDATION TRUST", "").replace(" HOSPITALS", "") - trust_colour_map = {t: nhs_colours[i % len(nhs_colours)] for i, t in enumerate(seen_trusts)} + trust_colour_map = {t: TRUST_PALETTE[i % len(TRUST_PALETTE)] for i, t in enumerate(seen_trusts)} lookup = {(d["drug"], d["trust_name"]): d for d in data} display_drugs = list(reversed(seen_drugs)) @@ -1852,30 +1810,24 @@ def create_trust_duration_figure( customdata=hover_texts, )) - display_title = f"Treatment Duration by Trust \u2014 {title}" if title else "Treatment Duration by Trust" + display_title = f"Treatment Duration by Trust — {title}" if title else "Treatment Duration by Trust" + n_trusts = len(seen_trusts) + legend_margins = _smart_legend_margin(n_trusts) fig = go.Figure(data=traces) - fig.update_layout( + layout = _base_layout(display_title) + layout.update( barmode="group", - title=dict( - text=display_title, - font=dict(family="Source Sans 3, system-ui, sans-serif", size=16, color="#003087"), - x=0.5, xanchor="center", - ), xaxis=dict( title="Average Duration (days)", titlefont=dict(size=13, color="#425563"), gridcolor="rgba(0,0,0,0.06)", zeroline=True, zerolinecolor="rgba(0,0,0,0.1)", ), yaxis=dict(title="", automargin=True, tickfont=dict(size=11, color="#425563")), - legend=dict( - title="Trust", orientation="h", yanchor="top", y=-0.12, - xanchor="center", x=0.5, font=dict(size=11), - ), - 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=40, b=100), + legend=_smart_legend(n_trusts, legend_title="Trust"), + margin=dict(t=60, l=8, **legend_margins), height=max(350, len(seen_drugs) * 35 + 200), bargap=0.15, bargroupgap=0.05, ) + fig.update_layout(**layout) return fig