Introduction

Understanding Parking Mandates

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.

Why Parking Mandates Are Costly:

  • Increased Housing Costs: Each parking space can cost $30,000-$75,000 to build, significantly raising the cost of housing development and ultimately housing prices.
  • Reduced Housing Supply: Land used for parking cannot be used for additional housing units, limiting the overall housing supply.
  • Environmental Impact: Excessive parking encourages car dependency, increasing traffic congestion and carbon emissions.
  • Inefficient Land Use: Parking lots create “dead spaces” in urban areas that could otherwise be used for housing, businesses, or public spaces.
  • Economic Burden: Many parking spaces sit empty much of the time, representing wasted resources and opportunity costs.

Why Parking Mandates Should Be Repealed:

  • Market-Based Solutions: Developers can better determine the appropriate amount of parking based on actual demand rather than arbitrary requirements.
  • Transit-Oriented Development: Eliminating parking mandates near transit encourages development that leverages existing public transportation infrastructure.
  • Affordability: Reducing or eliminating parking requirements can make housing more affordable and accessible.
  • Sustainability: Less parking promotes walking, cycling, and public transit use, reducing carbon emissions.
  • Vibrant Communities: Space previously dedicated to parking can be repurposed for housing, businesses, and community amenities.

Enter: The People Over Parking Act

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:

  • A rail transit station
  • A boat or ferry terminal served by either a bus connection stop or rail transit station
  • A bus connection stop of 2 or more major bus routes with a frequency of service interval of 15 minutes or less during peak commute periods

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)

Where would you be able to build a home without required parking?

# 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

How much of the Illinois portion of the Chicago MSA would be free from parking mandates?

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

Note on Bus Transit Hub Criteria

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:

  • CTA routes: 99.98% meet the 15-minute frequency criterion
  • Pace routes: Only 16.04% meet the 15-minute frequency criterion

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.

Methodology for Frequency Calculation

To accurately identify qualifying bus transit hubs, we implemented the following approach:

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

  2. Headway Analysis: We converted these time differences to minutes and calculated the median headway (time between buses) for each route at each stop.

  3. Frequency Criterion: Routes with a median headway of 15 minutes or less were identified as meeting the frequency criterion specified in the legislation.

  4. Hub Qualification: Bus stops were classified as transit hubs only if they:

    • Serve 2 or more bus routes
    • All routes at the stop meet the 15-minute frequency criterion

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

Transit Hub Distribution by Agency

The analysis includes transit hubs from three agencies:

  • CTA: 2316 transit hubs
  • Pace: 52 transit hubs
  • Metra: 240 transit hubs

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.