app_months / app.py
joseph-data's picture
Sync from GitHub via hub-sync
966f06e verified
from pathlib import Path
import polars as pl
from shiny import reactive, render
from shiny.express import app_opts, ui
from shiny.express import input as app_input
from shinywidgets import render_widget
from src.calcs import (
get_comp_radar,
get_comp_summary,
get_comparison_employment,
get_occ_ai_exposure,
get_occ_employment_by_sex,
get_occ_summary,
)
from src.setup import (
INTRO_MD,
METRICS,
OCC_CHOICES,
OCCS,
SEXES,
YEAR_MAX,
YEAR_MIN,
YEARS,
as_great_table_html,
download_extension,
download_media_type,
export_filtered_data,
first_cols,
lf,
)
from src.visuals import (
build_ai_exposure_bar,
build_comp_radar_plot,
build_comparison_employment_plot,
build_sex_chart,
build_value_boxes,
)
LOGOS_PATH = Path(__file__).parent / "logos"
app_opts(static_assets={"/logos": LOGOS_PATH})
ui.page_opts(
title="AI Exposure & Monthly Employment Explorer",
fillable=True,
theme=ui.Theme.from_brand(__file__),
)
_DEFAULT_OCC = OCCS[0] if OCCS else None
# ── Tab navigation ────────────────────────────────────────────
with ui.navset_pill(id="main_tabs"):
# ── Tab 1: Occupation View ────────────────────────────────
with ui.nav_panel("Occupation View"):
with ui.layout_sidebar():
with ui.sidebar(title="Occupation View", width=280):
ui.div(
ui.img(
src="/logos/lab.svg",
alt="AI-Econ Lab logo",
style="width:100%; max-width:180px;",
),
style="text-align:center; margin-bottom:1rem;",
)
ui.markdown(INTRO_MD)
ui.hr()
ui.input_select(
"occ_occupation",
"Occupation",
choices=OCC_CHOICES,
selected=_DEFAULT_OCC,
)
ui.input_select(
"occ_year",
"Year (snapshot)",
choices={str(y): str(y) for y in YEARS},
selected=str(YEAR_MAX),
)
ui.hr()
ui.p("Employment trend filters:", class_="fw-semibold mb-1 small")
ui.input_slider(
"occ_year_range",
"Year Range",
min=YEAR_MIN,
max=YEAR_MAX,
value=[YEAR_MIN, YEAR_MAX],
sep="",
)
ui.input_checkbox_group(
"occ_sexes",
"Sex",
choices=SEXES,
selected=SEXES,
)
# Value boxes
@render.ui
def occ_value_boxes():
summary = occ_summary()
if summary is None:
return ui.p(
"No data for the selected occupation and year.",
class_="text-muted p-3",
)
return build_value_boxes(summary, app_input.occ_occupation())
# AI Exposure bar chart
with ui.card(full_screen=True):
@render_widget
def occ_ai_bar():
df = occ_ai_exposure().to_pandas()
return build_ai_exposure_bar(
df,
app_input.occ_occupation(),
int(app_input.occ_year()),
)
# Monthly employment trend by sex
with ui.card(full_screen=True):
@render_widget
def occ_sex_chart():
df = occ_emp_by_sex().to_pandas()
return build_sex_chart(df, app_input.occ_occupation())
# ── Tab 2: Comparison View ────────────────────────────────
with ui.nav_panel("Comparison View"):
with ui.layout_sidebar():
with ui.sidebar(title="Comparison View", width=280):
ui.input_selectize(
"comp_occupations",
"Occupations (up to 5)",
choices=OCC_CHOICES,
multiple=True,
options={"maxItems": 5},
)
ui.input_checkbox_group(
"comp_sexes",
"Sex",
choices=SEXES,
selected=SEXES,
)
ui.input_select(
"comp_year",
"Year (AI snapshot)",
choices={str(y): str(y) for y in YEARS},
selected=str(YEAR_MAX),
)
# Employment summary table
with ui.card():
ui.card_header("Employment Summary")
@render.ui
def comp_summary_table():
occs = list(app_input.comp_occupations() or [])
sexes = list(app_input.comp_sexes() or [])
if not occs or not sexes:
return ui.p(
"Select at least one occupation.",
class_="text-muted p-3",
)
df = get_comp_summary(
lf, occs, sexes, int(app_input.comp_year()),
).to_pandas()
return as_great_table_html(df, METRICS)
# Employment change line chart
with ui.card(full_screen=True):
@render_widget
def comp_employment_chart():
df = comparison_data().to_pandas()
return build_comparison_employment_plot(df)
# AI percentile radar chart
with ui.card(full_screen=True):
@render_widget
def comp_radar_chart():
df = comp_radar_data().to_pandas()
return build_comp_radar_plot(df, METRICS)
# ── Tab 3: Download ───────────────────────────────────────
with ui.nav_panel("Download"):
with ui.layout_sidebar():
with ui.sidebar(title="Download Filters", width=280):
ui.input_slider(
"dl_year_range",
"Year Range",
min=YEAR_MIN,
max=YEAR_MAX,
value=[YEAR_MIN, YEAR_MAX],
sep="",
)
ui.input_checkbox_group(
"dl_sexes",
"Sex",
choices=SEXES,
selected=SEXES,
)
ui.input_selectize(
"dl_occupations",
"Occupation (blank = all)",
choices=OCC_CHOICES,
multiple=True,
)
ui.input_select(
"dl_format",
"Format",
choices={"csv": "CSV", "parquet": "Parquet", "excel": "Excel"},
selected="csv",
)
@render.download(
filename=lambda: f"daioe_months.{download_extension(app_input.dl_format())}",
media_type=lambda: download_media_type(app_input.dl_format()),
)
async def download_data():
df = download_frame().to_pandas()
yield export_filtered_data(df, app_input.dl_format())
# Row count
@render.ui
def dl_row_count():
n = len(download_frame())
return ui.p(f"{n:,} rows match the current filters.", class_="text-muted")
# Data preview
with ui.card(full_screen=True):
ui.card_header("Data Preview (first 50 rows)")
@render.ui
def dl_preview():
df = download_frame().head(50)
all_cols = df.columns
ordered = [c for c in first_cols if c in all_cols]
rest = [c for c in all_cols if c not in ordered]
return as_great_table_html(
df.select(ordered + rest).to_pandas(), METRICS,
)
# ── Reactive calculations ─────────────────────────────────────
@reactive.calc
def occ_summary():
return get_occ_summary(lf, app_input.occ_occupation(), int(app_input.occ_year()))
@reactive.calc
def occ_ai_exposure():
return get_occ_ai_exposure(lf, app_input.occ_occupation(), int(app_input.occ_year()))
@reactive.calc
def occ_emp_by_sex():
yr = app_input.occ_year_range()
return get_occ_employment_by_sex(
lf,
app_input.occ_occupation(),
(yr[0], yr[1]),
list(app_input.occ_sexes() or []),
)
@reactive.calc
def comparison_data():
occs = list(app_input.comp_occupations() or [])
sexes = list(app_input.comp_sexes() or [])
if not occs or not sexes:
return pl.DataFrame(schema={
"year": pl.Int64,
"month": pl.String,
"occupation": pl.String,
"emp_count": pl.Float64,
"pct_chg_1m": pl.Float64,
})
return get_comparison_employment(lf, occs, sexes)
@reactive.calc
def comp_radar_data():
occs = list(app_input.comp_occupations() or [])
if not occs:
return pl.DataFrame()
return get_comp_radar(lf, occs, int(app_input.comp_year()))
@reactive.calc
def download_frame():
yr = app_input.dl_year_range()
q = lf.filter(
(pl.col("year") >= yr[0])
& (pl.col("year") <= yr[1])
& (pl.col("sex").is_in(list(app_input.dl_sexes() or []))),
)
if app_input.dl_occupations():
q = q.filter(pl.col("occupation").is_in(list(app_input.dl_occupations())))
return q.collect()