Data visualization exercise: solutions

Published

September 1, 2026

This document includes only one possible solution. Yours may vary.

Setup (given)

library(tidyverse)

counties = left_join(
  read_csv("https://princeton-ddss.github.io/r-bootcamp/files/county_data.csv"),
  read_csv("https://princeton-ddss.github.io/r-bootcamp/files/county_population.csv"),
  join_by(GEOID == fips)
)

Part 1: the county data

Rent and income distributions by group

library(scales)

## 12 NA values. Fine.

counties |>
  filter(!is.na(region)) |>
  pivot_longer(c(income, rent)) |>
  mutate(name = str_to_sentence(name)) |>
  ggplot(aes(x = value, color = region)) +
  geom_density(linewidth = 1) +
  facet_wrap(~ name, scales = "free") +
  labs(x = NULL, y = "Density", color = "Region") +
  theme_bw(base_size = 20) +
  theme(
    legend.position = 'bottom',
    axis.text.y = element_blank(),
    strip.background = element_rect(
      fill = 'white',
      color = "black",
      linewidth = 0
      ),
    ) +
  scale_x_continuous(labels = label_dollar(scale_cut = cut_short_scale())) +
  expand_limits(x = 0)

Publication-ready

ggsave("rent_income_densities.pdf", width = 8, height = 4)

Part 2: mapping America’s partisan shift

Fetching data

pres = read_csv("data/countypres_2000-2024.csv")

glimpse(pres)
count(pres, year)
Rows: 94,151
Columns: 12
$ year           <dbl> 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2…
$ state          <chr> "ALABAMA", "ALABAMA", "ALABAMA", "ALABAMA", "ALABAMA", …
$ state_po       <chr> "AL", "AL", "AL", "AL", "AL", "AL", "AL", "AL", "AL", "…
$ county_name    <chr> "AUTAUGA", "AUTAUGA", "AUTAUGA", "AUTAUGA", "BALDWIN", …
$ county_fips    <dbl> 1001, 1001, 1001, 1001, 1003, 1003, 1003, 1003, 1005, 1…
$ office         <chr> "US PRESIDENT", "US PRESIDENT", "US PRESIDENT", "US PRE…
$ candidate      <chr> "OTHER", "CHASE OLIVER", "KAMALA D HARRIS", "DONALD J T…
$ party          <chr> "OTHER", "LIBERTARIAN", "DEMOCRAT", "REPUBLICAN", "OTHE…
$ candidatevotes <dbl> 293, 65, 7439, 20484, 1276, 95798, 24934, 241, 5606, 41…
$ totalvotes     <dbl> 28281, 28281, 28281, 28281, 122249, 122249, 122249, 122…
$ version        <dbl> 20260225, 20260225, 20260225, 20260225, 20260225, 20260…
$ mode           <chr> "TOTAL", "TOTAL", "TOTAL", "TOTAL", "TOTAL", "TOTAL", "…
# A tibble: 7 × 2
   year     n
  <dbl> <int>
1  2000 12628
2  2004  9474
3  2008  9474
4  2012  9474
5  2016  9474
6  2020 22093
7  2024 21534

Fixing problems

pres = pres |>
  filter(!is.na(party), !is.na(county_fips)) |>
  mutate(county_fips = str_pad(county_fips, width = 5, pad = "0"))

glimpse(pres)
Rows: 93,598
Columns: 12
$ year           <dbl> 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2…
$ state          <chr> "ALABAMA", "ALABAMA", "ALABAMA", "ALABAMA", "ALABAMA", …
$ state_po       <chr> "AL", "AL", "AL", "AL", "AL", "AL", "AL", "AL", "AL", "…
$ county_name    <chr> "AUTAUGA", "AUTAUGA", "AUTAUGA", "AUTAUGA", "BALDWIN", …
$ county_fips    <chr> "01001", "01001", "01001", "01001", "01003", "01003", "…
$ office         <chr> "US PRESIDENT", "US PRESIDENT", "US PRESIDENT", "US PRE…
$ candidate      <chr> "OTHER", "CHASE OLIVER", "KAMALA D HARRIS", "DONALD J T…
$ party          <chr> "OTHER", "LIBERTARIAN", "DEMOCRAT", "REPUBLICAN", "OTHE…
$ candidatevotes <dbl> 293, 65, 7439, 20484, 1276, 95798, 24934, 241, 5606, 41…
$ totalvotes     <dbl> 28281, 28281, 28281, 28281, 122249, 122249, 122249, 122…
$ version        <dbl> 20260225, 20260225, 20260225, 20260225, 20260225, 20260…
$ mode           <chr> "TOTAL", "TOTAL", "TOTAL", "TOTAL", "TOTAL", "TOTAL", "…

First, read_csv() guessed county_fips as a number (<dbl>). Second, the 2024 bookkeeping rows (TOTAL VOTES CAST, OVERVOTES, …) have an empty party, which read_csv() reads in as NA. County votes are overcalculated with these rows in place. We also drop the handful of state-level rows with no county_fips at all.

Republican vote share over time

rep_votes = pres |>
  filter(party %in% c('DEMOCRAT', 'REPUBLICAN')) |>
  summarize(
    rep_share = sum(candidatevotes[party == "REPUBLICAN"]) /
      sum(candidatevotes),
    .by = c(year, county_fips)
  )

head(rep_votes, 3)
# A tibble: 3 × 3
   year county_fips rep_share
  <dbl> <chr>           <dbl>
1  2024 01001           0.734
2  2024 01003           0.793
3  2024 01005           0.574
rep_wide = rep_votes |>
  pivot_wider(
    names_from = year,
    values_from = rep_share,
    names_prefix = "r"
    ) |>
  mutate(shift = r2024 - r2012)

head(rep_wide, 3)
# A tibble: 3 × 9
  county_fips r2024 r2000 r2004 r2008 r2012 r2016 r2020   shift
  <chr>       <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>   <dbl>
1 01001       0.734 0.708 0.762 0.741 0.732 0.754 0.726 0.00159
2 01003       0.793 0.745 0.773 0.760 0.782 0.798 0.773 0.0117 
3 01005       0.574 0.496 0.550 0.507 0.484 0.528 0.539 0.0899 

Visualizing county-level shifts

ggplot(rep_wide, aes(x = r2012, y = r2024)) +
  geom_point(size = 0.5) +
  geom_abline() +
  labs(x = "Republican share, 2012", y = "Republican share, 2024",
       title = "County vote shares, 2024 vs. 2012") +
  theme_bw(base_size = 20)

Mapping the shifts

library(sf)

shapes = st_read("https://princeton-ddss.github.io/r-bootcamp/files/county_shapes.geojson")
shift_map = shapes |>
  left_join(rep_wide, join_by(GEOID == county_fips))

ggplot(shift_map) +
  geom_sf(aes(fill = shift), linewidth = 0.02) +
  scale_fill_gradient2(
    low = "blue", mid = "white", high = "red",
    guide = guide_colorbar(
      ticks.colour = "black",  # Makes ticks stand out against light colors
      ticks.linewidth = 1      # Makes the tick marks thicker
      )
    ) +
  labs(fill = "Shift in R share, 2012 to 2024") +
  theme_void(base_size = 15) +
  theme(legend.position = 'top')

Part 3: Putting it together

Republican shifts and county-level income

shift_map = shift_map |>
  left_join(
    counties |> select(GEOID, income),
    join_by(GEOID)
    )

ggplot(shift_map, aes(x = income, y = shift)) +
  geom_point(size = 0.5) +
  geom_hline(yintercept = 0, linetype = "dotted") +
  labs(
    x = "Median household income ($)",
    y = "Shift in Republican share,\n2012 - 2024",
    title = "Median HH Income vs.\nShift in tpvs, 2012 - 2024"
    ) +
  theme_minimal(base_size = 15) +
  scale_x_continuous(labels = label_dollar(scale_cut = cut_short_scale()))

Thinking like a social scientist

This is a county-level (aggregate) relationship. Gelman et al.’s Red State, Blue State shows that aggregate and individual-level correlations can vary in direction. We should not conclude from this plot that “less wealthy voters swung right and wealthier voters swung left.” To examine this relationship properly, we need individual-level data: surveys with income and vote choice (ANES, CES, exit polls).


Election data: MIT Election Data and Science Lab, “County Presidential Election Returns 2000–2024”, doi:10.7910/DVN/VOQCHQ (CC0).