diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 6ba1864..2d40bcb 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -533,18 +533,18 @@ Additionally: KPI row removed, fraction KPIs moved to header, global filter sub- - **Checkpoint**: Landing page shows directorate buttons, clicking one transitions to dashboard state, back button works ### 10.8 Trust Comparison 6-chart dashboard -- [ ] Build 6-chart dashboard layout per design from 10.1 -- [ ] All 6 charts scoped to the selected directorate: +- [x] Build 6-chart dashboard layout per design from 10.1 +- [x] All 6 charts scoped to the selected directorate: 1. **Market Share**: Drug breakdown per trust (using `get_trust_market_share`) 2. **Cost Waterfall**: Per-trust cost within directorate (using `get_trust_cost_waterfall`) 3. **Dosing**: Drug dosing intervals by trust (using `get_trust_dosing`) 4. **Heatmap**: Trust × drug matrix (using `get_trust_heatmap`) 5. **Duration**: Drug durations by trust (using `get_trust_durations`) 6. **Cost Effectiveness**: Pathway costs within directorate, NOT split by trust (using `get_directorate_pathway_costs`) -- [ ] Create new visualization functions in `src/visualization/plotly_generator.py` where existing ones don't fit the trust-comparison perspective (may need `create_trust_market_share_figure`, `create_trust_heatmap_figure`, etc., or parameterize existing functions) -- [ ] All 6 charts respond to date filter and chart type toggle (global filters) -- [ ] Dashboard title shows selected directorate name -- [ ] Use `dcc.Loading` wrappers for each chart +- [x] Create new visualization functions in `src/visualization/plotly_generator.py` where existing ones don't fit the trust-comparison perspective (may need `create_trust_market_share_figure`, `create_trust_heatmap_figure`, etc., or parameterize existing functions) +- [x] All 6 charts respond to date filter and chart type toggle (global filters) +- [x] Dashboard title shows selected directorate name +- [x] Use `dcc.Loading` wrappers for each chart - **Checkpoint**: All 6 charts render for a selected directorate, comparing drugs across trusts. Charts update when date filter or chart type changes. ### 10.9 Patient Pathways filter relocation diff --git a/dash_app/callbacks/trust_comparison.py b/dash_app/callbacks/trust_comparison.py index cc4bafb..156c5d2 100644 --- a/dash_app/callbacks/trust_comparison.py +++ b/dash_app/callbacks/trust_comparison.py @@ -91,41 +91,170 @@ def register_trust_comparison_callbacks(app): else: return show, hide, "" - # Dashboard chart rendering will be added in Task 10.8. - # For now, register empty figure placeholders for the 6 chart IDs - # so the dcc.Graph components don't error on load. - _tc_chart_ids = [ - "tc-chart-market-share", - "tc-chart-cost-waterfall", - "tc-chart-dosing", - "tc-chart-heatmap", - "tc-chart-duration", - "tc-chart-cost-effectiveness", - ] + # --- Trust Comparison dashboard charts (6 charts) --- - for chart_id in _tc_chart_ids: - @app.callback( - Output(chart_id, "figure"), - Input("app-state", "data"), - prevent_initial_call=True, + def _tc_empty(message): + """Return a blank figure with a centered message for TC dashboard.""" + fig = go.Figure() + fig.update_layout( + xaxis={"visible": False}, yaxis={"visible": False}, + plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", + margin={"t": 0, "l": 0, "r": 0, "b": 0}, height=300, + annotations=[{ + "text": message, "xref": "paper", "yref": "paper", + "x": 0.5, "y": 0.5, "showarrow": False, + "font": {"size": 14, "color": "#768692", "family": "Source Sans 3"}, + "xanchor": "center", "yanchor": "middle", + }], ) - def _placeholder_chart(app_state, _cid=chart_id): - """Placeholder — returns empty figure until Task 10.8 implements real charts.""" - selected = (app_state or {}).get("selected_comparison_directorate") - if not selected: - return no_update - fig = go.Figure() - fig.update_layout( - template="plotly_white", - margin=dict(l=20, r=20, t=30, b=20), - height=300, - annotations=[ - dict( - text="Chart will be implemented in Task 10.8", - xref="paper", yref="paper", - x=0.5, y=0.5, showarrow=False, - font=dict(size=14, color="#999"), - ) - ], - ) - return fig + return fig + + def _tc_title(app_state): + """Generate a short title suffix from global filter state.""" + chart_type = (app_state or {}).get("chart_type", "directory") + label = "By Indication" if chart_type == "indication" else "By Directory" + initiated = (app_state or {}).get("initiated", "all") + last_seen = (app_state or {}).get("last_seen", "6mo") + i_labels = {"all": "All years", "1yr": "Last 1 yr", "2yr": "Last 2 yrs"} + l_labels = {"6mo": "6 mo", "12mo": "12 mo"} + return f"{label} | {i_labels.get(initiated, 'All')} / {l_labels.get(last_seen, '6 mo')}" + + # 1. Market Share — drug breakdown per trust + @app.callback( + Output("tc-chart-market-share", "figure"), + Input("app-state", "data"), + prevent_initial_call=True, + ) + def tc_market_share(app_state): + selected = (app_state or {}).get("selected_comparison_directorate") + if not selected: + return no_update + from dash_app.data.queries import get_trust_market_share + from visualization.plotly_generator import create_trust_market_share_figure + filter_id = app_state.get("date_filter_id", "all_6mo") + chart_type = app_state.get("chart_type", "directory") + try: + data = get_trust_market_share(filter_id, chart_type, selected) + except Exception: + return _tc_empty("Failed to load market share data.") + if not data: + return _tc_empty("No market share data for this selection.") + return create_trust_market_share_figure(data, _tc_title(app_state)) + + # 2. Cost Waterfall — cost per patient by trust + @app.callback( + Output("tc-chart-cost-waterfall", "figure"), + Input("app-state", "data"), + prevent_initial_call=True, + ) + def tc_cost_waterfall(app_state): + selected = (app_state or {}).get("selected_comparison_directorate") + if not selected: + return no_update + from dash_app.data.queries import get_trust_cost_waterfall + from visualization.plotly_generator import create_cost_waterfall_figure + filter_id = app_state.get("date_filter_id", "all_6mo") + chart_type = app_state.get("chart_type", "directory") + try: + data = get_trust_cost_waterfall(filter_id, chart_type, selected) + except Exception: + return _tc_empty("Failed to load cost data.") + if not data: + return _tc_empty("No cost data for this selection.") + # Reuse existing waterfall figure — map trust_name to directory key + mapped = [{"directory": d["trust_name"], "patients": d["patients"], + "total_cost": d["total_cost"], "cost_pp": d["cost_pp"]} for d in data] + return create_cost_waterfall_figure(mapped, _tc_title(app_state)) + + # 3. Dosing — drug dosing intervals by trust + @app.callback( + Output("tc-chart-dosing", "figure"), + Input("app-state", "data"), + prevent_initial_call=True, + ) + def tc_dosing(app_state): + selected = (app_state or {}).get("selected_comparison_directorate") + if not selected: + return no_update + from dash_app.data.queries import get_trust_dosing + from visualization.plotly_generator import create_dosing_figure + filter_id = app_state.get("date_filter_id", "all_6mo") + chart_type = app_state.get("chart_type", "directory") + try: + data = get_trust_dosing(filter_id, chart_type, selected) + except Exception: + return _tc_empty("Failed to load dosing data.") + if not data: + return _tc_empty("No dosing data for this selection.") + # Add directory field expected by _dosing_by_trust helper + for d in data: + d["directory"] = selected + return create_dosing_figure(data, _tc_title(app_state), group_by="trust") + + # 4. Heatmap — trust x drug matrix + @app.callback( + Output("tc-chart-heatmap", "figure"), + Input("app-state", "data"), + prevent_initial_call=True, + ) + def tc_heatmap(app_state): + selected = (app_state or {}).get("selected_comparison_directorate") + if not selected: + return no_update + from dash_app.data.queries import get_trust_heatmap + from visualization.plotly_generator import create_trust_heatmap_figure + filter_id = app_state.get("date_filter_id", "all_6mo") + chart_type = app_state.get("chart_type", "directory") + try: + data = get_trust_heatmap(filter_id, chart_type, selected) + except Exception: + return _tc_empty("Failed to load heatmap data.") + if not data.get("trusts") or not data.get("drugs"): + return _tc_empty("No heatmap data for this selection.") + return create_trust_heatmap_figure(data, _tc_title(app_state)) + + # 5. Duration — drug durations by trust + @app.callback( + Output("tc-chart-duration", "figure"), + Input("app-state", "data"), + prevent_initial_call=True, + ) + def tc_duration(app_state): + selected = (app_state or {}).get("selected_comparison_directorate") + if not selected: + return no_update + from dash_app.data.queries import get_trust_durations + from visualization.plotly_generator import create_trust_duration_figure + filter_id = app_state.get("date_filter_id", "all_6mo") + chart_type = app_state.get("chart_type", "directory") + try: + data = get_trust_durations(filter_id, chart_type, selected) + except Exception: + return _tc_empty("Failed to load duration data.") + if not data: + return _tc_empty("No duration data for this selection.") + return create_trust_duration_figure(data, _tc_title(app_state)) + + # 6. Cost Effectiveness — pathway costs within directorate (NOT split by trust) + @app.callback( + Output("tc-chart-cost-effectiveness", "figure"), + Input("app-state", "data"), + prevent_initial_call=True, + ) + def tc_cost_effectiveness(app_state): + selected = (app_state or {}).get("selected_comparison_directorate") + if not selected: + return no_update + from dash_app.data.queries import get_pathway_costs + from data_processing.parsing import calculate_retention_rate + from visualization.plotly_generator import create_cost_effectiveness_figure + filter_id = app_state.get("date_filter_id", "all_6mo") + chart_type = app_state.get("chart_type", "directory") + try: + data = get_pathway_costs(filter_id, chart_type, directory=selected) + except Exception: + return _tc_empty("Failed to load pathway cost data.") + if not data: + return _tc_empty("No pathway cost data for this selection.") + retention = calculate_retention_rate(data) + return create_cost_effectiveness_figure(data, retention, _tc_title(app_state)) diff --git a/src/visualization/plotly_generator.py b/src/visualization/plotly_generator.py index a15792b..4b43d1e 100644 --- a/src/visualization/plotly_generator.py +++ b/src/visualization/plotly_generator.py @@ -1473,3 +1473,309 @@ def create_duration_figure( ) return fig + + +# --- Trust Comparison chart functions --- + + +def create_trust_market_share_figure( + data: list[dict], + title: str = "", +) -> go.Figure: + """Create horizontal stacked bar chart showing drug market share per trust. + + Unlike create_market_share_figure (which groups by directorate), this groups + by trust within a single directorate — used by Trust Comparison dashboard. + + Args: + data: List of dicts from get_trust_market_share() with keys: + trust_name, drug, patients, proportion, cost, cost_pp_pa. + title: Chart title suffix. + """ + 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"] + if t not in seen_trusts: + seen_trusts.append(t) + + seen_drugs = [] + for d in data: + 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)} + lookup = {(d["trust_name"], d["drug"]): d for d in data} + + def short_trust(name): + return name.replace(" NHS FOUNDATION TRUST", "").replace(" HOSPITALS", "") + + display_trusts = list(reversed(seen_trusts)) + + traces = [] + for drug in seen_drugs: + y_vals = [] + x_vals = [] + hover_texts = [] + for trust in display_trusts: + row = lookup.get((trust, drug)) + y_vals.append(short_trust(trust)) + if row: + x_vals.append(row["proportion"] * 100) + hover_texts.append( + f"{drug}
" + 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}" + ) + else: + x_vals.append(0) + hover_texts.append("") + + traces.append(go.Bar( + name=drug, y=y_vals, x=x_vals, orientation="h", + marker_color=drug_colour_map[drug], + hovertemplate="%{customdata}", + customdata=hover_texts, + )) + + display_title = f"Drug Market Share by Trust \u2014 {title}" if title else "Drug Market Share by Trust" + + fig = go.Figure(data=traces) + fig.update_layout( + 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), + 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"), + height=max(300, len(seen_trusts) * 60 + 200), + ) + + return fig + + +def create_trust_heatmap_figure( + data: dict, + title: str = "", + metric: str = "patients", +) -> go.Figure: + """Create a trust x drug heatmap for a single directorate. + + Args: + data: Dict from get_trust_heatmap() with keys: + trusts (list), drugs (list), + matrix ({trust_name: {drug: {patients, cost, cost_pp_pa}}}). + title: Chart title suffix. + metric: Colour metric — "patients", "cost", or "cost_pp_pa". + """ + trusts = data.get("trusts", []) + drugs = data.get("drugs", []) + matrix = data.get("matrix", {}) + + if not trusts or not drugs: + return go.Figure() + + drugs = drugs[:25] + + metric_labels = { + "patients": "Patients", + "cost": "Total Cost (\u00a3)", + "cost_pp_pa": "Cost per Patient p.a. (\u00a3)", + } + metric_label = metric_labels.get(metric, "Patients") + + def short_trust(name): + return name.replace(" NHS FOUNDATION TRUST", "").replace(" HOSPITALS", "") + + z_values = [] + hover_texts = [] + + for t in trusts: + row_z = [] + row_hover = [] + trust_data = matrix.get(t, {}) + for drug in drugs: + cell = trust_data.get(drug) + if cell: + val = cell.get(metric, cell.get("patients", 0)) + patients = cell.get("patients", 0) + cost = cell.get("cost", 0) + cpp = cell.get("cost_pp_pa", 0) + row_z.append(val if val else 0) + row_hover.append( + f"{drug}
" + f"{short_trust(t)}
" + f"Patients: {patients:,}
" + f"Total cost: \u00a3{cost:,.0f}
" + f"Cost p.a.: \u00a3{cpp:,.0f}" + ) + else: + row_z.append(0) + row_hover.append(f"{drug}
{short_trust(t)}
No patients") + z_values.append(row_z) + hover_texts.append(row_hover) + + colorscale = [ + [0.0, "#F0F4F8"], [0.01, "#E3F2FD"], [0.1, "#90CAF9"], + [0.3, "#42A5F5"], [0.5, "#1E88E5"], [0.7, "#0066CC"], [1.0, "#003087"], + ] + + display_trusts = [short_trust(t) for t in trusts] + + fig = go.Figure( + data=go.Heatmap( + z=z_values, x=drugs, y=display_trusts, + colorscale=colorscale, + 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, + ) + ) + + chart_title = f"Trust \u00d7 Drug \u2014 {metric_label}" + if title: + chart_title = f"{chart_title} \u2014 {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", + ), + 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), + height=max(300, 80 + n_trusts * 50), + ) + + return fig + + +def create_trust_duration_figure( + data: list[dict], + title: str = "", +) -> go.Figure: + """Create grouped horizontal bar chart showing drug durations by trust. + + Args: + data: List of dicts from get_trust_durations() with keys: + drug, trust_name, avg_days, patients. + title: Chart title suffix. + """ + 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: + seen_drugs.append(d["drug"]) + + seen_trusts = [] + for d in data: + t = d["trust_name"] + if t not in seen_trusts: + seen_trusts.append(t) + + 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)} + lookup = {(d["drug"], d["trust_name"]): d for d in data} + + display_drugs = list(reversed(seen_drugs)) + + traces = [] + for trust in seen_trusts: + y_vals = [] + x_vals = [] + hover_texts = [] + for drug in display_drugs: + row = lookup.get((drug, trust)) + y_vals.append(drug) + if row: + years = row["avg_days"] / 365.25 + x_vals.append(row["avg_days"]) + hover_texts.append( + f"{drug}
" + f"{short_trust(trust)}
" + f"Avg duration: {row['avg_days']:,.0f} days ({years:.1f} yrs)
" + f"Patients: {row['patients']:,}" + ) + else: + x_vals.append(0) + hover_texts.append("") + + traces.append(go.Bar( + name=short_trust(trust), y=y_vals, x=x_vals, orientation="h", + marker_color=trust_colour_map[trust], + hovertemplate="%{customdata}", + customdata=hover_texts, + )) + + display_title = f"Treatment Duration by Trust \u2014 {title}" if title else "Treatment Duration by Trust" + + fig = go.Figure(data=traces) + fig.update_layout( + 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), + height=max(350, len(seen_drugs) * 35 + 200), + bargap=0.15, bargroupgap=0.05, + ) + + return fig