A Geospatial Framework for Child Environmental Health Vulnerability: Chicago, IL

Author

Purna Saud

Published

March 20, 2026

Abstract

This study establishes a reproducible geospatial screening framework to assess child environmental health vulnerability across Cook County, Chicago, Illinois. By synthesizing tract-level demographic indicators from the American Community Survey (ACS) with PM₂.₅ exposure data from OpenAQ, the analysis captures the interactions among biological (host), environmental, and social domains that influence child health.

A composite vulnerability index was developed to identify areas where pediatric sensitivity and environmental burden converge. Inverse Distance Weighting (IDW) was applied to address spatial gaps in monitoring coverage and to generate a continuous PM₂.₅ surface, enabling a fully spatially referenced assessment of exposure.

The results indicate pronounced spatial disparities across Chicago’s South and West Sides, where elevated air pollution coincides with higher poverty rates and greater concentrations of young children. The resulting maps and interactive web application offer a scalable, data-driven framework to support targeted public health interventions and promote equity-focused decision-making in child environmental health.

1. Study Objective

The primary objective of this study is to develop a multi-domain assessment of child environmental health vulnerability by identifying census tracts where pediatric sensitivity coincides with environmental exposure.

The analysis specifically evaluates three interrelated dimensions:

  • Biological (host) vulnerability, defined as the concentration of children under age five.

  • Social domain vulnerability, measured by the proportion of the population living below the poverty line.

  • Environmental exposure, represented by ambient PM₂.₅ concentration.

Integration of these domains into a composite vulnerability index enables quantification of spatial disparities and identification of priority areas where environmental and social risks converge, thereby supporting targeted interventions and place-based public health strategies.

2. Data Sources

This project integrates multiple public datasets:

a) American Community Survey (ACS), 2023

  • Total population (B01003_001)

  • Male under age 5 (B01001_003)

  • Female under age 5 (B01001_027)

  • Population below poverty (B17001_002)

b) OpenAQ API

  • PM2.5 monitoring locations and latest sensor values

c) Tiger/Line /Tigris boundary data

  • Chicago City Boundary

  • Census tract geometries for Cook County, Illinois

3. Methodology

3.1 Software Environment

Show Code
library(tidycensus)
library(tidyverse)
library(sf)
library(tmap)
library(janitor)
library(viridis)
library(httr)
library(jsonlite)
library(dplyr)
library(leaflet)
library(scales)
library(tigris)
library(leaflet)
library(leaflet.extras)
library(sf)
library(dplyr)
library(scales)
library(htmltools)
library(knitr)
library(kableExtra)
library(gt)
library (DT)

options(tigris_use_cache = TRUE)
tmap_mode("plot")

3.2 ACS Data Retrieval and Indicator Construction

American Community Survey (ACS) tract-level data were obtained for Cook County, Illinois. The variables total population, children under five, and poverty were utilized to calculate percentage-based indicators.

Show Code
CENSUS_API_KEY <- "312248c846fdb30816100611e375688ee60be39f"

census_api_key(CENSUS_API_KEY, install = FALSE, overwrite = TRUE)

state_name  <- "IL"
county_name <- "Cook"
year_use    <- 2023

acs_vars <- c(
  total_pop     = "B01003_001",
  male_under5   = "B01001_003",
  female_under5 = "B01001_027",
  below_poverty = "B17001_002"
)

acs_raw <- get_acs(
  geography = "tract",
  variables = acs_vars,
  state     = state_name,
  county    = county_name,
  year      = year_use,
  geometry  = TRUE,
  output    = "wide"
)

acs_data <- acs_raw %>%
  transmute(
    geoid         = GEOID,
    name          = NAME,
    total_pop     = total_popE,
    male_under5   = male_under5E,
    female_under5 = female_under5E,
    below_poverty = below_povertyE,
    geometry      = geometry
  ) %>%
  mutate(
    children_under5     = male_under5 + female_under5,
    pct_children_under5 = if_else(
      total_pop > 0,
      100 * children_under5 / total_pop,
      NA_real_
    ),
    pct_below_poverty   = if_else(
      total_pop > 0,
      100 * below_poverty / total_pop,
      NA_real_
    )
  ) %>%
  st_transform(crs = 4326)

3.3 Chicago Administrative Boundary

The analysis was limited to census tracts located within the boundaries of Chicago.

Show Code
chicago_boundary <- places(state = "IL", cb = TRUE) %>%
  filter(NAME == "Chicago") %>%
  st_transform(crs = 4326)

acs_chicago <- acs_data %>%
  st_filter(chicago_boundary, .predicate = st_within)

3.4 PM2.5 Monitoring Locations from OpenAQ

PM2.5 monitoring locations were obtained via the OpenAQ API. These locations were restricted to the Chicago city boundary, and only valid PM2.5 observations were included in the analysis.

Show Code
#  OpenAQ API key
OPENAQ_KEY <- "d9e539fa59e9ad36ba0c97863b47b6f9e5ee0ce7abe3507fed8d796e05ee59ad"

loc_res <- GET(
  "https://api.openaq.org/v3/locations",
  query = list(
    bbox          = "-88.0,41.6,-87.5,42.1",
    parameters_id = 2,
    limit         = 100
  ),
  add_headers("X-API-Key" = OPENAQ_KEY)
)

locations <- fromJSON(
  content(loc_res, "text", encoding = "UTF-8"),
  flatten = TRUE
)$results

locations_sf <- locations %>%
  filter(
    !is.na(coordinates.latitude),
    !is.na(coordinates.longitude)
  ) %>%
  st_as_sf(
    coords = c("coordinates.longitude", "coordinates.latitude"),
    crs    = 4326
  )

chicago_monitors <- locations_sf %>%
  st_filter(chicago_boundary, .predicate = st_within)

3.5 PM2.5 Sensor Retrieval and Cleaning

PM2.5 sensor data were collected from all selected monitoring sites in Chicago. Data cleaning procedures were applied to retain only plausible PM2.5 measurements.

Show Code
get_sensors <- function(loc_id) {
  res <- GET(
    paste0("https://api.openaq.org/v3/locations/", loc_id, "/sensors"),
    add_headers("X-API-Key" = OPENAQ_KEY)
  )
  if (status_code(res) != 200) return(NULL)

  d <- fromJSON(
    content(res, "text", encoding = "UTF-8"),
    flatten = TRUE
  )$results

  if (is.null(d) || length(d) == 0) return(NULL)

  d <- as.data.frame(d)
  d$location_id <- loc_id
  d
}

sensor_list <- lapply(chicago_monitors$id, function(id) {
  Sys.sleep(0.2)
  get_sensors(id)
})

sensors_df <- bind_rows(sensor_list)

pm25_df <- sensors_df %>%
  filter(
    parameter.name == "pm25",
    !is.na(latest.value),
    latest.value > 0,
    latest.value < 200
  ) %>%
  select(
    location_id,
    pm25_mean = latest.value,
    pm25_lat  = latest.coordinates.latitude,
    pm25_lon  = latest.coordinates.longitude
  ) %>%
  group_by(location_id) %>%
  summarise(
    pm25_mean = mean(pm25_mean, na.rm = TRUE),
    pm25_lat  = first(pm25_lat),
    pm25_lon  = first(pm25_lon),
    .groups   = "drop"
  ) %>%
  filter(!is.na(pm25_lat), !is.na(pm25_lon)) %>%
  left_join(
    chicago_monitors %>%
      st_drop_geometry() %>%
      select(id, name),
    by = c("location_id" = "id")
  )

aq_points <- pm25_df %>%
  st_as_sf(coords = c("pm25_lon", "pm25_lat"), crs = 4326)

3.6 PM2.5 Monitor Network in Crook County Chicago

A diagnostic map was generated to assess the spatial distribution of monitor locations and PM2.5 measurements in Chicago prior to interpolation.

Show Code
pal_diag <- colorNumeric(
  palette = c("#00e400", "#ffff00", "#ff7e00", "#ff0000"),
  domain  = pm25_df$pm25_mean
)

diagnostic_map <- leaflet() %>%
  addProviderTiles(providers$CartoDB.Positron) %>%
  addPolygons(
    data        = chicago_boundary,
    fillColor   = "transparent",
    color       = "#1a4f72",
    weight      = 2.5,
    dashArray   = "5,5",
    group       = "Chicago Boundary"
  ) %>%
  addCircleMarkers(
    data        = pm25_df,
    lng         = ~pm25_lon,
    lat         = ~pm25_lat,
    radius      = ~rescale(pm25_mean, to = c(6, 18)),
    fillColor   = ~pal_diag(pm25_mean),
    fillOpacity = 0.85,
    color       = "white",
    weight      = 1.5,
    group       = "PM2.5 Monitors",
    popup = ~paste0(
      "<div style='font-family:sans-serif; min-width:180px'>",
      "<b style='color:#1a4f72'>", name, "</b><hr>",
      "📍 ID: <b>", location_id, "</b><br>",
      "🌫️ PM2.5: <b>", round(pm25_mean, 1), " µg/m³</b><br>",
      ifelse(pm25_mean <= 12, "🟢 Good",
             ifelse(pm25_mean <= 35, "🟡 Moderate",
                    ifelse(pm25_mean <= 55, "🟠 Sensitive Groups",
                           "🔴 Unhealthy"))),
      "</div>"
    ),
    label = ~paste0(name, ": ", round(pm25_mean, 1), " µg/m³")
  ) %>%
  addLegend(
    position = "bottomright",
    pal      = pal_diag,
    values   = pm25_df$pm25_mean,
    title    = "PM2.5 (µg/m³)",
    opacity  = 0.9
  ) %>%
  addControl(
    html = "
      <div style='background:white;padding:10px;border-radius:6px;
                  font-size:12px;line-height:1.8'>
        <b>EPA Air Quality Index</b><br>
        🟢 Good: 0–12 µg/m³<br>
        🟡 Moderate: 12–35 µg/m³<br>
        🟠 Sensitive: 35–55 µg/m³<br>
        🔴 Unhealthy: 55+ µg/m³
      </div>",
    position = "topleft"
  ) %>%
  addLayersControl(
    overlayGroups = c("Chicago Boundary", "PM2.5 Monitors"),
    options       = layersControlOptions(collapsed = FALSE)
  ) %>%
  addScaleBar(position = "bottomleft") %>%
  setView(lng = -87.70, lat = 41.85, zoom = 11)

diagnostic_map

3.7 Spatial Join and IDW Interpolation

PM2.5 monitor values were joined to census tracts. Because many tracts lacked a monitor, inverse distance weighting (IDW) was used to estimate PM2.5 concentrations for unmonitored tracts.

Show Code
aq_in_tracts <- st_join(aq_points, acs_chicago, join = st_within)

tract_pm25 <- aq_in_tracts %>%
  st_drop_geometry() %>%
  group_by(geoid) %>%
  summarise(avg_pm25 = mean(pm25_mean, na.rm = TRUE), .groups = "drop")

acs_chicago <- acs_chicago %>%
  left_join(tract_pm25, by = "geoid")

tract_centroids <- acs_chicago %>%
  st_centroid() %>%
  select(geoid)

idw_pm25 <- function(tract_geom, monitors, power = 2) {
  dists   <- as.numeric(st_distance(tract_geom, monitors))
  dists[dists == 0] <- 1
  weights <- 1 / (dists ^ power)
  weighted.mean(monitors$pm25_mean, weights, na.rm = TRUE)
}

interpolated_values <- sapply(
  seq_len(nrow(tract_centroids)),
  function(i) idw_pm25(tract_centroids[i, ], aq_points)
)

acs_chicago$pm25_interpolated <- interpolated_values

acs_chicago <- acs_chicago %>%
  mutate(
    pm25_final = case_when(
      !is.na(avg_pm25)          ~ avg_pm25,
      !is.na(pm25_interpolated) ~ pm25_interpolated,
      TRUE                      ~ NA_real_
    ),
    pm25_source = case_when(
      !is.na(avg_pm25)          ~ "Monitor",
      !is.na(pm25_interpolated) ~ "Interpolated (IDW)",
      TRUE                      ~ "No Data"
    )
  )

3.8 Composite Environmental Vulnerability Index

A final tract-level environmental vulnerability index was created by averaging standardized values for:

  • percentage of children under age five,

  • percentage below poverty, and

  • PM2.5 concentration.

Show Code
acs_chicago <- acs_chicago %>%
  mutate(
    z_children = as.numeric(scale(pct_children_under5)),
    z_poverty  = as.numeric(scale(pct_below_poverty)),
    z_pm25     = as.numeric(scale(pm25_final)),
    env_vulnerability = (z_children + z_poverty + z_pm25) / 3,
    risk_tier = cut(
      env_vulnerability,
      breaks         = quantile(
        env_vulnerability,
        probs = c(0, .25, .5, .75, 1),
        na.rm = TRUE
      ),
      labels         = c("Low", "Moderate", "High", "Very High"),
      include.lowest = TRUE
    )
  )

acs_chicago %>%
  st_drop_geometry() %>%
  select(
    geoid, name, total_pop, children_under5,
    pct_children_under5, below_poverty,
    pct_below_poverty, pm25_final,
    env_vulnerability, risk_tier
  ) %>%
  mutate(
    pct_children_under5 = round(pct_children_under5, 1),
    pct_below_poverty   = round(pct_below_poverty,   1),
    pm25_final          = round(pm25_final,           1),
    env_vulnerability   = round(env_vulnerability,    2)
  ) %>%
  datatable(
    colnames = c(
      "GEOID", "Census Tract", "Total Pop",
      "Children <5", "% Children <5",
      "Below Poverty", "% Below Poverty",
      "PM2.5 (µg/m³)", "Vulnerability Index", "Risk Tier"
    ),
    rownames  = FALSE,
    filter    = "top",          
    extensions = "Scroller",    
    options   = list(
      pageLength  = 5,
      lengthMenu  = c(5, 10, 25, 50),
      scrollX     = TRUE,
      scrollY     = "280px",    
      scroller    = TRUE,
      deferRender = TRUE,
      dom         = "Bfrtip",   
      buttons     = list(
        list(extend = "csv",   text = "⬇ CSV"),
        list(extend = "excel", text = "⬇ Excel")
      ),
      columnDefs  = list(
        list(width = "180px", targets = 1),   
        list(width = "80px",  targets = c(2, 3, 4, 5, 6, 7, 8)),
        list(className = "dt-center", targets = c(2, 3, 4, 5, 6, 7, 8, 9))
      ),
      initComplete = JS("
        function(settings, json) {
          $(this.api().table().header()).css({
            'background-color': '#1a4f72',
            'color': 'white',
            'font-weight': 'bold',
            'font-size': '13px'
          });
          $(this.api().table().node()).css({
            'font-size': '13px',
            'font-family': 'Inter, sans-serif'
          });
        }
      ")
    ),
    callback = JS("
      table.on('draw', function() {
        table.rows().every(function(i) {
          var tier = this.data()[9];
          var colors = {
            'Very High': '#f8d7da',
            'High':      '#ffeeba',
            'Moderate':  '#d1ecf1',
            'Low':       '#d4edda'
          };
          if (colors[tier]) {
            $(this.node()).css('background-color', colors[tier]);
          }
        });
      });
    ")
  )

4. Results

4.1 Children Under Age Five

Show Code
tmap_options(component.autoscale = TRUE)

map1 <- tm_shape(acs_chicago) +
  tm_polygons(
    "pct_children_under5",
    palette = "YlGnBu",
    style = "quantile",
    title = "% Children Under 5"
  ) +
  tm_borders(col = "grey60", lwd = 0.15) +
  tm_layout(
    title = "Children Under 5 — Chicago Census Tracts",
    title.size = 1.4,
    title.fontface = "bold",
    title.position = c("center", "top"),
    frame = TRUE,
    bg.color = "white",
    legend.outside = TRUE,
    legend.outside.position = "right",
    inner.margins = c(0.09, 0.02, 0.14, 0.02)
  ) +
  tm_scale_bar(
    position = c(0.12, 0.085),
    text.size = 0.6,
    lwd = 0.6
  ) +
  tm_compass(
    position = c(0.88, 0.88),
    type = "arrow",
    size = 1.8
  )

map1

4.2 Poverty Rate

Show Code
map2 <- tm_shape(acs_chicago) +
  tm_polygons(
    "pct_below_poverty",
    palette = "BuGn",
    style = "quantile",
    title = "% Below Poverty"
  ) +
  tm_borders(col = "grey60", lwd = 0.2) +
  tm_layout(
    title = "Poverty Rate - Chicago Census Tracts",
    title.size = 2,
    title.fontface = "bold",
    title.position = c("center", "top"),
    frame = TRUE,
    bg.color = "white",
    legend.outside = TRUE,
    legend.outside.position = "right",
    inner.margins = c(0.09, 0.02, 0.14, 0.02)  
  ) +
  tm_scale_bar(
    position = c(0.12, 0.08),   
    text.size = 0.6,
    lwd = 0.6
  ) +
  tm_compass(
    position = c(0.88, 0.88),  
    type = "arrow",
    size = 1.8
  )

map2

4.3 PM2.5 Source Coverage

Show Code
map3 <- tm_shape(acs_chicago) +
  tm_polygons(
    col = "#f4a582",
    border.col = "grey60",
    lwd = 0.2
  ) +
  tm_shape(aq_points) +
  tm_symbols(
    shape = 21,
    size = 0.35,
    col = "red",
    fill = "red",
    border.col = "red"
  ) +
  tm_add_legend(
    type = "fill",
    labels = c("Interpolated (IDW)", "Monitor Stations"),
    col = c("#f4a582", "red"),
    title = "PM2.5 Source"
  ) +
  tm_layout(
    title = "PM2.5 Data Coverage — Chicago Census Tracts",
    title.size = 2,
    title.fontface = "bold",
    title.position = c("center", "top"),
    frame = TRUE,
    bg.color = "white",
    legend.outside = TRUE,
    legend.outside.position = "right",
    inner.margins = c(0.09, 0.02, 0.14, 0.02)
  ) +
  tm_scale_bar(
    position = c(0.12, 0.08),
    text.size = 0.6,
    lwd = 0.6
  ) +
  tm_compass(
    position = c(0.88, 0.88),
    type = "arrow",
    size = 1.8
  )

map3

4.4 PM2.5 Interpolated Surface

Show Code
map4 <- tm_shape(acs_chicago) +
  tm_polygons(
    "pm25_final",
    palette = c("#00e400", "#ffff00", "#ff7e00", "#ff0000", "#8f3f97"),
    title = "PM2.5 (µg/m³)",
    style = "quantile",
    colorNA = "white",   # hide NA but keep extent
    textNA = ""          # no "Missing" label
  ) +
  tm_layout(
    title = "PM2.5 Concentration Surface (IDW) — Chicago Census Tracts",
    title.size = 1.4,
    title.fontface = "bold",
    title.position = c("center", "top"),
    frame = TRUE,
    bg.color = "white",
    legend.outside = TRUE,
    legend.outside.position = "right",
    inner.margins = c(0.02, 0.01, 0.08, 0.01)
  ) +
  tm_scale_bar(
    position = c(0.12, 0.08),
    text.size = 0.6,
    lwd = 0.6
  ) +
  tm_compass(
    position = c(0.88, 0.88),
    type = "arrow",
    size = 1.4
  )

map4

4.5 Final Environmental Vulnerability Index

Show Code
map5 <- tm_shape(acs_chicago) +
  tm_polygons(
    "env_vulnerability",
    palette  = "-RdYlBu",
    title    = "Vulnerability Index",
    midpoint = 0,
    style    = "quantile",
    colorNA  = "white",   
    textNA   = ""         
  ) +
  tm_layout(
    title = "Child Environmental Vulnerability — Chicago Census Tracts",
    title.size = 1.4,
    title.fontface = "bold",
    title.position = c("center", "top"),
    frame = TRUE,
    bg.color = "white",
    legend.outside = TRUE,
    legend.outside.position = "right",
    inner.margins = c(0.02, 0.01, 0.08, 0.01)
  ) +
  tm_scale_bar(
    position = c(0.12, 0.08),
    text.size = 0.6,
    lwd = 0.6
  ) +
  tm_compass(
    position = c(0.88, 0.88),
    type = "arrow",
    size = 1.6
  )

map5

5. Summary Statistics

Show Code
summary_table <- acs_chicago %>%
  st_drop_geometry() %>%
  summarise(
    `Total Tracts`               = n(),
    `Avg % Children Under 5`     = round(mean(pct_children_under5, na.rm=TRUE), 1),
    `Avg % Below Poverty`        = round(mean(pct_below_poverty,   na.rm=TRUE), 1),
    `Avg PM2.5 (µg/m³)`          = round(mean(pm25_final,         na.rm=TRUE), 1),
    `Very High Risk Tracts`      = sum(risk_tier == "Very High",   na.rm=TRUE),
    `High Risk Tracts`           = sum(risk_tier == "High",        na.rm=TRUE),
    `Tracts with Real Monitor`   = sum(pm25_source == "Monitor",   na.rm=TRUE),
    `Tracts IDW Interpolated`    = sum(pm25_source == "Interpolated (IDW)", na.rm=TRUE)
  ) %>%
  pivot_longer(
    cols      = everything(),
    names_to  = "Indicator",
    values_to = "Value"
  ) %>%
  mutate(
    Category = c(
      "Study Area",
      "Demographics",
      "Demographics",
      "Air Quality",
      "Vulnerability",
      "Vulnerability",
      "Air Quality",
      "Air Quality"
    ),
    Unit = c(
      "tracts",
      "%",
      "%",
      "µg/m³",
      "tracts",
      "tracts",
      "tracts",
      "tracts"
    )
  )

# Render
summary_table %>%
  gt() %>%
  cols_label(
    Category  = "Category",
    Indicator = "Indicator",
    Value     = "Value",
    Unit      = "Unit"
  ) %>%
  tab_header(
    title    = "Summary Statistics — Child Environmental Health Indicators",
    subtitle = "Chicago Census Tracts · ACS 2023 & OpenAQ"
  ) %>%
  tab_style(
    style     = list(
      cell_fill(color = "#1a4f72"),
      cell_text(color = "white", weight = "bold")
    ),
    locations = cells_title(groups = "title")
  ) %>%
  tab_style(
    style     = list(
      cell_fill(color = "#2e86ab"),
      cell_text(color = "white", weight = "bold")
    ),
    locations = cells_column_labels()
  ) %>%
  tab_row_group(label = " Vulnerability",  rows = Category == "Vulnerability") %>%
  tab_row_group(label = " Air Quality",    rows = Category == "Air Quality") %>%
  tab_row_group(label = "Demographics",   rows = Category == "Demographics") %>%
  tab_row_group(label = " Study Area",     rows = Category == "Study Area") %>%
  cols_hide(columns = Category) %>%
  tab_style(
    style     = cell_fill(color = "#f8d7da"),
    locations = cells_body(rows = Category == "Vulnerability")
  ) %>%
  tab_style(
    style     = cell_fill(color = "#eaf4fb"),
    locations = cells_body(rows = Category == "Air Quality")
  ) %>%
  tab_style(
    style     = cell_fill(color = "#f0f8f0"),
    locations = cells_body(rows = Category == "Demographics")
  ) %>%
  cols_align(align = "center", columns = c(Value, Unit)) %>%
  cols_align(align = "left",   columns = Indicator) %>%
  tab_footnote(
    footnote  = "IDW = Inverse Distance Weighting interpolation for unmonitored tracts.",
    locations = cells_column_labels(columns = Indicator)
  ) %>%
  opt_table_font(font = "Inter") %>%
  opt_row_striping(row_striping = FALSE)
Summary Statistics — Child Environmental Health Indicators
Chicago Census Tracts · ACS 2023 & OpenAQ
Indicator1 Value Unit
Study Area
Total Tracts 789.0 tracts
Demographics
Avg % Children Under 5 5.6 %
Avg % Below Poverty 18.0 %
Air Quality
Avg PM2.5 (µg/m³) 23.7 µg/m³
Tracts with Real Monitor 60.0 tracts
Tracts IDW Interpolated 729.0 tracts
Vulnerability
Very High Risk Tracts 197.0 tracts
High Risk Tracts 196.0 tracts
1 IDW = Inverse Distance Weighting interpolation for unmonitored tracts.

6. Interactive Web Map of Child Environmental Health Vulnerability in Chicago

Show Code
# WGS84 for leaflet
acs_web       <- st_transform(acs_chicago, 4326)
aq_points_web <- st_transform(aq_points, 4326)
boundary_web  <- st_transform(chicago_boundary, 4326)

# Safe label field for monitor stations
aq_points_web$label_name <- if ("name" %in% names(aq_points_web)) {
  aq_points_web$name
} else {
  rep("Monitor Station", nrow(aq_points_web))
}

# Color palettes 
pal_children <- colorQuantile(
  palette  = "YlGnBu",
  domain   = acs_web$pct_children_under5,
  n        = 5,
  na.color = "transparent"
)

pal_poverty <- colorQuantile(
  palette  = "BuGn",
  domain   = acs_web$pct_below_poverty,
  n        = 5,
  na.color = "transparent"
)

pal_pm25_surface <- colorQuantile(
  palette  = c("#00e400", "#ffff00", "#ff7e00", "#ff0000", "#8f3f97"),
  domain   = acs_web$pm25_final,
  n        = 5,
  na.color = "white"
)

pal_vulnerability <- colorQuantile(
  palette  = rev(RColorBrewer::brewer.pal(11, "RdYlBu")),
  domain   = acs_web$env_vulnerability,
  n        = 5,
  na.color = "white"
)

pal_monitor <- colorNumeric(
  palette  = c("#00e400", "#ffff00", "#ff7e00", "#ff0000", "#8f3f97"),
  domain   = aq_points_web$pm25_mean,
  na.color = "transparent"
)

# Popups 
popup_children <- paste0(
  "<div style='font-family:sans-serif; min-width:220px;'>",
  "<b>Tract:</b> ", htmlEscape(acs_web$name), "<br>",
  "<b>GEOID:</b> ", acs_web$geoid, "<br>",
  "<b>Total Population:</b> ", scales::comma(acs_web$total_pop), "<br>",
  "<b>Children Under 5:</b> ", scales::comma(acs_web$children_under5), "<br>",
  "<b>% Children Under 5:</b> ", round(acs_web$pct_children_under5, 2), "%",
  "</div>"
)

popup_poverty <- paste0(
  "<div style='font-family:sans-serif; min-width:220px;'>",
  "<b>Tract:</b> ", htmlEscape(acs_web$name), "<br>",
  "<b>GEOID:</b> ", acs_web$geoid, "<br>",
  "<b>Population Below Poverty:</b> ", scales::comma(acs_web$below_poverty), "<br>",
  "<b>% Below Poverty:</b> ", round(acs_web$pct_below_poverty, 2), "%",
  "</div>"
)

popup_pm25_source <- paste0(
  "<div style='font-family:sans-serif; min-width:220px;'>",
  "<b>Tract:</b> ", htmlEscape(acs_web$name), "<br>",
  "<b>GEOID:</b> ", acs_web$geoid, "<br>",
  "<b>PM2.5 Source:</b> ", acs_web$pm25_source, "<br>",
  "<b>Final PM2.5:</b> ", round(acs_web$pm25_final, 2), " µg/m³",
  "</div>"
)

popup_pm25_surface <- paste0(
  "<div style='font-family:sans-serif; min-width:220px;'>",
  "<b>Tract:</b> ", htmlEscape(acs_web$name), "<br>",
  "<b>GEOID:</b> ", acs_web$geoid, "<br>",
  "<b>PM2.5 Concentration:</b> ", round(acs_web$pm25_final, 2), " µg/m³",
  "</div>"
)

popup_vulnerability <- paste0(
  "<div style='font-family:sans-serif; min-width:220px;'>",
  "<b>Tract:</b> ", htmlEscape(acs_web$name), "<br>",
  "<b>GEOID:</b> ", acs_web$geoid, "<br>",
  "<b>% Children Under 5:</b> ", round(acs_web$pct_children_under5, 2), "%<br>",
  "<b>% Below Poverty:</b> ", round(acs_web$pct_below_poverty, 2), "%<br>",
  "<b>PM2.5:</b> ", round(acs_web$pm25_final, 2), " µg/m³<br>",
  "<b>Vulnerability Index:</b> ", round(acs_web$env_vulnerability, 2),
  "</div>"
)

popup_monitors <- paste0(
  "<div style='font-family:sans-serif; min-width:200px;'>",
  "<b style='color:#1a4f72; font-size:14px;'>",
  htmlEscape(aq_points_web$label_name), "</b><br>",
  "<hr style='margin:5px 0;'>",
  if ("location_id" %in% names(aq_points_web)) {
    paste0("📍 <b>ID:</b> ", aq_points_web$location_id, "<br>")
  } else "",
  if ("pm25_mean" %in% names(aq_points_web)) {
    paste0(
      "🌫️ <b>PM2.5:</b> <span style='color:",
      pal_monitor(aq_points_web$pm25_mean),
      "; font-weight:bold;'>",
      round(aq_points_web$pm25_mean, 2), " µg/m³</span><br>"
    )
  } else "",
  ifelse(
    aq_points_web$pm25_mean <= 12,  "🟢 Good",
    ifelse(
      aq_points_web$pm25_mean <= 35, "🟡 Moderate",
      ifelse(
        aq_points_web$pm25_mean <= 55, "🟠 Unhealthy for Sensitive Groups",
        "🔴 Unhealthy"
      )
    )
  ),
  "</div>"
)

# Final interactive map 
leaflet(options = leafletOptions(zoomControl = TRUE, minZoom = 9)) %>%

  # Base maps
  addProviderTiles(providers$CartoDB.Positron,
                   group = "CartoDB Positron") %>%
  addProviderTiles(providers$OpenStreetMap.Mapnik,
                   group = "OpenStreetMap") %>%
  addProviderTiles(providers$Esri.WorldImagery,
                   group = "Esri World Imagery") %>%

  # Chicago boundary
  addPolylines(
    data      = boundary_web,
    color     = "#1a4f72",
    weight    = 2,
    dashArray = "5,5",
    fill      = FALSE,
    group     = "Chicago Boundary",
    label     = ~NAME
  ) %>%

  # Children Under 5
  addPolygons(
    data             = acs_web,
    fillColor        = ~pal_children(pct_children_under5),
    fillOpacity      = 0.8,
    color            = "grey60",
    weight           = 0.5,
    smoothFactor     = 0.2,
    popup            = popup_children,
    highlightOptions = highlightOptions(
      weight = 2, color = "#333333", bringToFront = TRUE),
    group = "Children Under 5"
  ) %>%

  # Poverty Rate
  addPolygons(
    data             = acs_web,
    fillColor        = ~pal_poverty(pct_below_poverty),
    fillOpacity      = 0.8,
    color            = "grey60",
    weight           = 0.5,
    smoothFactor     = 0.2,
    popup            = popup_poverty,
    highlightOptions = highlightOptions(
      weight = 2, color = "#333333", bringToFront = TRUE),
    group = "Poverty Rate"
  ) %>%

  # PM2.5 Source Coverage
  addPolygons(
    data             = acs_web,
    fillColor        = "#f4a582",
    fillOpacity      = 0.65,
    color            = "grey60",
    weight           = 0.5,
    smoothFactor     = 0.2,
    popup            = popup_pm25_source,
    highlightOptions = highlightOptions(
      weight = 2, color = "#333333", bringToFront = TRUE),
    group = "PM2.5 Source Coverage"
  ) %>%

  # PM2.5 Surface (IDW)
  addPolygons(
    data             = acs_web,
    fillColor        = ~pal_pm25_surface(pm25_final),
    fillOpacity      = 0.85,
    color            = "grey60",
    weight           = 0.5,
    smoothFactor     = 0.2,
    popup            = popup_pm25_surface,
    highlightOptions = highlightOptions(
      weight = 2, color = "#333333", bringToFront = TRUE),
    group = "PM2.5 Surface (IDW)"
  ) %>%

  # Environmental Vulnerability
  addPolygons(
    data             = acs_web,
    fillColor        = ~pal_vulnerability(env_vulnerability),
    fillOpacity      = 0.85,
    color            = "grey60",
    weight           = 0.5,
    smoothFactor     = 0.2,
    popup            = popup_vulnerability,
    highlightOptions = highlightOptions(
      weight = 2, color = "#333333", bringToFront = TRUE),
    group = "Environmental Vulnerability"
  ) %>%

  # Monitor Stations LAST = always on top
  addCircleMarkers(
    data        = aq_points_web,
    radius      = ~scales::rescale(pm25_mean, to = c(6, 18)),
    fillColor   = ~pal_monitor(pm25_mean),
    fillOpacity = 0.9,
    color       = "white",
    weight      = 1.5,
    popup       = popup_monitors,
    label       = ~paste0(label_name, ": ", round(pm25_mean, 1), " µg/m³"),
    group       = "Monitor Stations"
  ) %>%

  # PM2.5 legend — bottomright
  addLegend(
    position = "bottomright",
    pal      = pal_monitor,
    values   = aq_points_web$pm25_mean,
    title    = "PM2.5 (µg/m³)",
    opacity  = 0.9
  ) %>%
  
  #  EPA AQI box — bottomleft
  addControl(
    html = "
      <div style='background:white; padding:10px; border-radius:6px;
                  box-shadow:0 1px 4px rgba(0,0,0,0.2);
                  font-family:sans-serif; font-size:12px; line-height:1.8'>
        <b>EPA Air Quality Index</b><br>
        🟢 Good: 0–12 µg/m³<br>
        🟡 Moderate: 12–35 µg/m³<br>
        🟠 Sensitive Groups: 35–55 µg/m³<br>
        🔴 Unhealthy: 55+ µg/m³
      </div>",
    position = "bottomleft"
  ) %>%

  # Layer control
  addLayersControl(
    baseGroups = c(
      "CartoDB Positron",
      "OpenStreetMap",
      "Esri World Imagery"
    ),
    overlayGroups = c(
      "Chicago Boundary",
      "Monitor Stations",
      "Children Under 5",
      "Poverty Rate",
      "PM2.5 Source Coverage",
      "PM2.5 Surface (IDW)",
      "Environmental Vulnerability"
      
    ),
    options = layersControlOptions(collapsed = FALSE)
  ) %>%

  # Show PM2.5 Surface and Monitors by default
  # Hide everything else
  hideGroup(c(
    "Children Under 5",
    "Poverty Rate",
    "PM2.5 Source Coverage",
    "Environmental Vulnerability"
  )) %>%

  # Scale bar topleft 
  addScaleBar(position = "topleft") %>%
  addFullscreenControl() %>%
  addResetMapButton() %>%

  setView(lng = -87.70, lat = 41.85, zoom = 10.8)

7. Conclusion: Advancing Child Environmental Health through Spatial Intelligence

This study presents a reproducible, spatially integrated framework for identifying child environmental health vulnerability at the census tract level in Cook County, Chicago. It supports CEHI’s mission to foster environments where all people can prosper, with a focus on addressing children’s unique vulnerabilities through spatially informed research and social justice–driven analysis.

Key Insights

The results show a clear spatial convergence of risk in Chicago’s South and West Sides, where higher poverty rates and greater concentrations of children under five coincide with elevated PM₂.₅ exposure. This pattern underscores how environmental and social stressors compound, demonstrating the interaction of child vulnerability, air pollution, and poverty within specific geographic areas.

Integrating ACS demographic indicators with OpenAQ monitoring data creates a fully spatially referenced data architecture, enabling joint evaluation of multiple determinants of child health. Using IDW interpolation extends exposure estimates beyond monitored locations, supporting continuous and equitable spatial assessment.

Implications for CEHI's Research and Practice

This framework contributes to CEHI’s interdisciplinary approach by:

  • Identifying priority zones for targeted intervention and field-based sampling

  • Quantifying inequities through a tract-level vulnerability index grounded in multiple risk dimensions

  • Enhancing accessibility via an interactive web map that translates complex spatial relationships into actionable insights for stakeholders

Final Perspective

Integrating environmental exposure, demographic sensitivity, and spatial inequality within a unified GIS framework, this work advances a data-driven approach to pediatric environmental health. It offers a scalable, transparent foundation for equity-focused decision-making, ensuring interventions target communities where children face the highest combined risk.