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: