# =============================================================================
# DATA SOURCE 1: Annual metropolitan train station patronage (station entries)
# Department of Transport and Planning, DataVic, CC BY 4.0.
# Loaded directly from the source URLs below (no hard-coded data).
# =============================================================================
pat_urls <- c(
  "2018-19" = "https://opendata.transport.vic.gov.au/dataset/2fa2cdfa-84f1-455e-b6c9-058b92774b34/resource/1e01734d-d66d-458a-9a1b-62b2b351938b/download/annual_metropolitan_train_station_entries_fy_2018_2019.csv",
  "2019-20" = "https://opendata.transport.vic.gov.au/dataset/2fa2cdfa-84f1-455e-b6c9-058b92774b34/resource/aaa468ef-b1a7-4851-b6ad-09bf17139e48/download/annual_metropolitan_train_station_entries_fy_2019_2020.csv",
  "2020-21" = "https://opendata.transport.vic.gov.au/dataset/2fa2cdfa-84f1-455e-b6c9-058b92774b34/resource/9553aca6-97ee-40d5-9356-f181ed1f456f/download/annual_metropolitan_train_station_entries_fy_2020_2021.csv",
  "2021-22" = "https://opendata.transport.vic.gov.au/dataset/2fa2cdfa-84f1-455e-b6c9-058b92774b34/resource/18fe6dda-18bf-4816-876c-c3e036d35eb9/download/annual_metropolitan_train_station_entries_fy_2021_2022.csv",
  "2022-23" = "https://opendata.transport.vic.gov.au/dataset/2fa2cdfa-84f1-455e-b6c9-058b92774b34/resource/62c72e09-8bfd-4d15-a2e2-a14c706eaf4d/download/annual_metropolitan_train_station_entries_fy_2022_2023.csv",
  "2023-24" = "https://opendata.transport.vic.gov.au/dataset/2fa2cdfa-84f1-455e-b6c9-058b92774b34/resource/57faf356-36a3-4bbe-87fe-f0f05d1b8996/download/annual_metropolitan_train_station_entries_fy_2023_2024.csv",
  "2024-25" = "https://opendata.transport.vic.gov.au/dataset/2fa2cdfa-84f1-455e-b6c9-058b92774b34/resource/c9507eb5-aa48-4a43-aa09-c10a24d1f2fe/download/annual_metropolitan_train_station_entries_fy_2024_2025.csv"
)

# Station names are written differently across certain files and datasets,
# so every name is
# reduced to a common upper-case key before joining.
norm_stn <- function(x) {
  x %>%
    str_to_upper() %>%
    str_remove_all("\\(.*?\\)") %>%
    str_remove_all("\\bRAILWAY\\b|\\bSTATION\\b|\\bSTN\\b") %>%
    str_replace_all("[^A-Z ]", " ") %>%
    str_squish()
}

# Column names differ a little between years (case, wording), so columns are
# found by pattern rather than assumed.
read_pat <- function(url, fy) {
  df <- read_csv(url, show_col_types = FALSE, col_types = cols(.default = "c"))
  names(df) <- tolower(names(df))
  pick <- function(pattern) {
    nm <- grep(pattern, names(df), value = TRUE)[1]
    if (is.na(nm)) rep(NA_real_, nrow(df)) else suppressWarnings(parse_number(df[[nm]]))
  }
  name_col <- grep("stop_name|station", names(df), value = TRUE)[1]
  tibble(
    fin_year = fy,
    stop_name = df[[name_col]],
    lat = pick("lat"),
    lon = pick("long|lon"),
    weekday = coalesce(pick("^pax_norm_weekday$"), pick("^pax_weekday$")),
    pre_am = pick("pre_am"),
    am_peak = pick("^pax_am_peak$")
  )
}

pat_all <- imap_dfr(pat_urls, ~ tryCatch(read_pat(.x, .y), error = function(e) {
  message("Could not load ", .y, ": ", conditionMessage(e)); NULL
})) %>%
  mutate(stn_key = norm_stn(stop_name),
         station = str_to_title(stn_key),
         fy_start = as.numeric(substr(fin_year, 1, 4)),
         # "Early-morning entries": average weekday entries in DTP's
         # pre-AM-peak and AM-peak time bands, i.e. the people arriving while
         # a commuter car park is filling up
         early = coalesce(pre_am, 0) + am_peak)

latest_fy <- max(pat_all$fin_year)
latest <- pat_all %>% filter(fin_year == latest_fy, !is.na(lat), !is.na(lon))
stopifnot(nrow(latest) > 100)
# =============================================================================
# DATA SOURCE 2: OpenStreetMap car parks (ODbL), queried directly from the
# Overpass API. One query asks for every mapped car park (amenity=parking)
# 400 m of each metropolitan station in the file.
# 
# =============================================================================
stn_radius   <- 400  # metres from the station treated as "station parking"
m2_per_space <- 25   # rule of thumb incl. aisles, used ONLY when a lot has no capacity tag

osm_cache <- "data/osm_station_parking.rds"
if (!file.exists(osm_cache)) {
  dir.create("data", showWarnings = FALSE)
  stn_pts <- distinct(latest, stn_key, lat, lon)
  around  <- paste0('way["amenity"="parking"](around:', stn_radius, ',',
                    stn_pts$lat, ',', stn_pts$lon, ');', collapse = "\n")
  q <- paste0("[out:json][timeout:600];\n(\n", around, "\n);\nout geom;")
  resp <- POST("https://overpass-api.de/api/interpreter", body = list(data = q),
               encode = "form", timeout(700))
  stop_for_status(resp)
  saveRDS(fromJSON(content(resp, as = "text", encoding = "UTF-8"), simplifyVector = FALSE), osm_cache)
}
osm_json <- readRDS(osm_cache)

tag <- function(el, k) { v <- el$tags[[k]]; if (is.null(v)) NA_character_ else as.character(v) }
lots_raw <- map_dfr(osm_json$elements, function(el) {
  g <- el$geometry
  if (is.null(g) || length(g) < 4) return(NULL)
  xy <- do.call(rbind, lapply(g, function(p) c(p$lon, p$lat)))
  if (!isTRUE(all.equal(xy[1, ], xy[nrow(xy), ]))) return(NULL)   # not a closed area
  tibble(osm_id = el$id,
         name = tag(el, "name"), access = tag(el, "access"), parking = tag(el, "parking"),
         park_ride = tag(el, "park_ride"), fee = tag(el, "fee"),
         capacity_tag = tag(el, "capacity"), levels = tag(el, "building:levels"),
         maxstay = coalesce(tag(el, "maxstay"), tag(el, "parking:maxstay"),
                            tag(el, "parking:condition:maxstay")),
         condition = tag(el, "parking:condition"),
         wkt = st_as_text(st_polygon(list(xy))))
})
stopifnot(nrow(lots_raw) > 50)

stations_sf <- latest %>% st_as_sf(coords = c("lon", "lat"), crs = 4326, remove = FALSE) %>%
  st_transform(7855)  # GDA2020 / MGA zone 55, metres

lots <- lots_raw %>%
  distinct(osm_id, .keep_all = TRUE) %>%
  st_as_sf(wkt = "wkt", crs = 4326) %>% st_transform(7855) %>% st_make_valid() %>%
  # Keep lots a commuter could legally use: drop private / customer-only lots
  # (e.g. shopping centres) unless tagged park-and-ride, and drop kerbside strips
  filter(!access %in% c("private", "customers", "permit", "no", "employees", "delivery", "destination") |
           park_ride %in% c("yes", "train", "bus"),
         !parking %in% c("street_side", "lane", "on_street")) %>%
  mutate(area_m2   = as.numeric(st_area(.)),
         lv        = coalesce(suppressWarnings(as.numeric(levels)), 1),
         cap_tag   = suppressWarnings(parse_number(capacity_tag)),
         capacity  = coalesce(cap_tag, round(area_m2 * lv / m2_per_space)),
         estimated = is.na(cap_tag),
         # Time-limited or paid = any recorded max stay, time/ticket condition, or fee
         time_limited = !is.na(maxstay) |
           str_detect(coalesce(condition, ""), "disc|time|ticket|residents") |
           coalesce(fee, "no") %in% c("yes", "Yes")) %>%
  filter(capacity >= 10)

# Give each lot to its nearest station (so neighbouring stations don't double count)
lot_pts <- suppressWarnings(st_point_on_surface(lots))
nn <- st_nearest_feature(lot_pts, stations_sf)
lots <- lots %>%
  mutate(stn_key = stations_sf$stn_key[nn],
         dist_m  = as.numeric(st_distance(lot_pts, stations_sf[nn, ], by_element = TRUE))) %>%
  filter(dist_m <= stn_radius)

cap_by_stn <- lots %>% st_drop_geometry() %>%
  group_by(stn_key) %>%
  summarise(capacity    = sum(capacity),
            tl_capacity = sum(capacity[time_limited]),
            n_lots      = n(),
            pct_est     = mean(estimated), .groups = "drop")

pct_tagged <- percent(mean(!lots$estimated), 1)
pct_tl     <- percent(mean(lots$time_limited), 1)
# The "squeeze" - early-morning entries per commuter car park space.
#  Should keep in mind that many entries walk, cycle or
# catch a bus to the station  so this compares stations on the same footing.
squeeze <- latest %>%
  inner_join(cap_by_stn, by = "stn_key") %>%
  filter(capacity >= 20) %>%                       # ignore tiny kerbside lots
  mutate(per_space = early / capacity) %>%
  arrange(desc(per_space)) %>%
  mutate(rank = row_number(),
         highlight = case_when(stn_key == "SYNDAL" ~ "Syndal",
                               stn_key == "GLEN WAVERLEY" ~ "Glen Waverley",
                               TRUE ~ "Other stations"),
         hover = paste0("<b>", station, "</b>",
                        "<br>Car park spaces within ", stn_radius, " m: ", comma(capacity),
                        " (", comma(tl_capacity), " time-limited or paid, where mapped)",
                        "<br>Early-morning entries (avg weekday): ", comma(early),
                        "<br>Entries per space: ", number(per_space, 0.01),
                        "<br>Rank: ", rank, " of ", n(), " (1 = most squeezed)"))

stopifnot(all(c("SYNDAL", "GLEN WAVERLEY") %in% squeeze$stn_key))  

syn <- filter(squeeze, stn_key == "SYNDAL")
glw <- filter(squeeze, stn_key == "GLEN WAVERLEY")
net_median <- median(squeeze$per_space)
compare <- function(x) if (x > net_median) "more" else "fewer"

# Glen Waverley line stations, city to suburbs. 
gw_keys <- c("HEYINGTON", "KOOYONG", "TOORONGA", "GARDINER", "GLEN IRIS", "DARLING",
             "EAST MALVERN", "HOLMESGLEN", "JORDANVILLE", "MOUNT WAVERLEY", "SYNDAL", "GLEN WAVERLEY")

# Consistent, colour-blind-safe colours (Okabe-Ito) for the two focus stations
col_syn <- "#D55E00"; col_glw <- "#0072B2"; col_other <- "#BDBDBD"

The 8:15 problem

Most weekday mornings I drive to Syndal station (The nearest train station from my house) and start circling the parking lot. The all-day spaces will most definitely be taken, so I’ll have to hunt for a 2P or 5P bay that won’t get me a ticket before my classes finish and I get back from the city. On a bad day there is nothing at all, and I end up driving on to Glen Waverley (The next best option when it comes to train stations close to my house) to try my luck there. I regularly lose fifteen to twenty minutes of my time looking for parking before I even tap on. This report asks whether that is bad luck, or whether the numbers say these two car parks simply cannot keep up with the people trying to use them.

The State Government reports that more than 21,000 new and upgraded spaces have been built at train stations across Victoria (Premier of Victoria, 2025). The question is whether that is enough at the stations people actually drive to.

In 2024-25, an average weekday saw about 1,500 people enter Syndal in the early-morning time bands, competing for 737 car park spaces — that’s 2.0 people per space, more than the network median of 1.7. At Glen Waverley it was 0.8 per space.

1. Where do my stations rank?

Every metropolitan station with a commuter car park, ranked from most to least squeezed. Bigger circles are bigger car parks. Hover over any station for its numbers; drag to zoom, double-click to reset.

hl <- filter(squeeze, highlight != "Other stations")
sz <- function(cap) rescale(sqrt(cap), to = c(5, 26), from = range(sqrt(squeeze$capacity)))

p1 <- plot_ly() %>%
  add_markers(data = filter(squeeze, highlight == "Other stations"),
              x = ~rank, y = ~per_space, text = ~hover, hoverinfo = "text",
              name = "Other stations",
              marker = list(size = ~sz(capacity), color = col_other, opacity = 0.7,
                            line = list(width = 0))) %>%
  add_markers(data = filter(hl, highlight == "Syndal"), x = ~rank, y = ~per_space,
              text = ~hover, hoverinfo = "text", name = "Syndal",
              marker = list(size = ~sz(capacity), color = col_syn, line = list(color = "white", width = 1))) %>%
  add_markers(data = filter(hl, highlight == "Glen Waverley"), x = ~rank, y = ~per_space,
              text = ~hover, hoverinfo = "text", name = "Glen Waverley",
              marker = list(size = ~sz(capacity), color = col_glw, line = list(color = "white", width = 1))) %>%
  layout(
    title = list(text = paste0("<b>Early-morning commuters per car park space, ", latest_fy, "</b>"),
                 x = 0, font = list(size = 15)),
    xaxis = list(title = "Rank (1 = most squeezed car park)", zeroline = FALSE),
    yaxis = list(title = "Early-morning entries per space", rangemode = "tozero"),
    shapes = list(list(type = "line", xref = "paper", x0 = 0, x1 = 1, y0 = net_median, y1 = net_median,
                       line = list(dash = "dot", color = "#555555", width = 1))),
    annotations = c(
      list(list(xref = "paper", x = 1, y = net_median, text = "Network median", showarrow = FALSE,
                xanchor = "right", yanchor = "bottom", font = list(size = 11, color = "#555555"))),
      lapply(seq_len(nrow(hl)), function(i) list(
        x = hl$rank[i], y = hl$per_space[i], text = paste0("<b>", hl$station[i], "</b>"),
        showarrow = TRUE, arrowhead = 0, ax = 40, ay = -35,
        font = list(color = ifelse(hl$highlight[i] == "Syndal", col_syn, col_glw))))
    ),
    legend = list(orientation = "h", y = -0.2),
    margin = list(t = 60)
  ) %>%
  config(displaylogo = FALSE)
p1

Figure 1. Each dot is a metropolitan station with a commuter car park. The dot size shows car park capacity. Syndal (orange) and Glen Waverley (blue) are labelled. “Early-morning entries” are average weekday station entries in the Department of Transport and Planning’s pre-AM-peak and AM-peak time bands. Data: Department of Transport and Planning (2025); OpenStreetMap contributors (2026).

2. The map: where the squeeze is worst

Darker circles mean more early-morning commuters per space and bigger the circles then bigger the car parks. Use the layer control (top right) to show only the Glen Waverley line, and click a station for details.

pal <- colorBin(viridis(5, end = 0.95, direction = -1), domain = squeeze$per_space,
                bins = c(0, 1, 2, 3, 5, Inf), na.color = "#cccccc")
rad <- function(cap) rescale(sqrt(cap), to = c(3, 14), from = range(sqrt(squeeze$capacity)))
gw_sq <- filter(squeeze, stn_key %in% gw_keys)

leaflet(options = leafletOptions(minZoom = 9)) %>%
  addProviderTiles(providers$CartoDB.Positron) %>%
  addCircleMarkers(data = squeeze, lng = ~lon, lat = ~lat, radius = ~rad(capacity),
                   fillColor = ~pal(per_space), fillOpacity = 0.85, color = "#333333", weight = 0.5,
                   label = ~station, popup = ~hover, group = "All stations") %>%
  addCircleMarkers(data = gw_sq, lng = ~lon, lat = ~lat, radius = ~rad(capacity),
                   fillColor = ~pal(per_space), fillOpacity = 0.9, color = "#000000", weight = 1,
                   label = ~station, popup = ~hover, group = "Glen Waverley line only") %>%
  addPolygons(data = st_transform(filter(lots, stn_key %in% gw_keys), 4326),
              fillColor = ~ifelse(time_limited, col_syn, "#009E73"), fillOpacity = 0.55,
              color = "#333333", weight = 0.6,
              label = ~paste0(coalesce(name, "Car park"), ": ~", comma(capacity), " spaces"),
              popup = ~paste0("<b>", coalesce(name, "Car park"), "</b>",
                              "<br>Spaces: ", comma(capacity), ifelse(estimated, " (estimated from area)", " (mapped)"),
                              "<br>Time limit / fee: ", ifelse(time_limited, coalesce(maxstay, condition, "yes"), "none recorded"),
                              "<br>Walk to station: ~", round(dist_m), " m"),
              group = "Car park outlines (zoom in)") %>%
  addCircleMarkers(data = hl, lng = ~lon, lat = ~lat, radius = ~rad(capacity) + 4,
                   fill = FALSE, color = ~ifelse(highlight == "Syndal", col_syn, col_glw), weight = 3,
                   label = ~station, labelOptions = labelOptions(noHide = TRUE, direction = "right",
                                                                 textOnly = TRUE,
                                                                 style = list("font-weight" = "bold")),
                   group = "My stations") %>%
  addLegend("bottomright", pal = pal, values = squeeze$per_space,
            title = "Early-morning<br>entries per space", opacity = 0.9) %>%
  addLayersControl(baseGroups = c("All stations", "Glen Waverley line only"),
                   overlayGroups = c("My stations", "Car park outlines (zoom in)"),
                   options = layersControlOptions(collapsed = FALSE)) %>%
  setView(lng = syn$lon, lat = syn$lat, zoom = 11)

Figure 2. Map of metropolitan stations with nearby public car parks. Colour shows early-morning entries per car park space in five bands (light = fewer, dark = more). circle size shows car park capacity and Syndal and Glen Waverley are ringed. Turn on “Car park outlines” and zoom in to see each Glen Waverley line car park. In this regard orange lots have a recorded time limit or fee (like the 2P and 5P bays), green lots have none recorded. Data: Department of Transport and Planning (2025); OpenStreetMap contributors (2026).

3. Along the line: the parking all ends up at the end

The inner Glen Waverley line stations have no commuter car parks at all, so everyone who drives is funnelled to the last few stations. Click a legend item to hide it.

gw <- latest %>%
  filter(stn_key %in% gw_keys) %>%
  left_join(cap_by_stn, by = "stn_key") %>%
  mutate(capacity = replace_na(capacity, 0),
         tl_capacity = replace_na(tl_capacity, 0),
         station = factor(station, levels = str_to_title(gw_keys)))

plot_ly(gw, x = ~station) %>%
  add_markers(y = ~tl_capacity, name = "...of which time-limited or paid (where mapped)",
              marker = list(symbol = "triangle-down", size = 11, color = "#E69F00",
                            line = list(color = "white", width = 1)),
              hovertemplate = "%{x}<br>Time-limited or paid spaces: %{y:,}<extra></extra>") %>%
  add_bars(y = ~pre_am, name = "Entries before the morning peak", marker = list(color = "#56B4E9"),
           hovertemplate = "%{x}<br>Before the peak: %{y:,}<extra></extra>") %>%
  add_bars(y = ~am_peak, name = "Entries in the morning peak", marker = list(color = "#0072B2"),
           hovertemplate = "%{x}<br>Morning peak: %{y:,}<extra></extra>") %>%
  add_markers(y = ~capacity, name = "Commuter car park spaces",
              marker = list(symbol = "diamond", size = 12, color = col_syn, line = list(color = "white", width = 1)),
              hovertemplate = "%{x}<br>Car park spaces: %{y:,}<extra></extra>") %>%
  layout(barmode = "stack",
         title = list(text = paste0("<b>Glen Waverley line: early-morning entries vs car park spaces, ", latest_fy, "</b>"),
                      x = 0, font = list(size = 15)),
         xaxis = list(title = "Stations, city (left) to Glen Waverley (right)", tickangle = -35),
         yaxis = list(title = "Average weekday count"),
         legend = list(orientation = "h", y = -0.35),
         margin = list(t = 60, b = 120)) %>%
  config(displaylogo = FALSE)

Figure 3. Stacked bars show average weekday entries in the pre-AM-peak and AM-peak time bands at each Glen Waverley line station (excluding the four inner stations shared with other lines). orange diamonds show the capacity of public car parks within 400 m, and yellow triangles show how many of those spaces sit in lots with a recorded time limit or fee. Data: Department of Transport and Planning (2025); OpenStreetMap contributors (2026).

4. The crowds came back — the car park didn’t grow

Solid lines show early-morning entries for each year. The dotted lines show the same station’s car park capacity. (The shaded area marks the COVID-19 lockdown years)

trend <- pat_all %>%
  filter(stn_key %in% gw_keys) %>%
  left_join(cap_by_stn, by = "stn_key") %>%
  mutate(station = factor(station, levels = str_to_title(gw_keys)))

others <- setdiff(levels(trend$station), c("Syndal", "Glen Waverley"))
pal4 <- c(setNames(viridis(length(others), end = 0.85), others), "Syndal" = col_syn, "Glen Waverley" = col_glw)

sd4 <- SharedData$new(trend, key = ~station, group = "trend")
yrs <- sort(unique(trend$fy_start))

p4 <- plot_ly(sd4, x = ~fy_start, color = ~station, colors = pal4) %>%
  add_lines(y = ~early, line = list(width = 3),
            text = ~paste0("<b>", station, "</b> ", fin_year, "<br>Early-morning entries: ", comma(early)),
            hoverinfo = "text") %>%
  add_lines(y = ~capacity, line = list(dash = "dot", width = 2), showlegend = FALSE,
            text = ~paste0("<b>", station, "</b><br>Car park spaces: ", comma(capacity)),
            hoverinfo = "text") %>%
  layout(title = list(text = "<b>Early-morning entries by year, with each station's car park capacity (dotted)</b>",
                      x = 0, font = list(size = 15)),
         xaxis = list(title = "Financial year", tickvals = yrs,
                      ticktext = paste0(yrs, "-", substr(yrs + 1, 3, 4))),
         yaxis = list(title = "Average weekday count", rangemode = "tozero"),
         shapes = list(list(type = "rect", xref = "x", yref = "paper", x0 = 2019.5, x1 = 2021.5,
                            y0 = 0, y1 = 1, fillcolor = "#999999", opacity = 0.15, line = list(width = 0))),
         annotations = list(list(x = 2020.5, y = 1, xref = "x", yref = "paper", text = "COVID-19 lockdowns",
                                 showarrow = FALSE, yanchor = "bottom", font = list(size = 11, color = "#555555"))),
         legend = list(orientation = "h", y = -0.2),
         margin = list(t = 60)) %>%
  config(displaylogo = FALSE)

# filter_select() has no "selected" argument, so a short script pre-selects
# Syndal and Glen Waverley once the page has loaded
bscols(widths = c(3, 9),
       tagList(
         filter_select("stn4", "Choose stations", sd4, ~station, multiple = TRUE),
         tags$script(HTML(
           "window.addEventListener('load', function() {
              setTimeout(function() {
                var el = document.querySelector('#stn4 select');
                if (el && el.selectize) el.selectize.setValue(['Syndal', 'Glen Waverley']);
              }, 300);
            });"))
       ),
       p4)

Figure 4. Lines show average weekday early-morning entries per financial year from 2018–19 to 2024-25 for Glen Waverley line stations. The dotted lines show today’s mapped car park capacity, which is a single snapshot, so it is flat by design. Data: Department of Transport and Planning (2025); OpenStreetMap contributors (2026).

5. Should I drive one more stop?

When Syndal is full, is there a better bet nearby or not? This is something I ask myself every morning driving between train stations. Each dot is a station within the chosen driving radius of Syndal. Stations above the diagonal line have more early-morning commuters than car park spaces. Drag the slider to widen or narrow the search.

syn_pt <- stations_sf %>% filter(stn_key == "SYNDAL") %>% slice(1)
near <- squeeze %>%
  st_as_sf(coords = c("lon", "lat"), crs = 4326, remove = FALSE) %>%
  st_transform(7855) %>%
  mutate(km_from_syndal = round(as.numeric(st_distance(geometry, syn_pt)) / 1000, 1)) %>%
  st_drop_geometry() %>%
  filter(km_from_syndal <= 20) %>%
  mutate(hover5 = paste0(hover, "<br>Distance from Syndal: ", km_from_syndal, " km"))

sd5 <- SharedData$new(near, key = ~station, group = "near")
lim <- max(c(near$capacity, near$early)) * 1.05

p5 <- plot_ly(sd5, x = ~capacity, y = ~early, text = ~hover5, hoverinfo = "text",
              type = "scatter", mode = "markers",
              marker = list(size = 13, line = list(color = "white", width = 1),
                            color = ~per_space, cmin = 0, cmax = max(near$per_space),
                            colorscale = "Viridis", reversescale = TRUE,
                            colorbar = list(title = "Entries<br>per space"))) %>%
  add_lines(data = tibble(x = c(0, lim), y = c(0, lim)), x = ~x, y = ~y, inherit = FALSE,
            line = list(dash = "dash", color = "#777777", width = 1),
            hoverinfo = "skip", showlegend = FALSE) %>%
  add_annotations(data = filter(near, stn_key %in% c("SYNDAL", "GLEN WAVERLEY")),
                  x = ~capacity, y = ~early, text = ~paste0("<b>", station, "</b>"),
                  inherit = FALSE, showarrow = TRUE, arrowhead = 0, ax = 35, ay = -30) %>%
  layout(title = list(text = "<b>Stations near Syndal: car park size vs early-morning demand</b>",
                      x = 0, font = list(size = 15)),
         xaxis = list(title = "Commuter car park spaces", range = c(0, lim)),
         yaxis = list(title = "Early-morning entries (avg weekday)", range = c(0, lim)),
         annotations = list(list(x = lim * 0.62, y = lim * 0.97, text = "More commuters than spaces",
                                 showarrow = FALSE, font = list(size = 11, color = "#555555")),
                            list(x = lim * 0.95, y = lim * 0.55, text = "More spaces than commuters",
                                 showarrow = FALSE, xanchor = "right", font = list(size = 11, color = "#555555"))),
         margin = list(t = 60)) %>%
  config(displaylogo = FALSE)

bscols(widths = c(3, 9),
       filter_slider("km5", "Maximum distance from Syndal (km)", sd5, ~km_from_syndal,
                     min = 0, max = 20, step = 1),
       p5)

Figure 5. Stations within 20 km of Syndal, plotted by commuter car park capacity (x) against average weekday early-morning entries (y), coloured by entries per space. The dashed line marks one early-morning entry per space. Data: Department of Transport and Planning (2025); OpenStreetMap contributors (2026).

What this means

After reviewing the data, it is quite evident that what I’m doing right now - checking syndal station carpark for parking space and if I can’t find any spots, driving to glen waverley station to try my luck there - is the best course of action for my situation. Accordingly, i don’t think luck has anything to do with me finding a parking spot, but more to do with not enough parking spaces alotted to withstand the amount of commuters using public transportation at these train sttaions. Moreover from the figures above, the numbers clearly corraborates the fact that syndal station tends to be fully packed (e.g Syndal station entries per space is 2.04 while Glen Waverley the entries per space is 0.83). A solution for this issue would be to add more parking spaces for the more congested train stations (which they’ve initiated at the Glen Waverley station). Additionally, the government should look at the data and try to determine what the commuters needs are try to rectify the situation accordingly.

About the data

  • Early-morning entries are average weekday station entries in the pre-AM-peak and AM-peak time bands. They are estimated from myki data. The “entries per space” measure is therefore a pressure index for comparing stations, not a car park occupancy rate.

  • Car park capacity comes from OpenStreetMap, whicih is an open, volunteer-mapped map of the world. Each lot is assigned to its nearest station.

  • 5% of these lots record their number of spaces. For the rest, capacity is estimated from the lot’s mapped area at 25 m² per space.

  • Time limits (2P and 5P ) and fees are only recorded in OpenStreetMap for some lots so time-limited spaces are a minimum, not a full count.

  • The car park data is a current snapshot, while patronage covers 2018–19 to 2024-25. Stations with fewer than 20 mapped spaces are excluded from the comparisons.

  • All charts were created in R using plotly, leaflet and crosstalk.

  • Colours use the colour-blind-safe Okabe-Ito and viridis palettes.

Acknowledgements

The story idea, personal experience and final interpretation are my own. I wrote the code, reviewed, ran and edited everythign before submission. I used the Writing Help persona in Val (RMIT, 2026) to provide feedback on my written draft with regards to spelling and grammar errors that I could improve upon.

References

Department of Transport and Planning. (2025). Annual metropolitan train station patronage (station entries) [Data set]. DataVic. https://discover.data.vic.gov.au/dataset/annual-metropolitan-train-station-patronage-station-entries

OpenStreetMap contributors. (2026). OpenStreetMap [Data set; retrieved via the Overpass API]. OpenStreetMap Foundation. https://www.openstreetmap.org

Premier of Victoria. (2025, September 11). 21,000 new station car parks delivered [Media release]. https://www.premier.vic.gov.au/sites/default/files/2025-09/250911-21%2C000-New-Station-Car-Parks-Delivered.pdf