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

Why this story

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:

  1. how far rent has drifted from everything else,
  2. where and when the pressure hit,
  3. which suburbs slipped out of reach,
  4. how the whole market moved, and
  5. what happened in your suburb.

1. Rent has outrun everything else

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)
Source: Homes Victoria (2026), Rental Report – Moving annual rents by suburb; Australian Bureau of Statistics (2026), Consumer Price Index, Australia – All groups, Melbourne. Greater Melbourne values are bond-weighted averages of suburb medians. Nominal dollars.
Takeaway: A typical new lease in Greater Melbourne cost $192 a week in March 2000 and $591 a week by September 2025. If rent had only kept pace with general prices, it would be about $390 – a gap of roughly $10,452 a year for every renter signing a new lease. One-bedroom flats rose 249% and three-bedroom houses 193%, so no part of the market escaped.
How to read it: hover to compare dwelling types at any date; drag across the chart to zoom into a period (double-click to reset); click legend items to hide or show lines.

2. Where — and when — it hit hardest

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)
Source: Homes Victoria (2026), Rental Report – Moving annual rents by suburb (All properties). Year-on-year change in the bond-weighted average of suburb medians, September quarter each year.
Takeaway: In the year to September 2021, rents fell in 6 of 13 regions – hardest in Inner Melbourne (-16%). That relief did not last: in the year to September 2023 rents rose in 13 of 13 regions, by an average of +11%, the steepest rise since 2008. The single biggest jump was Inner Melbourne (+25% in 2023), as renters returned to the inner city after lockdowns. Over the full 25 years, Mornington Peninsula saw the largest overall increase (281%).
How to read it: each cell is one region in one year. Red means rents rose, blue means they fell. Hover for exact figures and drag to zoom.

3. The suburbs that slipped away

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)
Source: Homes Victoria (2026), Rental Report – Moving annual rents by suburb (All properties); Australian Bureau of Statistics (2026), CPI, Melbourne.
Takeaway: The cheapest quarter of suburbs in 2019 saw a typical rise of 48%, compared with 29% for the most expensive quarter. The squeeze landed hardest on the places renters move to save money. The typical regional suburb rose 48% against 33% in Greater Melbourne. The biggest jump was Moe-Newborough (Other Regional Centres): from $230 to $390 a week, +70%.
How to read it: bubbles above the dashed line rose faster than prices. Tick regions on the left to filter, click a bubble to highlight it, and double-click to reset.

4. Watch the whole market move

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)
Source: Homes Victoria (2026), Rental Report – Moving annual rents by suburb (One bedroom flat; Three bedroom house).
Takeaway: In 2000 the typical suburb charged $115 a week for a one-bedroom flat; by 2025 it was $400. Over the same period a regional three-bedroom house went from 68% of the Melbourne price to 67%. The regional discount has held, but only because Melbourne rose just as fast.
How to read it: press ▶ or drag the slider to move through the years, and hover any dot to see the suburb. Melbourne suburbs are blue; regional suburbs are orange.

5. Find your suburb

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)
Source: Homes Victoria (2026), Rental Report – Moving annual rents by suburb (All properties).
Takeaway: Albert Park-Middle Park-West St Kilda went from $260 a week in 2000 to $698 in 2025 (168%), compared with 207% 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.
How to read it: type a suburb in the search box, or click any grey line, to highlight it against the Melbourne average (black). Double-click to clear.

About the data

  • Rents are moving annual medians of weekly rent for new leases (bonds lodged with the RTBA). They are not the rent paid by existing tenants. The data covers March 2000 to September 2025, the latest available release.
  • Suburbs are Homes Victoria’s suburb groups (e.g. “Albert Park–Middle Park–West St Kilda”).
  • Regional and Greater Melbourne figures are averages of suburb medians weighted by the number of new bonds. This is an approximation, not an official median.
  • Inflation uses the ABS All groups CPI for Melbourne. All dollar values are nominal.
  • Preprocessing was done in R: the wide Excel sheets (one per dwelling type) were reshaped to long format, and “-” values were treated as missing. Click the “Show” button beside each section to see the R code for every step.

References

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

Acknowledgements

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