# =============================================================================
# DATA ACQUISITION (all data loaded directly from the Victorian Electoral
# Commission website; results are cached in /data so the report knits quickly
# and reproducibly.
# =============================================================================
dir.create("data", showWarnings = FALSE)
base_url <- "https://www.vec.vic.gov.au"
results_url <- paste0(base_url, "/results/state-election-results/2022-state-election-results")
stats_url <- paste0(base_url, "/results/electoral-statistics/state-election-statistics")
vc_url <- paste0(base_url, "/-/media/d6faeb7027634194bf56150194ecd7f6.xlsx")
ua <- user_agent("Mozilla/5.0 (RMIT MATH2270 student data-visualisation project)")
get_page <- function(url) {
resp <- RETRY("GET", url, ua, times = 4, pause_base = 2, quiet = TRUE)
stop_for_status(resp)
read_html(content(resp, as = "text", encoding = "UTF-8"))
}
grab_number <- function(txt, label) {
m <- str_match(txt, paste0(label, "\\s*:?\\s*([0-9][0-9,]*)"))[, 2]
readr::parse_number(m)
}
# 1. District-level results (87 districts; Narracan held a separate
# supplementary election in 2023 and is not part of the 2022 results)
parse_district <- function(url) {
pg <- get_page(url)
txt <- html_text2(pg)
name <- pg |> html_element("h1") |> html_text2() |>
str_remove(regex("\\s*District results\\s*$", ignore_case = TRUE)) |> str_squish()
tabs <- html_table(pg, convert = FALSE)
find_tab <- function(pattern) {
hit <- keep(tabs, ~ any(str_detect(names(.x), regex(pattern, ignore_case = TRUE))))
if (length(hit)) hit[[1]] else NULL
}
fp <- find_tab("1st pref") # first-preference table
dop <- find_tab("after distribution") # final two after preferences
out <- tibble(
district = name,
enrolment = grab_number(txt, "Total enrolment as at close of rolls"),
formal = grab_number(txt, "Formal votes"), # case-sensitive: not "Informal"
informal = grab_number(txt, "Informal votes"),
total_votes = grab_number(txt, "Total votes"),
n_candidates = if (is.null(fp)) NA_integer_ else nrow(fp)
)
if (!is.null(dop) && nrow(dop) >= 2) {
vcol <- names(dop)[str_detect(names(dop), regex("after distribution", ignore_case = TRUE))][1]
dop <- dop |>
mutate(votes = readr::parse_number(.data[[vcol]]),
Party = na_if(str_squish(Party), "")) |>
arrange(desc(votes))
out <- out |>
mutate(winner_name = dop$Candidate[1],
winner_party = dop$Party[1],
runner_party = dop$Party[2],
winner_votes = dop$votes[1],
runner_votes = dop$votes[2])
}
out
}
district_file <- "data/vec_2022_district_results.csv"
if (!file.exists(district_file)) {
idx <- get_page(results_url)
district_links <- idx |> html_elements("a") |> html_attr("href") |>
keep(~ !is.na(.x) &&
str_detect(.x, "results-by-district/[a-z0-9-]+-district-results/?$")) |>
unique()
district_links <- ifelse(str_starts(district_links, "http"),
district_links, paste0(base_url, district_links))
district_raw <- map_dfr(district_links, function(u) {
Sys.sleep(0.6) # be polite to the VEC server
tryCatch(parse_district(u), error = function(e) {
message("Could not read: ", u); NULL })
})
write_csv(district_raw, district_file)
}
district_raw <- read_csv(district_file, show_col_types = FALSE)
stopifnot("Fewer than 80 districts were scraped - check your internet connection" =
nrow(district_raw) >= 80)
# 2. Historical statistics (participation, informality, candidates)
hist_file <- "data/vec_state_election_statistics.csv"
if (!file.exists(hist_file)) {
st_tabs <- get_page(stats_url) |> html_table(convert = FALSE) |>
keep(~ "Election" %in% names(.x))
cand_tab <- keep(st_tabs, ~ "Candidates" %in% names(.x))[[1]]
house_tabs <- keep(st_tabs, ~ "Lower house" %in% names(.x)) # 1 = participation, 2 = informal
hist_raw <- tibble(year = readr::parse_number(house_tabs[[1]]$Election),
participation_pct = readr::parse_number(house_tabs[[1]]$`Lower house`)) |>
left_join(tibble(year = readr::parse_number(house_tabs[[2]]$Election),
informal_pct = readr::parse_number(house_tabs[[2]]$`Lower house`)),
by = "year") |>
left_join(tibble(year = readr::parse_number(cand_tab$Election),
candidates = readr::parse_number(cand_tab$Candidates)),
by = "year")
write_csv(hist_raw, hist_file)
}
hist_raw <- read_csv(hist_file, show_col_types = FALSE)
# 3. Voting-centre locations (used to locate each district)
vc_file <- "data/vec_voting_centres_2022.xlsx"
if (!file.exists(vc_file)) {
resp <- RETRY("GET", vc_url, ua, write_disk(vc_file, overwrite = TRUE), times = 4)
stop_for_status(resp)
}
read_vc <- function(path) {
for (sh in excel_sheets(path)) {
raw <- read_excel(path, sheet = sh, col_names = FALSE, col_types = "text")
is_hdr <- apply(raw, 1, function(r)
any(str_detect(r, regex("^\\s*lat", ignore_case = TRUE)), na.rm = TRUE) &&
any(str_detect(r, regex("district|electorate", ignore_case = TRUE)), na.rm = TRUE))
h <- which(is_hdr)
if (length(h)) {
hdr <- as.character(unlist(raw[h[1], ]))
hdr[is.na(hdr)] <- paste0("col", which(is.na(hdr)))
df <- raw[-seq_len(h[1]), ]
names(df) <- make.unique(str_squish(hdr))
return(df)
}
}
stop("Could not find a header row with 'Electorate' and 'Lat' in ", path)
}
vc_raw <- read_vc(vc_file)
pick_col <- function(df, pattern, avoid = "code|id$|number|no\\.?$") {
hit <- names(df)[str_detect(names(df), regex(pattern, ignore_case = TRUE))]
pref <- hit[!str_detect(hit, regex(avoid, ignore_case = TRUE))]
if (length(pref)) hit <- pref
if (!length(hit)) stop("No column matching '", pattern, "'. Columns are: ",
paste(names(df), collapse = ", "))
hit[1]
}
# =============================================================================
# DATA PREPARATION
# =============================================================================
norm_name <- function(x) x |> toupper() |> str_remove("\\s+DISTRICT$") |>
str_replace_all("[^A-Z ]", " ") |> str_squish()
# Haversine distance (km) from the Melbourne GPO
dist_cbd <- function(lat, lon, lat0 = -37.8136, lon0 = 144.9631) {
r <- pi / 180
a <- sin((lat0 - lat) * r / 2)^2 +
cos(lat * r) * cos(lat0 * r) * sin((lon0 - lon) * r / 2)^2
2 * 6371 * asin(sqrt(a))
}
# District "centre" = median location of its 2022 voting centres (inside Victoria)
centroids <- vc_raw |>
transmute(district = .data[[pick_col(vc_raw, "district|electorate")]],
lat = as.numeric(.data[[pick_col(vc_raw, "^lat")]]),
lon = as.numeric(.data[[pick_col(vc_raw, "^lon|^lng")]])) |>
# a centre serving several districts may list them all in one cell
separate_rows(district, sep = "\\s*[,;/]\\s*|\\s+and\\s+") |>
filter(!is.na(district), district != "",
between(lat, -39.6, -33.9), between(lon, 140.9, 150.0)) |>
mutate(key = norm_name(district)) |>
group_by(key) |>
summarise(lat = median(lat), lon = median(lon), .groups = "drop")
short_party <- function(p) case_when(
is.na(p) | p == "" ~ "Independent",
str_detect(p, "Labor") ~ "Labor",
str_detect(p, "^Liberal$") ~ "Liberal",
str_detect(p, "National") ~ "Nationals",
str_detect(p, "Greens") ~ "Greens",
TRUE ~ p)
res <- district_raw |>
mutate(key = norm_name(district)) |>
left_join(centroids, by = "key") |>
mutate(
informal_pct = informal / total_votes * 100,
turnout_pct = total_votes / enrolment * 100,
distance_km = dist_cbd(lat, lon),
zone = case_when(is.na(distance_km) ~ NA_character_,
distance_km < 15 ~ names(zone_cols)[1],
distance_km <= 60 ~ names(zone_cols)[2],
TRUE ~ names(zone_cols)[3]),
zone = factor(zone, levels = names(zone_cols)),
winner_party = factor(short_party(winner_party), levels = names(party_cols)),
runner_party = short_party(runner_party),
gap = winner_votes - runner_votes,
exceeds = informal >= gap,
exceeds_lab = if_else(exceeds, "Informal ballots exceed the winning gap",
"Winning gap exceeds informal ballots")
)
unmatched <- res |> filter(is.na(lat)) |> pull(district)
if (length(unmatched)) message("No location found for: ", paste(unmatched, collapse = ", "))
# Headline numbers used in the narrative (computed, never typed)
state_inf <- sum(res$informal, na.rm = TRUE) / sum(res$total_votes, na.rm = TRUE) * 100
tot_inf <- sum(res$informal, na.rm = TRUE)
top3 <- res |> slice_max(informal_pct, n = 3)
bottom1 <- res |> slice_min(informal_pct, n = 1)
zone_sum <- res |> filter(!is.na(zone)) |> group_by(zone) |>
summarise(inf = sum(informal, na.rm = TRUE) / sum(total_votes, na.rm = TRUE) * 100,
turnout = sum(total_votes, na.rm = TRUE) / sum(enrolment, na.rm = TRUE) * 100,
cands = mean(n_candidates, na.rm = TRUE), n = n(), .groups = "drop")
zone_val <- function(z, col) zone_sum[[col]][zone_sum$zone == names(zone_cols)[z]]
r_cands <- cor(res$n_candidates, res$informal_pct, use = "complete.obs")
r_turnout <- cor(res$turnout_pct, res$informal_pct, use = "complete.obs")
med_by_c <- res |> filter(!is.na(n_candidates)) |> group_by(big = n_candidates >= 10) |>
summarise(m = median(informal_pct, na.rm = TRUE), n = n(), .groups = "drop")
top_zone <- as.character(zone_sum$zone[which.max(zone_sum$inf)])
fringe_peak <- top_zone == names(zone_cols)[2] # is the growth ring highest?
cand_effect <- r_cands > 0.15 # does ballot length matter?
# Distance at which the (log-scale) loess trend peaks, matching Visualisation 3
res_d <- res |> filter(!is.na(distance_km))
lo_fit <- loess(informal_pct ~ log10(distance_km), data = res_d, span = 0.9)
grid_d <- tibble(distance_km = 10^seq(log10(min(res_d$distance_km)),
log10(max(res_d$distance_km)), length.out = 300))
peak_km <- grid_d$distance_km[which.max(predict(lo_fit, grid_d))]
n_exceed <- sum(res$exceeds, na.rm = TRUE)
exceed_tbl <- res |> filter(exceeds) |> arrange(gap)
h22 <- hist_raw |> filter(year == 2022); h99 <- hist_raw |> filter(year == 1999)
fmt <- function(x, d = 1) formatC(x, format = "f", digits = d)
fmtn <- function(x) format(round(x), big.mark = ",")
This topic holds a special place in my heart. Growing up in India, my father was deeply involved in politics, and I learned early on how much every single vote matters even the ones no one ever sees. When I saw that elections were a suggested topic for this assignment, I immediately wanted to explore the issue of uncounted votes. This isn’t just an Australian story; it’s relevant for democracies everywhere. Too often, votes that never get counted are overlooked, yet they can have a huge impact on the future of a country. Through this project and using data visualisation, I hope to shed light on this issue and share why it matters so much to me. I am also grateful to my lecturer for encouraging us to apply what we’ve learned in this course to real world questions like this.
On 26 November 2022, more than 211,000 Victorians filled out a lower house ballot that was never counted. That’s about 4 times the enrolment of an average district—a staggering number. As Victorians head to the polls again on 28 November 2026, the 2022 data reveals exactly where these silent ballots were concentrated.
Most of the time, election coverage focuses on who won, who lost, and which seats changed hands. But I was curious about something less discussed: the votes that never get counted at all. These are called informal votes—ballots that are left blank, filled out incorrectly, or missing numbers. In Victoria’s lower house, every single box must be numbered, so with more candidates, mistakes become more likely. To be honest, I was shocked to find that more than 200,000 votes were discarded this way in 2022. As someone new to Australia, the ‘number every box’ rule was confusing at first, and that made me want to dig deeper and visualise where and why so many votes go uncounted. The following charts break down the scale of the problem, where it’s most common, and whether it could have changed any results.
Victoria 1999–2022: more candidates, more informal votes, fewer voters
Orange marks 2022. Hover over a point for the exact value.
hist_long <- hist_raw |>
filter(year >= 1999) |>
pivot_longer(c(candidates, informal_pct, participation_pct),
names_to = "measure", values_to = "value") |>
mutate(measure = factor(measure,
levels = c("candidates", "informal_pct", "participation_pct"),
labels = c("Candidates contesting the election",
"Informal lower house votes (% of votes cast)",
"Voter participation, lower house (% of enrolled)")),
tt = paste0("<b>", year, " state election</b><br>", measure, ": ",
if_else(str_detect(measure, "Candidates"), fmtn(value),
paste0(fmt(value, 2), "%"))))
p1 <- ggplot(hist_long, aes(year, value)) +
geom_line(colour = "grey60", linewidth = 0.8) +
geom_point(aes(colour = year == 2022, size = year == 2022, text = tt)) +
scale_colour_manual(values = c(`FALSE` = "grey35", `TRUE` = "#D55E00"), guide = "none") +
scale_size_manual(values = c(`FALSE` = 2.2, `TRUE` = 3.8), guide = "none") +
scale_x_continuous(breaks = unique(hist_long$year)) +
scale_y_continuous(labels = label_comma()) +
facet_wrap(~ measure, ncol = 1, scales = "free_y") +
labs(x = "State election year", y = NULL) +
theme_story + theme(legend.position = "none")
ggplotly(p1, tooltip = "text") |>
layout(margin = list(t = 40)) |>
config(displaylogo = FALSE,
modeBarButtonsToRemove = c("lasso2d", "select2d", "autoScale2d"))
Source: Victorian Electoral Commission (2024), State election statistics.
The three charts below each use the same timeline, but have different vertical scales. In 2022, there were 1,194 candidates—a huge jump from 406 in 1999. At the same time, the informal vote rate climbed from 3% to over 5.5%, and turnout dropped from over 92% to just above 87%, the lowest in the series.
What surprised me most was just how much turnout has dropped. Over twenty years, ballots have gotten longer, informal voting has increased, and fewer people are showing up to vote. Five percent might not sound huge, but across the whole state, it’s a massive number. The real question is: where are these uncounted votes actually happening? They aren’t evenly spread across Victoria.
map_df <- res |>
filter(!is.na(lat)) |>
mutate(radius = 4 + 16 * sqrt(informal / max(informal)),
popup = paste0(
"<b>", district, " District</b><br>",
"Informal ballots: <b>", fmtn(informal), "</b> (", fmt(informal_pct), "%)<br>",
"Turnout: ", fmt(turnout_pct), "% of ", fmtn(enrolment), " enrolled<br>",
"Candidates on the ballot: ", n_candidates, "<br>",
"Won by: ", winner_party, " (gap of ", fmtn(gap), " votes)<br>",
"Distance from Melbourne CBD: ", fmtn(distance_km), " km"),
label = paste0(district, ": ", fmt(informal_pct), "% informal"))
pal_inf <- colorNumeric("YlOrRd", domain = map_df$informal_pct)
pal_turn <- colorNumeric("PuBu", domain = map_df$turnout_pct, reverse = TRUE)
leaflet(map_df, options = leafletOptions(minZoom = 6)) |>
addProviderTiles(providers$Esri.WorldGrayCanvas, group = "Light grey map") |>
addTiles(group = "Street map") |> # OpenStreetMap, no API key needed
addCircleMarkers(lng = ~lon, lat = ~lat, radius = ~radius,
fillColor = ~pal_inf(informal_pct), fillOpacity = 0.85,
color = "#333333", weight = 0.8, popup = ~popup, label = ~label,
group = "Informal vote rate") |>
addCircleMarkers(lng = ~lon, lat = ~lat, radius = ~radius,
fillColor = ~pal_turn(turnout_pct), fillOpacity = 0.85,
color = "#333333", weight = 0.8, popup = ~popup,
label = ~paste0(district, ": ", fmt(turnout_pct), "% turnout"),
group = "Turnout") |>
addLegend("bottomright", pal = pal_inf, values = ~informal_pct, opacity = 0.9,
title = "Informal<br>votes (%)", group = "Informal vote rate") |>
addLegend("bottomright", pal = pal_turn, values = ~turnout_pct, opacity = 0.9,
title = "Turnout<br>(%)", group = "Turnout") |>
addLayersControl(baseGroups = c("Light grey map", "Street map"),
overlayGroups = c("Informal vote rate", "Turnout"),
options = layersControlOptions(collapsed = FALSE)) |>
hideGroup("Turnout") |>
addEasyButton(easyButton(icon = "fa-globe", title = "Show all of Victoria",
onClick = htmlwidgets::JS("function(btn, map){ map.setView([-37.0, 144.8], 7); }"))) |>
addControl(HTML("<b>Informal ballots by district, 2022</b><br>
<span style='font-size:12px'>Circle size = number of informal ballots.<br>
Use the box to switch to turnout.</span>"),
position = "topright") |>
setView(lng = 144.95, lat = -37.82, zoom = 9)
Source: VEC (2022a), 2022 state election results; district locations derived from VEC (2022b), voting centre location data.
Reading this map: each circle marks the approximate centre of a district’s voting centres. The circle size represents the number of informal ballots, while the colour shows the informal voting rate. For example, Melton, Broadmeadows, and Point Cook stand out with the highest informal vote rates (over 10%), while Hawthorn has the lowest at just 2.6%. Compared with turnout data, you can see that places with more informal votes often also had lower turnout.
If you zoom in on Melbourne, look for the big, dark circles. The three districts with the most informal votes are Melton, Broadmeadows and Point Cook, which are 37, 13 and 21 km from the CBD. Now tick the “Turnout” box. You’ll notice that many of the places where lots of votes were informal are also places where fewer people turned up to vote. In other words, people in these areas were less likely to vote, and when they did vote, their ballots were more likely not to count (correlation between turnout and informal votes across the 87 districts: r = −0.51).
Informality peaks on the suburban fringe, not in the city or the bush
Each point is a district; point size = enrolled voters; grey line = loess trend; dashed line = all-district average. Click a legend item to hide that group.
res3 <- res |>
filter(!is.na(distance_km)) |>
mutate(tt = paste0("<b>", district, "</b><br>",
fmtn(distance_km), " km from the CBD<br>",
"Informal: ", fmt(informal_pct), "% (", fmtn(informal), " ballots)<br>",
"Candidates: ", n_candidates, "<br>Enrolled: ", fmtn(enrolment)))
p3 <- ggplot(res3, aes(distance_km, informal_pct)) +
geom_hline(yintercept = state_inf, linetype = "dashed", colour = "grey45") +
geom_smooth(method = "loess", formula = y ~ x, span = 0.9, se = FALSE,
colour = "grey30", linewidth = 0.9) +
geom_point(aes(fill = zone, size = enrolment, text = tt),
shape = 21, colour = "white", stroke = 0.4, alpha = 0.9) +
annotate("text", x = 1.6, y = state_inf + 0.45, size = 3.3, colour = "grey30", hjust = 0,
label = paste0("Average: ", fmt(state_inf), "%")) +
scale_x_log10(breaks = c(2, 5, 10, 20, 50, 100, 200, 400),
labels = function(x) paste0(x, " km")) +
scale_y_continuous(labels = function(x) paste0(x, "%"), limits = c(0, NA)) +
scale_fill_manual(values = zone_cols, name = NULL) +
scale_size_area(max_size = 7, guide = "none") +
labs(x = "Distance from Melbourne CBD (log scale)",
y = "Informal votes (% of votes cast)") +
theme_story
ggplotly(p3, tooltip = "text") |>
layout(legend = list(orientation = "h", x = 0, y = 1.02, yanchor = "bottom"),
margin = list(t = 40)) |>
config(displaylogo = FALSE,
modeBarButtonsToRemove = c("lasso2d", "select2d", "autoScale2d"))
Source: VEC (2022a) district results; distances derived from VEC (2022b) voting centre locations.
Reading this chart: the horizontal axis uses a log scale so inner-city and regional districts are both legible. The informal rate was 4.4% in inner Melbourne, 6.1% in the middle and growth ring, and 5.6% in regional Victoria. The grey trend line reaches its highest point about 28 km from the CBD.
In this analysis, distance from the city centre is really just a way to hint at what each district is like it doesn’t explain everything by itself. What’s clear, though, is that Melbourne’s middle and outer suburbs, especially the rapidly growing areas, are where the most votes are lost. One thing that makes these districts stand out is the ballot paper itself, which I’ll explore more in the next chart.
The longer the ballot paper, the more votes are lost
Boxes show the spread for each ballot length; points are districts coloured by distance band.
res4 <- res |>
filter(!is.na(n_candidates)) |>
mutate(tt = paste0("<b>", district, "</b><br>", n_candidates, " candidates<br>",
"Informal: ", fmt(informal_pct), "%<br>", zone))
p4 <- ggplot(res4, aes(factor(n_candidates), informal_pct)) +
geom_boxplot(fill = "grey94", colour = "grey55", outlier.shape = NA, width = 0.6) +
geom_jitter(aes(fill = zone, text = tt), shape = 21, size = 2.9, colour = "white",
stroke = 0.4, width = 0.15, height = 0) +
scale_fill_manual(values = zone_cols, name = NULL) +
scale_y_continuous(labels = function(x) paste0(x, "%")) +
labs(x = "Number of candidates on the lower house ballot paper",
y = "Informal votes (% of votes cast)") +
theme_story
ggplotly(p4, tooltip = "text") |>
layout(legend = list(orientation = "h", x = 0, y = 1.02, yanchor = "bottom"),
margin = list(t = 40)) |>
config(displaylogo = FALSE,
modeBarButtonsToRemove = c("lasso2d", "select2d", "autoScale2d"))
Source: VEC (2022a), 2022 state election results by district.
Reading this chart: districts are grouped by how many boxes voters had to number. Districts with 10 or more candidates had a median informal rate of 7.7% (16 districts), compared with 4.7% for shorter ballots (71 districts). The correlation between ballot length and informality is r = 0.66. On average, voters faced 8.6 candidates in the middle and growth ring (orange), 8.1 in inner Melbourne (blue) and 8.4 in regional Victoria (green).
This is where things get really practical. In Victoria, if you accidentally skip a number or repeat one while numbering your ballot, your vote won’t count at all. That means every extra candidate on the ballot is another opportunity for someone to make a mistake. The numbers back this up: districts with longer ballots ended up with more informal votes. Of course, ballot length isn’t the only reason votes go uncounted some people intentionally leave their ballots blank, and the data can’t always tell us the reason behind each informal vote. Still, unlike other factors, ballot length is something that can actually be changed before the 2026 election.
In 13 districts, the uncounted ballots outnumbered the winning gap
Points above the dashed line: more ballots were set aside than separated the final two candidates.
res5 <- res |>
filter(!is.na(gap), gap > 0) |>
mutate(tt = paste0("<b>", district, "</b><br>",
"Won by ", winner_party, " over ", runner_party, "<br>",
"Winning gap: ", fmtn(gap), " votes<br>",
"Informal ballots: ", fmtn(informal), "<br>",
"Informal ÷ gap: ", fmt(informal / gap, 2), "×"))
lims <- range(c(res5$gap, res5$informal))
diag_df <- tibble(v = 10^seq(log10(lims[1] * 0.8), log10(lims[2] * 1.2), length.out = 50))
sd5 <- SharedData$new(res5, key = ~district, group = "margin")
p5 <- plot_ly(sd5, x = ~gap, y = ~informal, type = "scatter", mode = "markers",
color = ~winner_party, colors = party_cols,
symbol = ~winner_party, symbols = party_syms,
marker = list(size = 11, opacity = 0.9, line = list(color = "white", width = 1)),
text = ~tt, hoverinfo = "text") |>
add_lines(data = diag_df, x = ~v, y = ~v, inherit = FALSE, hoverinfo = "skip",
line = list(color = "grey40", dash = "dash", width = 1.2),
name = "Informal ballots = winning gap") |>
layout(
xaxis = list(type = "log", title = "Winning gap after preferences (votes, log scale)",
tickvals = c(100, 200, 500, 1000, 2000, 5000, 10000, 20000),
ticktext = c("100", "200", "500", "1,000", "2,000", "5,000", "10,000", "20,000")),
yaxis = list(type = "log", title = "Informal ballots (log scale)",
tickvals = c(200, 500, 1000, 2000, 5000, 10000),
ticktext = c("200", "500", "1,000", "2,000", "5,000", "10,000")),
legend = list(title = list(text = "<b>Winning party</b>")),
annotations = list(list(x = log10(lims[1] * 1.1), y = log10(lims[2] * 1.05),
text = "Informal ballots > gap", showarrow = FALSE,
xanchor = "left", font = list(color = "#D55E00", size = 13)),
list(x = log10(lims[2] * 0.9), y = log10(lims[1] * 1.3),
text = "Gap > informal ballots", showarrow = FALSE,
xanchor = "right", font = list(color = "grey40", size = 13))),
margin = list(t = 30)) |>
highlight(on = "plotly_click", off = "plotly_doubleclick", color = "#D55E00") |>
config(displaylogo = FALSE)
bscols(widths = c(3, 9),
list(
filter_select("zone5", "Distance band", sd5, ~zone),
filter_checkbox("exc5", "Show districts where…", sd5, ~exceeds_lab),
filter_slider("cand5", "Candidates on the ballot", sd5, ~n_candidates, step = 1, ticks = FALSE)
),
p5)
Source: VEC (2022a), 2022 state election results (district results after distribution of preferences).
Reading this chart: each district is placed by the vote gap between the final two candidates (horizontal) and its number of informal ballots (vertical). Use the filters on the left to show one distance band, only the seats above the line, or a range of ballot lengths; click a point to highlight it and double-click to reset. Click a party in the legend to hide it. The closest such seat was Northcote, decided by 184 votes while 2,445 ballots were set aside.
There is no way to determine how those people would have made their decisions, and an informal ballot is not a vote for anybody. That’s exactly the purpose. The number of participants whose motives were lost exceeded the margin that determined their representation in 13 out of 87 districts.
I used generative artificial intelligence (GenAI) tools to support the preparation of this assignment in the following ways:
Refining the structure, organisation and presentation of written content. Reviewing spelling, grammar and academic writing style. Identifying relevant references and online information sources, which I independently evaluated and verified before inclusion.
Providing guidance on the use of software and technology relevant to the completion of the assignment.
I completed, reviewed and approved all data collection, analysis, coding, visualisation design, interpretation of results and final written content. I accept full responsibility for the accuracy, integrity and originality of the submitted work.
The analytical approach draws upon concepts and techniques covered in MATH2270, including narrative visualisation, multivariate analysis, interactive visualisation and principles of accessible design.
Colour choices were informed by the colour-blind-safe palette proposed by Okabe and Ito (2008) and sequential colour scales from ColorBrewer.
Data were sourced from the Victorian Electoral Commission.
OpenAI. (2026). ChatGPT [Large language model]. https://chat.openai.com
Cheng, J., & Sievert, C. (2025). crosstalk: Inter-widget interactivity for HTML widgets (R package version 1.2.2) [Computer software]. https://CRAN.R-project.org/package=crosstalk
Cheng, J., Schloerke, B., Karambelkar, B., & Xie, Y. (2025). leaflet: Create interactive web maps with the JavaScript ‘Leaflet’ library (R package version 2.2.3) [Computer software]. https://CRAN.R-project.org/package=leaflet
Okabe, M., & Ito, K. (2008). Color universal design (CUD): How to make figures and presentations that are friendly to colorblind people. J*Fly. https://jfly.uni-koeln.de/color/
R Core Team. (2025). R: A language and environment for statistical computing [Computer software]. R Foundation for Statistical Computing. https://www.R-project.org/
Sievert, C. (2020). Interactive web-based data visualization with R, plotly, and shiny. Chapman and Hall/CRC. https://plotly-r.com
Victorian Electoral Commission. (n.d.). How to fill out state ballot papers. Retrieved October 27, 2026, from https://www.vec.vic.gov.au/voting/learn-to-vote/how-to-fill-out-state-ballot-papers
Victorian Electoral Commission. (2022a). 2022 state election results [Data set]. https://www.vec.vic.gov.au/results/state-election-results/2022-state-election-results
Victorian Electoral Commission. (2022b). Voting centre location data: 2022 State election [Data set]. https://www.vec.vic.gov.au/electoral-boundaries/download-boundary-maps
Victorian Electoral Commission. (2024). State election statistics [Data set]. https://www.vec.vic.gov.au/results/electoral-statistics/state-election-statistics
Wickham, H. (2025). rvest: Easily harvest (scrape) web pages (R package version 1.0.5) [Computer software]. https://CRAN.R-project.org/package=rvest
Wickham, H., Averick, M., Bryan, J., Chang, W., McGowan, L. D., François, R., Grolemund, G., Hayes, A., Henry, L., Hester, J., Kuhn, M., Pedersen, T. L., Miller, E., Bache, S. M., Müller, K., Ooms, J., Robinson, D., Seidel, D. P., Spinu, V., … Yutani, H. (2019). Welcome to the tidyverse. Journal of Open Source Software, 4(43), 1686. https://doi.org/10.21105/joss.01686
Wickham, H., & Bryan, J. (2026). readxl: Read Excel files (R package version 1.6.0) [Computer software]. https://CRAN.R-project.org/package=readxl