feat: add First-Line Market Share chart (Task 9.3)

- create_market_share_figure() in src/visualization/plotly_generator.py
- Horizontal stacked bar chart: directorates × drugs with patient %
- Wire into tab dispatch via _render_market_share() helper in chart.py
- Responds to date, chart type, trust, and directorate filters
This commit is contained in:
Andrew Charlwood
2026-02-06 19:28:20 +00:00
parent 4375d22022
commit f8960a3064
3 changed files with 176 additions and 8 deletions
+7 -7
View File
@@ -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)
+32 -1
View File
@@ -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)
+137
View File
@@ -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"<b>{drug}</b><br>"
f"{directory}<br>"
f"Patients: {row['patients']:,}<br>"
f"Share: {row['proportion']:.1%}<br>"
f"Cost: £{row['cost']:,.0f}<br>"
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}<extra></extra>",
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: