From 40ce7fc5f93c3da4ac83577e010392bb54496c7c Mon Sep 17 00:00:00 2001 From: Andrew Charlwood Date: Fri, 6 Feb 2026 13:44:13 +0000 Subject: [PATCH] feat: add icicle chart rendering with NHS colorscale and dynamic titles (Task 3.4) - Add create_icicle_from_nodes() to src/visualization/plotly_generator.py accepting list-of-dicts from dcc.Store with NHS blue gradient colorscale, 10-field customdata, and matching text/hover templates from Reflex version - Add update_chart callback to dash_app/callbacks/chart.py rendering go.Icicle figure from chart-data store with dynamic subtitle - Title generation helper mirrors Reflex _generate_pathway_chart_title() --- IMPLEMENTATION_PLAN.md | 4 +- dash_app/callbacks/chart.py | 63 +++++++++++- src/visualization/plotly_generator.py | 134 ++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 5 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 5a4ef20..6375db4 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -186,12 +186,12 @@ Drawer selection → update_drug_selection → app-state store → load_pathway_ - **Checkpoint**: KPIs update when date filters change ### 3.4 Icicle chart rendering callback -- [ ] Add a `create_icicle_from_nodes(nodes: list[dict], title: str) -> go.Figure` function to `src/visualization/plotly_generator.py`: +- [x] Add a `create_icicle_from_nodes(nodes: list[dict], title: str) -> go.Figure` function to `src/visualization/plotly_generator.py`: - Accepts list-of-dicts (the format stored in `chart-data` dcc.Store / returned by `load_pathway_data`) - Same 10-field customdata, colorscale, texttemplate, hovertemplate as the existing Reflex `icicle_figure` (pathways_app.py lines 769-920) - The existing `create_icicle_figure(ice_df)` stays untouched — the new function is an additional entry point for dict-based data - Use the NHS blue gradient colorscale from the Reflex version: `[[0.0, "#003087"], [0.25, "#0066CC"], ...]` -- [ ] Add to `dash_app/callbacks/chart.py`: +- [x] Add to `dash_app/callbacks/chart.py`: - `update_chart` callback: Input=`chart-data` store, Output=`pathway-chart` figure - Calls `create_icicle_from_nodes(chart_data["nodes"], title)` from the shared visualization module - Dynamic title based on chart type and filters diff --git a/dash_app/callbacks/chart.py b/dash_app/callbacks/chart.py index bb6df5e..6cc9dc7 100644 --- a/dash_app/callbacks/chart.py +++ b/dash_app/callbacks/chart.py @@ -1,9 +1,42 @@ -"""Callback for loading pathway data from SQLite into chart-data store.""" -from dash import Input, Output, callback, no_update +"""Callbacks for pathway data loading and icicle chart rendering.""" +from dash import Input, Output, no_update + + +def _generate_chart_title(app_state): + """Generate chart title from current filter state.""" + parts = [] + + chart_type = app_state.get("chart_type", "directory") + parts.append("By Indication" if chart_type == "indication" else "By Directory") + + initiated = app_state.get("initiated", "all") + initiated_labels = {"all": "All years", "1yr": "Last 1 year", "2yr": "Last 2 years"} + last_seen = app_state.get("last_seen", "6mo") + last_seen_labels = {"6mo": "Last 6 months", "12mo": "Last 12 months"} + parts.append( + f"{initiated_labels.get(initiated, 'All years')} / " + f"{last_seen_labels.get(last_seen, 'Last 6 months')}" + ) + + selected_drugs = app_state.get("selected_drugs") or [] + if selected_drugs: + if len(selected_drugs) <= 3: + parts.append(", ".join(selected_drugs)) + else: + parts.append(f"{len(selected_drugs)} drugs selected") + + selected_directorates = app_state.get("selected_directorates") or [] + if selected_directorates: + if len(selected_directorates) <= 2: + parts.append(", ".join(selected_directorates)) + else: + parts.append(f"{len(selected_directorates)} directorates") + + return " | ".join(parts) if parts else "All Patients" def register_chart_callbacks(app): - """Register pathway data loading callback.""" + """Register pathway data loading and chart rendering callbacks.""" @app.callback( Output("chart-data", "data"), @@ -27,3 +60,27 @@ def register_chart_callbacks(app): selected_drugs=selected_drugs, selected_directorates=selected_directorates, ) + + @app.callback( + Output("pathway-chart", "figure"), + Output("chart-subtitle", "children"), + Input("chart-data", "data"), + Input("app-state", "data"), + ) + def update_chart(chart_data, app_state): + """Render icicle chart from chart-data nodes.""" + if not chart_data or not chart_data.get("nodes"): + return no_update, no_update + + 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) + + chart_type = (app_state or {}).get("chart_type", "directory") + if chart_type == "indication": + subtitle = "Trust \u2192 Indication \u2192 Drug \u2192 Patient Pathway" + else: + subtitle = "Trust \u2192 Directorate \u2192 Drug \u2192 Patient Pathway" + + return fig, subtitle diff --git a/src/visualization/plotly_generator.py b/src/visualization/plotly_generator.py index fec7ccb..fd117a3 100644 --- a/src/visualization/plotly_generator.py +++ b/src/visualization/plotly_generator.py @@ -110,6 +110,140 @@ def create_icicle_figure(ice_df: pd.DataFrame, title: str) -> go.Figure: return fig +def create_icicle_from_nodes(nodes: list[dict], title: str = "") -> go.Figure: + """ + Create Plotly icicle figure from a list of pathway node dicts. + + This is the dict-based entry point used by the Dash app. The nodes list + comes directly from the chart-data dcc.Store (JSON-serialized dicts with + underscore keys matching SQLite column names). + + Args: + nodes: List of dicts with keys: parents, ids, labels, value, cost, + costpp, cost_pp_pa, colour, first_seen, last_seen, + first_seen_parent, last_seen_parent, average_spacing + title: Chart title (e.g. "By Directory | All years / Last 6 months") + + Returns: + Plotly Figure object ready for dcc.Graph + """ + if not nodes: + return go.Figure() + + parents = [d.get("parents", "") for d in nodes] + ids = [d.get("ids", "") for d in nodes] + labels = [d.get("labels", "") for d in nodes] + values = [d.get("value", 0) for d in nodes] + colours = [d.get("colour", 0.0) for d in nodes] + + costs = [d.get("cost", 0.0) for d in nodes] + costpp = [d.get("costpp", 0.0) for d in nodes] + first_seen = [d.get("first_seen", "N/A") or "N/A" for d in nodes] + last_seen = [d.get("last_seen", "N/A") or "N/A" for d in nodes] + first_seen_parent = [d.get("first_seen_parent", "N/A") or "N/A" for d in nodes] + last_seen_parent = [d.get("last_seen_parent", "N/A") or "N/A" for d in nodes] + average_spacing = [d.get("average_spacing", "") or "" for d in nodes] + cost_pp_pa = [d.get("cost_pp_pa", 0.0) or 0.0 for d in nodes] + + customdata = list(zip( + values, # [0] + colours, # [1] + costs, # [2] + costpp, # [3] + first_seen, # [4] + last_seen, # [5] + first_seen_parent, # [6] + last_seen_parent, # [7] + average_spacing, # [8] + cost_pp_pa, # [9] + )) + + # NHS blue gradient (Heritage Blue → Primary Blue → Vibrant Blue → Sky Blue → Pale Blue) + colorscale = [ + [0.0, "#003087"], + [0.25, "#0066CC"], + [0.5, "#1E88E5"], + [0.75, "#4FC3F7"], + [1.0, "#E3F2FD"], + ] + + fig = go.Figure( + go.Icicle( + labels=labels, + ids=ids, + parents=parents, + values=values, + branchvalues="total", + marker=dict( + colors=colours, + colorscale=colorscale, + line=dict(width=1, color="#FFFFFF"), + ), + maxdepth=3, + customdata=customdata, + texttemplate=( + "%{label} " + "
Total patients: %{customdata[0]} (including children/further treatments)" + "
First seen: %{customdata[4]}" + "
Last seen (including further treatments): %{customdata[7]}" + "
Average treatment duration: %{customdata[8]}" + "
Total cost: \u00a3%{customdata[2]:.3~s}" + "
Average cost per patient: \u00a3%{customdata[3]:.3~s}" + "
Average cost per patient per annum: \u00a3%{customdata[9]:.3~s}" + ), + hovertemplate=( + "%{label}" + "
Total patients: %{customdata[0]} - %{customdata[1]:.3p} of patients in level" + "
Total cost: \u00a3%{customdata[2]:.3~s}" + "
Average cost per patient: \u00a3%{customdata[3]:.3~s}" + "
Average cost per patient per annum: \u00a3%{customdata[9]:.3~s}" + "
First seen: %{customdata[4]}" + "
Last seen (including further treatments): %{customdata[7]}" + "
Average treatment duration:" + "%{customdata[8]}" + "" + ), + textfont=dict( + family="Source Sans 3, system-ui, sans-serif", + size=12, + ), + ) + ) + + display_title = f"Patient Pathways \u2014 {title}" if title else "Patient Pathways" + + fig.update_layout( + title=dict( + text=display_title, + font=dict( + family="Source Sans 3, system-ui, sans-serif", + size=18, + color="#1E293B", + ), + x=0.5, + xanchor="center", + ), + margin=dict(t=40, l=8, r=8, b=24), + hoverlabel=dict( + bgcolor="#FFFFFF", + bordercolor="#CBD5E1", + font=dict( + family="Source Sans 3, system-ui, sans-serif", + size=14, + color="#1E293B", + ), + ), + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + autosize=True, + clickmode="event+select", + ) + + fig.update_traces(sort=False) + + return fig + + def save_figure_html( fig: go.Figure, save_dir: str, title: str, open_browser: bool = False ) -> str: