Make dashboard usable on mobile

Move inline styles into assets/style.css with wrapping KPI and chart rows,
a horizontally scrolling tab bar, and phone breakpoints. Shared components
put chart titles in HTML headings, legends above plots, and disable
drag-to-zoom so swiping scrolls the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9jG4CUFcH6n4vFvgtwndY
This commit is contained in:
emfurst 2026-10-17 11:10:56 -04:00
commit eb489a782d
9 changed files with 319 additions and 196 deletions

View file

@ -18,7 +18,7 @@ def create_app() -> dash.Dash:
[
html.H1(
"University of Delaware — Administrative Analytics",
style={"textAlign": "center", "padding": "20px", "color": "#00539F"},
className="app-title",
),
dcc.Tabs(
id="tabs",
@ -32,11 +32,12 @@ def create_app() -> dash.Dash:
dcc.Tab(label="Current Headcount", value="headcount"),
dcc.Tab(label="About", value="about"),
],
style={"marginBottom": "20px"},
className="tabs-bar",
mobile_breakpoint=0,
),
html.Div(id="tab-content", style={"padding": "0 20px 20px 20px"}),
html.Div(id="tab-content", className="tab-content"),
],
style={"fontFamily": "system-ui, -apple-system, sans-serif", "maxWidth": "1400px", "margin": "0 auto"},
className="app",
)
@app.callback(Output("tab-content", "children"), Input("tabs", "value"))

View file

@ -0,0 +1,159 @@
/* Served automatically by Dash from the assets/ folder. */
body {
margin: 0;
background: #fff;
}
.app {
font-family: system-ui, -apple-system, sans-serif;
max-width: 1400px;
margin: 0 auto;
}
.app-title {
text-align: center;
color: #00539F;
padding: 20px;
margin: 0;
font-size: 2rem;
}
/* Tabs: one horizontally scrollable row at every width, instead of the
vertical stack dcc.Tabs switches to on small screens. */
.tabs-bar {
display: flex !important;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
margin-bottom: 20px;
}
.tabs-bar .tab {
flex: 1 0 auto !important;
width: auto !important;
white-space: nowrap;
padding: 12px 16px !important;
}
.tab-content {
padding: 0 20px 20px 20px;
}
.no-data {
text-align: center;
padding: 40px;
color: #888;
}
/* KPI cards wrap onto multiple lines rather than squeezing. */
.kpi-row {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 24px;
}
.kpi-card {
flex: 1 1 200px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
text-align: center;
}
.kpi-card h4 {
margin: 0;
color: #666;
font-size: 14px;
}
.kpi-card h2 {
margin: 5px 0;
color: #00539F;
}
.kpi-card p {
margin: 0;
color: #999;
font-size: 12px;
}
/* Side-by-side charts stack once each would be narrower than ~450px. */
.chart-row {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.chart-row > * {
flex: 1 1 450px;
min-width: 0;
}
.chart {
margin-bottom: 16px;
}
.chart-title {
margin: 16px 0 0 0;
font-size: 17px;
font-weight: 500;
color: #2a3f5f;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.filter-bar .dash-dropdown {
width: 200px;
}
.note {
color: #555;
font-size: 14px;
line-height: 1.5;
}
@media (max-width: 600px) {
.app-title {
font-size: 1.25rem;
padding: 12px;
}
.tab-content {
padding: 0 12px 12px 12px;
}
.kpi-row {
gap: 8px;
}
.kpi-card {
flex-basis: 140px;
padding: 12px;
}
.kpi-card h2 {
font-size: 1.3rem;
}
/* Charts are view-only on phones; the toolbar overlaps titles. */
.modebar-container {
display: none;
}
}
/* Markdown tables (About page) scroll within themselves instead of
widening the page. */
.tab-content table {
display: block;
overflow-x: auto;
max-width: 100%;
}

View file

@ -0,0 +1,69 @@
"""Shared layout components for dashboard pages.
Layout classes (kpi-row, chart-row, chart-title, ...) are styled in
assets/style.css, which handles the responsive wrapping for narrow screens.
"""
import plotly.graph_objects as go
from dash import html, dcc
GRAPH_CONFIG = {"displaylogo": False, "responsive": True}
def no_data(message: str) -> html.Div:
return html.Div(message, className="no-data")
def kpi_card(title: str, value: str, subtitle: str = "") -> html.Div:
children = [html.H4(title), html.H2(value)]
if subtitle:
children.append(html.P(subtitle))
return html.Div(children, className="kpi-card")
def kpi_row(cards: list) -> html.Div:
return html.Div(cards, className="kpi-row")
def chart_row(*items) -> html.Div:
"""Place charts side by side on wide screens; they stack on narrow ones."""
return html.Div(list(items), className="chart-row")
def style_figure(fig: go.Figure) -> go.Figure:
"""Apply layout defaults that read well at both phone and desktop widths.
The legend sits horizontally above the plot area so it never steals width
from the data, and drag-to-zoom is off so swiping on a phone scrolls the
page instead of zooming the chart (zoom remains available in the modebar).
"""
fig.update_layout(
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0},
margin={"l": 50, "r": 16, "t": 24, "b": 40},
dragmode=False,
)
fig.update_yaxes(automargin=True, title_standoff=8)
fig.update_xaxes(automargin=True)
return fig
def graph_style(height: int | None) -> dict:
"""Pin the graph container to the figure height.
With responsive=True the plot fills its container, so the container needs
an explicit height or flex layouts collapse it to nothing.
"""
return {"height": f"{height or 450}px"}
def graph(fig: go.Figure, **kwargs) -> html.Div:
"""Render a figure with its title as an HTML heading.
HTML headings wrap on narrow screens; Plotly titles get clipped instead.
"""
title = fig.layout.title.text
fig.update_layout(title=None)
style_figure(fig)
children = [html.H3(title, className="chart-title")] if title else []
children.append(dcc.Graph(figure=fig, config=GRAPH_CONFIG, style=graph_style(fig.layout.height), **kwargs))
return html.Div(children, className="chart")

View file

@ -6,6 +6,16 @@ from dash import html, dcc, Input, Output, dash_table
import plotly.express as px
import plotly.graph_objects as go
from admin_analytics.dashboard.components import (
GRAPH_CONFIG,
graph,
graph_style,
kpi_card,
kpi_row,
chart_row,
no_data,
style_figure,
)
from admin_analytics.dashboard.queries import (
query_top_earners,
query_comp_by_role,
@ -15,33 +25,12 @@ from admin_analytics.dashboard.queries import (
query_aggregate_comp_cagr,
)
_NO_DATA = html.Div(
"No IRS 990 data loaded. Run: admin-analytics ingest irs990",
style={"textAlign": "center", "padding": "40px", "color": "#888"},
)
_NO_DATA = no_data("No IRS 990 data loaded. Run: admin-analytics ingest irs990")
# Roles to highlight in trend chart
_KEY_ROLES = ["PRESIDENT", "PROVOST", "VP_FINANCE", "VP_RESEARCH", "VP_ADVANCEMENT", "CFO"]
def _kpi_card(title: str, value: str, subtitle: str = "") -> html.Div:
return html.Div(
[
html.H4(title, style={"margin": "0", "color": "#666", "fontSize": "14px"}),
html.H2(value, style={"margin": "5px 0", "color": "#00539F"}),
html.P(subtitle, style={"margin": "0", "color": "#999", "fontSize": "12px"}),
],
style={
"flex": "1",
"padding": "20px",
"backgroundColor": "#f8f9fa",
"borderRadius": "8px",
"textAlign": "center",
"margin": "0 8px",
},
)
def layout(conn: duckdb.DuckDBPyConnection):
all_earners = query_top_earners(conn)
if all_earners.height == 0:
@ -57,28 +46,28 @@ def layout(conn: duckdb.DuckDBPyConnection):
agg_cagr = query_aggregate_comp_cagr(conn)
kpi_cards = []
if cagr:
kpi_cards.append(_kpi_card(
kpi_cards.append(kpi_card(
"President Compensation",
f"${cagr['end_comp']:,}",
f"Tax year {cagr['end_year']}",
))
kpi_cards.append(_kpi_card(
kpi_cards.append(kpi_card(
"President CAGR",
f"{cagr['cagr_pct']}%",
f"Annualized growth, {cagr['start_year']}-{cagr['end_year']}",
))
if agg_cagr:
kpi_cards.append(_kpi_card(
kpi_cards.append(kpi_card(
"Top-10 Total Compensation",
f"${agg_cagr['end_comp']:,}",
f"Tax year {agg_cagr['end_year']}",
))
kpi_cards.append(_kpi_card(
kpi_cards.append(kpi_card(
"Top-10 CAGR",
f"{agg_cagr['cagr_pct']}%",
f"Annualized growth, {agg_cagr['start_year']}-{agg_cagr['end_year']}",
))
kpi_row = html.Div(kpi_cards, style={"display": "flex", "marginBottom": "24px"}) if kpi_cards else html.Div()
kpis = kpi_row(kpi_cards) if kpi_cards else html.Div()
# Compensation by role trend
role_df = query_comp_by_role(conn)
@ -127,7 +116,7 @@ def layout(conn: duckdb.DuckDBPyConnection):
)
return html.Div([
kpi_row,
kpis,
html.Div(
[
html.Label("Filter by Tax Year: ", style={"fontWeight": "bold"}),
@ -135,10 +124,10 @@ def layout(conn: duckdb.DuckDBPyConnection):
id="comp-year-dropdown",
options=year_options,
value="all",
style={"width": "200px", "display": "inline-block"},
clearable=False,
),
],
style={"marginBottom": "16px"},
className="filter-bar",
),
dash_table.DataTable(
id="comp-table",
@ -162,14 +151,17 @@ def layout(conn: duckdb.DuckDBPyConnection):
style_cell={"textAlign": "left", "padding": "8px", "fontSize": "13px"},
style_header={"fontWeight": "bold", "backgroundColor": "#f0f0f0"},
),
chart_row(
html.Div(
[
html.Div(dcc.Graph(id="comp-breakdown-chart", config={"displaylogo": False}), style={"flex": "1"}),
html.Div(dcc.Graph(figure=cpi_fig, config={"displaylogo": False}), style={"flex": "1"}),
html.H3(id="comp-breakdown-title", className="chart-title"),
dcc.Graph(id="comp-breakdown-chart", config=GRAPH_CONFIG, style=graph_style(380)),
],
style={"display": "flex", "gap": "16px", "marginTop": "16px"},
className="chart",
),
dcc.Graph(figure=role_fig, config={"displaylogo": False}),
graph(cpi_fig),
),
graph(role_fig),
])
@ -177,7 +169,11 @@ def register_callbacks(app: dash.Dash, conn: duckdb.DuckDBPyConnection) -> None:
"""Register interactive callbacks for the compensation page."""
@app.callback(
[Output("comp-table", "data"), Output("comp-breakdown-chart", "figure")],
[
Output("comp-table", "data"),
Output("comp-breakdown-chart", "figure"),
Output("comp-breakdown-title", "children"),
],
Input("comp-year-dropdown", "value"),
)
def update_compensation(year_value):
@ -218,9 +214,9 @@ def register_callbacks(app: dash.Dash, conn: duckdb.DuckDBPyConnection) -> None:
title_suffix = f" ({year})" if year else " (All Years)"
breakdown_fig.update_layout(
title=f"Compensation Breakdown — Top 10{title_suffix}",
xaxis_title="", yaxis_title="$",
template="plotly_white", height=380,
)
style_figure(breakdown_fig)
return table_data, breakdown_fig
return table_data, breakdown_fig, f"Compensation Breakdown — Top 10{title_suffix}"

View file

@ -1,35 +1,15 @@
"""Page: Endowment Performance."""
import duckdb
from dash import html, dcc
from dash import html
import plotly.graph_objects as go
from admin_analytics.dashboard.components import graph, kpi_card, kpi_row, no_data
from admin_analytics.dashboard.queries import (
query_endowment, query_endowment_per_student, query_cio_vs_endowment,
)
_NO_DATA = html.Div(
"No endowment data loaded. Run: admin-analytics ingest ipeds --component finance",
style={"textAlign": "center", "padding": "40px", "color": "#888"},
)
def _kpi_card(title: str, value: str, subtitle: str = "") -> html.Div:
return html.Div(
[
html.H4(title, style={"margin": "0", "color": "#666", "fontSize": "14px"}),
html.H2(value, style={"margin": "5px 0", "color": "#00539F"}),
html.P(subtitle, style={"margin": "0", "color": "#999", "fontSize": "12px"}),
],
style={
"flex": "1",
"padding": "20px",
"backgroundColor": "#f8f9fa",
"borderRadius": "8px",
"textAlign": "center",
"margin": "0 8px",
},
)
_NO_DATA = no_data("No endowment data loaded. Run: admin-analytics ingest ipeds --component finance")
def layout(conn: duckdb.DuckDBPyConnection):
@ -62,29 +42,29 @@ def layout(conn: duckdb.DuckDBPyConnection):
# KPI cards — single row
kpi_cards = [
_kpi_card(
kpi_card(
"Endowment Value",
f"${latest['endowment_eoy'] / 1e9:.2f}B" if latest["endowment_eoy"] else "N/A",
f"End of FY {int(latest['year'])}",
),
_kpi_card(
kpi_card(
"Endowment CAGR",
f"{endow_cagr}%" if endow_cagr is not None else "N/A",
f"FY {start_year}-{end_year}" if start_year else "",
),
]
if latest_eps is not None and latest_eps["endowment_per_student"]:
kpi_cards.append(_kpi_card(
kpi_cards.append(kpi_card(
"Endowment per Student",
f"${int(latest_eps['endowment_per_student']):,}",
f"FY {int(latest_eps['year'])}",
))
kpi_cards.append(_kpi_card(
kpi_cards.append(kpi_card(
"New Gifts to Endowment",
f"${latest['new_gifts'] / 1e6:.1f}M" if latest["new_gifts"] else "N/A",
f"FY {int(latest['year'])}",
))
kpi_row = html.Div(kpi_cards, style={"display": "flex", "marginBottom": "24px"})
kpis = kpi_row(kpi_cards)
# Endowment value trend
value_fig = go.Figure()
@ -189,13 +169,13 @@ def layout(conn: duckdb.DuckDBPyConnection):
)
charts = [
kpi_row,
dcc.Graph(figure=value_fig, config={"displaylogo": False}),
dcc.Graph(figure=eps_fig, config={"displaylogo": False}),
dcc.Graph(figure=components_fig, config={"displaylogo": False}),
dcc.Graph(figure=rate_fig, config={"displaylogo": False}),
kpis,
graph(value_fig),
graph(eps_fig),
graph(components_fig),
graph(rate_fig),
]
if cio_df.height > 1:
charts.append(dcc.Graph(figure=cio_fig, config={"displaylogo": False}))
charts.append(graph(cio_fig))
return html.Div(charts)

View file

@ -1,36 +1,17 @@
"""Page 4: Current Admin Headcount (from scraper)."""
import duckdb
from dash import html, dcc, dash_table
from dash import html, dash_table
import plotly.express as px
import plotly.graph_objects as go
from admin_analytics.dashboard.components import graph, kpi_card, kpi_row, chart_row, no_data
from admin_analytics.dashboard.queries import (
query_admin_headcount,
query_headcount_summary,
)
_NO_DATA = html.Div(
"No headcount data loaded. Run: admin-analytics ingest scrape",
style={"textAlign": "center", "padding": "40px", "color": "#888"},
)
def _kpi_card(title: str, value: str) -> html.Div:
return html.Div(
[
html.H4(title, style={"margin": "0", "color": "#666", "fontSize": "14px"}),
html.H2(value, style={"margin": "5px 0", "color": "#00539F"}),
],
style={
"flex": "1",
"padding": "20px",
"backgroundColor": "#f8f9fa",
"borderRadius": "8px",
"textAlign": "center",
"margin": "0 8px",
},
)
_NO_DATA = no_data("No headcount data loaded. Run: admin-analytics ingest scrape")
def layout(conn: duckdb.DuckDBPyConnection):
@ -47,13 +28,12 @@ def layout(conn: duckdb.DuckDBPyConnection):
overhead_pct = round(overhead_count * 100 / total, 1) if total > 0 else 0
# KPI cards
kpi_row = html.Div(
kpis = kpi_row(
[
_kpi_card("Total Staff Scraped", str(total)),
_kpi_card("Overhead Staff", str(overhead_count)),
_kpi_card("Overhead %", f"{overhead_pct}%"),
kpi_card("Total Staff Scraped", str(total)),
kpi_card("Overhead Staff", str(overhead_count)),
kpi_card("Overhead %", f"{overhead_pct}%"),
],
style={"display": "flex", "marginBottom": "24px"},
)
# Staff by unit bar chart
@ -104,13 +84,7 @@ def layout(conn: duckdb.DuckDBPyConnection):
)
return html.Div([
kpi_row,
html.Div(
[
html.Div(dcc.Graph(figure=unit_fig, config={"displaylogo": False}), style={"flex": "1"}),
html.Div(dcc.Graph(figure=pie_fig, config={"displaylogo": False}), style={"flex": "1"}),
],
style={"display": "flex", "gap": "16px"},
),
dcc.Graph(figure=cat_fig, config={"displaylogo": False}),
kpis,
chart_row(graph(unit_fig), graph(pie_fig)),
graph(cat_fig),
])

View file

@ -1,10 +1,11 @@
"""Page 1: Administrative Cost Overview."""
import duckdb
from dash import html, dcc
from dash import html
import plotly.express as px
import plotly.graph_objects as go
from admin_analytics.dashboard.components import graph, kpi_card, kpi_row, chart_row, no_data
from admin_analytics.dashboard.queries import (
query_admin_cost_ratio,
query_expense_breakdown,
@ -12,28 +13,7 @@ from admin_analytics.dashboard.queries import (
query_admin_faculty_ratio,
)
_NO_DATA = html.Div(
"No data loaded. Run: admin-analytics ingest all",
style={"textAlign": "center", "padding": "40px", "color": "#888"},
)
def _kpi_card(title: str, value: str, subtitle: str = "") -> html.Div:
return html.Div(
[
html.H4(title, style={"margin": "0", "color": "#666", "fontSize": "14px"}),
html.H2(value, style={"margin": "5px 0", "color": "#00539F"}),
html.P(subtitle, style={"margin": "0", "color": "#999", "fontSize": "12px"}),
],
style={
"flex": "1",
"padding": "20px",
"backgroundColor": "#f8f9fa",
"borderRadius": "8px",
"textAlign": "center",
"margin": "0 8px",
},
)
_NO_DATA = no_data("No data loaded. Run: admin-analytics ingest all")
def layout(conn: duckdb.DuckDBPyConnection):
@ -56,25 +36,24 @@ def layout(conn: duckdb.DuckDBPyConnection):
latest_afr = afr_pd.iloc[-1] if afr_pd is not None else None
# KPI cards
kpi_row = html.Div(
kpis = kpi_row(
[
_kpi_card(
kpi_card(
"Admin Cost Ratio",
f"{latest['admin_cost_pct']:.1f}%",
f"Institutional Support / Total Expenses ({int(latest['year'])})",
),
_kpi_card(
kpi_card(
"Admin Cost per Student",
f"${int(latest_aps['admin_per_student']):,}" if latest_aps is not None else "N/A",
f"CPI-adjusted: ${int(latest_aps['admin_per_student_cpi']):,}" if latest_aps is not None else "",
),
_kpi_card(
kpi_card(
"Admin-to-Faculty Ratio",
f"{latest_afr['admin_faculty_ratio']:.2f}" if latest_afr is not None else "N/A",
f"Management / Faculty ({int(latest_afr['year'])})" if latest_afr is not None else "",
),
],
style={"display": "flex", "marginBottom": "24px"},
)
# Admin cost ratio trend
@ -155,14 +134,8 @@ def layout(conn: duckdb.DuckDBPyConnection):
)
return html.Div([
kpi_row,
dcc.Graph(figure=ratio_fig, config={"displaylogo": False}),
dcc.Graph(figure=breakdown_fig, config={"displaylogo": False}),
html.Div(
[
html.Div(dcc.Graph(figure=aps_fig, config={"displaylogo": False}), style={"flex": "1"}),
html.Div(dcc.Graph(figure=afr_fig, config={"displaylogo": False}), style={"flex": "1"}),
],
style={"display": "flex", "gap": "16px"},
),
kpis,
graph(ratio_fig),
graph(breakdown_fig),
chart_row(graph(aps_fig), graph(afr_fig)),
])

View file

@ -1,33 +1,13 @@
"""Page: Philanthropic Giving."""
import duckdb
from dash import html, dcc
from dash import html
import plotly.graph_objects as go
from admin_analytics.dashboard.components import graph, kpi_card, kpi_row, no_data
from admin_analytics.dashboard.queries import query_philanthropy, query_comp_vs_philanthropy
_NO_DATA = html.Div(
"No philanthropy data loaded. Run: admin-analytics ingest ipeds --component finance",
style={"textAlign": "center", "padding": "40px", "color": "#888"},
)
def _kpi_card(title: str, value: str, subtitle: str = "") -> html.Div:
return html.Div(
[
html.H4(title, style={"margin": "0", "color": "#666", "fontSize": "14px"}),
html.H2(value, style={"margin": "5px 0", "color": "#00539F"}),
html.P(subtitle, style={"margin": "0", "color": "#999", "fontSize": "12px"}),
],
style={
"flex": "1",
"padding": "20px",
"backgroundColor": "#f8f9fa",
"borderRadius": "8px",
"textAlign": "center",
"margin": "0 8px",
},
)
_NO_DATA = no_data("No philanthropy data loaded. Run: admin-analytics ingest ipeds --component finance")
def layout(conn: duckdb.DuckDBPyConnection):
@ -39,20 +19,19 @@ def layout(conn: duckdb.DuckDBPyConnection):
latest = pd.iloc[-1]
# KPI cards
kpi_row = html.Div(
kpis = kpi_row(
[
_kpi_card(
kpi_card(
"Total Private Gifts & Grants",
f"${latest['total_private_gifts'] / 1e6:.1f}M" if latest["total_private_gifts"] else "N/A",
f"FY {int(latest['year'])}",
),
_kpi_card(
kpi_card(
"Gifts to Endowment",
f"${latest['endowment_gifts'] / 1e6:.1f}M" if latest["endowment_gifts"] else "N/A",
f"FY {int(latest['year'])}",
),
],
style={"display": "flex", "marginBottom": "24px"},
)
# Private gifts trend (nominal and CPI-adjusted)
@ -122,11 +101,11 @@ def layout(conn: duckdb.DuckDBPyConnection):
)
charts = [
kpi_row,
dcc.Graph(figure=gifts_fig, config={"displaylogo": False}),
dcc.Graph(figure=split_fig, config={"displaylogo": False}),
kpis,
graph(gifts_fig),
graph(split_fig),
]
if cvp_df.height > 1:
charts.append(dcc.Graph(figure=cvp_fig, config={"displaylogo": False}))
charts.append(graph(cvp_fig))
return html.Div(charts)

View file

@ -1,19 +1,17 @@
"""Page 3: Staffing & Enrollment."""
import duckdb
from dash import html, dcc
from dash import html
import plotly.graph_objects as go
from admin_analytics.dashboard.components import graph, chart_row, no_data
from admin_analytics.dashboard.queries import (
query_staff_composition,
query_student_staff_ratios,
query_growth_index,
)
_NO_DATA = html.Div(
"No IPEDS staff data loaded. Run: admin-analytics ingest ipeds",
style={"textAlign": "center", "padding": "40px", "color": "#888"},
)
_NO_DATA = no_data("No IPEDS staff data loaded. Run: admin-analytics ingest ipeds")
def layout(conn: duckdb.DuckDBPyConnection):
@ -91,12 +89,6 @@ def layout(conn: duckdb.DuckDBPyConnection):
)
return html.Div([
dcc.Graph(figure=comp_fig, config={"displaylogo": False}),
html.Div(
[
html.Div(dcc.Graph(figure=ratio_fig, config={"displaylogo": False}), style={"flex": "1"}),
html.Div(dcc.Graph(figure=growth_fig, config={"displaylogo": False}), style={"flex": "1"}),
],
style={"display": "flex", "gap": "16px"},
),
graph(comp_fig),
chart_row(graph(ratio_fig), graph(growth_fig)),
])