feat: add parsing utilities and 8-tab chart infrastructure (Task 9.1)
- Create src/data_processing/parsing.py with parse_average_spacing(), parse_pathway_drugs(), and calculate_retention_rate() - Add 8-tab bar to chart_card.py (Icicle, Market Share, Cost Effectiveness, Cost Waterfall, Sankey, Dosing, Heatmap, Duration) - Add active-tab dcc.Store and tab switching callback in chart.py - Remove Chart Views section from sidebar (now in tab bar) - Lazy rendering: only active tab's chart is computed
This commit is contained in:
@@ -29,6 +29,7 @@ app.layout = dmc.MantineProvider(
|
||||
}),
|
||||
dcc.Store(id="chart-data", storage_type="memory"),
|
||||
dcc.Store(id="reference-data", storage_type="session"),
|
||||
dcc.Store(id="active-tab", storage_type="memory", data="icicle"),
|
||||
dcc.Location(id="url", refresh=False),
|
||||
|
||||
# Page structure
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"""Callbacks for pathway data loading and icicle chart rendering."""
|
||||
"""Callbacks for tab switching, pathway data loading, and chart rendering."""
|
||||
import logging
|
||||
|
||||
from dash import Input, Output, no_update
|
||||
from dash import Input, Output, State, ctx, no_update
|
||||
import plotly.graph_objects as go
|
||||
|
||||
from dash_app.components.chart_card import TAB_DEFINITIONS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Tab IDs for callback inputs
|
||||
_TAB_IDS = [f"tab-{tab_id}" for tab_id, _ in TAB_DEFINITIONS]
|
||||
|
||||
|
||||
def _empty_figure(message):
|
||||
"""Return a blank Plotly figure with a centered message annotation."""
|
||||
@@ -78,8 +83,44 @@ def _generate_chart_title(app_state):
|
||||
|
||||
|
||||
def register_chart_callbacks(app):
|
||||
"""Register pathway data loading and chart rendering callbacks."""
|
||||
"""Register tab switching, pathway data loading, and chart rendering callbacks."""
|
||||
|
||||
# --- Tab switching callback ---
|
||||
tab_inputs = [Input(tid, "n_clicks") for tid in _TAB_IDS]
|
||||
tab_outputs = [Output(tid, "className") for tid in _TAB_IDS]
|
||||
|
||||
@app.callback(
|
||||
Output("active-tab", "data"),
|
||||
*tab_outputs,
|
||||
*tab_inputs,
|
||||
State("active-tab", "data"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def switch_tab(*args):
|
||||
"""Handle tab button clicks — update active-tab store and CSS classes."""
|
||||
n_tabs = len(_TAB_IDS)
|
||||
# args layout: n_clicks_0..n_clicks_N-1, current_active_tab
|
||||
current_tab = args[-1] or "icicle"
|
||||
|
||||
triggered_id = ctx.triggered_id
|
||||
if not triggered_id:
|
||||
return (no_update,) * (1 + n_tabs)
|
||||
|
||||
# Determine new active tab from triggered button ID
|
||||
new_tab = current_tab
|
||||
for tab_id, (short_id, _) in zip(_TAB_IDS, TAB_DEFINITIONS):
|
||||
if triggered_id == tab_id:
|
||||
new_tab = short_id
|
||||
break
|
||||
|
||||
# Build CSS class outputs
|
||||
base = "chart-tab"
|
||||
active = f"{base} chart-tab--active"
|
||||
classes = [active if short_id == new_tab else base for short_id, _ in TAB_DEFINITIONS]
|
||||
|
||||
return (new_tab, *classes)
|
||||
|
||||
# --- Pathway data loading callback ---
|
||||
@app.callback(
|
||||
Output("chart-data", "data"),
|
||||
Input("app-state", "data"),
|
||||
@@ -115,15 +156,19 @@ def register_chart_callbacks(app):
|
||||
"error": "Database query failed. Check logs for details.",
|
||||
}
|
||||
|
||||
# --- Chart rendering callback ---
|
||||
@app.callback(
|
||||
Output("pathway-chart", "figure"),
|
||||
Output("chart-subtitle", "children"),
|
||||
Input("chart-data", "data"),
|
||||
Input("active-tab", "data"),
|
||||
Input("app-state", "data"),
|
||||
)
|
||||
def update_chart(chart_data, app_state):
|
||||
"""Render icicle chart from chart-data nodes."""
|
||||
def update_chart(chart_data, active_tab, app_state):
|
||||
"""Render the active tab's chart from chart-data nodes."""
|
||||
active_tab = active_tab or "icicle"
|
||||
chart_type = (app_state or {}).get("chart_type", "directory")
|
||||
|
||||
if chart_type == "indication":
|
||||
subtitle = "Trust \u2192 Indication \u2192 Drug \u2192 Patient Pathway"
|
||||
else:
|
||||
@@ -142,9 +187,15 @@ def register_chart_callbacks(app):
|
||||
"Try adjusting your filters."
|
||||
), subtitle
|
||||
|
||||
from visualization.plotly_generator import create_icicle_from_nodes
|
||||
# Lazy rendering — only compute the active tab's chart
|
||||
if active_tab == "icicle":
|
||||
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)
|
||||
title = _generate_chart_title(app_state) if app_state else ""
|
||||
fig = create_icicle_from_nodes(chart_data["nodes"], title)
|
||||
else:
|
||||
# Placeholder for charts not yet implemented
|
||||
tab_label = dict(TAB_DEFINITIONS).get(active_tab, active_tab)
|
||||
fig = _empty_figure(f"{tab_label} chart — coming soon")
|
||||
|
||||
return fig, subtitle
|
||||
|
||||
@@ -1,19 +1,50 @@
|
||||
"""Chart card component — header and dcc.Graph for icicle chart."""
|
||||
"""Chart card component — tab bar, header, and dcc.Graph for charts."""
|
||||
from dash import html, dcc
|
||||
|
||||
|
||||
TAB_DEFINITIONS = [
|
||||
("icicle", "Icicle"),
|
||||
("market-share", "Market Share"),
|
||||
("cost-effectiveness", "Cost Effectiveness"),
|
||||
("cost-waterfall", "Cost Waterfall"),
|
||||
("sankey", "Sankey"),
|
||||
("dosing", "Dosing"),
|
||||
("heatmap", "Heatmap"),
|
||||
("duration", "Duration"),
|
||||
]
|
||||
|
||||
|
||||
def make_chart_card():
|
||||
"""Return a chart card matching 01_nhs_classic.html structure.
|
||||
"""Return a chart card with tab bar and dcc.Graph.
|
||||
|
||||
Contains:
|
||||
- Header with title and dynamic subtitle (hierarchy label)
|
||||
- Tab bar with 8 chart tabs (Icicle active by default)
|
||||
- Header with title and dynamic subtitle
|
||||
- dcc.Loading wrapper around dcc.Graph for loading spinner
|
||||
Chart view selection (icicle/sankey/timeline) is in the sidebar.
|
||||
"""
|
||||
tab_buttons = []
|
||||
for tab_id, label in TAB_DEFINITIONS:
|
||||
is_active = tab_id == "icicle"
|
||||
class_name = "chart-tab chart-tab--active" if is_active else "chart-tab"
|
||||
tab_buttons.append(
|
||||
html.Button(
|
||||
label,
|
||||
id=f"tab-{tab_id}",
|
||||
className=class_name,
|
||||
n_clicks=0,
|
||||
)
|
||||
)
|
||||
|
||||
return html.Section(
|
||||
className="chart-card",
|
||||
**{"aria-label": "Patient pathway chart"},
|
||||
children=[
|
||||
# Tab bar
|
||||
html.Div(
|
||||
className="chart-card__tabs",
|
||||
role="tablist",
|
||||
children=tab_buttons,
|
||||
),
|
||||
# Card header
|
||||
html.Div(
|
||||
className="chart-card__header",
|
||||
|
||||
@@ -19,9 +19,6 @@ def _svg_icon(svg_body):
|
||||
# SVG icon bodies (Feather-style)
|
||||
_ICONS = {
|
||||
"pathway": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
|
||||
"icicle": '<rect x="3" y="3" width="18" height="4" rx="1"/><rect x="3" y="10" width="10" height="4" rx="1"/><rect x="3" y="17" width="6" height="4" rx="1"/>',
|
||||
"sankey": '<path d="M6 3v18"/><path d="M18 3v18"/><path d="M6 8c6 0 6 5 12 5"/><path d="M6 16c4 0 4-3 12-3"/>',
|
||||
"timeline": '<line x1="3" y1="12" x2="21" y2="12"/><circle cx="6" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="18" cy="12" r="2"/>',
|
||||
}
|
||||
|
||||
|
||||
@@ -39,16 +36,6 @@ def make_sidebar():
|
||||
_sidebar_item("Pathway Overview", "pathway", active=True),
|
||||
],
|
||||
),
|
||||
# Chart views section
|
||||
html.Div(
|
||||
className="sidebar__section",
|
||||
children=[
|
||||
html.Div("Chart Views", className="sidebar__label"),
|
||||
_sidebar_item("Icicle Chart", "icicle", active=True),
|
||||
_sidebar_item("Sankey Diagram", "sankey", disabled=True),
|
||||
_sidebar_item("Timeline", "timeline", disabled=True),
|
||||
],
|
||||
),
|
||||
# Footer
|
||||
html.Div(
|
||||
className="sidebar__footer",
|
||||
@@ -62,13 +49,11 @@ def make_sidebar():
|
||||
)
|
||||
|
||||
|
||||
def _sidebar_item(label, icon_key, active=False, disabled=False, item_id=None):
|
||||
def _sidebar_item(label, icon_key, active=False, item_id=None):
|
||||
"""Create a single sidebar navigation item."""
|
||||
class_name = "sidebar__item"
|
||||
if active:
|
||||
class_name += " sidebar__item--active"
|
||||
if disabled:
|
||||
class_name += " sidebar__item--disabled"
|
||||
|
||||
props = {"className": class_name}
|
||||
if item_id:
|
||||
|
||||
Reference in New Issue
Block a user