diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 4f57b6e..4ece051 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -371,13 +371,13 @@ Drawer selection → update_drug_selection → app-state store → load_pathway_ - **Checkpoint**: All 7 query functions return correct data via manual Python tests (`python -c "..."`) ✓ ### 9.3 First-Line Market Share chart (Tab 2) -- [ ] Create `dash_app/callbacks/market_share.py`: - - Build horizontal grouped bar chart from `get_drug_market_share()` data - - One cluster per directorate/indication (top N), bars within = drugs, length = % of patients - - Sorted by total patients desc, NHS blue palette - - Responds to all existing filters -- [ ] Create figure function in `src/visualization/` (e.g., `create_market_share_figure(data)`) -- [ ] Wire into tab switching in `update_chart` callback +- [x] Create market share chart rendering: + - Build horizontal stacked bar chart from `get_drug_market_share()` data + - One cluster per directorate/indication (sorted by total patients desc), bars = drugs, length = % of patients + - NHS colour palette, stacked bars with hover showing patients, share, cost, cost_pp_pa + - Responds to all existing filters (date, chart type, trust, directorate) +- [x] Create figure function in `src/visualization/plotly_generator.py` — `create_market_share_figure(data, title)` +- [x] Wire into tab switching in `update_chart` callback via `_render_market_share()` helper - **Checkpoint**: Market Share tab renders real data, responds to filters, icicle still works ### 9.4 Pathway Cost Effectiveness chart (Tab 3) diff --git a/dash_app/callbacks/chart.py b/dash_app/callbacks/chart.py index 51312e0..7446528 100644 --- a/dash_app/callbacks/chart.py +++ b/dash_app/callbacks/chart.py @@ -82,6 +82,32 @@ def _generate_chart_title(app_state): return " | ".join(parts) if parts else "All Patients" +def _render_market_share(app_state, title): + """Build the market share figure from current filter state.""" + from dash_app.data.queries import get_drug_market_share + from visualization.plotly_generator import create_market_share_figure + + filter_id = (app_state or {}).get("date_filter_id", "all_6mo") + chart_type = (app_state or {}).get("chart_type", "directory") + + # Market share query supports single directory/trust filter + selected_dirs = (app_state or {}).get("selected_directorates") or [] + selected_trusts = (app_state or {}).get("selected_trusts") or [] + directory = selected_dirs[0] if len(selected_dirs) == 1 else None + trust = selected_trusts[0] if len(selected_trusts) == 1 else None + + try: + data = get_drug_market_share(filter_id, chart_type, directory, trust) + except Exception: + log.exception("Failed to load market share data") + return _empty_figure("Failed to load market share data.") + + if not data: + return _empty_figure("No market share data available.\nTry adjusting your filters.") + + return create_market_share_figure(data, title) + + def register_chart_callbacks(app): """Register tab switching, pathway data loading, and chart rendering callbacks.""" @@ -188,11 +214,16 @@ def register_chart_callbacks(app): ), subtitle # Lazy rendering — only compute the active tab's chart + title = _generate_chart_title(app_state) if app_state else "" + if active_tab == "icicle": from visualization.plotly_generator import create_icicle_from_nodes - title = _generate_chart_title(app_state) if app_state else "" fig = create_icicle_from_nodes(chart_data["nodes"], title) + + elif active_tab == "market-share": + fig = _render_market_share(app_state, title) + else: # Placeholder for charts not yet implemented tab_label = dict(TAB_DEFINITIONS).get(active_tab, active_tab) diff --git a/src/visualization/plotly_generator.py b/src/visualization/plotly_generator.py index fd117a3..315773c 100644 --- a/src/visualization/plotly_generator.py +++ b/src/visualization/plotly_generator.py @@ -244,6 +244,143 @@ def create_icicle_from_nodes(nodes: list[dict], title: str = "") -> go.Figure: return fig +def create_market_share_figure(data: list[dict], title: str = "") -> go.Figure: + """ + Create horizontal grouped bar chart showing first-line drug market share by directorate. + + Args: + data: List of dicts from get_drug_market_share() with keys: + directory, drug, patients, proportion, cost, cost_pp_pa + Sorted by directory total patients desc, drugs desc within. + title: Chart title suffix (filter description) + + Returns: + Plotly Figure with horizontal bars grouped by directorate. + """ + 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: + if d["directory"] not in seen_dirs: + seen_dirs.append(d["directory"]) + + # Collect unique drugs across all directorates (preserve first-encountered order) + seen_drugs = [] + for d in data: + if d["drug"] not in seen_drugs: + 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)} + + # Build a lookup: (directory, drug) -> row + lookup = {(d["directory"], d["drug"]): d for d in data} + + # Reverse directory order so highest total is at the top of horizontal chart + display_dirs = list(reversed(seen_dirs)) + + traces = [] + for drug in seen_drugs: + y_vals = [] + x_vals = [] + hover_texts = [] + for directory in display_dirs: + row = lookup.get((directory, drug)) + if row: + y_vals.append(directory) + x_vals.append(row["proportion"] * 100) + hover_texts.append( + f"{drug}
" + f"{directory}
" + f"Patients: {row['patients']:,}
" + f"Share: {row['proportion']:.1%}
" + f"Cost: £{row['cost']:,.0f}
" + f"Cost p.p.p.a: £{row['cost_pp_pa']:,.0f}" + ) + else: + y_vals.append(directory) + 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"First-Line Drug Market Share — {title}" if title else "First-Line Drug Market Share" + + 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=18, + 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, 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", + ), + height=max(400, len(seen_dirs) * 60 + 200), + ) + + return fig + + def save_figure_html( fig: go.Figure, save_dir: str, title: str, open_browser: bool = False ) -> str: