Introduction

This post explores New Mexico’s political landscape from 2000 to 2024 using a cleaned and standardized dataset of election results sourced from the New Mexico Secretary of State’s election statistics. The dataset covers all general elections for statewide offices, the state legislature, and the federal delegation, with consistent party names, uniform formatting, and structured organization. It’s available on GitHub at newmexico-political-data and processed to be analysis-ready. The visualizations below track election results, party control, and compositional changes over this 24-year period.

Data

Data are loaded directly from GitHub. The dataset includes statewide results, county-level statewide results, and precinct-level results (2004-2024).

pacman::p_load(dplyr, ggplot2, tidyr, viridis, scales, patchwork, purrr, rlang, DT, knitr)
# Load final results data from GitHub
results <- read.csv("https://raw.githubusercontent.com/jaytimm/newmexico-political-data/main/data/elections/nm_election_results_2000-24.csv", 
                    stringsAsFactors = FALSE)

# Define office categories
statewide_offices <- c("Governor", "Attorney General", "Secretary of State", 
                      "State Treasurer", "State Auditor", "Commissioner of Public Lands",
                      "President of the United States", "United States Senator")

legislative_offices <- c("State Representative", "State Senate")

federal_offices <- c("United States Representative", "United States Senator")

# Load county-level statewide results from GitHub
county_results <- read.csv("https://raw.githubusercontent.com/jaytimm/newmexico-political-data/main/data/elections/nm_county_statewide_results_2000-24.csv", 
                           stringsAsFactors = FALSE)

# Load precinct-level data from GitHub (2004-2024)
precinct_data <- read.csv("https://raw.githubusercontent.com/jaytimm/newmexico-political-data/main/data/elections/nm_precinct_results_2004-24.csv", 
                          stringsAsFactors = FALSE)
# Show first 100 records
results |>
  head(100) |>
  datatable(
    options = list(
      pageLength = 10,
      scrollX = TRUE
    ),
    rownames = FALSE
  )

Track Statewide and Presidential Results

This section shows vote margins and winners for statewide offices and presidential elections from 2000-2024.

Statewide Office Control Over Time

Vote margins for statewide offices (Governor, Attorney General, Secretary of State, State Treasurer, State Auditor) by election year. Margins are calculated as Democratic vote share minus Republican vote share; values shown are absolute values. Third parties are excluded from the calculation. Blue indicates Democratic margins, red indicates Republican margins. The winner’s last name and margin are shown for each office and year.

# Create heatmap of vote margins by office and year
# Filter to 2002-2022 and order by importance
statewide_offices_list <- c("Governor", "Secretary of State", "Attorney General", 
                            "State Treasurer", "State Auditor")

# Get winners and their names
winners <- results |>
  filter(office_name %in% statewide_offices_list) |>
  filter(election_date >= 2002 & election_date <= 2022) |>
  filter(election_date != 2016) |>
  filter(is_winner == TRUE) |>
  select(election_date, office_name, candidate_name, candidate_party_name) |>
  mutate(
    last_name = gsub(".* ", "", candidate_name),  # Get everything after last space
    last_name = gsub(" and .*", "", last_name)  # Handle ticket names
  )

# Calculate margins using existing pct column from results
heatmap_data <- results |>
  filter(office_name %in% statewide_offices_list) |>
  filter(election_date >= 2002 & election_date <= 2022) |>
  filter(election_date != 2016) |>
  filter(candidate_party_name %in% c("Democratic", "Republican")) |>
  group_by(election_date, office_name, candidate_party_name) |>
  summarise(pct = first(pct), .groups = "drop") |>
  pivot_wider(names_from = candidate_party_name, values_from = pct) |>
  mutate(
    Democratic = ifelse(is.na(Democratic), 0, Democratic),
    Republican = ifelse(is.na(Republican), 0, Republican),
    margin = Democratic - Republican
  ) |>
  left_join(winners, by = c("election_date", "office_name")) |>
  select(election_date, office_name, margin, last_name) |>
  mutate(
    office_name = factor(office_name, levels = rev(statewide_offices_list))
  )

# Create the heatmap
ggplot(heatmap_data, aes(x = as.factor(election_date), y = office_name, fill = margin)) +
  geom_tile(color = "white", linewidth = 1) +
  geom_text(aes(label = paste0(last_name, "\n", round(abs(margin), 1))), 
            color = "black", size = 3.5) +
  scale_fill_gradient2(
    low = "#d62728", 
    mid = "white", 
    high = "#1f77b4",
    midpoint = 0,
    limits = c(-40, 40),
    guide = "none"
  ) +
  labs(
    title = "Statewide Office Control in New Mexico (2002-2022)",
    x = "Election Year",
    y = "Office"
  ) +
  theme_minimal() +
  theme(
    axis.text.x = element_text(angle = 45, hjust = 1),
    plot.title = element_text(size = 16)
  )

Presidential Results in Selected Counties

Presidential election results for selected New Mexico counties (Bernalillo, Torrance, Sandoval, Valencia, Santa Fe, Luna, McKinley, Doña Ana) plus statewide totals. Margins are calculated as Democratic vote share minus Republican vote share; values shown are absolute values. Third parties are excluded from the calculation. Shows vote margins and winning candidate by county and election year.

# Filter to presidential elections in selected counties
selected_counties <- c("Bernalillo", "Torrance", "Sandoval", "Valencia", "Santa Fe", "Luna", "McKinley", "Doña Ana")

# Get statewide margins
statewide_prez <- results |>
  filter(office_name == "President of the United States") |>
  filter(candidate_party_name %in% c("Democratic", "Republican")) |>
  group_by(election_date, candidate_party_name) |>
  summarise(pct = first(pct), .groups = "drop") |>
  pivot_wider(names_from = candidate_party_name, values_from = pct) |>
  mutate(
    Democratic = ifelse(is.na(Democratic), 0, Democratic),
    Republican = ifelse(is.na(Republican), 0, Republican),
    margin = Democratic - Republican,
    county_name = "Statewide"
  ) |>
  select(election_date, county_name, margin)

# Get winners by county
prez_winners <- county_results |>
  filter(office_name == "President of the United States") |>
  filter(county_name %in% selected_counties) |>
  filter(is_winner == TRUE) |>
  select(election_date, county_name, candidate_name, candidate_party_name) |>
  mutate(
    last_name = gsub(".* ", "", candidate_name),  # Get everything after last space
    last_name = gsub(" and .*", "", last_name)  # Handle ticket names
  )

# Get statewide winner
statewide_winners <- results |>
  filter(office_name == "President of the United States") |>
  filter(is_winner == TRUE) |>
  group_by(election_date) |>
  slice(1) |>
  select(election_date, candidate_name) |>
  mutate(
    last_name = gsub(".* ", "", candidate_name),  # Get everything after last space
    last_name = gsub(" and .*", "", last_name),  # Handle ticket names
    county_name = "Statewide"
  ) |>
  select(election_date, county_name, last_name)

prez_county <- county_results |>
  filter(office_name == "President of the United States") |>
  filter(county_name %in% selected_counties) |>
  filter(candidate_party_name %in% c("Democratic", "Republican")) |>
  group_by(election_date, county_name, candidate_party_name) |>
  summarise(pct = first(pct), .groups = "drop") |>
  pivot_wider(names_from = candidate_party_name, values_from = pct) |>
  mutate(
    Democratic = ifelse(is.na(Democratic), 0, Democratic),
    Republican = ifelse(is.na(Republican), 0, Republican),
    margin = Democratic - Republican
  ) |>
  left_join(prez_winners, by = c("election_date", "county_name")) |>
  select(election_date, county_name, margin, last_name) |>
  bind_rows(
    statewide_prez |>
      left_join(statewide_winners, by = c("election_date", "county_name"))
  )

# Order counties (Statewide at top)
prez_county$county_name <- factor(prez_county$county_name, 
                                   levels = c("Statewide", "Valencia", "Torrance", "Sandoval", "Bernalillo", "Santa Fe", "Luna", "McKinley", "Doña Ana"))

# Calculate range from data
margin_range <- range(prez_county$margin, na.rm = TRUE)
margin_max <- max(abs(margin_range))

ggplot(prez_county, aes(x = as.factor(election_date), y = county_name, fill = margin)) +
  geom_tile(color = "white", linewidth = 1) +
  geom_text(aes(label = paste0(last_name, "\n", round(abs(margin), 1))), 
            color = "black", size = 3.5) +
  scale_fill_gradient2(
    low = "#d62728", 
    mid = "white", 
    high = "#1f77b4",
    midpoint = 0,
    limits = c(-margin_max, margin_max),
    guide = "none"
  ) +
  labs(
    title = "Presidential Election Results: Selected Counties",
    x = "Election Year",
    y = "County"
  ) +
  theme_minimal() +
  theme(
    axis.text.x = element_text(angle = 45, hjust = 1),
    plot.title = element_text(size = 16)
  )


Track Legislative Composition

This section shows the party composition of the State House and State Senate from 2000-2024.

State House Composition Over Time

Number of Democratic and Republican seats in the State House by election year. Positive values show Democratic seats, negative values show Republican seats. Labels show the seat count for each party.

# Calculate State House breakdown by year
house_data <- results |>
  filter(office_name == "State Representative") |>
  filter(is_winner == TRUE)

house_composition <- house_data |>
  group_by(election_date, candidate_party_name) |>
  summarise(seats = n(), .groups = "drop") |>
  complete(election_date, candidate_party_name, fill = list(seats = 0)) |>
  filter(candidate_party_name != "Other") |>
  group_by(election_date) |>
  summarise(
    dem_seats = sum(seats[candidate_party_name == "Democratic"]),
    rep_seats = sum(seats[candidate_party_name == "Republican"]),
    label = paste0(dem_seats, "/", rep_seats),
    .groups = "drop"
  )

ggplot(house_composition, aes(x = as.factor(election_date))) +
  geom_col(aes(y = dem_seats), fill = "#1f77b4", alpha = 0.8) +
  geom_col(aes(y = -rep_seats), fill = "#d62728", alpha = 0.8) +
  geom_text(aes(y = pmax(dem_seats, abs(-rep_seats)) + 2, label = label), size = 3) +
  geom_hline(yintercept = 0, color = "black", linewidth = 0.5) +
  scale_y_continuous(
    breaks = seq(-40, 50, by = 10),
    labels = abs(seq(-40, 50, by = 10))
  ) +
  labs(
    title = "State House Composition (2000-2024)",
    x = "Election Year",
    y = "Number of Seats"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(size = 16)
  )

State Senate Composition Over Time

Number of Democratic and Republican seats in the State Senate by election year. Positive values show Democratic seats, negative values show Republican seats. Labels show the seat count for each party.

# Calculate State Senate breakdown by year
senate_data <- results |>
  filter(office_name == "State Senate") |>
  filter(is_winner == TRUE)

senate_composition <- senate_data |>
  group_by(election_date, candidate_party_name) |>
  summarise(seats = n(), .groups = "drop") |>
  complete(election_date, candidate_party_name, fill = list(seats = 0)) |>
  filter(candidate_party_name != "Other") |>
  group_by(election_date) |>
  summarise(
    dem_seats = sum(seats[candidate_party_name == "Democratic"]),
    rep_seats = sum(seats[candidate_party_name == "Republican"]),
    label = paste0(dem_seats, "/", rep_seats),
    .groups = "drop"
  )

ggplot(senate_composition, aes(x = as.factor(election_date))) +
  geom_col(aes(y = dem_seats), fill = "#1f77b4", alpha = 0.8) +
  geom_col(aes(y = -rep_seats), fill = "#d62728", alpha = 0.8) +
  geom_text(aes(y = pmax(dem_seats, abs(-rep_seats)) + 2, label = label), size = 4) +
  geom_hline(yintercept = 0, color = "black", linewidth = 0.5) +
  scale_y_continuous(
    breaks = seq(-25, 30, by = 5),
    labels = abs(seq(-25, 30, by = 5))
  ) +
  labs(
    title = "State Senate Composition (2000-2024)",
    x = "Election Year",
    y = "Number of Seats"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(size = 16)
  )


Trace Federal Delegation Control

This section shows party control of New Mexico’s federal delegation: 3 U.S. House representatives and 2 U.S. senators from 2000-2024.

# Reusable timeline plot function
create_timeline <- function(data, title, subtitle, group_col = NULL) {
  # Expand to one row per year
  expanded <- data |>
    rowwise() |>
    do(data.frame(label = .$label, party = .$party, 
                  year = seq(.$start_year, .$end_year), stringsAsFactors = FALSE)) |>
    ungroup()
  
  # Order labels - group by class/district if provided, then by start year
  if(!is.null(group_col) && group_col %in% names(data)) {
    label_order <- data |>
      arrange(!!sym(group_col), start_year) |>
      pull(label) |>
      unique() |>
      rev()
  } else {
    label_order <- rev(unique(data$label[order(data$start_year)]))
  }
  
  expanded$label <- factor(expanded$label, levels = label_order)
  
  ggplot(expanded, aes(x = year, y = label, fill = party)) +
    geom_tile(color = "white", linewidth = 0.5, height = 0.8) +
    scale_fill_manual(values = c("Democratic" = "#1f77b4", "Republican" = "#d62728", "Other" = "#ff7f0e")) +
    scale_x_continuous(breaks = seq(2000, 2024, 2), limits = c(1999.5, 2024.5)) +
    labs(title = title, subtitle = subtitle, x = "Year", y = "") +
    theme_minimal() +
    theme(plot.title = element_text(size = 16), 
          axis.text.y = element_text(size = 9),
          panel.grid.major.y = element_blank(), 
          legend.position = "none")
}

Build U.S. Senate Delegation Timeline

This figure tracks which party held each of New Mexico’s two U.S. Senate seats continuously from 2000 through 2024. The two seats are separated by class, which determines their election cycle: Class 1 seats come up in presidential years (2000, 2006, 2012, 2018, 2024) and Class 2 seats in midterm years (2002, 2008, 2014, 2020). Each row spans the years a senator served, labeled with their last name and class. Blue tiles indicate Democratic control and red indicates Republican.

# Senate: 2 seats (Class 1: 2000,2006,2012,2018,2024 | Class 2: 2002,2008,2014,2020)
senate <- results |>
  filter(office_name == "United States Senator", is_winner == TRUE) |>
  mutate(
    yr = as.numeric(election_date),
    name = gsub(".*,? ([A-Z][a-z]+)$", "\\1", candidate_name),
    party = ifelse(candidate_party_name == "Democratic", "Democratic", "Republican")
  )

build_seat <- function(years, class_name) {
  map_dfr(years, ~{
    s <- senate |> filter(yr == .x) |> slice(1)
    if(nrow(s) > 0) {
      next_yr <- c(years[years > .x], 2025)[1] - 1
      data.frame(name = s$name, party = s$party, class = class_name,
                 start_year = .x, end_year = next_yr, stringsAsFactors = FALSE)
    }
  }) |> mutate(label = paste0(name, " (", class, ")"))
}

senate_timeline <- bind_rows(
  build_seat(c(2000, 2006, 2012, 2018, 2024), "Class 1"),
  build_seat(c(2002, 2008, 2014, 2020), "Class 2")
)

create_timeline(senate_timeline, "U.S. Senate",
                "Senator tenure by class from 2000-2024", group_col = "class")

Build U.S. House Delegation Timeline

This figure shows the party holding each of New Mexico’s three U.S. House seats in every two-year term from 2000 through 2024. Each row represents a representative’s stint in office, labeled with their last name and district number; consecutive rows in the same district are stacked to show turnover. Blue tiles mark Democratic tenures and red marks Republican.

# House: 3 districts with 2-year terms (each election is a separate term)
house_timeline <- results |>
  filter(office_name == "United States Representative", is_winner == TRUE) |>
  mutate(
    yr = as.numeric(election_date),
    name = gsub(".*,? ([A-Z][a-z]+)$", "\\1", candidate_name),
    party = ifelse(candidate_party_name == "Democratic", "Democratic", "Republican"),
    dist = gsub(".*District (\\d+).*", "\\1", district_name)
  ) |>
  arrange(dist, yr) |>
  group_by(dist) |>
  mutate(
    next_election = lead(yr, default = 2025),
    end_year = pmin(next_election - 1, 2024)
  ) |>
  ungroup() |>
  mutate(label = paste0(name, " (D", dist, ")")) |>
  select(label, party, dist, start_year = yr, end_year)

create_timeline(house_timeline, "U.S. House New Mexico Delegation",
                "Representative tenure by district from 2000-2024", group_col = "dist")

Delegation Composition Over Time

This diverging bar chart aggregates the U.S. House delegation by election year, showing how many of New Mexico’s three seats each party held after each election. Democratic seat counts extend upward (blue) and Republican counts extend downward (red), with D/R labels at the top of each bar pair.

# Calculate delegation breakdown by year
federal_data <- results |>
  filter(office_name %in% federal_offices) |>
  filter(is_winner == TRUE)

delegation <- federal_data |>
  filter(office_name == "United States Representative") |>
  group_by(election_date, candidate_party_name) |>
  summarise(seats = n(), .groups = "drop") |>
  complete(election_date, candidate_party_name, fill = list(seats = 0)) |>
  filter(candidate_party_name != "Other") |>
  mutate(
    delegation_label = paste0(seats, ifelse(candidate_party_name == "Democratic", "D", "R"))
  ) |>
  group_by(election_date) |>
  summarise(
    dem_seats = sum(seats[candidate_party_name == "Democratic"]),
    rep_seats = sum(seats[candidate_party_name == "Republican"]),
    label = paste0(dem_seats, "/", rep_seats),
    .groups = "drop"
  )

ggplot(delegation, aes(x = as.factor(election_date))) +
  geom_col(aes(y = dem_seats), fill = "#1f77b4", alpha = 0.8) +
  geom_col(aes(y = -rep_seats), fill = "#d62728", alpha = 0.8) +
  geom_text(aes(y = ifelse(dem_seats >= rep_seats, dem_seats + 0.15, -rep_seats - 0.15), label = label), size = 4) +
  geom_hline(yintercept = 0, color = "black", linewidth = 0.5) +
  scale_y_continuous(
    breaks = seq(-3, 3, by = 1),
    labels = abs(seq(-3, 3, by = 1))
  ) +
  labs(
    title = "U.S. House Delegation Composition (2000-2024)",
    subtitle = "Positive = Democratic seats, Negative = Republican seats",
    x = "Election Year",
    y = "Number of Seats"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(size = 16)
  )


Measure Harris Underperformance in 2024

Precinct-level results show Kamala Harris performed worse than House Democratic candidates across New Mexico’s three congressional districts, with the gap varying by region. CD1 (Albuquerque) shows rough parity, CD2 (southern, more conservative) shows the House candidate slightly ahead, and CD3 (northern Democratic stronghold) shows the largest divergence. Points above the diagonal line indicate where the House candidate outperformed Harris.

Scatter Plot by Congressional District

This scatter plot shows Harris vote share (x-axis) versus House Democratic candidate vote share (y-axis) for each precinct, colored by congressional district. The diagonal line represents parity – points above the line show where the House Democratic candidate outperformed Harris, points below show where Harris outperformed the House Democratic candidate.

# Filter for 2024 and standardize column names
precinct_2024 <- precinct_data |>
  filter(election_date == 2024)

# Standardize column names
if("COUNTY_NAM" %in% names(precinct_2024)) {
  precinct_2024$county_name <- precinct_2024$COUNTY_NAM
}
if("VTD_NUM" %in% names(precinct_2024)) {
  precinct_2024$precinct_name <- as.character(precinct_2024$VTD_NUM)
}

precinct_2024 <- precinct_2024 |>
  mutate(cd = gsub(".*District (\\d+).*", "\\1", district_name))

# Get Harris (President) results
harris <- precinct_2024 |>
  filter(office_name == "President of the United States") |>
  mutate(pct_harris = (dem / votes_cast) * 100) |>
  select(county_name, precinct_name, pct_harris)

# Get House results by CD
house_2024 <- precinct_2024 |>
  filter(office_name == "United States Representative") |>
  group_by(county_name, precinct_name, cd) |>
  summarise(
    votes_cast = sum(votes_cast, na.rm = TRUE),
    pct_house_dem = (sum(dem, na.rm = TRUE) / votes_cast) * 100,
    .groups = "drop"
  )

# Merge and plot
comparison <- harris |>
  inner_join(house_2024, by = c("county_name", "precinct_name"))

ggplot(comparison, aes(x = pct_harris, y = pct_house_dem, color = cd)) +
  geom_point(alpha = 0.3, size = 0.8) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "black", linewidth = 1.2) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 1.5) +
  scale_color_manual(values = c("#1f77b4", "#ff7f0e", "#2ca02c")) +
  xlim(25, 75) +
  ylim(25, 75) +
  labs(
    x = "Harris %", 
    y = "House Democratic Candidate %",
    title = "Democratic Performance: House vs Harris (2024)",
    subtitle = "Points above diagonal = House outperformed Harris; below = Harris outperformed House (x-axis zoomed 25-75% for visualization purposes)",
    color = "CD"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(size = 16)
  )

Summary by Congressional District

The table below summarizes the mean underperformance by congressional district, showing how much Harris underperformed relative to the House Democratic candidate on average in each district (negative values indicate underperformance).

# Summary table by CD
comparison |>
  group_by(cd) |>
  summarise(
    mean_harris = mean(pct_harris, na.rm = TRUE),
    mean_house = mean(pct_house_dem, na.rm = TRUE),
    mean_underperform = mean(pct_harris - pct_house_dem, na.rm = TRUE),
    .groups = "drop"
  ) |>
  arrange(mean_underperform) |>
  mutate(
    mean_harris = round(mean_harris, 1),
    mean_house = round(mean_house, 1),
    mean_underperform = round(mean_underperform, 1)
  ) |>
  DT::datatable(
    rownames = FALSE,
    colnames = c("CD", "Mean Harris %", "Mean House Dem %", "Mean Underperformance"),
    options = list(dom = 't', scrollX = TRUE)
  )

Summary

So, a quick visualization of New Mexico electoral data from 2000-2024 using a ready-to-go dataset. The data includes statewide offices, legislative races, and federal congressional races, with consistent formatting across all election years. The clean, structured format makes these types of analyses straightforward – whether examining party control over time, vote margins by office or geography, or tracking individual officeholder tenure.