Ranked Choice Voting in the 2025 Santa Fe Mayoral Race
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 nameroundX_votes: Vote count for each candidate in round XroundX_pct: Percentage of votes in round XroundX_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_colorsTotal 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:
- Reshaping to long format: Convert the wide format (one column per round) to long format (one row per candidate per round)
- Identifying vote flows: For each round, determine which candidates maintained votes and which received transfers from eliminated candidates
- 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
)Building Vote Transfer Links
For each round transition, we identify two types of flows: - Maintained votes: Candidates who keep their votes from one round to the next - Transferred votes: Votes flowing from eliminated candidates to remaining candidates
# Initialize links data frame
all_links <- data.frame(
source = character(),
target = character(),
value = numeric(),
stringsAsFactors = FALSE
)
# Build links for each round transition
for (r in 1:(max_round - 1)) {
curr <- rcv_long |> filter(round == r)
next_r <- rcv_long |> filter(round == r + 1)
# Compare votes between rounds to identify changes
vote_compare <- curr |>
select(candidate, votes_curr = votes) |>
inner_join(next_r |> select(candidate, votes_next = votes), by = "candidate")
# Identify eliminated candidates (had votes, now have zero)
eliminated <- vote_compare |>
filter(votes_curr > 0 & votes_next == 0) |>
pull(candidate)
# Identify surviving candidates
survivors <- next_r |> filter(votes > 0) |> pull(candidate)
# Create links for candidates who maintained votes
for (cand in survivors) {
v_curr <- curr |> filter(candidate == cand) |> pull(votes)
if (!is.na(v_curr) && v_curr > 0) {
all_links <- rbind(all_links, data.frame(
source = paste0(cand, "_R", r),
target = paste0(cand, "_R", r + 1),
value = v_curr,
stringsAsFactors = FALSE
))
}
}
# Create links for vote transfers from eliminated candidates
if (length(eliminated) == 0) next
# Calculate net vote gains for remaining candidates
next_with_curr <- vote_compare |>
mutate(net_gain = votes_next - votes_curr)
gainers <- next_with_curr |> filter(net_gain > 0) |> pull(candidate)
total_gain <- sum(next_with_curr$net_gain[next_with_curr$net_gain > 0])
if (total_gain <= 0) next
# Distribute eliminated candidate's votes proportionally to gainers
for (loser in eliminated) {
loser_votes <- curr |> filter(candidate == loser) |> pull(votes)
if (is.na(loser_votes)) next
for (winner in gainers) {
winner_gain <- next_with_curr |> filter(candidate == winner) |> pull(net_gain)
value <- round(loser_votes * (winner_gain / total_gain))
if (value > 0) {
all_links <- rbind(all_links, data.frame(
source = paste0(loser, "_R", r),
target = paste0(winner, "_R", r + 1),
value = value,
stringsAsFactors = FALSE
))
}
}
}
}
if (nrow(all_links) == 0) stop("No links!")
# Display vote transfer links
DT::datatable(
all_links,
caption = htmltools::tags$caption(
style = 'caption-side: top; text-align: left;',
'Vote Transfer Links: Source (candidate_round) to Target with vote counts'
),
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);
});
}
}
')
pSummary
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.