---
title: "Educational offer and attainment across Swedish municipalities"
subtitle: "Preliminary exploration for QRM"
date: today
format:
  pdf:
    toc: true
    number-sections: true
    fig-pos: "H"
    geometry: margin=2.5cm
    fontsize: 11pt
execute:
  echo: false
  warning: false
  message: false
---

```{r setup}
library(tidyverse)
library(FactoMineR)
library(ggrepel)
library(knitr)
library(kableExtra)
library(showtext)

font_add_google("Source Sans 3", "source_sans_3")
showtext_auto()

# 01 - Load preprocessed data -----------------------------------------------------
m_sample        <- read_rds("data/processed/m_sample.rds")
ca              <- read_rds("data/processed/ca_exploratory.rds")
var_groups      <- read_rds("data/processed/ca_var_groups.rds")
edu_offer       <- read_rds("data/processed/edu_offer.rds")
att_summary     <- read_rds("data/processed/attainment_summary.rds")

# 02 - County lookup --------------------------------------------------------------
county_names <- c(
  "01" = "Stockholm",      "03" = "Uppsala",       "04" = "Södermanland",
  "05" = "Östergötland",   "06" = "Jönköping",     "07" = "Kronoberg",
  "08" = "Kalmar",         "09" = "Gotland",        "10" = "Blekinge",
  "12" = "Skåne",          "13" = "Halland",        "14" = "Västra Götaland",
  "17" = "Värmland",       "18" = "Örebro",         "19" = "Västmanland",
  "20" = "Dalarna",        "21" = "Gävleborg",      "22" = "Västernorrland",
  "23" = "Jämtland",       "24" = "Västerbotten",   "25" = "Norrbotten"
)

m_sample <- m_sample |>
  mutate(county = county_names[str_sub(code, 1, 2)])

edu_offer <- edu_offer |>
  mutate(county = county_names[str_sub(code, 1, 2)])

att_summary <- att_summary |>
  left_join(m_sample |> select(code, municipality, county), by = "code")

# 03 - Key municipalities for labels ----------------------------------------------
key_munis <- c("Stockholm", "Göteborg", "Malmö", "Uppsala", "Umeå",
               "Lund", "Linköping", "Kiruna", "Danderyd", "Filipstad")
```

# RQ1: Educational attainment over time and its relationship to educational offer

Educational attainment varies substantially across Swedish municipalities and has risen throughout this century. The pace of change is uneven. Urban and university municipalities have consistently outpaced smaller, industrial, or peripheral ones, widening the educational gap in the population. The following analyses draw on register data from Statistics Sweden for the population aged 25--64 in all 290 municipalities, at five time-points (2000, 2005, 2010, 2015, 2022). educational offer is measured from the 2024 school-unit registry and the UKÄ register.

## Trends in post-secondary attainment

```{r fig-rq1-trends, fig.width=8, fig.height=5.5, fig.cap="Share of 25--64 year olds with post-secondary education (ISCED 5+), all 290 municipalities, 2000--2022. Grey lines = individual municipalities; thick blue line = national median; shaded band = interquartile range. Selected municipalities labelled."}
# National quartiles per year
nat_stats <- att_summary |>
  group_by(year) |>
  summarise(
    med = median(pct_postsec, na.rm = TRUE),
    q25 = quantile(pct_postsec, 0.25, na.rm = TRUE),
    q75 = quantile(pct_postsec, 0.75, na.rm = TRUE)
  )

# Labels at endpoint (2022)
label_df <- att_summary |>
  filter(year == 2022, municipality %in% key_munis)

ggplot() +
  geom_line(
    data  = att_summary,
    aes(x = year, y = pct_postsec, group = municipality),
    colour = "grey72", linewidth = 0.3, alpha = 0.6
  ) +
  geom_ribbon(
    data = nat_stats, aes(x = year, ymin = q25, ymax = q75),
    fill = "#2171b5", alpha = 0.15
  ) +
  geom_line(
    data = nat_stats, aes(x = year, y = med),
    colour = "#2171b5", linewidth = 1.1
  ) +
  geom_line(
    data   = filter(att_summary, municipality %in% key_munis),
    aes(x  = year, y = pct_postsec, group = municipality),
    colour = "#cb181d", linewidth = 0.55
  ) +
  geom_text_repel(
    data          = label_df,
    aes(x = year, y = pct_postsec, label = municipality),
    direction     = "y",
    nudge_x       = 1.5,
    segment.size  = 0.3,
    size          = 2.8,
    hjust         = 0
  ) +
  scale_x_continuous(
    breaks = c(2000, 2005, 2010, 2015, 2022),
    limits = c(2000, 2029)
  ) +
  labs(
    x = NULL, y = "% with post-secondary education (ISCED 5+)",
    caption = "Source: SCB UF0506B. Blue line = national median; shaded = IQR."
  ) +
  theme_minimal(base_size = 10, base_family = "source_sans_3") +
  theme(panel.grid.minor = element_blank())
```

The national median post-secondary share rose from `r round(filter(nat_stats, year==2000)$med, 1)`% in 2000 to `r round(filter(nat_stats, year==2022)$med, 1)`% in 2022. The interquartile range widened over the same period, reflecting growing polarisation between knowledge-economy and peripheral municipalities. Danderyd, Lund, and Solna consistently lead; small industrial municipalities in Värmland, Bergslagen, and Norrland remain clustered at the bottom.

## Attainment and educational offer

```{r fig-rq1-scatter, fig.width=7.5, fig.height=5, fig.cap="2022 post-secondary attainment vs. number of upper-secondary units, by type of higher education presence. Point size = number of komvux units. Municipalities with no gymnasieskola (n=39) omitted. Selected municipalities labelled."}
att_22 <- att_summary |>
  filter(year == 2022) |>
  left_join(
    edu_offer |> select(code, gymnasieskola_n_total, komvux_n_total,
                        university_n_total, university_college_n_total),
    by = "code"
  ) |>
  mutate(
    he_cat = case_when(
      university_n_total         > 0 ~ "University",
      university_college_n_total > 0 ~ "University college",
      TRUE                           ~ "No HE institution"
    ) |> factor(levels = c("No HE institution", "University college", "University"))
  ) |>
  filter(gymnasieskola_n_total > 0)

label_22 <- filter(att_22, municipality %in% key_munis)

ggplot(att_22, aes(x = gymnasieskola_n_total, y = pct_postsec, colour = he_cat)) +
  geom_point(aes(size = komvux_n_total), alpha = 0.6) +
  geom_text_repel(
    data = label_22,
    aes(label = municipality),
    size = 2.8, max.overlaps = 15, show.legend = FALSE
  ) +
  scale_colour_manual(
    values = c("No HE institution" = "grey60",
               "University college" = "#fd8d3c",
               "University"         = "#cb181d"),
    name = NULL
  ) +
  scale_size_continuous(name = "Komvux units", range = c(1, 6)) +
  scale_x_log10(breaks = c(1, 2, 5, 10, 20, 50, 100)) +
  labs(
    x = "Number of gymnasieskola units (log scale)",
    y = "% with post-secondary education (2022)",
    caption = "Point size = number of komvux units."
  ) +
  theme_minimal(base_size = 10, base_family = "source_sans_3") +
  theme(legend.position = "bottom", panel.grid.minor = element_blank())
```

Municipalities with a university consistently show higher post-secondary attainment than those without, regardless of overall size. The relationship extends to komvux. Municipalities with a larger adult education offer tend to have higher attainment levels. These associations suggest a long-term structural co-constitution of educational offer and educated populations rather than a simple effect in one direction.

# RQ2: Distribution of educational offer across municipalities

The supply of education varies substantially by level, institution type, and geography. The following describes the landscape of educational offer using the Skolverket school-unit registry and the UKÄ register of higher education institutions. Both reflect the state of educational offer at the time of analysis; the CA in Section 3 uses 2022 population data, and the school registry is a close but not identical snapshot. Two institution types remain outside the current dataset: *yrkeshögskola* (vocational higher education; source MYH) and *folkhögskola* (folk high schools; source Folkbildningsrådet).

## Coverage by institution type

```{r tbl-coverage}
#| tbl-cap: "educational offer by institution type across 290 Swedish municipalities."
offer_vars <- tribble(
  ~var,                      ~label,
  "forskoleklass",           "Förskoleklass",
  "grundskola",              "Grundskola",
  "anpassad_grundskola",     "Anpassad grundskola",
  "gymnasieskola",           "Gymnasieskola",
  "komvux",                  "Komvux",
  "sfi",                     "SFI (within komvux)",
  "anpassad_gymnasieskola",  "Anpassad gymnasieskola",
  "specialskola",            "Specialskola",
  "sameskola",               "Sameskola",
  "university_college",      "University college",
  "university",              "University"
)

coverage_tbl <- offer_vars |>
  mutate(
    n_tot   = map_int(var, \(v) sum(edu_offer[[paste0(v,"_n_total")]] > 0, na.rm=TRUE)),
    pct     = round(100 * n_tot / 290, 0),
    med_n   = map_dbl(var, \(v) {
      x <- edu_offer[[paste0(v,"_n_total")]]; median(x[x>0], na.rm=TRUE)
    }),
    max_n   = map_int(var, \(v) max(edu_offer[[paste0(v,"_n_total")]], na.rm=TRUE)),
    pct_priv = map_dbl(var, \(v) {
      tot  <- sum(edu_offer[[paste0(v,"_n_total")]],  na.rm=TRUE)
      priv <- sum(edu_offer[[paste0(v,"_n_private")]], na.rm=TRUE)
      if(tot==0 || is.na(priv)) return(NA_real_)
      round(100*priv/tot, 1)
    })
  )

coverage_tbl |>
  transmute(
    `Institution type`       = label,
    `Municipalities (n)`     = n_tot,
    `Coverage (%)`           = pct,
    `Median units*`          = round(med_n, 1),
    `Max units`              = max_n,
    `Private share (%)`      = pct_priv
  ) |>
  kbl(
    booktabs = TRUE, linesep = ""
  ) |>
  kable_styling(font_size = 9, latex_options = c("hold_position", "scale_down")) |>
  footnote(
    general = "Median units computed among municipalities with at least one unit. Yrkeshögskola and folkhögskola not included (data pending).",
    general_title = "Note:",
    footnote_as_chunk = TRUE
  )
```

Primary and lower secondary education is effectively universal. Every municipality has both förskoleklass and grundskola units. Gymnasieskola is present in 87% of municipalities; the remaining 13% (mostly sparsely populated northern or island municipalities) rely on inter-municipal agreements. Komvux covers 90% and SFI 87% of municipalities. Higher education is concentrated with only 11 municipalities hosting a university and 20 hosting a university college, together covering 10% of all municipalities.

## Regional distribution

```{r fig-rq2-region, fig.width=9, fig.height=6, fig.cap="educational offer coverage (\\% of municipalities with at least one unit) by county and selected institution types. Counties ordered from south to north."}
county_order <- c(
  "Skåne", "Blekinge", "Halland", "Kronoberg", "Kalmar", "Gotland",
  "Jönköping", "Östergötland", "Södermanland", "Västra Götaland",
  "Örebro", "Västmanland", "Stockholm", "Uppsala", "Dalarna",
  "Värmland", "Gävleborg", "Västernorrland", "Jämtland",
  "Västerbotten", "Norrbotten"
)

sel_types <- tribble(
  ~var,              ~label,
  "gymnasieskola",   "Gymnasieskola",
  "komvux",          "Komvux",
  "sfi",             "SFI",
  "university_college","University college",
  "university",      "University"
)

county_coverage <- edu_offer |>
  group_by(county) |>
  summarise(
    across(
      all_of(paste0(sel_types$var, "_n_total")),
      \(x) 100 * mean(x > 0, na.rm = TRUE)
    ),
    n_munis = n()
  ) |>
  pivot_longer(
    -c(county, n_munis),
    names_to  = "var",
    values_to = "pct_with"
  ) |>
  mutate(var = str_remove(var, "_n_total")) |>
  left_join(sel_types, by = "var") |>
  mutate(county = factor(county, levels = county_order))

ggplot(county_coverage, aes(x = county, y = pct_with, fill = pct_with)) +
  geom_col() +
  geom_text(aes(label = round(pct_with), y = pmax(pct_with - 5, 3)),
            size = 2.2, colour = "white", fontface = "bold") +
  facet_wrap(~label, nrow = 2) +
  scale_fill_distiller(palette = "YlOrRd", direction = 1, guide = "none") +
  scale_y_continuous(labels = \(x) paste0(x,"%"), limits = c(0,105)) +
  labs(x = NULL, y = "% of municipalities with ≥1 unit") +
  theme_minimal(base_size = 9, base_family = "source_sans_3") +
  theme(
    axis.text.x  = element_text(angle = 45, hjust = 1, size = 7),
    panel.grid.major.x = element_blank(),
    strip.text   = element_text(face = "bold")
  )
```

Gymnasieskola coverage approaches 100% in most counties; gaps appear in sparsely populated northern counties (Jämtland, Norrbotten). SFI and komvux gaps are more pronounced in low-density counties. Higher education—universities and university colleges—is geographically clustered with Stockholm, Gothenburg, and Malmö counties accounting for the majority of institutions, while large parts of northern Sweden and smaller coastal counties have no HE institution within their borders.

## Public and private provision

```{r fig-rq2-private, fig.width=8, fig.height=4, fig.cap="Private provision by institution type. Left: share of municipalities with at least one private unit. Right: national aggregate private share (private units as \\% of all units of that type). HE institutions excluded (all classified as public)."}
priv_types <- offer_vars |>
  filter(!var %in% c("university", "university_college", "specialskola", "sameskola"))

priv_df <- priv_types |>
  mutate(
    pct_munis_with_private = map_dbl(var, \(v) {
      p <- edu_offer[[paste0(v, "_n_private")]]
      100 * mean(p > 0, na.rm = TRUE)
    }),
    pct_national = map_dbl(var, \(v) {
      tot  <- sum(edu_offer[[paste0(v, "_n_total")]],  na.rm = TRUE)
      priv <- sum(edu_offer[[paste0(v, "_n_private")]], na.rm = TRUE)
      if (tot == 0) return(NA_real_)
      100 * priv / tot
    })
  ) |>
  filter(!is.na(pct_national)) |>
  mutate(label = fct_reorder(label, pct_national))

p_left <- ggplot(priv_df, aes(y = label, x = pct_munis_with_private)) +
  geom_col(fill = "#6baed6") +
  geom_text(aes(label = paste0(round(pct_munis_with_private), "%")),
            hjust = -0.15, size = 3) +
  scale_x_continuous(limits = c(0, 75), labels = \(x) paste0(x, "%")) +
  labs(x = "% of municipalities", y = NULL,
       title = "Municipalities with ≥1 private unit") +
  theme_minimal(base_size = 9, base_family = "source_sans_3") +
  theme(panel.grid.minor = element_blank(),
        plot.title = element_text(size = 9, face = "bold"))

p_right <- ggplot(priv_df, aes(y = label, x = pct_national)) +
  geom_col(fill = "#cb181d") +
  geom_text(aes(label = paste0(round(pct_national, 1), "%")),
            hjust = -0.15, size = 3) +
  scale_x_continuous(limits = c(0, 50), labels = \(x) paste0(x, "%")) +
  labs(x = "% of all units (national)", y = NULL,
       title = "National private share") +
  theme_minimal(base_size = 9, base_family = "source_sans_3") +
  theme(panel.grid.minor = element_blank(), axis.text.y = element_blank(),
        plot.title = element_text(size = 9, face = "bold"))

library(patchwork)
p_left + p_right
```

Private provision is most common in upper secondary (gymnasieskola) and basic primary (grundskola). The two figures show different aspects of the same phenomenon. At the national level, 35% of all gymnasieskola units and 18% of all grundskola units are privately run. At the municipal level, however, the majority of municipalities have no private gymnasieskola at all: private upper-secondary schools are heavily concentrated in a handful of larger municipalities, particularly in the Stockholm region. Anpassad utbildning and adult education (komvux, SFI) remain almost exclusively municipal.

# RQ3: Educational assets in the CA space and their relationship to educational offer

Correspondence analysis (CA) is applied to a contingency table in which the 290 municipalities are rows and population-composition variables are columns—all measured as resident counts: age structure (11 groups), educational attainment of Swedish-born men and women (4 levels each), employment by activity sector, and birth country (Swedish-born vs. foreign-born). Two municipalities are close when their resident profiles are similar, regardless of size. Educational offer variables enter the analysis as *supplementary* columns. This section first describes the CA axes, then examines how educational offer relates to them.

## CA structure: eigenvalues and active variables

```{r tbl-ca-eig}
#| tbl-cap: "CA eigenvalues for the first seven dimensions."
eig_df <- as.data.frame(ca$eig[1:7, ]) |>
  rownames_to_column("Dimension") |>
  mutate(Dimension = str_replace(Dimension, "^dim ", "Dim ")) |>
  transmute(
    Dimension,
    Eigenvalue       = round(eigenvalue, 4),
    `% variance`     = round(`percentage of variance`, 1),
    `Cumulative %`   = round(`cumulative percentage of variance`, 1)
  )

kbl(eig_df, booktabs = TRUE, linesep = "") |>
  kable_styling(font_size = 9, latex_options = "hold_position")
```

```{r tbl-ca-active}
#| tbl-cap: !expr paste0("Active variables in the CA (*n* = ", length(var_groups$active), "). All measure counts of residents.")
domain_labels <- c(
  "Age"          = "Age structure (11 groups)",
  "Edu (men)"    = "Education level, Swedish-born men (4 levels)",
  "Edu (women)"  = "Education level, Swedish-born women (4 levels)",
  "Empl. sector" = "Employment by activity sector",
  "Birth country"= "Birth country (Sweden-born vs. foreign-born)"
)

active_counts <- tibble(
  Domain = names(domain_labels),
  Description = unname(domain_labels),
  `Variables (n)` = c(
    sum(str_detect(var_groups$active, "^age_")),
    sum(str_detect(var_groups$active, "^education_level_of_swedish_men_")),
    sum(str_detect(var_groups$active, "^education_level_of_swedish_women_")),
    sum(str_detect(var_groups$active, "^employment_by_activity_sectors_")),
    sum(str_detect(var_groups$active, "^birth_country_"))
  )
)

kbl(active_counts, booktabs = TRUE, linesep = "") |>
  kable_styling(font_size = 9, latex_options = "hold_position")
```

```{r tbl-ca-contrib}
#| tbl-cap: "Top six active-variable contributions (%) to Dimensions 1--3."
contrib_df <- ca$col$contrib |>
  as.data.frame() |>
  rownames_to_column("variable")

top_contrib <- map_dfr(1:3, \(d) {
  col <- paste0("Dim ", d)
  contrib_df |>
    slice_max(.data[[col]], n = 6) |>
    transmute(
      Dimension = paste0("Dim ", d),
      Variable  = variable,
      `Contribution (%)` = round(.data[[col]], 1)
    )
})

top_contrib |>
  mutate(Variable = Variable |>
    str_replace("^education_level_of_swedish_men_",    "Edu men: ") |>
    str_replace("^education_level_of_swedish_women_",  "Edu women: ") |>
    str_replace("^employment_by_activity_sectors_",    "Sector: ") |>
    str_replace("^birth_country_",                     "Birth country: ") |>
    str_replace("^age_",                               "Age: ") |>
    str_replace_all("_", " ")
  ) |>
  kbl(booktabs = TRUE, linesep = "") |>
  kable_styling(font_size = 9, latex_options = c("hold_position", "scale_down")) |>
  collapse_rows(columns = 1, latex_hline = "major")
```

The first two dimensions together account for `r round(sum(ca$eig[1:2, "percentage of variance"]), 1)`% of total inertia. Dimension 1 (`r round(ca$eig[1, "percentage of variance"], 1)`%) is the dominant axis capturing a rural--urban opposition. Its inertia is distributed across five variable themes: the foreign-born share (16.4%), post-secondary attainment among Swedish-born men and women (11.2% and 6.1%), and a sectoral contrast between personal and cultural services on one side and industrial and agricultural employment on the other (8.4%, 8.0%, 6.1%). The placement of immigration and education on the same axis suggest that in Sweden, both concentrations are features of the same metropolitan geography. At the positive pole (right) sit Stockholm, Göteborg, Malmö, and university towns such as Lund and Uppsala; at the negative pole (left) sit small, peripheral municipalities.

Dimension 2 (`r round(ca$eig[2, "percentage of variance"], 1)`%) identifies a secondary opposition that is structurally distinct from Dimension 1. Here, birth-country alone contributes 48.6% of the dimension's inertia, dwarfing all other variables. This axis separates suburban municipalities with an exceptionally high concentration of foreign-born residents like Södertälje and Botkyrka from both metropolitan education centres and rural municipalities.

Dimension 3 (`r round(ca$eig[3, "percentage of variance"], 1)`%) represents a third opposition. Mining and manufacturing employment contributes 34.0% of its inertia, and a young adult age profile adds 16.3%. This axis picks out resource-extraction municipalities like Kiruna, Gällivare, and similar northern towns, whose population structure is shaped by industrial labour demand rather than either urban services or immigration patterns.

## Correlations between CA dimensions and educational offer

```{r fig-rq3-heatmap, fig.width=8, fig.height=5.5, fig.cap="Pearson correlations between CA row scores (Dimensions 1--3) and educational offer indicators. Only variables with $|r| \\geq 0.15$ on at least one dimension are shown. Labels printed where $|r| \\geq 0.25$."}
ca_coords <- as.data.frame(ca$row$coord) |>
  rownames_to_column("municipality") |>
  rename_with(\(x) str_replace(x, "^Dim ", "Dim_"), starts_with("Dim"))

offer_num <- edu_offer |>
  select(municipality, ends_with("_n_total")) |>
  rename_with(\(x) str_remove(x, "_n_total"), ends_with("_n_total"))

offer_joined <- ca_coords |>
  left_join(offer_num, by = "municipality")

offer_vars_sel <- names(offer_num)[-1]

# Use case_match for exact lookup — avoids substring collision in str_replace chain
clean_label <- function(v) {
  case_match(v,
    "forskoleklass"          ~ "Förskoleklass",
    "grundskola"             ~ "Grundskola",
    "anpassad_grundskola"    ~ "Anp. grundskola",
    "anpassad_gymnasieskola" ~ "Anp. gymnasieskola",
    "gymnasieskola"          ~ "Gymnasieskola",
    "komvux"                 ~ "Komvux",
    "sfi"                    ~ "SFI",
    "specialskola"           ~ "Specialskola",
    "sameskola"              ~ "Sameskola",
    "university_college"     ~ "University college",
    "university"             ~ "University",
    .default = v
  )
}

cor_mat <- cor(
  offer_joined[, paste0("Dim_", 1:3)],
  offer_joined[, offer_vars_sel],
  use = "pairwise.complete.obs"
)

cor_long <- as.data.frame(cor_mat) |>
  rownames_to_column("dimension") |>
  pivot_longer(-dimension, names_to = "variable", values_to = "r") |>
  filter(!is.na(r))

keep_vars <- cor_long |>
  group_by(variable) |>
  summarise(max_r = max(abs(r))) |>
  filter(max_r >= 0.15) |>
  pull(variable)

plot_df <- cor_long |>
  filter(variable %in% keep_vars) |>
  mutate(
    label     = clean_label(variable),
    dimension = str_replace(dimension, "_", " ")
  )

ggplot(plot_df, aes(x = dimension, y = reorder(label, r), fill = r)) +
  geom_tile(colour = "white", linewidth = 0.4) +
  geom_text(
    data  = filter(plot_df, abs(r) >= 0.25),
    aes(label = sprintf("%.2f", r)),
    size  = 3
  ) +
  scale_fill_gradient2(
    low  = "#b2182b", mid = "white", high = "#2166ac",
    midpoint = 0, limits = c(-1, 1), name = "r"
  ) +
  labs(x = NULL, y = NULL,
       caption = "Correlations with CA row scores. Blue = positive, red = negative.") +
  theme_minimal(base_size = 10, base_family = "source_sans_3") +
  theme(axis.text.y = element_text(size = 9))
```

```{r fig-rq3-biplot, fig.width=7.5, fig.height=6.5, fig.cap="CA biplot (Dimensions 1--2) with educational offer as vectors. Arrows represent the direction of Pearson correlations with CA row scores (arrow length proportional to $|r|$, scaled for readability). Municipalities shown as grey points; key municipalities labelled."}
scale_fac <- 3.5  # scale correlation vectors to biplot space

rows_bp <- ca_coords |>
  left_join(m_sample |> select(municipality), by = "municipality")

offer_arrows <- tibble(variable = offer_vars_sel) |>
  mutate(
    d1 = map_dbl(variable, \(v) cor(offer_joined$Dim_1, offer_joined[[v]], use="pairwise.complete.obs")),
    d2 = map_dbl(variable, \(v) cor(offer_joined$Dim_2, offer_joined[[v]], use="pairwise.complete.obs")),
    label = clean_label(variable)
  ) |>
  filter(abs(d1) >= 0.2 | abs(d2) >= 0.2) |>
  mutate(d1 = d1 * scale_fac, d2 = d2 * scale_fac)

key_rows <- rows_bp |> filter(municipality %in% key_munis)

dim_pct <- round(ca$eig[1:2, "percentage of variance"], 1)

ggplot() +
  geom_point(data = rows_bp,
             aes(x = Dim_1, y = Dim_2),
             colour = "grey80", size = 0.8, alpha = 0.7) +
  geom_segment(data  = offer_arrows,
               aes(x = 0, y = 0, xend = d1, yend = d2),
               arrow = arrow(length = unit(0.18, "cm"), type = "closed"),
               colour = "#2166ac", linewidth = 0.65) +
  geom_text_repel(data  = offer_arrows,
                  aes(x = d1, y = d2, label = label),
                  colour = "#2166ac", size = 3,
                  max.overlaps = 20, force = 3) +
  geom_text_repel(data      = key_rows,
                  aes(x     = Dim_1, y = Dim_2, label = municipality),
                  colour    = "black", fontface = "bold",
                  size      = 2.8, max.overlaps = 15) +
  geom_hline(yintercept = 0, linetype = "dashed", colour = "grey60", linewidth = 0.3) +
  geom_vline(xintercept = 0, linetype = "dashed", colour = "grey60", linewidth = 0.3) +
  labs(
    x = paste0("Dimension 1 (", dim_pct[1], "%)"),
    y = paste0("Dimension 2 (", dim_pct[2], "%)"),
    caption = "Arrows = educational offer variables (Pearson r × 3.5, threshold |r| ≥ 0.20)."
  ) +
  theme_minimal(base_size = 10, base_family = "source_sans_3") +
  theme(panel.grid.minor = element_blank())
```

All offer types correlate positively with Dimension 1, confirming that the volume of educational offer is related to the rural--urban opposition already captured by population composition. Municipalities at the positive pole have more units of every type (grundskola, gymnasieskola, komvux, SFI, and higher education). This reflects the structural co-location of educated populations and educational institutions in the same urban places.

Komvux and SFI correlate positively with Dimension 2, meaning that the municipalities located at the top on this axis—those with the highest foreign-born concentrations—also tend to have more adult and language education relative to their position on Dimension 1. Universities and university colleges show negligible correlation with Dimension 2, consistent with the interpretation that higher education in this space relates to educational attainment (Dimension 1) rather than immigration reception (Dimension 2). The separation of SFI and komvux from higher education suggests that adult integration education and academic provision relate to different municipal profiles.
