library(tidyverse)
library(leaflet)     # htmlwidget: interactive maps
library(plotly)      # htmlwidget: interactive plots
library(htmltools)   # for HTML() in popups/labels
# ==========================================
# 1. LOAD AND HARMONIZE DATA
# ==========================================
ccc_phase1 <- read_csv("ccc_compiled_20172020.csv", show_col_types = FALSE)
ccc_phase2 <- read_csv("ccc_compiled_20212024.csv", show_col_types = FALSE)
ccc_phase3 <- read_csv("ccc-phase3-public.csv",    show_col_types = FALSE)

ccc1_clean <- ccc_phase1 %>% select(-starts_with("source")) %>% mutate(across(everything(), as.character))
ccc2_clean <- ccc_phase2 %>% select(-starts_with("source")) %>% mutate(across(everything(), as.character))
ccc3_clean <- ccc_phase3 %>% select(-starts_with("source")) %>% mutate(across(everything(), as.character))

ccc_full <- bind_rows(ccc1_clean, ccc2_clean, ccc3_clean) %>%
  mutate(
    date      = as.Date(date),
    size_mean = as.numeric(size_mean),
    size_low  = as.numeric(size_low),
    size_high = as.numeric(size_high),
    lat       = as.numeric(lat),
    lon       = as.numeric(lon),
    state     = as.character(state)
  )
# ==========================================
# 2. DEFINE SUBSETS
# ==========================================
blm_protests <- ccc_full %>%
  filter(state == "MI" & date >= "2020-05-25" & date <= "2020-08-31")

election_protests <- ccc_full %>%
  filter(state == "MI" & date >= "2020-11-03" & date <= "2021-01-20")

strategic_labels <- data.frame(
  locality = c("Detroit", "Grand Rapids", "Warren", "Sterling Heights", "Ann Arbor", "Lansing"),
  lon      = c(-83.0458, -85.6681, -83.0238, -83.0302, -83.7430, -84.5555),
  lat      = c(42.3314,  42.9634,  42.4931,  42.5803,  42.2808,  42.7325),
  is_hub   = c(TRUE, FALSE, FALSE, FALSE, FALSE, TRUE)
)

comparison_cities <- strategic_labels$locality

calc_estimate <- function(df) {
  df %>%
    mutate(estimate = case_when(
      !is.na(size_mean) ~ size_mean,
      !is.na(size_low) & !is.na(size_high) ~ (size_low + size_high) / 2,
      !is.na(size_low) ~ size_low,
      TRUE ~ 1)) %>%
    group_by(locality) %>%
    summarize(total_estimate = sum(estimate, na.rm = TRUE),
              lon = first(lon), lat = first(lat), .groups = "drop") %>%
    mutate(visual_size = pmin(total_estimate, 5000))
}

election_summary <- calc_estimate(election_protests)
blm_summary      <- calc_estimate(blm_protests)
# ==========================================
# 3. SHARED MAP HELPERS
# ==========================================
make_radius <- function(x, r_min = 5, r_max = 22) {
  if (length(x) == 0) return(numeric(0))
  rng <- range(x, na.rm = TRUE)
  if (diff(rng) == 0) return(rep(r_min, length(x)))
  scales::rescale(x, to = c(r_min, r_max), from = rng)
}

build_popup <- function(df) {
  paste0(
    "<b>", df$locality, "</b><br>",
    "Total participants: ", scales::comma(round(df$total_estimate))
  )
}

mi_bounds <- list(lng1 = -90.5, lat1 = 41.7, lng2 = -82.2, lat2 = 47.5)

The Michigan Norm: Movement through Population

An expectation is that most protests will occur wherever the most amount of people are. The logic is simple, the more people who live in a city the larger potential for an event to occur. As shown in the map of the “Summer 2020 BLM Protests,” the Black lives matter movement followed this exact pattern. Participation was spread across the entire state, with massive turnouts in residential hubs like Detroit, Sterling Heights, Grand Rapids, and Ann Arbor. These massive bubbles on the map show that the movement grew naturally where the population was the largest. At this point in 2020, activism was about widespread community presence and reaching as many people as possible.

pal_blm <- colorNumeric(palette = "plasma", domain = blm_summary$total_estimate, reverse = TRUE)

leaflet(blm_summary) %>%
  addProviderTiles(providers$CartoDB.Positron) %>%
  fitBounds(mi_bounds$lng1, mi_bounds$lat1, mi_bounds$lng2, mi_bounds$lat2) %>%
  addCircleMarkers(
    lng         = ~lon,
    lat         = ~lat,
    radius      = ~make_radius(visual_size),
    color       = ~pal_blm(total_estimate),
    fillColor   = ~pal_blm(total_estimate),
    fillOpacity = 0.7,
    stroke      = TRUE,
    weight      = 1,
    popup       = ~build_popup(blm_summary),
    label       = ~lapply(
                     paste0("<b>", locality, "</b>: ", scales::comma(round(total_estimate))),
                     HTML)
  ) %>%
  addLabelOnlyMarkers(
    data = strategic_labels,
    lng  = ~lon, lat = ~lat,
    label = ~locality,
    labelOptions = labelOptions(
      noHide = TRUE, direction = "top", textOnly = FALSE,
      style = list("font-weight" = "bold", "font-size" = "12px")
    )
  ) %>%
  addLegend(
    "bottomright", pal = pal_blm, values = ~total_estimate,
    title = "BLM Participants<br>(Summer 2020)",
    labFormat = labelFormat(big.mark = ",")
  )

The Puzzle of the Silent Suburbs

The surprise comes when we look at the Winter 2020-2021 Post Election Protests map. A massive gap opens up between what we expected to see vs what actually happened. If these protests acted according to the last logic, we would see massive concentrations of people in the major populous cities. However, cities like Warren and Sterling heights the 3rd and 4th largest cities in the state essentially disappeared from the map. Groups left the residential areas, and focused their efforts strongly on electoral hubs in Detroit and Lansing. Groups changed their activism plan of reaching as many people as possible to intentionally redirecting their efforts towards the “Power” proving this shift was more strategic than past movements.

pal_election <- colorNumeric(palette = "viridis", domain = election_summary$total_estimate, reverse = TRUE)

leaflet(election_summary) %>%
  addProviderTiles(providers$CartoDB.Positron) %>%
  fitBounds(mi_bounds$lng1, mi_bounds$lat1, mi_bounds$lng2, mi_bounds$lat2) %>%
  addCircleMarkers(
    lng         = ~lon,
    lat         = ~lat,
    radius      = ~make_radius(visual_size),
    color       = ~pal_election(total_estimate),
    fillColor   = ~pal_election(total_estimate),
    fillOpacity = 0.7,
    stroke      = TRUE,
    weight      = 1,
    popup       = ~build_popup(election_summary),
    label       = ~lapply(
                     paste0("<b>", locality, "</b>: ", scales::comma(round(total_estimate))),
                     HTML)
  ) %>%
  addLabelOnlyMarkers(
    data = strategic_labels,
    lng  = ~lon, lat = ~lat,
    label = ~locality,
    labelOptions = labelOptions(
      noHide = TRUE, direction = "top", textOnly = FALSE,
      style = list("font-weight" = "bold", "font-size" = "12px")
    )
  ) %>%
  addLegend(
    "bottomright", pal = pal_election, values = ~total_estimate,
    title = "Election Participants<br>(Winter 2020-21)",
    labFormat = labelFormat(big.mark = ",")
  )

Surgical Pressure vs. Population Presence

The bar chart, “Surgical Pressure vs. Population Presence,” provides another angle of the picture. In a typical movement such as the summer 2020 protests participation is a normally driven by city size. But the post election data shows a strategic strategy in place. While participation in almost every other major city collapsed, Lansing remained a constant hotspot for activity. When looking at the scale of each major Michigan city, Detroit, Grand Rapids, Warren, Sterling Heights, Ann Arbor, and Lansing are the States most populous cities. Even though Lansing has a fraction of the population of the Detroit metro area, its post election protest scale rivaled even the 2020 summer mass turnouts. According to the CCC data, in summer 2020 Detroit exhibited 10,457 participants while Lansing had 5,171 participants. Looking at the post election protests, Detroit had 707 participants while Lansing had 3,370. The drop off of participation from summer to winter protests in Detroit really makes you observe the change in movement dynamics, focusing their efforts to the state capital.

mi_blm_bars <- blm_summary %>%
  filter(locality %in% comparison_cities) %>%
  mutate(movement = "BLM (Summer 2020)")

mi_election_bars <- election_summary %>%
  filter(locality %in% comparison_cities) %>%
  mutate(movement = "Election (Winter 2020-21)")

combined_bars <- bind_rows(mi_blm_bars, mi_election_bars) %>%
  complete(locality = comparison_cities, movement, fill = list(total_estimate = 0)) %>%
  mutate(tooltip = paste0(
    "<b>", locality, "</b><br>",
    movement, "<br>",
    "Participants: ", scales::comma(round(total_estimate))
  ))

bar_chart <- ggplot(combined_bars,
                    aes(x = reorder(locality, -total_estimate),
                        y = total_estimate,
                        fill = movement,
                        text = tooltip)) +
  geom_col(position = position_dodge(width = 0.8), width = 0.7) +
  scale_fill_manual(values = c("BLM (Summer 2020)"         = "#355C7D",
                               "Election (Winter 2020-21)" = "#C06C84")) +
  scale_y_continuous(labels = scales::comma) +
  labs(title = NULL, x = NULL, y = "Total Participants", fill = NULL) +
  theme_minimal() +
  theme(legend.position = "bottom")

ggplotly(bar_chart, tooltip = "text") %>%
  layout(legend = list(orientation = "h", x = 0.2, y = -0.15))

Targeting the Process, Not People

This finding matters because it changed how we understand political pressure. It shows that the 2020 election protests weren’t trying to win over the general public, but they were designed to target the areas of power. The election protests weren’t just another version of the BLM turnouts, but was a targeted attempt to pressure political figures at the state capital in Lansing. These protests represented a shift from large public outreach movements to redirecting all efforts to influencing those in power. For election officials and other political figures, this shows the protest scale can be less about how many people you have and more so about exactly where you put them.