# =============================================================================
# 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 = ",")

Why it matters to me personally:

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.


1. A record field and a record low in participation

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.

2. Mapping the missing ballots

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).

3. The Outer Suburbs Problem

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.

4. More boxes, more mistakes

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.

5. When Lost Votes Outnumber the Winning Margin

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.


What to watch on 28 November 2026

  1. Focus on where votes are being lost, not only on marginal seats. In 2022, the highest rate of informal voting occurred in districts located 15-60 km from Melbourne’s CBD, where 6.1% of ballots were informal, compared with the statewide average of 5.5%.
  2. The number of candidates on the ballot appears to matter. Districts with ten or more candidates recorded a median informal voting rate of 7.7%, which is substantially higher than the 4.7% observed in districts with fewer candidates. If candidate numbers remain high in 2026, clear instructions on how to correctly complete ballot papers will be particularly important.
  3. Informal votes can influence election outcomes In 13 districts, the number of informal ballots exceeded the winning margin. In closely contested seats, the volume of uncounted votes may be just as significant as changes in voter support between elections.

Data and methods

  • Results: lower house (Legislative Assembly) results for each of the 87 districts that voted on 26 November 2022 were read directly from the VEC district results pages (enrolment, formal, informal and total votes, every candidate’s first preferences, and the final two candidates after distribution of preferences). Narracan District held a separate supplementary election in January 2023 and is excluded.
  • History: participation, informal voting rates and candidate numbers for 1999–2022 were read from the VEC State election statistics page.
  • Location: each district was located at the median latitude and longitude of its 2022 voting centres (VEC voting centre location data). Distance from the Melbourne GPO was calculated with the haversine formula. Distance bands (< 15 km, 15–60 km, > 60 km) are analytical groupings chosen for this story, not official boundaries.
  • Measures: informal rate = informal ÷ total votes; turnout = total votes ÷ enrolment; winning gap = votes for the winner minus votes for the runner-up after preferences.
  • Limits: these are district-level associations, not proof of individual behaviour. Informal ballots include deliberate blanks as well as numbering mistakes, and point locations are approximations of district centres.
  • All data were processed and visualised in R (R version 4.5.2 (2025-10-31)). The complete code is available via the Code buttons throughout this page.

Acknowledgements

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.

References

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