Introduction

Ranked Choice Voting (RCV) allows voters to rank candidates by preference rather than selecting a single choice. This post uses the 2025 Santa Fe mayoral race to visualize how the process works.

Under RCV, if no candidate receives more than 50% of first-choice votes, the candidate with the fewest votes is eliminated. Ballots cast for the eliminated candidate are then redistributed to each voter’s next-ranked choice. This process repeats until a candidate achieves a majority.


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.

if (!requireNamespace("pacman", quietly = TRUE)) {
  install.packages("pacman")
}

suppressPackageStartupMessages(
  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 = 'Bfrtip'
  ),
  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)

The first round shows the initial distribution of first-choice votes. No candidate reached the 50% threshold needed to win, so elimination rounds begin.

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",
       subtitle = "No candidate has a majority (>50%), so we begin eliminations",
       x = NULL, y = "Votes") +
  theme_minimal(base_size = 14) +
  theme(legend.position = "none",
        plot.title = element_text(face = "bold", size = 16))


Vote Proportions Across Rounds

This stacked bar chart shows how the proportion of votes held by each candidate changes as elimination rounds progress. 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,
            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",
       subtitle = "Percentage of votes held by each candidate as rounds progress and candidates are eliminated",
       x = NULL, 
       y = "Proportion of Votes", 
       fill = "Candidate") +
  theme_minimal(base_size = 14) +
  theme(legend.position = "right",
        plot.title = element_text(face = "bold", size = 16),
        panel.grid.major.x = element_blank(),
        panel.grid.minor = element_blank())


Vote Transfers: 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.

Preparing Data for Sankey Visualization

To create the Sankey diagram, we need to restructure the RCV data into nodes (candidates at each round) and links (vote flows between rounds). The restructuring process involves:

  1. Reshaping to long format: Convert the wide format (one column per round) to long format (one row per candidate per round)
  2. Identifying vote flows: For each round, determine which candidates maintained votes and which received transfers from eliminated candidates
  3. Creating nodes and links: Build the network structure with nodes representing candidates at each round and links representing vote transfers
# Reshape data from wide to long format
# Each row represents a candidate's vote count in a specific round
vote_cols <- grep("^round\\d+_votes$", names(rcv_data), value = TRUE)
transfer_cols <- grep("^round\\d+_transfer$", names(rcv_data), value = TRUE)

rcv_long <- rcv_data |>
  select(candidate, all_of(vote_cols), all_of(transfer_cols)) |>
  tidyr::pivot_longer(
    cols = all_of(c(vote_cols, transfer_cols)),
    names_to = c("round", ".value"),
    names_pattern = "round(\\d+)_(.*)"
  ) |>
  mutate(round = as.numeric(round)) |>
  arrange(candidate, round)

max_round <- max(rcv_long$round, na.rm = TRUE)

# Order candidates by Round 1 votes for consistent visualization
candidate_order <- rcv_data |>
  select(candidate, round1_votes) |>
  arrange(desc(round1_votes)) |>
  pull(candidate)

# Display reshaped data
DT::datatable(
  rcv_long,
  caption = htmltools::tags$caption(
    style = 'caption-side: top; text-align: left;',
    'Reshaped Data: Long format with one row per candidate per round'
  ),
  options = list(
    scrollX = TRUE,
    pageLength = 10,
    dom = 'Brtip',
    searching = FALSE
  ),
  rownames = FALSE
)

Creating Nodes and Mapping IDs

Nodes represent each candidate at each round. We order them by candidate (based on Round 1 vote totals) and then by round number for consistent visualization.

# Extract unique nodes from links
all_nodes <- sort(unique(c(all_links$source, all_links$target)))
candidate_rank <- setNames(seq_along(candidate_order) - 1, candidate_order)

# Create nodes data frame with ordering
nodes_df <- tibble(name = all_nodes) |>
  mutate(
    candidate = gsub("_R\\d+$", "", name),
    round_num = as.numeric(gsub(".*_R(\\d+)", "\\1", name)),
    rank = candidate_rank[candidate]
  ) |>
  arrange(rank, round_num) |>
  mutate(node_index = row_number() - 1)

# Add vote counts to nodes
nodes_df <- nodes_df |>
  left_join(
    rcv_long |> select(candidate, round, votes),
    by = c("candidate" = "candidate", "round_num" = "round")
  )

# Helper function to extract last name for labels
get_last_name <- function(full_name) {
  parts <- trimws(unlist(strsplit(full_name, " ")))
  parts <- parts[parts != ""]
  if (length(parts) == 0) return("UNKNOWN")
  toupper(parts[length(parts)])
}

# Add last names for tooltips
nodes_df <- nodes_df |>
  mutate(
    last_name = sapply(candidate, get_last_name)
    )

# Map link source/target names to node indices
links_df <- all_links |>
  mutate(
    IDsource = nodes_df$node_index[match(source, nodes_df$name)],
    IDtarget = nodes_df$node_index[match(target, nodes_df$name)]
  )

if (any(is.na(links_df$IDsource)) || any(is.na(links_df$IDtarget))) {
  stop("Node/link mismatch")
}

# Display nodes data
DT::datatable(
  nodes_df |> select(name, candidate, round_num, votes, last_name),
  caption = htmltools::tags$caption(
    style = 'caption-side: top; text-align: left;',
    'Nodes Data: Each candidate at each round with vote counts'
  ),
  options = list(
    scrollX = TRUE,
    pageLength = 10,
    dom = 'Brtip',
    searching = FALSE
  ),
  rownames = FALSE
)
# Display links with mapped IDs
DT::datatable(
  links_df |> select(source, target, value, IDsource, IDtarget),
  caption = htmltools::tags$caption(
    style = 'caption-side: top; text-align: left;',
    'Links with Mapped IDs: Source and target node indices for Sankey diagram'
  ),
  options = list(
    scrollX = TRUE,
    pageLength = 10,
    dom = 'Brtip',
    searching = FALSE
  ),
  rownames = FALSE
)
# Create JavaScript color scale matching candidate order
color_domain <- paste0('"', candidate_order, '"', collapse = ",")
color_range <- paste0('"', candidate_colors[candidate_order], '"', collapse = ",")
color_scale_js <- paste0("d3.scaleOrdinal().domain([", color_domain, "]).range([", color_range, "])")

Creating the Sankey Diagram

We use networkD3::sankeyNetwork() to render the diagram. The function takes nodes (candidates at each round) and links (vote flows), with nodes ordered by Round 1 vote totals and arranged in columns by round. Link width is proportional to vote count, and colors match the candidate color scheme used throughout the post.

# Plot with colors - set initial large width, will be resized by JavaScript
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 = 600
)

# 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

This post demonstrates how to work with RCV data, restructuring wide-format election results into long format for visualization, and creating Sankey diagrams to visualize vote flows through elimination rounds.

Ranked Choice Voting ensures winners achieve majority support rather than simply receiving more votes than any other candidate. Voters can rank their true preference without strategic concerns about “wasting” votes on less viable candidates. The system incentivizes candidates to build broader coalitions and appeal beyond their base, as second- and third-choice votes become strategically important. RCV also eliminates the need for costly runoff elections while producing outcomes that better reflect the electorate’s collective preferences.