---
title: "Lab 2 Tutorial | PAF 516"
subtitle: "Classification & Spatial Scale"
format:
  html:
    theme: readable
    highlight-style: tango
    toc: true
    toc-title: "Steps"
    self-contained: true
    number-sections: false
execute:
  echo: true
  warning: false
  message: false
  fig-width: 10
---

::: {.callout-tip}
## How to use this file
**First time:** Click the **Render** button (or Cmd+Shift+K / Ctrl+Shift+K) to produce a complete HTML with all results. Then come back and run chunk by chunk to understand each step.

**Chunk by chunk:** Place cursor inside any chunk → Cmd+Return (Mac) / Ctrl+Enter (PC).

This file is for learning — do not edit it. When you are ready for the assignment, download `Lab2_Assignment.qmd` from the [Lab 2 Assignment page](https://antjam-howell.github.io/paf-516-labs/lab2-assignment.html).
:::


```{r}
#| label: setup
#| include: false
knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE, fig.width = 10)

# Cache Census TIGER/Line geometries so they don't re-download every render
options(tigris_use_cache = TRUE)
options(tigris_progress_bar = FALSE)
```


```{r}
#| label: packages
# ── One-time course environment setup ─────────────────────────────────────
if (!file.exists("~/.paf516_ready")) {
  if (!requireNamespace("renv", quietly = TRUE)) install.packages("renv")
  tmp <- tempfile(fileext = ".lock")
  download.file(
    url      = "https://raw.githubusercontent.com/AntJam-Howell/paf-516-labs/main/renv.lock",
    destfile = tmp, mode = "wb"
  )
  renv::restore(lockfile = tmp, prompt = FALSE)
  file.create("~/.paf516_ready")
}

# Core data & spatial
library(tidyverse)
library(tidycensus)
library(sf)
library(tigris)     # For shift_geometry (AK/HI insets)

# Cartographic tools (new in Lab 2)
library(classInt)    # Classification algorithms (Jenks, quantile, etc.)
library(biscale)     # Bivariate choropleth palette + legend
library(mapgl)       # Interactive GPU-rendered maps via MapLibre GL JS
library(ggspatial)   # Scale bars and north arrows for ggplot maps
library(patchwork)   # Combine multiple ggplots into one figure
library(viridis)     # Perceptually uniform color scales
```

```{r}
#| label: api-key
# Get a free Census API key at: https://api.census.gov/data/key_signup.html
# After receiving your key by email, run this line ONCE in the RStudio console:
# census_api_key("YOUR_KEY_HERE", install = TRUE)
# Then restart R: Session → Restart R
# You only need to do this once — the key is saved to your .Renviron file.
```

```{r}
#| label: fallback-data
# ── FALLBACK: If Census API is unavailable ─────────────────────────────
# Pre-built .rds files are available for each data pull step.
# See the commented fallback lines in Step 1b (CBG data) and
# Step 6a (county data). Uncomment the readRDS() lines
# and skip the get_acs() calls in those steps.
#
# Option A — from local file (save .rds files to PAF516/Lab2/data/ first):
# cbg_raw      <- readRDS("../data/cbg_extended_2023.rds")
# county_raw   <- readRDS("../data/county_hardship_2023.rds")
#
# Option B — directly from GitHub:
# cbg_raw      <- readRDS(url("https://raw.githubusercontent.com/AntJam-Howell/paf-516-labs/main/Lab2/data/cbg_extended_2023.rds"))
# county_raw   <- readRDS(url("https://raw.githubusercontent.com/AntJam-Howell/paf-516-labs/main/Lab2/data/county_hardship_2023.rds"))
#
# If using fallback data, skip the get_acs() calls in Steps 1b and 6a,
# then continue from Step 1c.
```


# Step 1: Pull Data & Build the Economic Hardship Index

We reuse the exact same index construction from Lab 1: five ACS variables
measuring economic hardship, z-score standardized, with median income reversed.
This time we also pull **two new demographic variables** --- percent minority
(non-white) and percent renter-occupied --- that we will use for bivariate
mapping later.

## 1a. Define variables

```{r}
#| label: step.01a

# The 5 economic hardship index variables (identical to Lab 1)
hardship_vars <- c(
  pov_below50  = "C17002_002",   # Income-to-poverty ratio under .50
  pov_50to99   = "C17002_003",   # Income-to-poverty ratio .50 to .99
  pov_den      = "C17002_001",   # Poverty universe total
  med_income   = "B19013_001",   # Median household income
  unemp_num    = "B23025_005",   # Unemployed
  unemp_den    = "B23025_002",   # In labor force
  nohs_num     = "B15003_002",   # No high school diploma (< 9th grade)
  nohs_den     = "B15003_001",   # Population 25+
  noins_num    = "B27010_017",   # No health insurance (18-34)
  noins_den    = "B27010_001"    # Insurance universe
)

# New demographic variables for bivariate mapping
demo_vars <- c(
  race_total   = "B03002_001",   # Total population (Hispanic/Latino origin by race)
  race_wnh     = "B03002_003",   # White alone, not Hispanic or Latino
  tenure_total = "B25003_001",   # Total occupied housing units
  tenure_rent  = "B25003_003"    # Renter-occupied units
)

# Combine into one request
all_vars <- c(hardship_vars, demo_vars)
```

## 1b. Pull Maricopa County block group data

```{r}
#| label: step.01b
#| results: hide

# Pull ACS 5-year estimates at the block group level for Maricopa County.
# geometry = TRUE attaches spatial boundaries for mapping.
cbg_raw <- get_acs(
  geography = "block group",
  variables = all_vars,
  state     = "AZ",
  county    = "Maricopa",
  year      = 2023,
  survey    = "acs5",
  geometry  = TRUE,
  progress_bar = FALSE
)

# Fallback — uncomment if API unavailable:
# cbg_raw <- readRDS("../data/cbg_extended_2023.rds")
# OR via URL:
# cbg_raw <- readRDS(url("https://raw.githubusercontent.com/AntJam-Howell/paf-516-labs/main/Lab2/data/cbg_extended_2023.rds"))
# cbg_raw <- cbg_raw %>%
#   mutate(variable = recode(variable,
#     pct_unemp_num = "unemp_num", pct_unemp_den = "unemp_den",
#     pct_no_hs_num = "nohs_num", pct_no_hs_den = "nohs_den",
#     pct_no_ins_num = "noins_num", pct_no_ins_den = "noins_den",
#     median_income = "med_income",
#     total_pop = "race_total", white_nh = "race_wnh",
#     total_tenure = "tenure_total", renter_occ = "tenure_rent"))
```

```{r}
#| label: step.01b-offline
#| eval: false
# ── Offline fallback — run this block instead if the Census API is down ──
# Replace the get_acs() chunk above with these readRDS() calls.
# All datasets are pre-built and stored in data/ for offline use.
#
# cbg <- readRDS("../data/cbg.rds")
```

## 1c. Reshape and compute rates

```{r}
#| label: step.01c

# Pivot from long to wide: one row per CBG, one column per variable.
# We drop the margin of error column since we're working with estimates only.
cbg <- cbg_raw %>%
  select(-moe) %>%
  pivot_wider(names_from = variable, values_from = estimate)

# Compute derived rates from numerator/denominator pairs
cbg <- cbg %>%
  mutate(
    poverty_rate = ( pov_below50 + pov_50to99 ) / pov_den,
    unemp_rate   = unemp_num / unemp_den,
    no_hs_rate   = nohs_num / nohs_den,
    no_ins_rate  = noins_num / noins_den,
    pct_minority = (race_total - race_wnh) / race_total,
    pct_renter   = tenure_rent / tenure_total
  )

# Z-score standardize the 5 hardship variables, reverse median income,
# and compute the composite index as the row mean of z-scores.
cbg <- cbg %>%
  mutate(
    across(
      c(poverty_rate, unemp_rate, no_hs_rate, no_ins_rate, med_income),
      ~ as.numeric(scale(.)),
      .names = "z_{.col}"
    ),
    # Reverse median income so higher = more hardship
    z_med_income = -1 * z_med_income,
    # Composite economic hardship index: mean of the 5 z-scores
    hardship_index = rowMeans(
      pick(z_poverty_rate, z_unemp_rate, z_no_hs_rate, z_no_ins_rate, z_med_income),
      na.rm = TRUE
    )
  )

# Quick summary — how many CBGs, what's the index range?
cat("CBGs:", nrow(cbg), "\n")
cat("Economic Hardship Index range:",
    round(min(cbg$hardship_index, na.rm = TRUE), 2), "to",
    round(max(cbg$hardship_index, na.rm = TRUE), 2), "\n")
```


# Step 2: Classification Comparison

A choropleth map's story depends heavily on how you **classify** continuous
values into discrete color bins. Here we apply four common methods to the
economic hardship index and compare them side-by-side.

| Method | Logic | Effect |
|--------|-------|--------|
| **Quantile** | Equal number of observations per bin | Maximizes color variation; can exaggerate small differences |
| **Jenks (natural breaks)** | Minimizes within-class variance | Finds "natural" groupings; data-driven |
| **Equal interval** | Equal-width bins across the range | Intuitive scale; can leave most observations in one or two bins |
| **Standard deviation** | Bins centered on the mean, each 1 SD wide | Highlights outliers; assumes roughly normal distribution |

```{r}
#| label: step.02a

# ── Compute classification breaks using classInt ──────────────
# classInt::classIntervals() takes a numeric vector and a method name,
# and returns an object whose $brks element contains the break points.
# We request 5 classes (n = 5) for each method.

# Drop NAs for classification
hi_values <- cbg$hardship_index[!is.na(cbg$hardship_index)]

brk_quantile <- classIntervals(hi_values, n = 5, style = "quantile")
brk_jenks    <- classIntervals(hi_values, n = 5, style = "jenks")
brk_equal    <- classIntervals(hi_values, n = 5, style = "equal")
brk_sd       <- classIntervals(hi_values, n = 5, style = "sd")
```

```{r}
#| label: step.02b

# ── Helper function to cut the index and produce a map ────────
# This avoids repeating the same ggplot code four times.
# We use cut() with the break points from classIntervals to assign
# each CBG to a class, then map the classes as a factor fill.

make_class_map <- function(data, breaks_obj, method_label) {
  # cut() assigns each value to a bin defined by breaks_obj$brks
  data <- data %>%
    mutate(
      hi_class = cut(
        hardship_index,
        breaks  = breaks_obj$brks,
        include.lowest = TRUE,
        dig.lab = 2
      )
    )

  ggplot(data) +
    geom_sf(aes(fill = hi_class), color = NA) +
    scale_fill_viridis_d(option = "inferno", direction = -1, na.value = "grey80") +
    coord_sf(datum = NA) +
    theme_void(base_size = 11) +
    labs(
      title = method_label,
      fill  = "Hardship\nClass"
    )
}
```

```{r}
#| label: step.02c
#| fig-height: 10

# ── Build 4 maps and arrange in a 2x2 grid ───────────────────
# patchwork lets us combine ggplots with + and / operators.
# The + operator places plots side-by-side; / stacks them vertically.

p_quant <- make_class_map(cbg, brk_quantile, "Quantile")
p_jenks <- make_class_map(cbg, brk_jenks,    "Jenks Natural Breaks")
p_equal <- make_class_map(cbg, brk_equal,    "Equal Interval")
p_sd    <- make_class_map(cbg, brk_sd,       "Standard Deviation")

(p_quant + p_jenks) / (p_equal + p_sd) +
  plot_annotation(
    title    = "How Classification Method Changes the Story",
    subtitle = "Economic Hardship Index — Maricopa County CBGs (ACS 2019-2023)",
    caption  = "Source: ACS 5-Year Estimates, 2019-2023"
  )
```

**Key observation:** The *same data* produces four visually different maps. The
quantile method spreads color evenly across CBGs, making hardship appear
widespread. The equal interval method concentrates most CBGs in a narrow range,
making only extreme outliers visible. Jenks finds natural clusters in the data.
Standard deviation highlights how far each area deviates from the mean.


# Step 3: Bivariate Choropleth — Hardship x % Minority

A standard choropleth maps **one** variable. A bivariate choropleth maps
**two** variables simultaneously by crossing their classifications into a 3x3
(or 2x2) color grid. This lets us see where *both* hardship and minority
concentration are high --- areas of potential "double disadvantage."

```{r}
#| label: step.03a

# ── Create the bivariate classes using the biscale package ────
# bi_class() takes a data frame and two variable names, and adds a
# new column (bi_class) that encodes the 3x3 combination.
# dim = 3 means each variable is split into terciles (low/mid/high).

cbg_bi <- cbg %>%
  filter(!is.na(hardship_index), !is.na(pct_minority)) %>%
  bi_class(
    x   = hardship_index,  # x-axis variable
    y   = pct_minority,    # y-axis variable
    dim = 3,               # 3x3 grid = 9 color categories
    style = "quantile"     # use quantile breaks for both variables
  )
```

```{r}
#| label: step.03b
#| fig-height: 8

# ── Build the bivariate map ──────────────────────────────────
# bi_scale_fill() maps the bi_class column to a bivariate color palette.
# "DkViolet" is a purple-to-teal palette that works well for diverging pairs.

map_bi <- ggplot(cbg_bi) +
  geom_sf(aes(fill = bi_class), color = NA, show.legend = FALSE) +
  bi_scale_fill(pal = "DkViolet", dim = 3) +
  coord_sf(datum = NA) +
  theme_void(base_size = 12) +
  labs(
    title    = "Economic Hardship x Percent Minority",
    subtitle = "Maricopa County Block Groups (ACS 2019-2023)",
    caption  = "Source: ACS 5-Year Estimates, 2019-2023"
  )

# ── Build the bivariate legend ────────────────────────────────
# bi_legend() creates the 3x3 color key that tells readers how to
# interpret the two dimensions. We place it in the lower-left.

legend_bi <- bi_legend(
  pal = "DkViolet",
  dim = 3,
  xlab = "Higher Hardship →",
  ylab = "Higher % Minority →",
  size = 9
)

# ── Combine map and legend using patchwork ────────────────────
# inset_element() overlays the legend on the map at specified coordinates.
map_bi +
  inset_element(
    legend_bi,
    left   = 0.0,
    bottom = 0.0,
    right  = 0.30,
    top    = 0.30
  )
```

**Interpreting the bivariate map:** The darkest purple areas (upper-right of the
legend) have *both* high hardship and high minority concentration --- these are
the "double disadvantage" neighborhoods. Light areas (lower-left) have low
hardship and low minority share. The off-diagonal colors show areas where the
two variables diverge: high hardship but low minority share, or vice versa.


# Step 4: Interactive Map with mapgl

Static maps are great for print; interactive maps let users explore. Here we
build an interactive GPU-rendered map of Maricopa County CBGs using `mapgl`.
The `mapgl` package renders spatial data via MapLibre GL JS --- producing smooth,
fast, interactive maps even with thousands of polygons. We pass the sf object
directly as a GeoJSON source so the map works in rendered HTML without a local
tile server.

```{r}
#| label: step.04a

# ── Prepare data for mapgl ───────────────────────────────────
# mapgl uses WGS84 (EPSG:4326) coordinate reference system.
# We filter out NAs and transform to the standard lat/lon CRS.

cbg_map <- cbg %>%
  filter(!is.na(hardship_index)) %>%
  st_transform(crs = 4326) %>%
  mutate(
    hardship_round = round(hardship_index, 3),
    poverty_pct    = round(poverty_rate * 100, 1),
    minority_pct   = round(pct_minority * 100, 1),
    renter_pct     = round(pct_renter * 100, 1)
  )
```

```{r}
#| label: step.04b

# ── Build the interactive mapgl map ──────────────────────────
# maplibre() initializes a MapLibre GL JS map with a basemap style.
# add_source() loads the sf object as GeoJSON directly — no local
# tile server needed, so the map works in rendered HTML.
# add_fill_layer() renders polygons with color, opacity, and tooltips.

maplibre(style = openfreemap_style("positron"),
         bounds = st_bbox(cbg_map)) |>
  add_source(id = "hardship-src",
             type = "geojson",
             data = cbg_map) |>
  add_fill_layer(id = "hardship-fill",
                 source = "hardship-src",
                 fill_color = interpolate(
                   column = "hardship_round",
                   values = c(-1, 0, 1, 2),
                   stops  = c("#1a9850", "#fee08b", "#d73027", "#67001f")
                 ),
                 fill_opacity = 0.7,
                 hover_options = list(fill_opacity = 1),
                 tooltip = concat(
                   "GEOID: ", get_column("GEOID"),
                   " | Hardship: ", number_format(get_column("hardship_round")),
                   " | Poverty: ", get_column("poverty_pct"), "%",
                   " | Minority: ", get_column("minority_pct"), "%",
                   " | Renter: ", get_column("renter_pct"), "%"
                 ))
```


# Step 5: Export-Quality Figure with ggspatial

For reports and presentations you often need a polished static map with a scale
bar and north arrow. The `ggspatial` package adds these cartographic elements to
any `ggplot2` + `geom_sf()` map.

```{r}
#| label: step.05
#| fig-height: 8

# ── Publication-ready map with scale bar and north arrow ──────
# annotation_scale() adds a scale bar (default = km).
# annotation_north_arrow() adds a north arrow with configurable style.

ggplot(cbg %>% filter(!is.na(hardship_index))) +
  geom_sf(aes(fill = hardship_index), color = NA) +
  scale_fill_viridis(
    option    = "inferno",
    direction = -1,
    name      = "Hardship\nIndex"
  ) +
  # ggspatial scale bar — placed at bottom-left

  annotation_scale(
    location = "bl",
    width_hint = 0.3,
    style = "ticks"
  ) +
  # ggspatial north arrow — placed at top-right
  annotation_north_arrow(
    location = "tr",
    style = north_arrow_fancy_orienteering()
  ) +
  coord_sf(datum = NA) +
  theme_void(base_size = 12) +
  labs(
    title    = "Economic Hardship Index",
    subtitle = "Maricopa County Block Groups (ACS 2019-2023)",
    caption  = "Source: ACS 5-Year Estimates, 2019-2023",
    fill     = "Hardship\nIndex"
  )
```


# Step 6: The Modifiable Areal Unit Problem (MAUP)

One of the most important lessons in spatial analysis is that **the geographic
unit you choose changes the story your data tells.** This is called the
**Modifiable Areal Unit Problem (MAUP)** --- the same underlying phenomenon
looks dramatically different depending on whether you map it at the county,
tract, or block group level.

A county-level map smooths over enormous internal variation. Maricopa County ---
home to over 4 million people --- gets a single hardship score, but that score
masks neighborhoods where hardship is severe alongside affluent suburbs. A state
official relying only on the county map might conclude Maricopa doesn't need
targeted intervention, completely missing the block groups driving the county's
overall score.

Here we build the economic hardship index at the **US county level** and map it
interactively. Compare this county-level view to the **block group level** map
you already explored in Step 4 --- that contrast is MAUP in action.

## 6a. Pull US county-level data

```{r}
#| label: step.06a
#| results: hide

# ── Pull US county-level data ─────────────────────────────────
# This gives us ~3,200 counties across the entire US.
county_raw <- get_acs(
  geography = "county",
  variables = hardship_vars,
  year      = 2023,
  survey    = "acs5",
  geometry  = TRUE,
  progress_bar = FALSE
)

# Fallback — uncomment if API unavailable:
# county_raw <- readRDS("../data/county_hardship_2023.rds")
# OR via URL:
# county_raw <- readRDS(url("https://raw.githubusercontent.com/AntJam-Howell/paf-516-labs/main/Lab2/data/county_hardship_2023.rds"))
# county_raw <- county_raw %>%
#   mutate(variable = recode(variable,
#     pct_unemp_num = "unemp_num", pct_unemp_den = "unemp_den",
#     pct_no_hs_num = "nohs_num", pct_no_hs_den = "nohs_den",
#     pct_no_ins_num = "noins_num", pct_no_ins_den = "noins_den",
#     median_income = "med_income"))
```

```{r}
#| label: step.06a-offline
#| eval: false
# ── Offline fallback — run this block instead if the Census API is down ──
# Replace the get_acs() chunk above with these readRDS() calls.
# All datasets are pre-built and stored in data/ for offline use.
#
# county_df <- readRDS("../data/county_df.rds")
# county_map <- readRDS("../data/county_map.rds")
```

## 6b. Build the economic hardship index at the county level

```{r}
#| label: step.06b

# ── Reshape and compute economic hardship index ────────────────────────
# Same pipeline as CBGs: pivot wide, compute rates,
# z-score standardize, reverse income, take row mean.

build_index <- function(df) {
  df %>%
    select(-moe) %>%
    pivot_wider(names_from = variable, values_from = estimate) %>%
    mutate(
      poverty_rate = ( pov_below50 + pov_50to99 ) / pov_den,
      unemp_rate   = unemp_num / unemp_den,
      no_hs_rate   = nohs_num / nohs_den,
      no_ins_rate  = noins_num / noins_den
    ) %>%
    mutate(
      across(
        c(poverty_rate, unemp_rate, no_hs_rate, no_ins_rate, med_income),
        ~ as.numeric(scale(.)),
        .names = "z_{.col}"
      ),
      z_med_income = -1 * z_med_income,
      hardship_index = rowMeans(
        pick(z_poverty_rate, z_unemp_rate, z_no_hs_rate, z_no_ins_rate, z_med_income),
        na.rm = TRUE
      )
    )
}

county_df <- build_index(county_raw)
```

## 6c. Interactive county-level map

```{r}
#| label: step.06c

# ── Prepare county data for mapgl ─────────────────────────────
county_map <- county_df %>%
  filter(!is.na(hardship_index)) %>%
  st_transform(crs = 4326) %>%
  mutate(
    hardship_round = round(hardship_index, 3),
    poverty_pct    = round(poverty_rate * 100, 1)
  )
```

```{r}
#| label: step.06d

# ── Interactive US county hardship map ────────────────────────
# Zoom in on Maricopa County, AZ and note the single color assigned
# to the entire county. Then compare with the block group map in
# Step 4 to see what that single county value hides.

maplibre(style = openfreemap_style("positron"),
         bounds = c(-125, 24, -66, 50)) |>
  add_source(id = "county-src",
             type = "geojson",
             data = county_map) |>
  add_fill_layer(id = "county-fill",
                 source = "county-src",
                 fill_color = interpolate(
                   column = "hardship_round",
                   values = c(-1, 0, 1, 2),
                   stops  = c("#1a9850", "#fee08b", "#d73027", "#67001f")
                 ),
                 fill_opacity = 0.7,
                 hover_options = list(fill_opacity = 1),
                 tooltip = concat(
                   get_column("NAME"),
                   " | Hardship: ", number_format(get_column("hardship_round")),
                   " | Poverty: ", get_column("poverty_pct"), "%"
                 ))
```

**MAUP in action:** Zoom into Maricopa County in the map above and note the
single hardship score assigned to the entire county. Now scroll back to Step 4
and explore the same area at the block group level --- you will see that the
county's moderate score hides dramatic variation, with some block groups among
the most distressed in the state and others among the least. This is the
Modifiable Areal Unit Problem: **the unit of analysis determines the story.**


---


# Step 7: Aggregation Sensitivity — ICC Test

Step 6 showed MAUP visually: a county map hides block group variation. But **how much** variation is lost? The **Intraclass Correlation Coefficient (ICC)** quantifies this by measuring what fraction of total hardship variance falls *between* tracts versus *within* them.

If ICC is high, tracts are internally homogeneous and aggregating loses little. If ICC is low, most variation is *within* tracts — meaning block group geography captures meaningful differences that tract-level analysis would erase.

```{r}
#| label: step.07-icc

# Extract tract ID from block group GEOID (first 11 characters)
cbg <- cbg |>
  mutate(
    tract_id = str_sub(GEOID, 1, 11),
    area     = st_area(geometry) |> as.numeric()
  )

# Remove rows with missing hardship index
cbg_valid <- cbg |> filter(!is.na(hardship_index))

# ICC via one-way ANOVA — no heavy packages required
# Formula: (MS_between - MS_within) / (MS_between + (n0 - 1) * MS_within)
# where n0 is the effective average group size (adjusted for unequal groups)
aov_icc  <- aov(hardship_index ~ factor(tract_id), data = cbg_valid)
ms       <- summary(aov_icc)[[1]][, "Mean Sq"]
ms_b     <- ms[1]   # mean square between tracts
ms_w     <- ms[2]   # mean square within tracts (residual)

# Effective group size (Shrout & Fleiss, 1979) — accounts for unequal CBG counts per tract
k        <- length(unique(cbg_valid$tract_id))
n_total  <- nrow(cbg_valid)
n0       <- (n_total - sum(table(cbg_valid$tract_id)^2) / n_total) / (k - 1)

icc_val  <- round((ms_b - ms_w) / (ms_b + (n0 - 1) * ms_w), 3)
cat("ICC:", icc_val, "\n")
cat("(Between-tract MS:", round(ms_b, 3), "| Within-tract MS:", round(ms_w, 3), "| Avg group size:", round(n0, 1), ")\n")
```

```{r}
#| label: step.07-maps
#| fig-width: 14
#| fig-height: 6

# Aggregate block group hardship to tract level (area-weighted mean)
tract_agg <- cbg_valid |>
  st_drop_geometry() |>
  group_by(tract_id) |>
  summarise(hardship_tract = weighted.mean(hardship_index, w = area, na.rm = TRUE),
            .groups = "drop")

# Pull tract geometry and join aggregated values
tract_geo <- get_acs(
  geography = "tract", variables = "B01001_001",
  state = "AZ", county = "Maricopa", year = 2023,
  geometry = TRUE, progress_bar = FALSE
) |>
  select(GEOID, geometry) |>
  left_join(tract_agg, by = c("GEOID" = "tract_id"))

p_cbg   <- ggplot(cbg_valid) +
  geom_sf(aes(fill = hardship_index), color = NA) +
  scale_fill_viridis_c(option = "inferno", name = "Hardship\n(BG)") +
  theme_void() + labs(title = "Block Group Level")

p_tract <- ggplot(tract_geo) +
  geom_sf(aes(fill = hardship_tract), color = NA) +
  scale_fill_viridis_c(option = "inferno", name = "Hardship\n(Tract)") +
  theme_void() + labs(title = "Tract Level (Aggregated)")

p_cbg + p_tract +
  plot_annotation(title = paste0("Scale Sensitivity  |  ICC = ", icc_val))
```

::: {.callout-note}
## What the ICC tells us — and why it matters
The ICC is the fraction of **total hardship variance** that falls *between* tracts (as opposed to *within* them). It answers: "how much does it matter which tract a block group is in?"

- **ICC > 0.75**: tracts are very homogeneous — aggregating to tract loses little
- **ICC 0.40–0.75**: moderate within-tract variation — tract analysis is usable but imprecise
- **ICC < 0.40**: most variation is within tracts — block group geography is strongly preferred

An ICC around 0.5 means roughly half the variance in block group hardship is *between* tracts and half is *within* them — a direct empirical demonstration of MAUP. Aggregating to tracts would suppress meaningful neighborhood-level variation.
:::


---

::: {.callout-note}
## Ready for the assignment?
Head to the [Lab 2 Assignment page](https://antjam-howell.github.io/paf-516-labs/lab2-assignment.html) to download `Lab2_Assignment.qmd`. You will create a second bivariate choropleth and evaluate how the spatial patterns differ.
:::
