Parking mandates, also known as parking minimums, are zoning regulations that require developers to build a minimum number of parking spaces with new housing and commercial developments. These mandates typically specify a certain number of parking spaces per unit of housing, square footage of retail space, or other metrics depending on the development type.
Let’s analyzes and visualize the potential impact of the Illinois People Over Parking Act (HB3256) on Chicago. The proposed legislation prohibits local governments from imposing minimum parking requirements on development projects located within 1/2 mile of public transportation hubs.
According to the bill, a “public transportation hub” is defined as:
Where will land qualify under those parameters?
# 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 hubs according to the bill's definition.
# 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 Hubs
## Now we'll process the GTFS data from all three agencies to identify the public transportation hubs as defined in the legislation.
# 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()
)
}
# Read trips data
trips_file <- file.path(agency_dir, "trips.txt")
if (file.exists(trips_file)) {
trips <- fread(trips_file)
# Add agency identifier
trips[, agency := agency_name]
# Normalize trip_id and route_id to character
trips[, trip_id := as.character(trip_id)]
trips[, route_id := as.character(route_id)]
# Create unique IDs that include the agency
trips[, unique_trip_id := paste0(agency_name, "_", trip_id)]
trips[, unique_route_id := paste0(agency_name, "_", route_id)]
} else {
trips <- data.table(
trip_id = character(),
route_id = character(),
service_id = character(),
agency = character(),
unique_trip_id = character(),
unique_route_id = character()
)
}
# Read stop_times data
stop_times_file <- file.path(agency_dir, "stop_times.txt")
if (file.exists(stop_times_file)) {
stop_times <- fread(stop_times_file)
# Add agency identifier
stop_times[, agency := agency_name]
# Normalize trip_id and stop_id to character
stop_times[, trip_id := as.character(trip_id)]
stop_times[, stop_id := as.character(stop_id)]
# Create unique IDs that include the agency
stop_times[, unique_trip_id := paste0(agency_name, "_", trip_id)]
stop_times[, unique_stop_id := paste0(agency_name, "_", stop_id)]
} else {
stop_times <- data.table(
trip_id = character(),
stop_id = character(),
arrival_time = character(),
departure_time = character(),
stop_sequence = integer(),
agency = character(),
unique_trip_id = character(),
unique_stop_id = character()
)
}
# Read calendar data
calendar_file <- file.path(agency_dir, "calendar.txt")
if (file.exists(calendar_file)) {
calendar <- fread(calendar_file)
# Add agency identifier
calendar[, agency := agency_name]
} else {
calendar <- data.table(
service_id = character(),
monday = integer(),
tuesday = integer(),
wednesday = integer(),
thursday = integer(),
friday = integer(),
saturday = integer(),
sunday = integer(),
start_date = integer(),
end_date = integer(),
agency = character()
)
}
# Return all normalized tables
return(list(
stops = stops,
routes = routes,
trips = trips,
stop_times = stop_times,
calendar = calendar
))
}
# 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)
all_trips <- rbindlist(list(cta_data$trips, pace_data$trips, metra_data$trips), fill = TRUE)
all_stop_times <- rbindlist(list(cta_data$stop_times, pace_data$stop_times, metra_data$stop_times), fill = TRUE)
all_calendar <- rbindlist(list(cta_data$calendar, pace_data$calendar, metra_data$calendar), 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)
# Now identify bus stops that meet the criteria of 2+ major routes with frequency ≤ 15 min
# First determine the service periods for weekdays (Monday-Friday)
weekday_service <- all_calendar[
monday == 1 & tuesday == 1 & wednesday == 1 & thursday == 1 & friday == 1,
.(service_id, agency)
]
# Get the trips that operate on weekdays
weekday_trips <- merge(all_trips, weekday_service, by = c("service_id", "agency"))
# Define peak hours (e.g., 7-9 AM and 4-6 PM)
morning_peak_start <- as.ITime("07:00:00")
morning_peak_end <- as.ITime("09:00:00")
evening_peak_start <- as.ITime("16:00:00")
evening_peak_end <- as.ITime("18:00:00")
# Process stop times for peak hours
all_stop_times[, arrival_time_hhmmss := substr(arrival_time, 1, 8)]
all_stop_times[, arrival_time_obj := as.ITime(arrival_time_hhmmss)]
peak_stop_times <- all_stop_times[
(arrival_time_obj >= morning_peak_start & arrival_time_obj <= morning_peak_end) |
(arrival_time_obj >= evening_peak_start & arrival_time_obj <= evening_peak_end)
]
# Join with trips to get route information
peak_stop_times <- merge(
peak_stop_times,
weekday_trips[, .(unique_trip_id, unique_route_id, agency)],
by = c("unique_trip_id", "agency")
)
# Count unique routes per stop during peak hours
routes_per_stop <- peak_stop_times[, .(unique_routes = uniqueN(unique_route_id)), by = .(unique_stop_id, agency)]
# Get stops with 2+ routes
multi_route_stops <- routes_per_stop[unique_routes >= 2]
# Filter to bus stops only (not rail stations)
# For CTA: location_type == 0 or NA, and not in rail_stops
# For Pace: All stops are bus stops
# For Metra: None (all are rail)
bus_stops <- all_stops[
(agency == "cta" & (is.na(location_type) | location_type == 0) &
!(unique_stop_id %in% rail_stops$unique_stop_id)) |
(agency == "pace")
]
# Calculate the minimum frequency between runs for each route at each stop
# First, separate morning and evening peak periods
morning_peak_times <- peak_stop_times[
arrival_time_obj >= morning_peak_start & arrival_time_obj <= morning_peak_end
]
evening_peak_times <- peak_stop_times[
arrival_time_obj >= evening_peak_start & arrival_time_obj <= evening_peak_end
]
# Function to calculate headways for a given set of stop times
calculate_headways <- function(stop_times_data) {
# Sort by stop, route, and time
setorder(stop_times_data, unique_stop_id, unique_route_id, arrival_time_obj)
# Calculate time difference between consecutive arrivals of the same route at the same stop
stop_times_data[, time_diff := c(NA, diff(as.numeric(arrival_time_obj))),
by = .(unique_stop_id, unique_route_id)]
# Convert time difference from seconds to minutes
stop_times_data[, headway_minutes := time_diff / 60]
# Filter out unreasonable headways (e.g., when there's a large gap between trips)
stop_times_data <- stop_times_data[!is.na(headway_minutes) & headway_minutes <= 60]
return(stop_times_data)
}
# Calculate headways for morning and evening peak periods
morning_headways <- calculate_headways(morning_peak_times)
evening_headways <- calculate_headways(evening_peak_times)
# Combine morning and evening headways
all_headways <- rbind(morning_headways, evening_headways)
# Calculate median headway for each route at each stop
route_headways <- all_headways[, .(
median_headway = median(headway_minutes, na.rm = TRUE),
min_headway = min(headway_minutes, na.rm = TRUE),
max_headway = max(headway_minutes, na.rm = TRUE),
num_observations = .N
), by = .(unique_stop_id, unique_route_id, agency)]
# Filter to routes with sufficient observations (at least 3)
route_headways <- route_headways[num_observations >= 3]
# Identify routes that meet the 15-minute frequency criterion
route_headways[, meets_frequency := median_headway <= 15]
# For each stop with multiple routes, check if all routes meet the frequency criterion
stop_route_counts <- route_headways[, .(
total_routes = .N,
qualifying_routes = sum(meets_frequency),
all_routes_qualify = all(meets_frequency)
), by = .(unique_stop_id, agency)]
# Filter to stops with 2+ routes
multi_route_stops_with_frequency <- stop_route_counts[total_routes >= 2]
# Identify stops where all routes meet the frequency criterion
qualifying_stops <- multi_route_stops_with_frequency[all_routes_qualify == TRUE]
# Get the full stop information for qualifying stops
qualifying_bus_hubs <- merge(
bus_stops,
qualifying_stops[, .(unique_stop_id, agency, total_routes, qualifying_routes)],
by = c("unique_stop_id", "agency")
)
# Find bus stops that meet both criteria: multiple routes and frequency requirement
bus_hub_candidates <- qualifying_bus_hubs
# Create a spatial object for bus hubs
bus_hubs_sf <- st_as_sf(bus_hub_candidates, coords = c("stop_lon", "stop_lat"), crs = 4326)
# Ensure both spatial objects have the same columns before combining
# First, identify common columns
rail_cols <- names(rail_stations_sf)
bus_cols <- names(bus_hubs_sf)
# Add missing columns to each dataset
for (col in setdiff(bus_cols, rail_cols)) {
rail_stations_sf[[col]] <- NA
}
for (col in setdiff(rail_cols, bus_cols)) {
bus_hubs_sf[[col]] <- NA
}
# Add type column to both
rail_stations_sf$type <- "rail"
bus_hubs_sf$type <- "bus_hub"
# Now combine them
all_hubs_sf <- rbind(rail_stations_sf, bus_hubs_sf)
# Add agency information to the combined hubs
all_hubs_sf$agency_name <- factor(
all_hubs_sf$agency,
levels = c("cta", "pace", "metra"),
labels = c("CTA", "Pace", "Metra")
)
## Create 1/2 Mile Buffers Around Transportation Hubs
## Now we'll create a 1/2 mile buffer around each transportation hub 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
all_hubs_projected <- st_transform(all_hubs_sf, 3435)
# Create a 1/2 mile buffer (2640 feet)
half_mile_buffers <- st_buffer(all_hubs_projected, 2640)
# Union all buffers to create a single polygon that shows all affected areas
all_affected_areas <- st_union(half_mile_buffers)
# Convert back to WGS84 for mapping
all_affected_areas_wgs84 <- st_transform(all_affected_areas, 4326)
half_mile_buffers_wgs84 <- st_transform(half_mile_buffers, 4326)
# We'll use Leaflet to create an interactive map showing the affected areas.
# Define a color palette for transit agencies
agency_pal <- colorFactor(
palette = c("#009CDE", "#814C9E", "#E31837"), # CTA blue, Pace purple, Metra red
domain = all_hubs_sf$agency_name
)
# Define a color palette for transit hub types
type_pal <- colorFactor(
palette = c("blue", "green"),
domain = all_hubs_sf$type
)
# Create a leaflet map
map <- leaflet() %>%
addProviderTiles(providers$CartoDB.Positron) %>%
addPolygons(
data = all_affected_areas_wgs84,
fillColor = "purple",
fillOpacity = 0.25,
weight = 1,
color = "purple",
opacity = 0.7,
group = "Affected Areas (1/2 Mile from Hubs)"
) %>%
# Add points for transit hubs, colored by agency
addCircleMarkers(
data = all_hubs_sf,
radius = 3,
color = ~agency_pal(agency_name),
stroke = FALSE,
fillOpacity = 0.8,
group = "Transit Hubs by Agency",
popup = ~paste0(
"<strong>", stop_name, "</strong><br>",
"Agency: ", agency_name, "<br>",
"Type: ", type, "<br>",
"Stop ID: ", stop_id
)
) %>%
# Add layer controls
addLayersControl(
baseGroups = c("CartoDB Positron"),
overlayGroups = c("Affected Areas (1/2 Mile from Hubs)", "Transit Hubs by Agency"),
options = layersControlOptions(collapsed = FALSE)
) %>%
# Add legend for agencies
addLegend(
position = "bottomright",
colors = c("purple", "#009CDE", "#814C9E", "#E31837"),
labels = c("Transit-served Areas Affected by the People Over Parking Act",
"CTA", "Pace", "Metra"),
opacity = 0.7
) %>%
addFullscreenControl() %>%
addMeasure(
position = "bottomleft",
primaryLengthUnit = "miles",
primaryAreaUnit = "sqmiles",
activeColor = "#3D535D",
completedColor = "#7D4479"
) %>%
addMiniMap(
tiles = providers$CartoDB.Positron,
toggleDisplay = TRUE
)
# Display the map
map
# Calculate the total area affected in square miles
affected_area_sqft <- st_area(all_affected_areas)
affected_area_sqmi <- units::set_units(affected_area_sqft, "mi^2")
# Total area of the Illinois portion of the Chicago MSA (calculated using tigris package)
# This includes Cook, DuPage, Kane, Lake, McHenry, and Will counties
chicago_il_msa_area_sqmi <- 5323.82
# Calculate percentage of the Illinois MSA potentially affected
pct_affected <- as.numeric(affected_area_sqmi) / chicago_il_msa_area_sqmi * 100
# Count the number of transit hubs by agency
hub_counts <- table(all_hubs_sf$agency_name)
The analysis shows that approximately 319.37 square miles of the Illinois portion of the Chicago Metropolitan Statistical Area would be affected by the People Over Parking Act, which is roughly 6% of the Illinois MSA’s total land area (5,323.82 square miles).
This analysis strictly applies the frequency criterion from the legislation, requiring all bus routes at a hub to have service intervals of 15 minutes or less during peak periods. Our verification shows:
This difference in service frequency significantly impacts which areas qualify under the legislation, with a much higher concentration of qualifying hubs in areas served by CTA (primarily Chicago) compared to suburban areas served by Pace.
To accurately identify qualifying bus transit hubs, we implemented the following approach:
Minimum Frequency Calculation: For each route at each stop, we calculated the time difference between consecutive arrivals during peak hours (7-9 AM and 4-6 PM).
Headway Analysis: We converted these time differences to minutes and calculated the median headway (time between buses) for each route at each stop.
Frequency Criterion: Routes with a median headway of 15 minutes or less were identified as meeting the frequency criterion specified in the legislation.
Hub Qualification: Bus stops were classified as transit hubs only if they:
This methodology ensures that our analysis accurately reflects the definition of a “public transportation hub” as specified in the People Over Parking Act, which requires “a frequency of bus service interval of 15 minutes or less during the morning and afternoon peak commute periods.”
# Create a data frame for visualization
agency_stats <- route_headways[agency != "metra", .(
total_routes = .N,
qualifying_routes = sum(meets_frequency),
pct_qualifying = sum(meets_frequency) / .N * 100
), by = agency]
# Create a bar chart showing the percentage of routes meeting the frequency criterion by agency
ggplot(agency_stats, aes(x = agency, y = pct_qualifying, fill = agency)) +
geom_bar(stat = "identity") +
geom_text(aes(label = paste0(round(pct_qualifying, 1), "%")),
position = position_stack(vjust = 0.5), color = "white", size = 5) +
scale_fill_manual(values = c("cta" = "#009CDE", "pace" = "#814C9E")) +
labs(
title = "Percentage of Routes Meeting 15-Minute Frequency Criterion",
subtitle = "By Transit Agency",
x = "Agency",
y = "Percentage of Routes",
fill = "Agency"
) +
theme_minimal() +
theme(
plot.title = element_text(hjust = 0.5),
plot.subtitle = element_text(hjust = 0.5),
legend.position = "bottom"
)
The analysis includes transit hubs from three agencies:
This comprehensive approach ensures that all qualifying transit hubs in the Chicagoland region are included in the analysis, providing a more accurate representation of areas that would be affected by the People Over Parking Act.