import faicons as fa
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from shiny import ui
SCB_SOURCE_MD = (
"Source: [Swedish Occupational Register, SCB]"
"(https://www.scb.se/en/finding-statistics/statistics-by-subject-area/"
"labour-market/labour-force-supply/"
"the-swedish-occupational-register-with-statistics/)"
)
DAIOE_SOURCE_MD = "Source: [DAIOEs](https://www.ai-econlab.com/ai-exposure-daioe)"
# Brand colours from _brand.yml
_C_BG = "rgba(0,0,0,0)"
_C_GRID = "#E5E5E5"
_C_TEXT = "#1C2826" # black
_C_TITLE = "#0C0A3E" # primary / blue
_FONT_BASE = "Nunito Sans"
_FONT_HEAD = "Montserrat"
_BASE_LAYOUT = {
"paper_bgcolor": _C_BG,
"plot_bgcolor": _C_BG,
"font": {"family": _FONT_BASE, "color": _C_TEXT, "size": 13},
"title_font": {"family": _FONT_HEAD, "color": _C_TITLE, "size": 15},
"hoverlabel": {"font": {"family": _FONT_BASE, "size": 12}},
"margin": {"l": 20, "r": 20, "t": 45, "b": 20},
}
def build_value_boxes(summary: dict, occupation: str) -> ui.Tag:
"""
Build the employment summary value boxes for a given occupation.
Returns a div containing a heading, four value boxes (employment,
1/3/6-month change), and a markdown source note.
"""
def _arrow(v):
return "▼" if v < 0 else "▲"
def _theme(v):
return "danger" if v < 0 else "success"
def _fmt_pct(v):
return f"{_arrow(v)} {v:.0f}%" if v is not None else "N/A"
def _fmt_theme(v):
return _theme(v) if v is not None else "secondary"
emp = summary["employment"]
pct1 = summary["pct_1m"]
pct3 = summary["pct_3m"]
pct6 = summary["pct_6m"]
year = summary["year"]
return ui.div(
ui.h6(f"National Employment of {occupation}", class_="mt-3 mb-2 fw-semibold"),
ui.layout_columns(
ui.value_box(
title="Avg Monthly Employment",
showcase=fa.icon_svg("users"),
value=f"{emp:,.0f}",
theme="primary",
),
ui.value_box(
title="1-month change",
value=_fmt_pct(pct1),
showcase=fa.icon_svg("arrow-trend-up" if pct1 is None or pct1 >= 0 else "arrow-trend-down"),
theme=_fmt_theme(pct1),
),
ui.value_box(
title="3-month change",
value=_fmt_pct(pct3),
showcase=fa.icon_svg("arrow-trend-up" if pct3 is None or pct3 >= 0 else "arrow-trend-down"),
theme=_fmt_theme(pct3),
),
ui.value_box(
title="6-month change",
value=_fmt_pct(pct6),
showcase=fa.icon_svg("arrow-trend-up" if pct6 is None or pct6 >= 0 else "arrow-trend-down"),
theme=_fmt_theme(pct6),
),
col_widths=[3, 3, 3, 3],
),
ui.markdown(f"Average monthly employment as at **{year}**.\n\n{SCB_SOURCE_MD}"),
)
def build_sex_chart(df: pd.DataFrame, occupation: str) -> go.Figure:
"""
Build a Plotly line chart of monthly employment count by sex over time.
Returns an empty figure if df is empty.
"""
if df.empty:
return go.Figure()
df = df.fillna(0)
# Sort months chronologically for the x-axis
sorted_months = sorted(df["month"].unique(), key=lambda m: pd.to_datetime(m, format="%Y-%b"))
fig = px.line(
df,
x="month",
y="emp_count",
color="sex",
markers=True,
custom_data=["pct_chg_1m"],
labels={
"month": "Month",
"emp_count": "Employment",
"sex": "Sex",
},
category_orders={"month": sorted_months},
)
fig.update_traces(
hovertemplate=(
"%{fullData.name}
"
"Month: %{x}
"
"Employment: %{y:,.0f}
"
"1-mo Change: %{customdata[0]:.1f}%"
),
)
fig.update_layout(
**_BASE_LAYOUT,
title={
"text": f"Monthly Employment of {occupation} in Sweden",
"x": 0.01,
"xanchor": "left",
},
legend={"title": None},
)
fig.update_xaxes(gridcolor=_C_GRID, zeroline=False, tickangle=-45)
fig.update_yaxes(gridcolor=_C_GRID, zeroline=False)
return fig
def build_comparison_employment_plot(df: pd.DataFrame) -> go.Figure:
"""Build a line chart comparing monthly employment % change across selected occupations."""
if df.empty:
return go.Figure()
df = df.fillna(0)
sorted_months = sorted(df["month"].unique(), key=lambda m: pd.to_datetime(m, format="%Y-%b"))
fig = px.line(
df,
x="month",
y="pct_chg_1m",
color="occupation",
markers=True,
custom_data=["emp_count"],
labels={"pct_chg_1m": "Employment Change (%)", "month": "Month"},
category_orders={"month": sorted_months},
)
fig.update_traces(
hovertemplate=(
"%{fullData.name}
"
"Month: %{x}
"
"Change: %{y:.1f}%
"
"Employment: %{customdata[0]:,.0f}"
),
)
fig.add_hline(y=0, line_color="grey", line_width=1)
fig.update_layout(
**_BASE_LAYOUT,
legend={
"orientation": "h",
"yanchor": "bottom",
"y": -0.35,
"xanchor": "center",
"x": 0.5,
"title": None,
},
yaxis={"ticksuffix": "%"},
)
fig.update_xaxes(gridcolor=_C_GRID, zeroline=False, tickangle=-45)
fig.update_yaxes(gridcolor=_C_GRID, zeroline=False)
return fig
def build_comp_radar_plot(df: pd.DataFrame, metrics: dict[str, str]) -> go.Figure:
"""Build a radar chart comparing AI percentile scores across selected occupations."""
if df.empty:
return go.Figure()
df = df.fillna(0)
categories = list(metrics.values())
fig = go.Figure()
for _, row in df.iterrows():
r_values = [row[f"pctl_{k}_wavg"] for k in metrics]
r_values_closed = [*r_values, r_values[0]]
categories_closed = [*categories, categories[0]]
fig.add_trace(go.Scatterpolar(
r=r_values_closed,
theta=categories_closed,
fill="toself",
name=row["occupation"],
hovertemplate="%{theta}: %{r:.1f}%",
))
fig.update_layout(
**_BASE_LAYOUT,
polar={"radialaxis": {"visible": True, "range": [0, 100]}},
showlegend=True,
legend={
"orientation": "h",
"yanchor": "bottom",
"y": -0.25,
"xanchor": "center",
"x": 0.5,
},
)
return fig
def build_ai_exposure_bar(df: pd.DataFrame, occupation: str, year: int) -> go.Figure:
"""
Build a horizontal bar chart of AI exposure per sub-domain.
X-axis: percentile rank (0-100).
Y-axis: AI sub-domains with emoji labels, sorted by score ascending.
Bar colour intensity driven by percentile rank.
Hover shows exposure level label, index score, and percentile rank.
"""
if df.empty:
return go.Figure()
df = df.fillna({"score": 0.0, "level": 0, "percentile": 0.0})
fig = go.Figure(
go.Bar(
x=df["percentile"],
y=df["domain"],
orientation="h",
marker={
"color": df["percentile"],
"colorscale": "Blues",
"colorbar": {"title": "Percentile Rank"},
"showscale": True,
"cmin": 0,
"cmax": 100,
},
customdata=list(
zip(df["level_label"], df["level"], df["score"], strict=False)
),
hovertemplate=(
"%{y}
"
"Percentile Rank: %{x:.0f}
"
"Exposure Level: %{customdata[0]} (%{customdata[1]}/5)
"
"Index Score: %{customdata[2]:.3f}"
),
),
)
fig.update_layout(
**_BASE_LAYOUT,
title={
"text": f"{occupation} Level of AI Exposure ({year})",
"x": 0.01,
"xanchor": "left",
},
xaxis={"title": "Percentile Rank", "range": [0, 100]},
yaxis={"title": None},
)
fig.update_xaxes(gridcolor=_C_GRID, zeroline=False)
fig.update_yaxes(gridcolor=_C_GRID, zeroline=False)
return fig