diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 207c964..ffc5a41 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -235,10 +235,10 @@ Drawer selection → update_drug_selection → app-state store → load_pathway_ - **Checkpoint**: Selecting trusts filters the chart correctly ### 5.2 Loading/error/empty states + dynamic hierarchy label -- [ ] Add `dcc.Loading` wrapper around chart area -- [ ] Show "No data" message when chart-data is empty -- [ ] Show error toast/alert when database query fails -- [ ] Dynamic chart subtitle: "Trust → Directorate → Drug → Pathway" or "Trust → Indication → Drug → Pathway" based on chart type +- [x] Add `dcc.Loading` wrapper around chart area +- [x] Show "No data" message when chart-data is empty +- [x] Show error feedback when database query fails +- [x] Dynamic chart subtitle: "Trust → Directorate → Drug → Pathway" or "Trust → Indication → Drug → Pathway" based on chart type (done in Task 3.4) - **Checkpoint**: Loading spinner appears during data fetch, empty state shows message ### 5.3 Data freshness indicator diff --git a/dash_app/callbacks/chart.py b/dash_app/callbacks/chart.py index 9b013f4..bacd8e4 100644 --- a/dash_app/callbacks/chart.py +++ b/dash_app/callbacks/chart.py @@ -1,5 +1,40 @@ """Callbacks for pathway data loading and icicle chart rendering.""" +import logging + from dash import Input, Output, no_update +import plotly.graph_objects as go + +log = logging.getLogger(__name__) + + +def _empty_figure(message): + """Return a blank Plotly figure with a centered message annotation.""" + fig = go.Figure() + fig.update_layout( + xaxis={"visible": False}, + yaxis={"visible": False}, + plot_bgcolor="rgba(0,0,0,0)", + paper_bgcolor="rgba(0,0,0,0)", + margin={"t": 0, "l": 0, "r": 0, "b": 0}, + annotations=[ + { + "text": message, + "xref": "paper", + "yref": "paper", + "x": 0.5, + "y": 0.5, + "showarrow": False, + "font": { + "size": 16, + "color": "#768692", + "family": "Source Sans 3, Arial, sans-serif", + }, + "xanchor": "center", + "yanchor": "middle", + } + ], + ) + return fig def _generate_chart_title(app_state): @@ -62,13 +97,23 @@ def register_chart_callbacks(app): selected_directorates = app_state.get("selected_directorates") or None selected_trusts = app_state.get("selected_trusts") or None - return query_pathway_data( - filter_id=filter_id, - chart_type=chart_type, - selected_drugs=selected_drugs, - selected_directorates=selected_directorates, - selected_trusts=selected_trusts, - ) + try: + return query_pathway_data( + filter_id=filter_id, + chart_type=chart_type, + selected_drugs=selected_drugs, + selected_directorates=selected_directorates, + selected_trusts=selected_trusts, + ) + except Exception: + log.exception("Failed to load pathway data") + return { + "nodes": [], + "unique_patients": 0, + "total_drugs": 0, + "total_cost": 0.0, + "error": "Database query failed. Check logs for details.", + } @app.callback( Output("pathway-chart", "figure"), @@ -78,18 +123,28 @@ def register_chart_callbacks(app): ) 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" + if not chart_data: + return no_update, no_update + + error_msg = chart_data.get("error") + if error_msg: + return _empty_figure(error_msg), subtitle + + if not chart_data.get("nodes"): + return _empty_figure( + "No matching pathways found.\n" + "Try adjusting your filters." + ), subtitle + + 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) + return fig, subtitle diff --git a/dash_app/components/chart_card.py b/dash_app/components/chart_card.py index 04974c3..067bbd2 100644 --- a/dash_app/components/chart_card.py +++ b/dash_app/components/chart_card.py @@ -8,7 +8,7 @@ def make_chart_card(): Contains: - Header with title and dynamic subtitle (hierarchy label) - Tab row (Icicle active, Sankey and Timeline as disabled placeholders) - - dcc.Graph for the Plotly icicle figure + - dcc.Loading wrapper around dcc.Graph for loading spinner """ return html.Section( className="chart-card", @@ -60,15 +60,29 @@ def make_chart_card(): ), ], ), - # Chart area - dcc.Graph( - id="pathway-chart", - style={"minHeight": "500px", "flex": "1"}, - config={ - "displayModeBar": True, - "displaylogo": False, - "modeBarButtonsToRemove": ["lasso2d", "select2d"], - }, + # Chart area with loading spinner + dcc.Loading( + type="circle", + color="#005EB8", + children=[ + html.Div( + id="chart-container", + children=[ + dcc.Graph( + id="pathway-chart", + style={"minHeight": "500px", "flex": "1"}, + config={ + "displayModeBar": True, + "displaylogo": False, + "modeBarButtonsToRemove": [ + "lasso2d", + "select2d", + ], + }, + ), + ], + ), + ], ), ], )