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/5-yr
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_1y"]
pct3 = summary["pct_3y"]
pct5 = summary["pct_5y"]
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="Employment",
showcase=fa.icon_svg("users"),
value=f"{emp:,.0f}",
theme="primary",
),
ui.value_box(
title="1-yr 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-yr 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="5-yr change",
value=_fmt_pct(pct5),
showcase=fa.icon_svg("arrow-trend-up" if pct5 is None or pct5 >= 0 else "arrow-trend-down"),
theme=_fmt_theme(pct5),
),
col_widths=[3, 3, 3, 3],
),
ui.markdown(f"Data as at **{year}**.\n\n{SCB_SOURCE_MD}"),
)
def build_age_chart(df: pd.DataFrame, occupation: str) -> go.Figure:
"""
Build a Plotly line chart of 1-yr employment % change by age group over time.
Absolute employment count is shown on hover. Returns an empty figure if df is empty.
"""
if df.empty:
return go.Figure()
fig = px.line(
df,
x="year",
y="pct_chg_1y",
color="age_group",
markers=True,
custom_data=["count"],
labels={
"year": "Year",
"pct_chg_1y": "Employment change (%)",
"age_group": "Age Group",
},
)
fig.update_traces(
hovertemplate=(
"%{fullData.name}
"
"Year: %{x}
"
"Change: %{y:.1f}%
"
"Employment: %{customdata[0]:,}"
),
)
fig.add_hline(y=0, line_color="grey", line_width=1)
fig.update_layout(
**_BASE_LAYOUT,
title={
"text": f"Annual Employment Change of {occupation} in Sweden",
"x": 0.01,
"xanchor": "left",
},
legend={"title": None},
yaxis={"ticksuffix": "%"},
)
fig.update_xaxes(gridcolor=_C_GRID, zeroline=False, dtick=1)
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 1-yr employment % change across selected occupations."""
if df.empty:
return go.Figure()
fig = px.line(
df,
x="year",
y="pct_chg_1y",
color="occupation",
markers=True,
custom_data=["count"],
labels={"pct_chg_1y": "Employment Change (%)", "year": "Year"},
)
fig.update_traces(
hovertemplate=(
"%{fullData.name}
"
"Year: %{x}
"
"Change: %{y:.1f}%
"
"Employment: %{customdata[0]:,}"
),
)
fig.add_hline(y=0, line_color="grey", line_width=1)
fig.update_layout(
**_BASE_LAYOUT,
legend={"orientation": "h", "yanchor": "bottom", "y": -0.25, "xanchor": "center", "x": 0.5, "title": None},
yaxis={"ticksuffix": "%"},
)
fig.update_xaxes(gridcolor=_C_GRID, zeroline=False, dtick=1)
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()
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 vertical bar chart of AI exposure level per sub-domain.
X-axis: AI sub-domains with emoji labels.
Y-axis: exposure level (1=Low, 2=Medium, 3=High).
Bar colour intensity driven by the weighted average score.
Hover shows exposure level label, index score, and percentile rank.
"""
if df.empty:
return go.Figure()
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