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()
This commit is contained in:
@@ -186,12 +186,12 @@ Drawer selection → update_drug_selection → app-state store → load_pathway_
|
|||||||
- **Checkpoint**: KPIs update when date filters change
|
- **Checkpoint**: KPIs update when date filters change
|
||||||
|
|
||||||
### 3.4 Icicle chart rendering callback
|
### 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`)
|
- 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)
|
- 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
|
- 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"], ...]`
|
- 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
|
- `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
|
- Calls `create_icicle_from_nodes(chart_data["nodes"], title)` from the shared visualization module
|
||||||
- Dynamic title based on chart type and filters
|
- Dynamic title based on chart type and filters
|
||||||
|
|||||||
@@ -1,9 +1,42 @@
|
|||||||
"""Callback for loading pathway data from SQLite into chart-data store."""
|
"""Callbacks for pathway data loading and icicle chart rendering."""
|
||||||
from dash import Input, Output, callback, no_update
|
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):
|
def register_chart_callbacks(app):
|
||||||
"""Register pathway data loading callback."""
|
"""Register pathway data loading and chart rendering callbacks."""
|
||||||
|
|
||||||
@app.callback(
|
@app.callback(
|
||||||
Output("chart-data", "data"),
|
Output("chart-data", "data"),
|
||||||
@@ -27,3 +60,27 @@ def register_chart_callbacks(app):
|
|||||||
selected_drugs=selected_drugs,
|
selected_drugs=selected_drugs,
|
||||||
selected_directorates=selected_directorates,
|
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
|
||||||
|
|||||||
@@ -110,6 +110,140 @@ def create_icicle_figure(ice_df: pd.DataFrame, title: str) -> go.Figure:
|
|||||||
return fig
|
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=(
|
||||||
|
"<b>%{label}</b> "
|
||||||
|
"<br><b>Total patients:</b> %{customdata[0]} (including children/further treatments)"
|
||||||
|
"<br><b>First seen:</b> %{customdata[4]}"
|
||||||
|
"<br><b>Last seen (including further treatments):</b> %{customdata[7]}"
|
||||||
|
"<br><b>Average treatment duration:</b> %{customdata[8]}"
|
||||||
|
"<br><b>Total cost:</b> \u00a3%{customdata[2]:.3~s}"
|
||||||
|
"<br><b>Average cost per patient:</b> \u00a3%{customdata[3]:.3~s}"
|
||||||
|
"<br><b>Average cost per patient per annum:</b> \u00a3%{customdata[9]:.3~s}"
|
||||||
|
),
|
||||||
|
hovertemplate=(
|
||||||
|
"<b>%{label}</b>"
|
||||||
|
"<br><b>Total patients:</b> %{customdata[0]} - %{customdata[1]:.3p} of patients in level"
|
||||||
|
"<br><b>Total cost:</b> \u00a3%{customdata[2]:.3~s}"
|
||||||
|
"<br><b>Average cost per patient:</b> \u00a3%{customdata[3]:.3~s}"
|
||||||
|
"<br><b>Average cost per patient per annum:</b> \u00a3%{customdata[9]:.3~s}"
|
||||||
|
"<br><b>First seen:</b> %{customdata[4]}"
|
||||||
|
"<br><b>Last seen (including further treatments):</b> %{customdata[7]}"
|
||||||
|
"<br><b>Average treatment duration:</b>"
|
||||||
|
"%{customdata[8]}"
|
||||||
|
"<extra></extra>"
|
||||||
|
),
|
||||||
|
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(
|
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