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: