Ranked Choice Voting in the 2025 Santa Fe Mayoral Race
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 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.
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_colorsTotal 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.
Build Nodes and Links
For each round transition we create two types of flows: -
Maintained votes: a candidate’s round-r votes
carry forward to round r+1 - Transferred
votes: the eliminated candidate’s votes flow to
gainers, using the actual transfer column from
the raw data
rcv_long <- rcv_data |>
tidyr::pivot_longer(
cols = -candidate,
names_to = c("round", ".value"),
names_pattern = "round(\\d+)_(.*)"
) |>
mutate(round = as.integer(round))
max_round <- max(rcv_long$round, na.rm = TRUE)
candidate_order <- rcv_data |>
arrange(desc(round1_votes)) |>
pull(candidate)
links_df <- purrr::map_dfr(seq_len(max_round - 1), function(r) {
curr <- filter(rcv_long, round == r, votes > 0)
nxt <- filter(rcv_long, round == r + 1, votes > 0)
eliminated <- setdiff(curr$candidate, nxt$candidate)
bind_rows(
# maintained votes
curr |>
filter(!candidate %in% eliminated) |>
transmute(source = paste0(candidate, "_R", r),
target = paste0(candidate, "_R", r + 1),
value = votes),
# transfers — read directly from the transfer column
nxt |>
filter(transfer > 0) |>
transmute(source = paste0(eliminated[1], "_R", r),
target = paste0(candidate, "_R", r + 1),
value = transfer)
)
})
nodes_df <- data.frame(name = unique(c(links_df$source, links_df$target))) |>
mutate(candidate = gsub("_R\\d+$", "", name),
round_num = as.integer(gsub(".*_R", "", name))) |>
arrange(match(candidate, candidate_order), round_num)
links_df <- links_df |>
mutate(IDsource = match(source, nodes_df$name) - 1,
IDtarget = match(target, nodes_df$name) - 1)
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, "])")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);
});
}
}
')
pSummary
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.