# ---------------------------------------------------------------
# DATA SOURCE 1: Homes Victoria Rental Report - Moving Annual Rents by Suburb (Sep quarter 2025)
# Downloaded from the source if not already saved locally.
# HTTP/1.1 is forced because the DFFH server rejects R's default HTTP/2 request.
# ---------------------------------------------------------------
url <- "https://www.dffh.vic.gov.au/moving-annual-rent-suburb-september-quarter-2025-excel"
file <- "data/moving_annual_rents_suburb_sep2025.xlsx"
dir.create("data", showWarnings = FALSE)
if (!file.exists(file)) {
h <- new_handle(http_version = 2) # 2 = HTTP/1.1
handle_setheaders(h, "User-Agent" = "Mozilla/5.0")
curl_download(url, file, handle = h, mode = "wb")
}
# Each sheet = one dwelling type, laid out wide (suburb rows x quarter Count/Median columns).
# This function reshapes one sheet into a long, tidy table.
tidy_sheet <- function(sheet) {
raw <- read_excel(file, sheet = sheet, col_names = FALSE, col_types = "text")
dwelling <- raw[[1]][2]
quarter <- unlist(raw[2, -(1:2)])
measure <- unlist(raw[3, -(1:2)])
quarter <- tibble(q = quarter) |> fill(q) |> pull(q)
body <- raw[-(1:3), ]
names(body) <- c("region", "suburb", paste(quarter, measure, sep = "_"))
body |>
fill(region) |>
filter(!is.na(suburb), !str_detect(suburb, "Total")) |>
pivot_longer(-c(region, suburb),
names_to = c("quarter", "measure"), names_sep = "_",
values_to = "value") |>
mutate(value = suppressWarnings(parse_number(value, na = c("-", "NA", "")))) |>
pivot_wider(names_from = measure, values_from = value) |>
mutate(date = my(quarter), dwelling = dwelling) |>
select(dwelling, region, suburb, date, count = Count, median = Median)
}
regional <- c("Ballarat", "Bendigo", "Geelong", "Other Regional Centres")
rents <- map(excel_sheets(file), possibly(tidy_sheet, NULL)) |>
list_rbind() |>
mutate(area = if_else(region %in% regional, "Regional Victoria", "Greater Melbourne"))
latest <- max(rents$date, na.rm = TRUE)
latest_lab <- format(latest, "%B %Y")
# ---------------------------------------------------------------
# DATA SOURCE 2: ABS Consumer Price Index, Australia (6401.0), All groups CPI, Melbourne
# Loaded via the ABS time-series API using readabs. If the download fails, the
# inflation comparison is simply left out rather than breaking the report.
# ---------------------------------------------------------------
cpi_cache <- "data/abs_cpi_melbourne_quarterly.csv" # saved copy after first successful download
get_cpi <- function() tryCatch({
options(timeout = 900) # ABS files are large; allow up to 15 min
abs_raw <- read_abs("6401.0", tables = "all", show_progress_bars = FALSE)
cand <- abs_raw |>
filter(str_detect(series, regex("index numbers", ignore_case = TRUE)),
str_detect(series, "All groups CPI"),
str_detect(series, "Melbourne"))
if ("frequency" %in% names(cand)) cand <- cand |> filter(frequency == "Quarter")
# Keep only a quarterly series that goes back to at least March 2000
long_ids <- cand |> group_by(series_id) |>
summarise(start = min(date), .groups = "drop") |>
filter(start <= ymd("2000-03-01")) |> pull(series_id)
cand |> filter(series_id == long_ids[1]) |>
distinct(date, .keep_all = TRUE) |>
select(date, cpi = value) |>
arrange(date)
}, error = function(e) NULL)
cpi_ok <- function(x) !is.null(x) && nrow(x) > 0 && min(x$date) <= ymd("2000-03-01")
if (file.exists(cpi_cache)) {
cpi <- read_csv(cpi_cache, show_col_types = FALSE) |> mutate(date = as.Date(date))
} else {
cpi <- get_cpi()
if (!cpi_ok(cpi)) cpi <- get_cpi() # one retry
if (cpi_ok(cpi)) write_csv(cpi, cpi_cache) # save for future knits
}
# Only use CPI if it covers the whole rent period - otherwise leave it out rather than mislead
if (!cpi_ok(cpi)) cpi <- NULL
# Greater Melbourne summary: bond-weighted average of suburb medians
metro <- rents |>
filter(area == "Greater Melbourne", !is.na(median), !is.na(count), count > 0) |>
group_by(dwelling, date) |>
summarise(rent = weighted.mean(median, count), bonds = sum(count), .groups = "drop")
As an international student who moved to Melbourne to pursue my Master’s at RMIT, housing affordability became more than just a statistic to me. Finding a place to live while balancing student expenses made me curious about how much location influences the cost of living. This project is my way of exploring a challenge that many people, including students like me, experience firsthand.
Ask anyone renting in Melbourne about their last lease and you will hear some version of the same story: the inspection queue out the door, the rent-increase notice, the suburb that used to be “the cheap option” and isn’t any more. It is easy to dismiss that as anecdote. So this report checks it against the most complete record there is.
Every time a Victorian signs a lease, their bond is lodged with the Residential Tenancies Bond Authority. Those records give us the rent agreed on every new lease in the state since 2000. Five charts follow the money:
c1 <- metro |> filter(dwelling %in% names(pal_dwel)) |>
mutate(dwelling = factor(dwelling, levels = names(pal_dwel)))
base_date <- min(c1$date)
base_rent <- c1 |> filter(dwelling == "All properties", date == base_date) |> pull(rent)
now_rent <- c1 |> filter(dwelling == "All properties", date == latest) |> pull(rent)
rent_growth <- now_rent / base_rent - 1
grow_of <- function(d) {
x <- c1 |> filter(dwelling == d) |> arrange(date)
last(x$rent) / first(x$rent) - 1
}
g_flat1 <- grow_of("One bedroom flat")
g_house3 <- grow_of("Three bedroom house")
infl <- NULL
if (!is.null(cpi)) {
infl <- cpi |> filter(date >= base_date, date <= latest) |>
mutate(rent = base_rent * cpi / first(cpi))
if (nrow(infl) < 2) infl <- NULL
}
if (!is.null(infl)) {
cpi_growth <- last(infl$cpi) / first(infl$cpi) - 1
infl_rent <- last(infl$rent)
}
title1 <- if (!is.null(infl)) {
sprintf("Melbourne rents rose %s since 2000 – prices overall rose %s",
scales::percent(rent_growth, 1), scales::percent(cpi_growth, 1))
} else {
sprintf("Melbourne rents rose %s since 2000", scales::percent(rent_growth, 1))
}
# Takeaway text, written from the numbers
take1 <- paste0(
"A typical new lease in Greater Melbourne cost ", dol(base_rent), " a week in ",
format(base_date, "%B %Y"), " and ", dol(now_rent), " a week by ", latest_lab, ". ",
if (!is.null(infl)) paste0(
"If rent had only kept pace with general prices, it would be about ", dol(infl_rent),
" – a gap of roughly **", dol((now_rent - infl_rent) * 52), " a year** for every renter signing a new lease. "
) else "",
"One-bedroom flats rose ", pct(g_flat1 * 100), " and three-bedroom houses ", pct(g_house3 * 100),
", so no part of the market escaped."
)
p1 <- plot_ly(height = 620) |>
add_lines(data = c1, x = ~date, y = ~rent, color = ~dwelling, colors = pal_dwel,
line = list(width = 2.5),
hovertemplate = "$%{y:.0f}/wk<extra>%{fullData.name}</extra>")
if (!is.null(infl)) {
p1 <- p1 |>
add_lines(data = infl, x = ~date, y = ~rent, inherit = FALSE,
name = "All properties, if rent had only tracked inflation",
line = list(color = "#7F7F7F", dash = "dash", width = 2),
hovertemplate = "$%{y:.0f}/wk<extra>Inflation-only path</extra>")
}
# Event labels sit low on the chart, where there are no lines
annots <- list(
list(x = "2020-03-01", y = 0.04, yref = "paper", text = "COVID-19<br>lockdowns",
showarrow = FALSE, xanchor = "right", xshift = -4, yanchor = "bottom", align = "right",
font = list(size = 12, color = "#444")),
list(x = "2023-07-15", y = 0.04, yref = "paper", text = "Post-COVID<br>surge",
showarrow = FALSE, xanchor = "center", yanchor = "bottom",
font = list(size = 12, color = "#444"))
)
gap_shape <- list()
if (!is.null(infl)) {
annots <- append(annots, list(list(
x = as.character(latest), y = (now_rent + infl_rent) / 2,
text = sprintf("<b>$%.0f/wk<br>gap</b>", now_rent - infl_rent),
showarrow = FALSE, xanchor = "left", xshift = 8, align = "left",
font = list(size = 13, color = "#D55E00"))))
# Vertical bracket between actual rent and the inflation-only path
gap_shape <- list(list(type = "line", x0 = as.character(latest), x1 = as.character(latest),
y0 = infl_rent, y1 = now_rent,
line = list(color = "#D55E00", width = 2)))
}
p1 |>
layout(
title = list(text = paste0("<b>", title1, "</b>"), x = 0, font = list(size = 17)),
xaxis = list(title = "", range = c("2000-01-01", "2027-06-30")),
yaxis = list(title = "Median weekly rent, new leases (A$)", tickprefix = "$"),
hovermode = "x unified",
legend = list(orientation = "h", x = 0, xanchor = "left", y = -0.08, yanchor = "top",
font = list(size = 12)),
shapes = c(list(
list(type = "line", x0 = "2020-03-01", x1 = "2020-03-01", y0 = 0, y1 = 1, yref = "paper",
line = list(color = "#999", dash = "dot")),
list(type = "rect", x0 = "2022-03-01", x1 = "2024-12-01", y0 = 0, y1 = 1, yref = "paper",
fillcolor = "rgba(213,94,0,0.08)", line = list(width = 0))
), gap_shape),
annotations = annots,
margin = list(t = 70, r = 90, b = 40)
) |>
config(displaylogo = FALSE)
reg <- rents |>
filter(dwelling == "All properties", month(date) == 9,
!is.na(median), !is.na(count), count > 0) |>
group_by(region, year = year(date)) |>
summarise(rent = weighted.mean(median, count), .groups = "drop") |>
group_by(region) |> arrange(year) |>
mutate(change = (rent / lag(rent) - 1) * 100,
total = last(rent) / first(rent) - 1) |>
ungroup() |>
filter(!is.na(change))
reg$region <- fct_reorder(reg$region, reg$total)
top <- reg |> slice_max(change, n = 1, with_ties = FALSE)
lim <- max(abs(reg$change))
# Numbers for the takeaway
n_regions <- n_distinct(reg$region)
fastest <- reg |> distinct(region, total) |> slice_max(total, n = 1, with_ties = FALSE)
covid <- reg |> filter(year %in% 2020:2021) |> group_by(year) |>
summarise(n_fell = sum(change < 0), worst = region[which.min(change)],
worst_chg = min(change), .groups = "drop") |>
slice_max(n_fell, n = 1, with_ties = FALSE)
yr_avg <- reg |> group_by(year) |>
summarise(avg = mean(change), n_rose = sum(change > 0), .groups = "drop")
covid_yr <- if (nrow(covid) == 1) covid$year else 2021
rebound <- yr_avg |> filter(year > covid_yr) |> slice_max(avg, n = 1, with_ties = FALSE)
record <- yr_avg |> slice_max(avg, n = 1, with_ties = FALSE)
take2 <- paste0(
if (nrow(covid) == 1 && covid$n_fell > 0) paste0(
"In the year to September ", covid$year, ", rents fell in ", covid$n_fell, " of ", n_regions,
" regions – hardest in ", covid$worst, " (", sprintf("%+.0f%%", covid$worst_chg), "). "
) else "",
"That relief did not last: in the year to September ", rebound$year, " rents rose in ",
rebound$n_rose, " of ", n_regions, " regions, by an average of ", sprintf("%+.0f%%", rebound$avg), ", ",
if (rebound$year == record$year) "the steepest rise in 25 years. "
else paste0("the steepest rise since ", record$year, ". "),
"The single biggest jump was ", top$region, " (", sprintf("%+.0f%%", top$change), " in ", top$year,
")", if (str_detect(top$region, "Inner") && top$year > 2021) ", as renters returned to the inner city after lockdowns" else "",
". Over the full 25 years, ", fastest$region,
" saw the largest overall increase (", scales::percent(fastest$total, 1), ")."
)
p2 <- ggplot(reg, aes(x = year, y = region, fill = change,
text = sprintf("<b>%s</b><br>Year to Sep %d<br>Change: %+.1f%%<br>Avg rent: $%.0f/wk",
region, year, change, rent))) +
geom_tile(colour = "white", linewidth = 0.4) +
scale_fill_gradient2(low = "#2166AC", mid = "#F7F7F7", high = "#B2182B",
midpoint = 0, limits = c(-lim, lim), name = "Annual\nchange (%)") +
scale_x_continuous(breaks = seq(2001, 2025, 4), expand = c(0, 0)) +
labs(x = NULL, y = NULL) +
theme_minimal(base_size = 13) +
theme(panel.grid = element_blank())
ggplotly(p2, tooltip = "text") |>
layout(title = list(text = sprintf("<b>The sharpest one-year jump: %s, %+.0f%% in the year to Sep %d</b><br><sup>Regions ordered by total growth since 2000 (highest at top)</sup>",
top$region, top$change, top$year),
x = 0, font = list(size = 16)),
margin = list(t = 80)) |>
config(displaylogo = FALSE)
start3 <- ymd("2019-09-01")
sub <- rents |>
filter(dwelling == "All properties", date == start3) |>
select(region, area, suburb, rent_2019 = median) |>
inner_join(rents |> filter(dwelling == "All properties", date == latest) |>
select(region, suburb, rent_now = median, bonds_now = count),
by = c("region", "suburb")) |>
filter(!is.na(rent_2019), !is.na(rent_now)) |>
mutate(change = (rent_now / rent_2019 - 1) * 100)
cpi19 <- NULL
if (!is.null(cpi)) {
cpi19 <- (cpi$cpi[cpi$date == latest] / cpi$cpi[cpi$date == start3] - 1) * 100
if (length(cpi19) != 1) cpi19 <- NULL
}
share_above <- if (!is.null(cpi19)) mean(sub$change > cpi19) else NA
title3 <- if (!is.null(cpi19)) {
sprintf("Since 2019, rent outpaced inflation in %s of Victoria's suburbs", scales::percent(share_above, 1))
} else "How much did rent rise in each suburb since 2019?"
# Numbers for the takeaway
q <- quantile(sub$rent_2019, c(0.25, 0.75))
cheap_chg <- median(sub$change[sub$rent_2019 <= q[1]])
dear_chg <- median(sub$change[sub$rent_2019 >= q[2]])
area_chg <- sub |> group_by(area) |> summarise(m = median(change))
reg_chg <- area_chg$m[area_chg$area == "Regional Victoria"]
met_chg <- area_chg$m[area_chg$area == "Greater Melbourne"]
riser <- sub |> slice_max(change, n = 1, with_ties = FALSE)
take3 <- paste0(
"The cheapest quarter of suburbs in 2019 saw a typical rise of ", pct(cheap_chg),
", compared with ", pct(dear_chg), " for the most expensive quarter. ",
if (cheap_chg > dear_chg) "The squeeze landed hardest on the places renters move to *save* money. "
else "Pricier suburbs rose fastest, closing off the 'one more step up' that many renters plan for. ",
"The typical regional suburb rose ", pct(reg_chg), " against ", pct(met_chg), " in Greater Melbourne. ",
"The biggest jump was ", riser$suburb, " (", riser$region, "): from ", dol(riser$rent_2019),
" to ", dol(riser$rent_now), " a week, ", sprintf("%+.0f%%", riser$change), "."
)
sd3 <- SharedData$new(sub, key = ~suburb, group = "suburbs")
p3 <- plot_ly(sd3, x = ~rent_2019, y = ~change, size = ~bonds_now, sizes = c(8, 45),
color = ~area, colors = pal_area,
type = "scatter", mode = "markers",
marker = list(opacity = 0.75, line = list(width = 0.6, color = "white")),
text = ~sprintf("<b>%s</b><br>%s<br>Sep 2019: $%.0f/wk<br>%s: $%.0f/wk<br>Change: %+.0f%%<br>New leases in last year: %s",
suburb, region, rent_2019, format(latest, "%b %Y"), rent_now,
change, scales::comma(bonds_now)),
hoverinfo = "text") |>
layout(
title = list(text = paste0("<b>", title3, "</b><br><sup>Each bubble is a suburb group; size = number of new leases</sup>"),
x = 0, font = list(size = 16)),
xaxis = list(title = "Median weekly rent, Sep 2019 (A$)", tickprefix = "$"),
yaxis = list(title = sprintf("Rent change, Sep 2019 to %s (%%)", format(latest, "%b %Y")), ticksuffix = "%"),
legend = list(orientation = "h", y = -0.2),
margin = list(t = 80)
) |>
highlight(on = "plotly_click", off = "plotly_doubleclick", color = "#CC79A7") |>
config(displaylogo = FALSE)
if (!is.null(cpi19)) {
p3 <- p3 |> layout(
shapes = list(list(type = "line", xref = "paper", x0 = 0, x1 = 1, y0 = cpi19, y1 = cpi19,
line = list(color = "#555", dash = "dash"))),
annotations = list(list(xref = "paper", x = 1, y = cpi19, xanchor = "right", yanchor = "bottom",
text = sprintf("Inflation: %+.0f%%", cpi19), showarrow = FALSE,
font = list(color = "#555")))
)
}
bscols(widths = c(3, 9),
list(filter_checkbox("region3", "Show regions", sd3, ~region)),
p3)
anim <- rents |>
filter(dwelling %in% c("One bedroom flat", "Three bedroom house"), month(date) == 9) |>
mutate(year = year(date)) |>
select(dwelling, region, area, suburb, year, median) |>
pivot_wider(names_from = dwelling, values_from = median) |>
rename(flat1 = `One bedroom flat`, house3 = `Three bedroom house`) |>
filter(!is.na(flat1), !is.na(house3))
yr_first <- min(anim$year); yr_last <- max(anim$year)
h_first <- median(anim$house3[anim$year == yr_first])
h_last <- median(anim$house3[anim$year == yr_last])
f_first <- median(anim$flat1[anim$year == yr_first])
f_last <- median(anim$flat1[anim$year == yr_last])
# Regional 3-bed house rent as a share of Melbourne's, first vs last year
ratio_of <- function(y) {
d <- anim |> filter(year == y) |> group_by(area) |> summarise(m = median(house3))
d$m[d$area == "Regional Victoria"] / d$m[d$area == "Greater Melbourne"]
}
r_first <- ratio_of(yr_first); r_last <- ratio_of(yr_last)
take4 <- paste0(
"In ", yr_first, " the typical suburb charged ", dol(f_first), " a week for a one-bedroom flat; by ",
yr_last, " it was ", dol(f_last), ". Over the same period a regional three-bedroom house went from ",
pct(r_first * 100), " of the Melbourne price to ", pct(r_last * 100), ". ",
if (r_last > r_first) "Moving to the country is no longer the escape hatch it once was."
else "The regional discount has held, but only because Melbourne rose just as fast."
)
plot_ly(anim, x = ~flat1, y = ~house3, frame = ~year, ids = ~suburb,
color = ~area, colors = pal_area,
type = "scatter", mode = "markers",
marker = list(size = 10, opacity = 0.75, line = list(width = 0.5, color = "white")),
text = ~sprintf("<b>%s</b><br>%s<br>1-bed flat: $%.0f/wk<br>3-bed house: $%.0f/wk",
suburb, region, flat1, house3),
hoverinfo = "text") |>
layout(
title = list(text = sprintf("<b>A typical 3-bed house: $%.0f → $%.0f a week (%d–%d)</b><br><sup>Press play: each dot is a suburb group, September quarter each year</sup>",
h_first, h_last, yr_first, yr_last),
x = 0, font = list(size = 16)),
xaxis = list(title = "1-bedroom flat, median weekly rent (A$)", tickprefix = "$",
range = c(0, max(anim$flat1) * 1.05)),
yaxis = list(title = "3-bedroom house, median weekly rent (A$)", tickprefix = "$",
range = c(0, max(anim$house3) * 1.05)),
legend = list(orientation = "h", x = 1, xanchor = "right", y = 1.02, yanchor = "bottom"),
margin = list(t = 100)
) |>
animation_opts(frame = 700, transition = 400, redraw = FALSE) |>
animation_slider(currentvalue = list(prefix = "Year: ", font = list(size = 15))) |>
config(displaylogo = FALSE)
c5 <- rents |> filter(dwelling == "All properties", !is.na(median))
metro_all <- metro |> filter(dwelling == "All properties")
if (!my_suburb %in% c5$suburb) my_suburb <- c5$suburb[1] # safety fallback
s <- c5 |> filter(suburb == my_suburb) |> arrange(date)
s_growth <- last(s$median) / first(s$median) - 1
take5 <- paste0(
my_suburb, " went from ", dol(first(s$median)), " a week in ", format(first(s$date), "%Y"),
" to ", dol(last(s$median)), " in ", format(last(s$date), "%Y"), " (",
scales::percent(s_growth, 1), "), compared with ", scales::percent(rent_growth, 1),
" across Greater Melbourne. Whichever line you pick, the shape is the same: a long, steady climb, ",
"a brief pause in 2020, then the steepest rise in a generation. The rental crisis is not a feeling. ",
"It is written into every bond lodged in Victoria."
)
sd5 <- highlight_key(c5, ~suburb, group = "Search for a suburb")
plot_ly(sd5, x = ~date, y = ~median, text = ~suburb) |>
group_by(suburb) |>
add_lines(color = I("grey80"), line = list(width = 1), showlegend = FALSE,
hovertemplate = "%{text}<br>%{x|%b %Y}: $%{y:.0f}/wk<extra></extra>") |>
add_lines(data = metro_all, x = ~date, y = ~rent, inherit = FALSE,
name = "Greater Melbourne average", line = list(color = "black", width = 3),
hovertemplate = "Greater Melbourne<br>%{x|%b %Y}: $%{y:.0f}/wk<extra></extra>") |>
layout(
title = list(text = "<b>Type your suburb to see its 25-year rent story</b><br><sup>One suburb is pre-selected. Add more to compare.</sup>",
x = 0, font = list(size = 16)),
xaxis = list(title = ""),
yaxis = list(title = "Median weekly rent, new leases (A$)", tickprefix = "$"),
legend = list(orientation = "h", y = -0.15),
margin = list(t = 80)
) |>
highlight(on = "plotly_click", off = "plotly_doubleclick",
selectize = TRUE, persistent = TRUE, dynamic = FALSE,
color = "#D55E00", defaultValues = my_suburb) |>
config(displaylogo = FALSE)
Anthropic. (2026). Claude (Opus 5.5 version) [Large language model]. https://claude.ai
Australian Bureau of Statistics. (2026). Consumer price index, Australia [Data set]. https://www.abs.gov.au/statistics/economy/price-indexes-and-inflation/consumer-price-index-australia
Cheng, J., & Sievert, C. (2023). crosstalk: Inter-widget interactivity for HTML widgets [Computer software]. https://CRAN.R-project.org/package=crosstalk
Cowgill, M., Meers, Z., Lee, J., & Diviny, D. (2025). readabs: Download and tidy time series data from the Australian Bureau of Statistics [Computer software]. https://CRAN.R-project.org/package=readabs
Homes Victoria. (2026). Rental report – Quarterly: Moving annual rents by suburb, September quarter 2025 [Data set]. DataVic. https://discover.data.vic.gov.au/dataset/rental-report-quarterly-moving-annual-rents-by-suburb
Okabe, M., & Ito, K. (2008). Color universal design (CUD): How to make figures and presentations that are friendly to colorblind people. https://jfly.uni-koeln.de/color/
R Core Team. (2026). 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
Wickham, H., & Bryan, J. (2025). readxl: Read Excel files [Computer software]. https://CRAN.R-project.org/package=readxl
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
Generative AI: I used Claude (Anthropic, 2026) to help refine the structure and readability of the R code and explore suitable colour themes for the graphs and plots. I reviewed and tested the code, verified the outputs, and made the final decisions regarding the visualisations and their interpretation.
Design: The colour palette follows Okabe and Ito’s (2008) colour-blind-safe scheme. Interactive techniques follow Sievert (2020).