Introduction

This post uses official results from the 2025 Santa Fe mayoral race to walk through how ranked choice voting (RCV) data is structured – tracking vote counts and transfers across elimination rounds, where the last-place candidate is repeatedly dropped and their ballots redistributed to voters’ next-ranked choice until someone clears 50%, and pulling the full process into a Sankey diagram.


Data

This analysis uses official Ranked Choice Voting data from the 2025 Santa Fe mayoral race. The data are available in the newmexico-political-data package on GitHub: RcvShortReport-CC2.csv. The raw data has a pretty nasty structure – it’s in wide format with separate columns for each round (e.g., round1_votes, round2_votes, round3_votes, etc.), making it difficult to work with directly. We restructure this data throughout the analysis to make it more manageable for visualization.

The original data structure includes:

  • candidate: Candidate name
  • roundX_votes: Vote count for each candidate in round X
  • roundX_pct: Percentage of votes in round X
  • roundX_transfer: Votes transferred in round X (positive = received, negative = eliminated)

For the visualizations, we reshape this wide format into long format (one row per candidate per round) and calculate vote transfers by comparing vote changes between rounds.

pacman::p_load(dplyr, ggplot2, tidyr, forcats, purrr, ggalluvial, ggthemes, DT, htmltools, knitr, scales, gt, networkD3, htmlwidgets)
# Read the CSV file
rcv_data <- read.csv("/home/jtimm/Dropbox/GitHub/git-projects/newmexico-political-data/data/sf-rcv/RcvShortReport-CC2.csv", stringsAsFactors = FALSE)

# Display the raw data
DT::datatable(
  rcv_data,
  caption = htmltools::tags$caption(
    style = 'caption-side: top; text-align: left;',
    'Raw RCV Election Data: All rounds and vote transfers'
  ),
  options = list(
    scrollX = TRUE,
    pageLength = 10,
    dom = 'frtip'
  ),
  rownames = FALSE
)
# Calculate total votes
total_votes <- sum(rcv_data$round1_votes)

# Create consistent color mapping for candidates (used across all plots)
# Order by round 1 votes (descending) to match Sankey order
candidate_order_colors <- rcv_data |>
  select(candidate, round1_votes) |>
  arrange(desc(round1_votes)) |>
  pull(candidate)

candidate_colors <- ggthemes::stata_pal()(nrow(rcv_data))
names(candidate_colors) <- candidate_order_colors

Total votes cast: 24,570
Votes needed to win (50% + 1): 12,285


Initial Vote Distribution (Round 1)

rcv_data |>
  arrange(desc(round1_votes)) |>
  mutate(candidate = forcats::fct_reorder(candidate, round1_votes)) |>
  ggplot(aes(x = candidate, y = round1_votes, fill = candidate)) +
  geom_col(alpha = 0.8) +
  geom_text(aes(label = paste0(scales::comma(round1_votes), "\n(", 
                               round(round1_pct, 1), "%)")), 
            hjust = -0.1, size = 3.5) +
  geom_hline(yintercept = total_votes / 2, linetype = "dashed", 
             color = "grey", linewidth = 1) +
  annotate("text", x = 7, y = total_votes / 2 + 500, 
           label = paste0("50% threshold: ", scales::comma(ceiling(total_votes/2)), " votes"), 
           color = "red", size = 4) +
  coord_flip() +
  scale_y_continuous(labels = scales::comma, limits = c(0, max(rcv_data$round1_votes) * 1.15)) +
  scale_fill_manual(values = candidate_colors) +
  labs(title = "Round 1: First Choice Votes",
       x = NULL, y = "Votes") +
  theme_minimal() +
  theme(legend.position = "none",
        plot.title = element_text(size = 16))


Track Vote Proportions Across Rounds

This stacked bar chart shows how the proportion of votes held by each candidate changes as elimination rounds progress, with transferred votes gradually pushing the eventual winner above the majority line. The grey dashed line indicates the 50% threshold needed to win.

# Prepare data for proportion plot showing vote percentages
round_cols <- names(rcv_data)[grepl("^round\\d+_votes$", names(rcv_data))]
round_nums <- as.integer(gsub("^round(\\d+)_votes$", "\\1", round_cols))
round_nums <- sort(round_nums)

# Order candidates by round 1 votes (same as Sankey)
candidate_order_prop <- rcv_data |>
  select(candidate, round1_votes) |>
  arrange(desc(round1_votes)) |>
  pull(candidate)

# Pivot to long format and calculate proportions
proportion_long <- rcv_data |>
  select(candidate, all_of(paste0("round", round_nums, "_votes"))) |>
  tidyr::pivot_longer(cols = starts_with("round"),
               names_to = "round",
               values_to = "votes") |>
  filter(votes > 0) |>
  mutate(round_num = as.integer(gsub("round(\\d+)_votes", "\\1", round)),
         round = factor(paste0("Round ", round_num), 
                       levels = paste0("Round ", round_nums))) |>
  group_by(round) |>
  mutate(
    total_votes = sum(votes),
    proportion = votes / total_votes,
    pct = proportion * 100
  ) |>
  ungroup() |>
  # Order candidates by round 1 votes
  mutate(candidate = factor(candidate, levels = candidate_order_prop))

# Create proportion plot (stacked area or bars)
ggplot(proportion_long,
       aes(x = round, y = proportion, fill = candidate)) +
  geom_col(position = "stack", alpha = 0.8) +
  geom_hline(yintercept = 0.5, linetype = "dashed", 
             color = "grey", linewidth = 1) +
  geom_text(aes(label = ifelse(pct > 3, 
                               paste0(round(pct, 1), "%\n", scales::comma(votes)), 
                               "")), 
            position = position_stack(vjust = 0.5),
            size = 3.75,
            color = "white",
            fontface = "bold") +
  scale_fill_manual(values = candidate_colors) +
  scale_y_continuous(labels = scales::percent_format(), 
                     expand = c(0, 0)) +
  labs(title = "Vote Proportions Through RCV Rounds",
       x = NULL, 
       y = "Proportion of Votes", 
       fill = "Candidate") +
  theme_minimal() +
  theme(legend.position = "none",
        plot.title = element_text(size = 16),
        panel.grid.major.x = element_blank(),
        panel.grid.minor = element_blank())


Build the Sankey Diagram

The Sankey diagram below visualizes how votes flow from eliminated candidates to remaining candidates across all rounds. Each flow represents votes being transferred, with the width proportional to the number of votes.

Render the Sankey Diagram

The Sankey diagram below visualizes how votes flow from eliminated candidates to remaining candidates across all rounds. Each flow represents votes being transferred, with the width proportional to the number of votes.

p <- networkD3::sankeyNetwork(
  Links = links_df,
  Nodes = nodes_df,
  Source = "IDsource",
  Target = "IDtarget",
  Value = "value",
  NodeID = "name",
  NodeGroup = "candidate",
  colourScale = htmlwidgets::JS(color_scale_js),
  sinksRight = TRUE,
  iterations = 0,
  fontSize = 12,
  nodeWidth = 25,
  width = 1200,
  height = 900
)

# Clean labels and make fully responsive
p <- htmlwidgets::onRender(p, '
function(el, x) {
  // Clean labels
  d3.select(el).selectAll(".node text").text(function(d) {
    if (d.name.endsWith("_R1")) {
      var name = d.name.replace(/_R1$/, "");
      var parts = name.trim().split(" ");
      return parts[parts.length - 1].toUpperCase();
    }
    return "";
  });
  
  // Get container and SVG - set to 100% immediately
  var container = el.closest(".html-widget-container") || el.parentElement;
  if (container) {
    container.style.width = "100%";
    container.style.maxWidth = "100%";
  }
  el.style.width = "100%";
  
  var svg = d3.select(el).select("svg");
  if (svg.empty()) return;
  
  // Function to update size - use 100% of container
  function updateSize() {
    // Get the width of the post content container
    var postContent = document.querySelector(".post-content") || 
                     document.querySelector(".container") ||
                     document.querySelector(".site-main") ||
                     document.body;
    var containerWidth = postContent.offsetWidth || window.innerWidth;
    
    // Also check the html-widget-container directly
    if (container && container.offsetWidth > 0) {
      containerWidth = Math.max(containerWidth, container.offsetWidth);
    }
    
    if (containerWidth > 0) {
      // Get aspect ratio from current SVG or use default
      var currentWidth = parseInt(svg.attr("width")) || containerWidth;
      var currentHeight = parseInt(svg.attr("height")) || 500;
      var aspectRatio = currentHeight / currentWidth;
      
      // Use full container width
      var newWidth = containerWidth;
      var newHeight = newWidth * aspectRatio;
      
      svg.attr("width", newWidth)
         .attr("height", newHeight);
      
      // Update the widget element size
      el.style.width = "100%";
      el.style.height = newHeight + "px";
    }
  }
  
  // Initial size update
  setTimeout(updateSize, 100);
  
  // Use ResizeObserver for better resize detection
  if (window.ResizeObserver) {
    var targetContainer = container || el.parentElement || document.body;
    var resizeObserver = new ResizeObserver(function(entries) {
      updateSize();
    });
    resizeObserver.observe(targetContainer);
  } else {
    // Fallback to window resize
    var resizeTimer;
    window.addEventListener("resize", function() {
      clearTimeout(resizeTimer);
      resizeTimer = setTimeout(updateSize, 100);
    });
  }
}
')
p

Summary

RCV data is structured around elimination rounds – each one dropping a candidate and redistributing their votes. The Sankey puts that process in one view. The system also guarantees a majority winner and sidesteps both runoff elections and the spoiler problem.