On May 28th 2025, an omnibus bill was filed in Illinois that would allow transit agencies (Metra, CTA, Pace) to develop land around their train and bus stations.
According to the legislation, “transit-supportive development” is defined as residential, commercial, and governmental facilities and supporting infrastructure improvements that:
The Board of Trustees of any Transit District may acquire, construct, own, operate, or maintain transit-supportive development in the metropolitan region and may exercise all powers necessary to accomplish the purposes of this Section.
This analysis visualizes the areas around Chicago that would fall under this authority.
Note: at this time, this map does not include ‘trail-supportive development’, which would allow the agencies to develop land surrounding public trails.
# Install packages if not already installed
required_packages <- c("tidyverse", "sf", "leaflet", "leaflet.extras",
"data.table", "zip", "httr", "lubridate", "mapview")
new_packages <- required_packages[!required_packages %in% installed.packages()[,"Package"]]
if(length(new_packages)) install.packages(new_packages)
# Load required packages
library(tidyverse)
library(sf)
library(leaflet)
library(leaflet.extras)
library(data.table)
library(zip)
library(httr)
library(lubridate)
library(mapview)
## Download and Process GTFS Data
## We'll download the latest GTFS data for all three transit agencies (CTA, Pace, and Metra),
## extract them, and process them to identify public transportation stations and bus stops.
# Function to download and extract GTFS data
download_and_extract_gtfs <- function(agency_name, zip_link) {
# Create a temporary directory to store the downloaded file
temp_dir <- file.path(tempdir(), agency_name)
if (!dir.exists(temp_dir)) {
dir.create(temp_dir, recursive = TRUE)
}
temp_file <- file.path(temp_dir, paste0(agency_name, "_gtfs.zip"))
# Create a cache directory
cache_dir <- "gtfs_cache"
if (!dir.exists(cache_dir)) {
dir.create(cache_dir)
}
cache_file <- file.path(cache_dir, paste0(agency_name, "_gtfs.zip"))
# Try to download or use cached data
tryCatch({
# Download the GTFS ZIP file with a timeout and user agent
options(timeout = 60) # Increase timeout to 60 seconds
# Use httr::GET with a user agent to avoid 403 Forbidden errors
response <- httr::GET(
zip_link,
httr::user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"),
httr::write_disk(temp_file, overwrite = TRUE),
httr::timeout(60)
)
# Check if the request was successful
if (httr::status_code(response) != 200) {
stop(paste0("Failed to download with status code: ", httr::status_code(response)))
}
# Save a copy to cache
file.copy(temp_file, cache_file, overwrite = TRUE)
}, error = function(e) {
message(paste0("Download failed for ", agency_name, ": ", e$message))
if (file.exists(cache_file)) {
message(paste0("Using cached GTFS data for ", agency_name, " from ", cache_file))
file.copy(cache_file, temp_file, overwrite = TRUE)
} else {
stop(paste0("Could not download GTFS data for ", agency_name, " and no cache available."), call. = FALSE)
}
})
# Extract the files
gtfs_files <- unzip(temp_file, exdir = temp_dir)
# Return the directory containing the extracted files
return(temp_dir)
}
# Download and extract GTFS data for all three agencies
cta_dir <- download_and_extract_gtfs("cta", "https://www.transitchicago.com/downloads/sch_data/google_transit.zip")
pace_dir <- download_and_extract_gtfs("pace", "https://www.pacebus.com/sites/default/files/2025-02/GTFS.zip")
metra_dir <- download_and_extract_gtfs("metra", "https://schedules.metrarail.com/gtfs/schedule.zip")
## Identify Public Transportation Stations and Bus Stops
## Now we'll process the GTFS data from all three agencies to identify public transportation stations and bus stops.
# Function to read and normalize GTFS data
read_normalize_gtfs <- function(agency_name, agency_dir) {
# Read the stops data
stops_file <- file.path(agency_dir, "stops.txt")
if (file.exists(stops_file)) {
stops <- fread(stops_file)
# Add agency identifier
stops[, agency := agency_name]
# Normalize column names and types
if (!"location_type" %in% names(stops)) {
stops[, location_type := NA_integer_]
}
if (!"parent_station" %in% names(stops)) {
stops[, parent_station := NA_character_]
}
# Ensure stop_id is character
stops[, stop_id := as.character(stop_id)]
# Create a unique ID that includes the agency
stops[, unique_stop_id := paste0(agency_name, "_", stop_id)]
} else {
stops <- data.table(
stop_id = character(),
stop_name = character(),
stop_lat = numeric(),
stop_lon = numeric(),
location_type = integer(),
parent_station = character(),
agency = character(),
unique_stop_id = character()
)
}
# Read the routes data
routes_file <- file.path(agency_dir, "routes.txt")
if (file.exists(routes_file)) {
routes <- fread(routes_file)
# Add agency identifier
routes[, agency := agency_name]
# Normalize route_id to character
routes[, route_id := as.character(route_id)]
# Create a unique ID that includes the agency
routes[, unique_route_id := paste0(agency_name, "_", route_id)]
} else {
routes <- data.table(
route_id = character(),
route_type = integer(),
agency = character(),
unique_route_id = character()
)
}
# Return the normalized tables
return(list(
stops = stops,
routes = routes
))
}
# Read and normalize GTFS data for all three agencies
cta_data <- read_normalize_gtfs("cta", cta_dir)
pace_data <- read_normalize_gtfs("pace", pace_dir)
metra_data <- read_normalize_gtfs("metra", metra_dir)
# Combine data from all agencies
all_stops <- rbindlist(list(cta_data$stops, pace_data$stops, metra_data$stops), fill = TRUE)
all_routes <- rbindlist(list(cta_data$routes, pace_data$routes, metra_data$routes), fill = TRUE)
# Identify rail transit stations across all agencies
# CTA: route_type = 1 (subway/metro)
# Metra: route_type = 2 (rail)
# Pace: No rail routes
rail_routes <- all_routes[route_type %in% c(1, 2)]
# For CTA, use parent_station or location_type to identify stations
cta_rail_stops <- all_stops[
agency == "cta" &
((!is.na(parent_station) & parent_station != "") |
(!is.na(location_type) & location_type == 1))
]
# For Metra, all stops are rail stations, but filter out Wisconsin stations
# The Illinois-Wisconsin border is approximately at 42.5 degrees latitude
metra_rail_stops <- all_stops[agency == "metra" & stop_lat <= 42.5]
# Combine all rail stations
rail_stops <- rbindlist(list(cta_rail_stops, metra_rail_stops), fill = TRUE)
# Create a spatial object for rail stations
rail_stations_sf <- st_as_sf(rail_stops, coords = c("stop_lon", "stop_lat"), crs = 4326)
# Identify all bus stops (CTA and Pace)
# For CTA: location_type == 0 or NA, and not in rail_stops
# For Pace: All stops are bus stops
bus_stops <- all_stops[
(agency == "cta" & (is.na(location_type) | location_type == 0) &
!(unique_stop_id %in% rail_stops$unique_stop_id)) |
(agency == "pace")
]
# Create a spatial object for bus stops
bus_stops_sf <- st_as_sf(bus_stops, coords = c("stop_lon", "stop_lat"), crs = 4326)
# Add type column to both datasets
rail_stations_sf$type <- "rail_station"
bus_stops_sf$type <- "bus_stop"
# Add agency information to both datasets
rail_stations_sf$agency_name <- factor(
rail_stations_sf$agency,
levels = c("cta", "pace", "metra"),
labels = c("CTA", "Pace", "Metra")
)
bus_stops_sf$agency_name <- factor(
bus_stops_sf$agency,
levels = c("cta", "pace", "metra"),
labels = c("CTA", "Pace", "Metra")
)
## Create Buffers Around Transportation Stations and Stops
## Now we'll create buffers around each transportation station and stop to visualize the areas affected by the legislation.
# Convert to a projected CRS for accurate buffer calculation
# NAD83 / Illinois East (ftUS) EPSG:3435 is appropriate for Chicago
rail_stations_projected <- st_transform(rail_stations_sf, 3435)
bus_stops_projected <- st_transform(bus_stops_sf, 3435)
# Create a 1/2 mile buffer (2640 feet) around rail stations
half_mile_buffers <- st_buffer(rail_stations_projected, 2640)
# Create a 1/8 mile buffer (660 feet) around bus stops
eighth_mile_buffers <- st_buffer(bus_stops_projected, 660)
# Union all buffers to create a single polygon that shows all affected areas
rail_affected_areas <- st_union(half_mile_buffers)
bus_affected_areas <- st_union(eighth_mile_buffers)
all_affected_areas <- st_union(c(rail_affected_areas, bus_affected_areas))
# Convert back to WGS84 for mapping
rail_affected_areas_wgs84 <- st_transform(rail_affected_areas, 4326)
bus_affected_areas_wgs84 <- st_transform(bus_affected_areas, 4326)
all_affected_areas_wgs84 <- st_transform(all_affected_areas, 4326)
half_mile_buffers_wgs84 <- st_transform(half_mile_buffers, 4326)
eighth_mile_buffers_wgs84 <- st_transform(eighth_mile_buffers, 4326)