joseph-data commited on
Commit
0a13764
·
verified ·
1 Parent(s): af128a9

Sync from GitHub via hub-sync

Browse files
Files changed (4) hide show
  1. README.md +1 -1
  2. src/calcs.py +152 -0
  3. src/setup.py +256 -0
  4. src/visuals.py +188 -0
README.md CHANGED
@@ -24,4 +24,4 @@ An interactive Shiny app for exploring AI exposure and employment trends across
24
  | Data | Source |
25
  |------|--------|
26
  | AI Exposure Index | [DAIOE — AI Econ Lab](https://www.ai-econlab.com/ai-exposure-daioe) |
27
- | Employment Statistics | [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/).
 
24
  | Data | Source |
25
  |------|--------|
26
  | AI Exposure Index | [DAIOE — AI Econ Lab](https://www.ai-econlab.com/ai-exposure-daioe) |
27
+ | Employment Statistics | [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/)
src/calcs.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import polars as pl
2
+
3
+
4
+ def get_occ_summary(lf: pl.LazyFrame, occupation: str, year: int) -> dict | None:
5
+ """
6
+ Aggregate employment count and percentage changes for one occupation and year.
7
+
8
+ Returns a dict with keys: employment, pct_1y, pct_3y, pct_5y, year.
9
+ Returns None if no data matches the filters.
10
+ """
11
+ df = (
12
+ lf.filter(
13
+ (pl.col("occupation") == occupation) & (pl.col("year") == year),
14
+ )
15
+ .select(["count", "pct_chg_1y", "pct_chg_3y", "pct_chg_5y", "year"])
16
+ .collect()
17
+ )
18
+
19
+ if df.is_empty():
20
+ return None
21
+
22
+ def _mean_or_none(col: str) -> float | None:
23
+ val = df[col].mean()
24
+ return None if val is None else float(val)
25
+
26
+ return {
27
+ "employment": df["count"].sum(),
28
+ "pct_1y": _mean_or_none("pct_chg_1y"),
29
+ "pct_3y": _mean_or_none("pct_chg_3y"),
30
+ "pct_5y": _mean_or_none("pct_chg_5y"),
31
+ "year": int(df["year"][0]),
32
+ }
33
+
34
+
35
+ AI_WAVG_COLS = [
36
+ "daioe_genai_wavg",
37
+ "daioe_allapps_wavg",
38
+ "daioe_stratgames_wavg",
39
+ "daioe_videogames_wavg",
40
+ "daioe_imgrec_wavg",
41
+ "daioe_imgcompr_wavg",
42
+ "daioe_imggen_wavg",
43
+ "daioe_readcompr_wavg",
44
+ "daioe_lngmod_wavg",
45
+ "daioe_translat_wavg",
46
+ "daioe_speechrec_wavg",
47
+ ]
48
+
49
+ AI_LABELS = {
50
+ "daioe_genai_wavg": "🧠 Generative AI",
51
+ "daioe_allapps_wavg": "📚 All Applications",
52
+ "daioe_stratgames_wavg": "♟️ Strategy Games",
53
+ "daioe_videogames_wavg": "🎮 Video Games",
54
+ "daioe_imgrec_wavg": "🖼️ Image Recognition",
55
+ "daioe_imgcompr_wavg": "🧩 Image Comprehension",
56
+ "daioe_imggen_wavg": "🎨 Image Generation",
57
+ "daioe_readcompr_wavg": "📖 Reading Comprehension",
58
+ "daioe_lngmod_wavg": "✍️ Language Modeling",
59
+ "daioe_translat_wavg": "🌐 Translation",
60
+ "daioe_speechrec_wavg": "🎙️ Speech Recognition",
61
+ }
62
+
63
+
64
+ AI_LEVEL_COLS = [c.replace("_wavg", "_Level_Exposure") for c in AI_WAVG_COLS]
65
+ AI_PCTL_COLS = [f"pctl_{c}" for c in AI_WAVG_COLS]
66
+
67
+ EXPOSURE_LABELS = {1: "Very Low", 2: "Low", 3: "Medium", 4: "High", 5: "Very High"}
68
+
69
+
70
+ def get_occ_ai_exposure(
71
+ lf: pl.LazyFrame, occupation: str, year: int,
72
+ ) -> pl.DataFrame:
73
+ """
74
+ Return mean weighted AI exposure scores, exposure levels, and percentile ranks per sub-domain.
75
+
76
+ Returns a long-format DataFrame with columns: domain, score, level, level_label, percentile.
77
+ Used to power the ranked horizontal bar chart in Card 2.
78
+ """
79
+ select_cols = AI_WAVG_COLS + AI_LEVEL_COLS + AI_PCTL_COLS
80
+ df = (
81
+ lf.filter(
82
+ (pl.col("occupation") == occupation) & (pl.col("year") == year),
83
+ )
84
+ .select(select_cols)
85
+ .collect()
86
+ )
87
+
88
+ rows = []
89
+ for wavg_col, level_col, pctl_col in zip(AI_WAVG_COLS, AI_LEVEL_COLS, AI_PCTL_COLS, strict=False):
90
+ raw_level = df[level_col].mean()
91
+ level_val = round(raw_level) if raw_level is not None else None
92
+ rows.append({
93
+ "domain": AI_LABELS[wavg_col],
94
+ "score": df[wavg_col].mean(),
95
+ "level": level_val,
96
+ "level_label": EXPOSURE_LABELS.get(level_val, "Unknown") if level_val else "Unknown",
97
+ "percentile": df[pctl_col].mean(),
98
+ })
99
+ return pl.DataFrame(rows).sort("score")
100
+
101
+
102
+ def get_occ_ai_trend(
103
+ lf: pl.LazyFrame, occupation: str, year_range: tuple[int, int],
104
+ ) -> pl.DataFrame:
105
+ """
106
+ Return yearly mean weighted AI exposure (All Applications) for one occupation over a year range.
107
+
108
+ Returns a DataFrame with columns: year, daioe_allapps_wavg.
109
+ Used to power the trend line in Card 2.
110
+ """
111
+ year_min, year_max = year_range
112
+ return (
113
+ lf.filter(
114
+ (pl.col("occupation") == occupation)
115
+ & (pl.col("year") >= year_min)
116
+ & (pl.col("year") <= year_max),
117
+ )
118
+ .group_by("year")
119
+ .agg(pl.col("daioe_allapps_wavg").mean())
120
+ .sort("year")
121
+ .collect()
122
+ )
123
+
124
+
125
+ def get_occ_employment_by_age(
126
+ lf: pl.LazyFrame,
127
+ occupation: str,
128
+ year_range: tuple[int, int],
129
+ age_groups: list[str],
130
+ ) -> pl.DataFrame:
131
+ """
132
+ Return yearly employment counts per age group for a given occupation and year range.
133
+
134
+ Used to power the employment change line chart in Card 3.
135
+ Returns a long-format DataFrame with columns: year, age_group, count.
136
+ """
137
+ year_min, year_max = year_range
138
+ return (
139
+ lf.filter(
140
+ (pl.col("occupation") == occupation)
141
+ & (pl.col("year") >= year_min)
142
+ & (pl.col("year") <= year_max)
143
+ & (pl.col("age_group").is_in(age_groups)),
144
+ )
145
+ .group_by(["year", "age_group"])
146
+ .agg([
147
+ pl.col("count").sum(),
148
+ pl.col("pct_chg_1y").mean(),
149
+ ])
150
+ .sort(["age_group", "year"])
151
+ .collect()
152
+ )
src/setup.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib.util
2
+ import io
3
+ import re
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+ import plotly.graph_objects as go
8
+ import polars as pl
9
+ from great_tables import GT
10
+ from shiny import ui
11
+
12
+ # ---------------------------------------------------
13
+ # Mardown Files
14
+ # ------------
15
+
16
+ BASE_DIR = Path(__file__).resolve().parent.parent
17
+
18
+ INTRO_MD = (BASE_DIR / "md_files" / "intro.md").read_text(encoding="utf-8")
19
+
20
+
21
+ # ---------------------------------------------------
22
+ # Data Preliminaries
23
+ # ---------------------------------------------------
24
+
25
+ DATA_PATH = BASE_DIR / "data" / "daioe_scb_years_processed.parquet"
26
+
27
+ lf = pl.scan_parquet(DATA_PATH)
28
+
29
+ lf.collect_schema()
30
+
31
+
32
+ # ---------------------------------------------------
33
+ # Defining Input Values
34
+ # ---------------------------------------------------
35
+
36
+ # 1. SSYK12 Levels
37
+
38
+ LEVELS = lf.select(pl.col("level").unique().sort()).collect().to_series().to_list()
39
+
40
+
41
+ def build_choices_by_level(
42
+ lf: pl.LazyFrame,
43
+ levels: list[str],
44
+ ) -> dict[str, dict[str, str]]:
45
+ out = {}
46
+ for lvl in levels:
47
+ occs = (
48
+ lf.filter(pl.col("level") == lvl)
49
+ .select(pl.col("occupation").unique().sort())
50
+ .collect()
51
+ .to_series()
52
+ .to_list()
53
+ )
54
+ out[lvl] = {o: o for o in occs}
55
+ return out
56
+
57
+
58
+ # 2. Men and Women
59
+
60
+ SEXES = lf.select(pl.col("sex").unique().sort()).collect().to_series().to_list()
61
+
62
+ # 3. Age groupings
63
+
64
+ AGE_ORDER = [
65
+ "Early Career 1 (16-24)",
66
+ "Early Career 2 (25-29)",
67
+ "Developing (30-34)",
68
+ "Mid-Career 1 (35-39)",
69
+ "Mid-Career 1 (40-44)",
70
+ "Mid-Career 2 (45-49)",
71
+ "Senior (50+)",
72
+ ]
73
+
74
+ present = lf.select(pl.col("age_group").unique()).collect().to_series().to_list()
75
+
76
+ AGES = [x for x in AGE_ORDER if x in present]
77
+
78
+
79
+ YEARS = lf.select(pl.col("year").unique().sort()).collect().to_series().to_list()
80
+
81
+ # 4. Years from the dataset
82
+
83
+ YEAR_MIN, YEAR_MAX = min(YEARS), max(YEARS)
84
+
85
+ # 5. AI Sub-Indexes
86
+
87
+ METRICS: dict[str, str] = {
88
+ "daioe_genai": "🧠 Generative AI",
89
+ "daioe_allapps": "📚 All Applications",
90
+ "daioe_stratgames": "♟️ Strategy Games",
91
+ "daioe_videogames": "🎮 Video Games (Real-Time)",
92
+ "daioe_imgrec": "🖼️🔎 Image Recognition",
93
+ "daioe_imgcompr": "🧩🖼️ Image Comprehension",
94
+ "daioe_imggen": "🖌️🖼️ Image Generation",
95
+ "daioe_readcompr": "📖 Reading Comprehension",
96
+ "daioe_lngmod": "✍️🤖 Language Modeling",
97
+ "daioe_translat": "🌐🔤 Translation",
98
+ "daioe_speechrec": "🗣️🎙️ Speech Recognition",
99
+ }
100
+
101
+
102
+ first_cols = [
103
+ "level",
104
+ "ssyk_code",
105
+ "occupation",
106
+ "year",
107
+ "sex",
108
+ "age",
109
+ "age_group",
110
+ "count",
111
+ "weight_sum",
112
+ "chg_1y",
113
+ "chg_3y",
114
+ "chg_5y",
115
+ "pct_chg_1y",
116
+ "pct_chg_3y",
117
+ "pct_chg_5y",
118
+ ]
119
+
120
+
121
+ # ---------------------------------------------------
122
+ # Shared UI Helpers
123
+ # ---------------------------------------------------
124
+ def apply_plot_style(fig: go.Figure, brand: dict[str, str]) -> go.Figure:
125
+ """Apply a consistent visual style to Plotly charts."""
126
+ fig.update_layout(
127
+ paper_bgcolor="rgba(0,0,0,0)",
128
+ plot_bgcolor="rgba(0,0,0,0)",
129
+ font={"family": "Nunito Sans", "color": brand["text"]},
130
+ hoverlabel={"bgcolor": "white", "font_size": 12},
131
+ margin={"l": 20, "r": 20, "t": 40, "b": 20},
132
+ )
133
+ fig.update_xaxes(gridcolor="#E5E5E5", zeroline=False)
134
+ fig.update_yaxes(gridcolor="#E5E5E5", zeroline=False)
135
+ return fig
136
+
137
+
138
+ def empty_figure(message: str, brand: dict[str, str]) -> go.Figure:
139
+ """Create a styled empty Plotly figure with a centered message."""
140
+ fig = go.Figure()
141
+ fig.add_annotation(text=message, showarrow=False, font_size=16)
142
+ fig.update_xaxes(visible=False)
143
+ fig.update_yaxes(visible=False)
144
+ return apply_plot_style(fig, brand)
145
+
146
+
147
+ # ---------------------------------------------------
148
+ # Shared Table/Label Helpers
149
+ # ---------------------------------------------------
150
+ def metric_display_name(metric_key: str, metrics: dict[str, str]) -> str:
151
+ """Return a clean human-readable metric label without leading icons."""
152
+ label = metrics.get(metric_key, metric_key.replace("_", " ").title())
153
+ return re.sub(r"^[^A-Za-z0-9]+\s*", "", label).strip()
154
+
155
+
156
+ def readable_column_name(col: str, metrics: dict[str, str]) -> str:
157
+ """Convert raw dataset column names into readable table headers."""
158
+ exact = {
159
+ "ssyk_code": "SSYK Code",
160
+ "age_group": "Age Group",
161
+ "count": "Employees",
162
+ "year": "Year",
163
+ "sex": "Sex",
164
+ "level": "SSYK Level",
165
+ "occupation": "Occupation",
166
+ "chg_1y": "1-year Change",
167
+ "chg_3y": "3-year Change",
168
+ "chg_5y": "5-year Change",
169
+ }
170
+ if col in exact:
171
+ return exact[col]
172
+
173
+ col_l = col.lower()
174
+ if col_l.startswith("pctl_") and col_l.endswith("_wavg"):
175
+ metric_key = col[5:-5]
176
+ return f"{metric_display_name(metric_key, metrics)} Percentile (Weighted Avg)"
177
+ if col_l.endswith("_wavg"):
178
+ metric_key = col[:-5]
179
+ return f"{metric_display_name(metric_key, metrics)} (Weighted Avg)"
180
+ if col_l.endswith("_avg"):
181
+ metric_key = col[:-4]
182
+ return f"{metric_display_name(metric_key, metrics)} (Average)"
183
+ if col_l.endswith("_level_exposure"):
184
+ metric_key = col[: -len("_level_exposure")]
185
+ return f"{metric_display_name(metric_key, metrics)} Exposure Level"
186
+
187
+ fallback = col.replace("_", " ").title()
188
+ return (
189
+ fallback.replace("Ssyk", "SSYK").replace("Ai", "AI").replace("Daioe", "DAIOE")
190
+ )
191
+
192
+
193
+ def as_great_table_html(df, metrics: dict[str, str]) -> ui.TagChild:
194
+ """Render a pandas DataFrame as Great Tables HTML with readable headers."""
195
+ if df.empty:
196
+ return ui.p("No data available for the selected filters.")
197
+
198
+ df_display = df.rename(
199
+ columns={c: readable_column_name(c, metrics) for c in df.columns},
200
+ )
201
+
202
+ float_cols = [
203
+ c
204
+ for c in df_display.columns
205
+ if c != "Year" and pd.api.types.is_float_dtype(df_display[c])
206
+ ]
207
+
208
+ gt = (
209
+ GT(df_display)
210
+ .opt_row_striping()
211
+ .tab_options(table_font_names=["Nunito Sans", "Arial", "sans-serif"])
212
+ .opt_stylize(style=2, color="blue")
213
+ )
214
+
215
+ if float_cols:
216
+ gt = gt.fmt_number(columns=float_cols, decimals=2)
217
+
218
+ return ui.HTML(gt.as_raw_html())
219
+
220
+
221
+ # ---------------------------------------------------
222
+ # Shared Download Helpers
223
+ # ---------------------------------------------------
224
+ def download_extension(fmt: str) -> str:
225
+ """Map selected download format to its file extension."""
226
+ return {"csv": "csv", "parquet": "parquet", "excel": "xlsx"}.get(fmt, "csv")
227
+
228
+
229
+ def download_media_type(fmt: str) -> str:
230
+ """Return browser media type for each supported download format."""
231
+ if fmt == "parquet":
232
+ return "application/octet-stream"
233
+ if fmt == "excel":
234
+ return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
235
+ return "text/csv"
236
+
237
+
238
+ def export_filtered_data(df, fmt: str) -> str | bytes:
239
+ """Export a pandas DataFrame to csv/parquet/excel payload for Shiny download."""
240
+ if fmt == "parquet":
241
+ return df.to_parquet(index=False)
242
+
243
+ if fmt == "excel":
244
+ engine = None
245
+ if importlib.util.find_spec("openpyxl") is not None:
246
+ engine = "openpyxl"
247
+ elif importlib.util.find_spec("xlsxwriter") is not None:
248
+ engine = "xlsxwriter"
249
+ else:
250
+ raise RuntimeError("Excel export requires openpyxl or xlsxwriter.")
251
+
252
+ buffer = io.BytesIO()
253
+ df.to_excel(buffer, index=False, engine=engine)
254
+ return buffer.getvalue()
255
+
256
+ return df.to_csv(index=False)
src/visuals.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import faicons as fa
2
+ import pandas as pd
3
+ import plotly.express as px
4
+ import plotly.graph_objects as go
5
+ from shiny import ui
6
+
7
+ SCB_SOURCE_MD = (
8
+ "Source: [Swedish Occupational Register, SCB]"
9
+ "(https://www.scb.se/en/finding-statistics/statistics-by-subject-area/"
10
+ "labour-market/labour-force-supply/"
11
+ "the-swedish-occupational-register-with-statistics/)"
12
+ )
13
+
14
+ DAIOE_SOURCE_MD = "Source: [DAIOEs](https://www.ai-econlab.com/ai-exposure-daioe)"
15
+
16
+ # Brand colours from _brand.yml
17
+ _C_BG = "rgba(0,0,0,0)"
18
+ _C_GRID = "#E5E5E5"
19
+ _C_TEXT = "#1C2826" # black
20
+ _C_TITLE = "#0C0A3E" # primary / blue
21
+
22
+ _FONT_BASE = "Nunito Sans"
23
+ _FONT_HEAD = "Montserrat"
24
+
25
+ _BASE_LAYOUT = {
26
+ "paper_bgcolor": _C_BG,
27
+ "plot_bgcolor": _C_BG,
28
+ "font": {"family": _FONT_BASE, "color": _C_TEXT, "size": 13},
29
+ "title_font": {"family": _FONT_HEAD, "color": _C_TITLE, "size": 15},
30
+ "hoverlabel": {"font": {"family": _FONT_BASE, "size": 12}},
31
+ "margin": {"l": 20, "r": 20, "t": 45, "b": 20},
32
+ }
33
+
34
+
35
+ def build_value_boxes(summary: dict, occupation: str) -> ui.Tag:
36
+ """
37
+ Build the employment summary value boxes for a given occupation.
38
+
39
+ Returns a div containing a heading, four value boxes (employment, 1/3/5-yr
40
+ change), and a markdown source note.
41
+ """
42
+
43
+ def _arrow(v):
44
+ return "▼" if v < 0 else "▲"
45
+
46
+ def _theme(v):
47
+ return "danger" if v < 0 else "success"
48
+
49
+ def _fmt_pct(v):
50
+ return f"{_arrow(v)} {v:.0f}%" if v is not None else "N/A"
51
+
52
+ def _fmt_theme(v):
53
+ return _theme(v) if v is not None else "secondary"
54
+
55
+ emp = summary["employment"]
56
+ pct1 = summary["pct_1y"]
57
+ pct3 = summary["pct_3y"]
58
+ pct5 = summary["pct_5y"]
59
+ year = summary["year"]
60
+
61
+ return ui.div(
62
+ ui.h6(f"National Employment of {occupation}", class_="mt-3 mb-2 fw-semibold"),
63
+ ui.layout_columns(
64
+ ui.value_box(
65
+ title="Employment",
66
+ showcase=fa.icon_svg("users"),
67
+ value=f"{emp:,.0f}",
68
+ theme="primary",
69
+ ),
70
+ ui.value_box(
71
+ title="1-yr change",
72
+ value=_fmt_pct(pct1),
73
+ showcase=fa.icon_svg("arrow-trend-up" if pct1 is None or pct1 >= 0 else "arrow-trend-down"),
74
+ theme=_fmt_theme(pct1),
75
+ ),
76
+ ui.value_box(
77
+ title="3-yr change",
78
+ value=_fmt_pct(pct3),
79
+ showcase=fa.icon_svg("arrow-trend-up" if pct3 is None or pct3 >= 0 else "arrow-trend-down"),
80
+ theme=_fmt_theme(pct3),
81
+ ),
82
+ ui.value_box(
83
+ title="5-yr change",
84
+ value=_fmt_pct(pct5),
85
+ showcase=fa.icon_svg("arrow-trend-up" if pct5 is None or pct5 >= 0 else "arrow-trend-down"),
86
+ theme=_fmt_theme(pct5),
87
+ ),
88
+ col_widths=[3, 3, 3, 3],
89
+ ),
90
+ ui.markdown(f"Data as at **{year}**.\n\n{SCB_SOURCE_MD}"),
91
+ )
92
+
93
+
94
+ def build_age_chart(df: pd.DataFrame, occupation: str) -> go.Figure:
95
+ """
96
+ Build a Plotly line chart of 1-yr employment % change by age group over time.
97
+
98
+ Absolute employment count is shown on hover. Returns an empty figure if df is empty.
99
+ """
100
+ if df.empty:
101
+ return go.Figure()
102
+
103
+ fig = px.line(
104
+ df,
105
+ x="year",
106
+ y="pct_chg_1y",
107
+ color="age_group",
108
+ markers=True,
109
+ custom_data=["count"],
110
+ labels={
111
+ "year": "Year",
112
+ "pct_chg_1y": "Employment change (%)",
113
+ "age_group": "Age Group",
114
+ },
115
+ )
116
+ fig.update_traces(
117
+ hovertemplate=(
118
+ "<b>%{fullData.name}</b><br>"
119
+ "Year: %{x}<br>"
120
+ "Change: %{y:.1f}%<br>"
121
+ "Employment: %{customdata[0]:,}<extra></extra>"
122
+ ),
123
+ )
124
+ fig.add_hline(y=0, line_color="grey", line_width=1)
125
+ fig.update_layout(
126
+ **_BASE_LAYOUT,
127
+ title={
128
+ "text": f"Annual Employment Change of {occupation} in Sweden",
129
+ "x": 0.01,
130
+ "xanchor": "left",
131
+ },
132
+ legend={"title": None},
133
+ yaxis={"ticksuffix": "%"},
134
+ )
135
+ fig.update_xaxes(gridcolor=_C_GRID, zeroline=False, dtick=1)
136
+ fig.update_yaxes(gridcolor=_C_GRID, zeroline=False)
137
+ return fig
138
+
139
+
140
+ def build_ai_exposure_bar(df: pd.DataFrame, occupation: str, year: int) -> go.Figure:
141
+ """
142
+ Build a vertical bar chart of AI exposure level per sub-domain.
143
+
144
+ X-axis: AI sub-domains with emoji labels.
145
+ Y-axis: exposure level (1=Low, 2=Medium, 3=High).
146
+ Bar colour intensity driven by the weighted average score.
147
+ Hover shows exposure level label, index score, and percentile rank.
148
+ """
149
+ if df.empty:
150
+ return go.Figure()
151
+
152
+ fig = go.Figure(
153
+ go.Bar(
154
+ x=df["percentile"],
155
+ y=df["domain"],
156
+ orientation="h",
157
+ marker={
158
+ "color": df["percentile"],
159
+ "colorscale": "Blues",
160
+ "colorbar": {"title": "Percentile Rank"},
161
+ "showscale": True,
162
+ "cmin": 0,
163
+ "cmax": 100,
164
+ },
165
+ customdata=list(
166
+ zip(df["level_label"], df["level"], df["score"], strict=False)
167
+ ),
168
+ hovertemplate=(
169
+ "<b>%{y}</b><br>"
170
+ "Percentile Rank: %{x:.0f}<br>"
171
+ "Exposure Level: %{customdata[0]} (%{customdata[1]}/5)<br>"
172
+ "Index Score: %{customdata[2]:.3f}<extra></extra>"
173
+ ),
174
+ ),
175
+ )
176
+ fig.update_layout(
177
+ **_BASE_LAYOUT,
178
+ title={
179
+ "text": f"{occupation} Level of AI Exposure ({year})",
180
+ "x": 0.01,
181
+ "xanchor": "left",
182
+ },
183
+ xaxis={"title": "Percentile Rank", "range": [0, 100]},
184
+ yaxis={"title": None},
185
+ )
186
+ fig.update_xaxes(gridcolor=_C_GRID, zeroline=False)
187
+ fig.update_yaxes(gridcolor=_C_GRID, zeroline=False)
188
+ return fig