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:
@@ -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 "..."`) ✓
|
- **Checkpoint**: All 7 query functions return correct data via manual Python tests (`python -c "..."`) ✓
|
||||||
|
|
||||||
### 9.3 First-Line Market Share chart (Tab 2)
|
### 9.3 First-Line Market Share chart (Tab 2)
|
||||||
- [ ] Create `dash_app/callbacks/market_share.py`:
|
- [x] Create market share chart rendering:
|
||||||
- Build horizontal grouped bar chart from `get_drug_market_share()` data
|
- Build horizontal stacked bar chart from `get_drug_market_share()` data
|
||||||
- One cluster per directorate/indication (top N), bars within = drugs, length = % of patients
|
- One cluster per directorate/indication (sorted by total patients desc), bars = drugs, length = % of patients
|
||||||
- Sorted by total patients desc, NHS blue palette
|
- NHS colour palette, stacked bars with hover showing patients, share, cost, cost_pp_pa
|
||||||
- Responds to all existing filters
|
- Responds to all existing filters (date, chart type, trust, directorate)
|
||||||
- [ ] Create figure function in `src/visualization/` (e.g., `create_market_share_figure(data)`)
|
- [x] Create figure function in `src/visualization/plotly_generator.py` — `create_market_share_figure(data, title)`
|
||||||
- [ ] Wire into tab switching in `update_chart` callback
|
- [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
|
- **Checkpoint**: Market Share tab renders real data, responds to filters, icicle still works
|
||||||
|
|
||||||
### 9.4 Pathway Cost Effectiveness chart (Tab 3)
|
### 9.4 Pathway Cost Effectiveness chart (Tab 3)
|
||||||
|
|||||||
@@ -82,6 +82,32 @@ def _generate_chart_title(app_state):
|
|||||||
return " | ".join(parts) if parts else "All Patients"
|
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):
|
def register_chart_callbacks(app):
|
||||||
"""Register tab switching, pathway data loading, and chart rendering callbacks."""
|
"""Register tab switching, pathway data loading, and chart rendering callbacks."""
|
||||||
|
|
||||||
@@ -188,11 +214,16 @@ def register_chart_callbacks(app):
|
|||||||
), subtitle
|
), subtitle
|
||||||
|
|
||||||
# Lazy rendering — only compute the active tab's chart
|
# Lazy rendering — only compute the active tab's chart
|
||||||
|
title = _generate_chart_title(app_state) if app_state else ""
|
||||||
|
|
||||||
if active_tab == "icicle":
|
if active_tab == "icicle":
|
||||||
from visualization.plotly_generator import create_icicle_from_nodes
|
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)
|
fig = create_icicle_from_nodes(chart_data["nodes"], title)
|
||||||
|
|
||||||
|
elif active_tab == "market-share":
|
||||||
|
fig = _render_market_share(app_state, title)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Placeholder for charts not yet implemented
|
# Placeholder for charts not yet implemented
|
||||||
tab_label = dict(TAB_DEFINITIONS).get(active_tab, active_tab)
|
tab_label = dict(TAB_DEFINITIONS).get(active_tab, active_tab)
|
||||||
|
|||||||
@@ -244,6 +244,143 @@ def create_icicle_from_nodes(nodes: list[dict], title: str = "") -> go.Figure:
|
|||||||
return fig
|
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(
|
def save_figure_html(
|
||||||
fig: go.Figure, save_dir: str, title: str, open_browser: bool = False
|
fig: go.Figure, save_dir: str, title: str, open_browser: bool = False
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|||||||
Reference in New Issue
Block a user