library(tidyverse)
library(plotly)
library(leaflet)
I used to travel from Rosanna to the city, Templestowe, and Ivanhoe by bus and train, at different times of the day. Many times the service did not arrive on time, and it made me late for class and work. This made me curious about how many people still use public transport in Victoria, and when and where they travel.
Melbourne’s train network is carrying fewer people than it did before 2020. But an average hides a lot. This report asks which stations have come back, which have not, and when in the day and week the gap is biggest. It uses official station entry counts for 222 metropolitan stations (Department of Transport and Planning, 2025).
base <- "https://opendata.transport.vic.gov.au/dataset/2fa2cdfa-84f1-455e-b6c9-058b92774b34/resource/"
urls <- c(
paste0(base, "1e01734d-d66d-458a-9a1b-62b2b351938b/download/annual_metropolitan_train_station_entries_fy_2018_2019.csv"),
paste0(base, "aaa468ef-b1a7-4851-b6ad-09bf17139e48/download/annual_metropolitan_train_station_entries_fy_2019_2020.csv"),
paste0(base, "9553aca6-97ee-40d5-9356-f181ed1f456f/download/annual_metropolitan_train_station_entries_fy_2020_2021.csv"),
paste0(base, "18fe6dda-18bf-4816-876c-c3e036d35eb9/download/annual_metropolitan_train_station_entries_fy_2021_2022.csv"),
paste0(base, "62c72e09-8bfd-4d15-a2e2-a14c706eaf4d/download/annual_metropolitan_train_station_entries_fy_2022_2023.csv"),
paste0(base, "57faf356-36a3-4bbe-87fe-f0f05d1b8996/download/annual_metropolitan_train_station_entries_fy_2023_2024.csv"),
paste0(base, "c9507eb5-aa48-4a43-aa09-c10a24d1f2fe/download/annual_metropolitan_train_station_entries_fy_2024_2025.csv")
)
stations <- map_dfr(urls, ~ read_csv(.x, col_types = cols(.default = col_character()))) %>%
mutate(across(c(Stop_lat, Stop_long, starts_with("Pax")), as.numeric))
# Straight-line distance (km) between two points
haversine <- function(lat1, lon1, lat2, lon2) {
R <- 6371
dlat <- (lat2 - lat1) * pi/180
dlon <- (lon2 - lon1) * pi/180
a <- sin(dlat/2)^2 + cos(lat1*pi/180) * cos(lat2*pi/180) * sin(dlon/2)^2
2 * R * asin(sqrt(a))
}
info <- stations %>% distinct(Stop_ID, .keep_all = TRUE) %>%
select(Stop_ID, Stop_name, Stop_lat, Stop_long)
# One row per station: 2018-19 vs 2024-25
wide <- stations %>%
filter(Fin_year %in% c("FY18-19", "FY24-25")) %>%
mutate(total_tb = Pax_pre_AM_peak + Pax_AM_peak + Pax_interpeak + Pax_PM_peak + Pax_PM_late,
peak_share = (Pax_AM_peak + Pax_PM_peak) / total_tb) %>%
select(Fin_year, Stop_ID, Pax_annual, peak_share) %>%
pivot_wider(id_cols = Stop_ID, names_from = Fin_year,
values_from = c(Pax_annual, peak_share)) %>%
drop_na() %>%
left_join(info, by = "Stop_ID") %>%
mutate(recovery = `Pax_annual_FY24-25` / `Pax_annual_FY18-19`,
peak_change = (`peak_share_FY24-25` - `peak_share_FY18-19`) * 100,
dist_cbd = haversine(Stop_lat, Stop_long, -37.8183, 144.9671))
This chart shows the total number of times people entered a Melbourne metropolitan train station each year, compared with 2018-19. Entries were already lower in 2019-20, dropped to about a third in 2020-21, and have only come back to 77% by 2024-25.
net <- stations %>%
group_by(Fin_year) %>%
summarise(entries = sum(Pax_annual, na.rm = TRUE),
n_stations = n(), .groups = "drop") %>%
mutate(index = 100 * entries / entries[Fin_year == "FY18-19"],
label = paste0(Fin_year, ": ", round(index), "% of 2018-19<br>",
scales::comma(entries), " entries<br>",
n_stations, " stations"))
p1 <- ggplot(net, aes(x = Fin_year, y = index, group = 1)) +
geom_hline(yintercept = 100, linetype = "dashed", colour = "grey40") +
geom_line(colour = "#0072B2", linewidth = 1) +
geom_point(aes(text = label), colour = "#0072B2", size = 3) +
scale_y_continuous(limits = c(0, 110), labels = function(x) paste0(x, "%")) +
labs(x = "Financial year", y = "Station entries as % of 2018-19") +
theme_minimal(base_size = 13)
ggplotly(p1, tooltip = "text")
Chart description: a line chart of total yearly station entries as a percentage of 2018-19. Entries were 77% of the 2018-19 level in 2019-20, fell to 34% in 2020-21, and had recovered to 77% by 2024-25. The 2019-20 and 2024-25 totals are almost identical, at about 178.6 million and 178.5 million entries.
Source: Department of Transport and Planning (2025).
Each dot on this map is a station. Hover over a dot to see how many people entered it in 2018-19 and in 2024-25. For example, Flinders Street Station is at 69% of its 2018-19 level, and Ivanhoe is at 79%.
map_df <- wide %>%
filter(`Pax_annual_FY18-19` >= 100000) %>%
mutate(rec_pct = round(recovery * 100),
radius = scales::rescale(sqrt(`Pax_annual_FY18-19`), to = c(4, 14)),
label = paste0("<b>", Stop_name, "</b><br>2018-19: ",
scales::comma(`Pax_annual_FY18-19`), " entries<br>2024-25: ",
scales::comma(`Pax_annual_FY24-25`), " entries<br>Recovery: ",
rec_pct, "%"))
pal <- colorNumeric("RdYlBu", domain = c(40, 140))
leaflet(map_df, height = 600) %>%
addProviderTiles(providers$Esri.WorldGrayCanvas) %>%
setView(lng = 145.0, lat = -37.85, zoom = 10) %>%
addCircleMarkers(
lng = ~Stop_long, lat = ~Stop_lat, radius = ~radius,
color = "grey30", weight = 0.5,
fillColor = ~pal(pmin(pmax(rec_pct, 40), 140)), fillOpacity = 0.9,
label = ~lapply(label, htmltools::HTML)
) %>%
addLegend(pal = pal, values = c(40, 140),
title = "Entries 2024-25<br>as % of 2018-19",
labFormat = labelFormat(suffix = "%"))
Chart description: a map of 209 stations with at least 100,000 entries in 2018-19. Colour shows entries in 2024-25 as a percentage of 2018-19 (red is low, blue is high) and size shows 2018-19 traffic. Most stations are between about 60% and 90%, and a small number are far lower or higher. The five stations with the lowest recovery are Watsonia, Croydon, Greensborough, Macleod, Montmorency.
Source: Department of Transport and Planning (2025). Map tiles © Esri.
This chart ranks every station by how much of its 2018-19 traffic it has kept. Most stations sit close to the middle, but a few are far from it: 8 stations are below 60%, while 20 have reached or passed their 2018-19 level.
dist_df <- wide %>%
filter(`Pax_annual_FY18-19` >= 100000) %>%
mutate(rec_pct = round(recovery * 100)) %>%
arrange(rec_pct) %>%
mutate(rank = row_number(),
label = paste0(Stop_name, ": ", rec_pct, "% of 2018-19"))
med <- median(dist_df$rec_pct)
p3 <- ggplot(dist_df, aes(x = rank, y = rec_pct, colour = rec_pct, text = label)) +
geom_hline(yintercept = 100, linetype = "dashed", colour = "grey40") +
geom_hline(yintercept = med, colour = "grey20") +
geom_point(size = 2.5) +
annotate("text", x = 30, y = med + 5, label = paste0("Median: ", med, "%"),
hjust = 0, size = 4) +
scale_colour_distiller(palette = "RdYlBu", direction = 1,
limits = c(40, 140), oob = scales::squish,
name = "Recovery %") +
scale_y_continuous(labels = function(x) paste0(x, "%")) +
labs(x = "Stations, ranked from lowest to highest recovery",
y = "Entries 2024-25 as % of 2018-19") +
theme_minimal(base_size = 13) +
theme(axis.text.x = element_blank(), panel.grid.major.x = element_blank())
ggplotly(p3, tooltip = "text")
Chart description: each dot is one of 209 stations, ranked from lowest to highest recovery. The median station recorded 77% of its 2018-19 entries in 2024-25. 20 stations reached or exceeded their 2018-19 level, and 8 recorded less than 60%. The lowest station is at about 44% and the highest at about 188%.
Source: Department of Transport and Planning (2025).
The recovery looks very different depending on the day. The typical station is at 73% of its 2018-19 level on weekdays, but at 94% on Saturdays and 89% on Sundays.
day_wide <- stations %>%
filter(Fin_year %in% c("FY18-19", "FY24-25")) %>%
select(Fin_year, Stop_ID, Pax_annual, Pax_norm_weekday, Pax_Saturday, Pax_Sunday) %>%
pivot_wider(id_cols = Stop_ID, names_from = Fin_year,
values_from = c(Pax_annual, Pax_norm_weekday, Pax_Saturday, Pax_Sunday)) %>%
drop_na() %>%
filter(`Pax_annual_FY18-19` >= 100000) %>%
left_join(info, by = "Stop_ID") %>%
mutate(Weekday = 100 * `Pax_norm_weekday_FY24-25` / `Pax_norm_weekday_FY18-19`,
Saturday = 100 * `Pax_Saturday_FY24-25` / `Pax_Saturday_FY18-19`,
Sunday = 100 * `Pax_Sunday_FY24-25` / `Pax_Sunday_FY18-19`)
day_long <- day_wide %>%
select(Stop_name, Weekday, Saturday, Sunday) %>%
pivot_longer(-Stop_name, names_to = "day_type", values_to = "rec_pct") %>%
mutate(rec_pct = round(rec_pct),
day_type = factor(day_type, levels = c("Weekday", "Saturday", "Sunday")),
label = paste0(Stop_name, ": ", rec_pct, "%"))
p4 <- ggplot(day_long, aes(x = day_type, y = rec_pct, text = label)) +
geom_hline(yintercept = 100, linetype = "dashed", colour = "grey40") +
geom_boxplot(outlier.shape = NA, fill = NA, colour = "grey30") +
geom_jitter(width = 0.15, alpha = 0.5, colour = "#0072B2") +
scale_y_continuous(labels = function(x) paste0(x, "%")) +
labs(x = NULL, y = "Entries 2024-25 as % of 2018-19") +
coord_cartesian(ylim = c(0, 200)) +
theme_minimal(base_size = 13)
ggplotly(p4, tooltip = "text")
Chart description: each dot is a station with at least 100,000 entries in 2018-19. The median station recorded 73% of its 2018-19 weekday entries in 2024-25, compared with 94% on Saturdays and 89% on Sundays. The vertical axis is limited to 200%, so four weekend values above that are not shown. Weekend counts per station are small, so individual values are less reliable.
Source: Department of Transport and Planning (2025).
This chart splits weekday travel by time of day, and groups stations by how far they are from Flinders Street. In the middle and outer stations, evening travel has recovered much more than morning travel.
band_names <- c(Pax_pre_AM_peak = "Before 7am",
Pax_AM_peak = "AM peak",
Pax_interpeak = "Interpeak",
Pax_PM_peak = "PM peak",
Pax_PM_late = "PM late")
zones <- wide %>%
filter(`Pax_annual_FY18-19` >= 100000) %>%
select(Stop_ID, dist_cbd)
band_df <- stations %>%
filter(Fin_year %in% c("FY18-19", "FY24-25")) %>%
inner_join(zones, by = "Stop_ID") %>%
mutate(zone = cut(dist_cbd, breaks = c(0, 10, 25, Inf),
labels = c("Inner (under 10 km)", "Middle (10-25 km)", "Outer (over 25 km)"),
include.lowest = TRUE)) %>%
pivot_longer(all_of(names(band_names)), names_to = "band", values_to = "pax") %>%
group_by(zone, band, Fin_year) %>%
summarise(pax = sum(pax, na.rm = TRUE), n = n_distinct(Stop_ID), .groups = "drop") %>%
pivot_wider(names_from = Fin_year, values_from = pax) %>%
mutate(rec_pct = round(100 * `FY24-25` / `FY18-19`),
band = factor(band, levels = names(band_names), labels = band_names),
label = paste0(zone, "<br>", band, ": ", rec_pct, "% of 2018-19<br>",
n, " stations"))
p5 <- ggplot(band_df, aes(x = band, y = rec_pct, text = label)) +
geom_hline(yintercept = 100, linetype = "dashed", colour = "grey40") +
geom_col(fill = "#0072B2", width = 0.7) +
facet_grid(. ~ zone) +
scale_y_continuous(labels = function(x) paste0(x, "%")) +
labs(x = NULL, y = "Entries 2024-25 as % of 2018-19") +
theme_minimal(base_size = 12) +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
ggplotly(p5, tooltip = "text")
Chart description: entries in 2024-25 as a percentage of 2018-19, by time of day, for 209 stations. Stations are grouped by straight-line distance from Flinders Street: inner (84 stations), middle (91) and outer (34). In the middle and outer zones, evening travel recovered more than morning travel: the AM peak is at 68% and 78% of 2018-19 levels, against 91% and 97% for the PM peak. Inner stations recovered fairly evenly (68 to 70%) apart from the interpeak (82%). Time bands are weekday averages. Before 7am is 12:00am to 6:59am and PM late is 7:00pm to 11:59pm. The outer zone has few stations, so its figures are more sensitive to individual stations.
Source: Department of Transport and Planning (2025).
Melbourne’s train network carries about 77% of the traffic it did in 2018-19, but that average hides big differences. Saturdays and Sundays are much closer to their old levels (94% and 89%) than weekdays are (73%). Outside the inner suburbs, evening travel has recovered much more than weekday morning travel: at the outer stations the PM peak is at 97% while the AM peak is at 78%. Inner stations are down by about 30% across most of the day. This data shows where and when travel changed, but it cannot tell me why. For me, this means that the trains are not as empty as the 2020-21 low suggests, but the weekday morning trip is still much quieter than before, and the data cannot yet say whether it will return.
I used Claude (Anthropic), an AI assistant, during this assignment. I used it to explain R concepts, and to help write and debug R code for the charts. I ran the code, checked the outputs against the data, and decided which analyses and charts to keep. The question I explored came from my own experience of delays on Melbourne’s public transport.
Anthropic. (2026). Claude (Claude Sonnet 5.5) [Large language model]. https://claude.ai
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
Department of Transport and Planning. (2025). Annual metropolitan train station patronage (station entries) [Data set]. Transport Victoria Open Data. https://opendata.transport.vic.gov.au/dataset/annual-metropolitan-train-station-patronage-station-entries
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
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