Mapping each step for one Birmingham site

library(catchmentACS)

catchmentACS has functions that map, for one site, how cacs_run() turns American Community Survey (ACS) estimates for census tracts into estimates for the site’s drive-time area. This article draws their four maps for a site in Birmingham, Alabama:

  1. cacs_plot_site_isochrone(): the site and its drive-time area (isochrone), the area within a given driving time of the site.
  2. cacs_plot_site_intersection(): the census tracts that overlap the drive-time area, shaded by the share of each tract’s area that lies inside it.
  3. cacs_plot_site_weighted(): the estimates of one ACS variable for those tracts.
  4. cacs_plot_site_rates(): the five rates for the drive-time area, with their margins of error.

cacs_plot_site_pipeline() builds all four maps in one call. The maps are interactive and need the leaflet package. vignette("getting-started", package = "catchmentACS") shows how to run cacs_run() and read its result.

The maps are drawn from visual_walkthrough_fixture.rds, a file installed with the package that holds data for a real place. The site, AL_BHM_01, is a point near the Vulcan statue on Red Mountain, south of downtown Birmingham. Its 10-minute drive-time area was computed on May 27, 2026, by the Open Source Routing Machine (OSRM) with the default settings of cacs_isochrone(). The requests went to the public OSRM demo server, and the osrm package drew the area from travel times to a grid of 30 by 30 points around the site (res = 30). The server routes on OpenStreetMap road data, © OpenStreetMap contributors, available under the Open Database License (https://www.openstreetmap.org/copyright); the date of the data it used is not recorded. An area computed later, or with another res, can differ, and so can the estimates computed from it.

The file also holds the census tracts within 2 km of the drive-time area: their boundaries and their ACS 5-year estimates for 2019–2023, with margins of error, for the variables in cacs_acs_default_vars, as downloaded by cacs_acs_prefetch(). It also holds the result of cacs_run() for the site, computed from these inputs. The map functions only draw such results; they do not contact a routing service or the Census Bureau.

walkthrough <- readRDS(system.file("extdata", "visual_walkthrough_fixture.rds",
                                   package = "catchmentACS"))
names(walkthrough)
#> [1] "iso_sf"      "tract_sf"    "acs_sf"      "run_result"  "sites_df"   
#> [6] "anchor_site"
one_site <- walkthrough$anchor_site
one_site
#> [1] "AL_BHM_01"

iso_sf holds the drive-time area and sites_df the site’s point. tract_sf and acs_sf are the same table of tract boundaries and estimates, with one row for each tract and variable; the map functions take the boundaries and the estimates through separate arguments, which can be the same object. run_result is the result of cacs_run(), and anchor_site gives the site_id used below as one_site. For your own sites, the maps take your site points and the results of cacs_isochrone(), cacs_acs_prefetch(), and cacs_run() in place of these elements.

Stage 1: the drive-time area

cacs_plot_site_isochrone() draws the drive-time areas of a site, in one color for each drive time, and marks the site with a red point. Clicking the point shows the site’s site_id, name, and coordinates.

cacs_plot_site_isochrone(
  site_id  = one_site,
  iso_sf   = walkthrough$iso_sf,
  sites_df = walkthrough$sites_df
)

The example has one drive-time area, for 10 minutes. The area is built from travel times on the road network, and its edge lies much farther from the site in some directions than in others.

Stage 2: the tracts and their weights

cacs_plot_site_intersection() overlays the drive-time area on the census tracts. It shades the part of each tract that lies inside the area by the tract’s coverage weight, labeled area_wt on the map,

area_wtj=area(drive-time area∩tractj)area(tractj), \text{area\_wt}_j \;=\; \frac{\text{area}\!\left(\text{drive-time area} \cap \text{tract}_j\right)} {\text{area}\!\left(\text{tract}_j\right)},

the share of the tract’s area that lies inside the drive-time area. The weight is 0 when the two do not overlap and 1 when the whole tract is inside. The function measures both areas on the equal-area projection EPSG:5070, as cacs_intersect_weight() does, and a tract’s area includes any water inside its boundary. The colors run from 0 to 1 on every map that this function draws, so a weight has the same color on all of them.

cacs_plot_site_intersection(
  site_id  = one_site,
  iso_sf   = walkthrough$iso_sf,
  tract_sf = walkthrough$tract_sf,
  sites_df = walkthrough$sites_df
)

The tracts that lie wholly inside the drive-time area have a weight of 1 and are yellow. Along the edge of the area, the tracts cut by it have smaller weights and darker colors, down to dark purple for tracts that lie almost entirely outside. Hovering over a tract shows its GEOID and weight; clicking it also shows intersection_km2, the area of its part inside the drive-time area in square kilometers.

Counts, including the two counts of each rate, are added up with these weights. A tract with 18 percent of its area inside the drive-time area adds 18 percent of its count, such as its number of people below the poverty level, to the count for the area. This assumes that whatever the count counts is spread evenly over the tract’s area: for the number of people below the poverty level, that those people are spread evenly, and not only the population as a whole; vignette("theory-spatial-aggregation", package = "catchmentACS") discusses this assumption and when it fails. Medians and per-person values are averaged with other weights, the area shares, which vignette("methodology", package = "catchmentACS") describes.

Stage 3: one ACS variable

cacs_plot_site_weighted() shades the same parts of the tracts by each tract’s published estimate for one ACS variable, before any weighting, and draws the outline of the drive-time area on top. The map below shows B17001_002, the number of people whose income in the past 12 months was below the poverty level. Clicking a tract shows the same values as the Stage 2 map and also the variable code, the tract’s estimate, its margin of error (MOE), and the estimate multiplied by the coverage weight, labeled area_wt x estimate. The MOE is the half-width of the 90 percent confidence interval published with the estimate.

cacs_plot_site_weighted(
  site_id  = one_site,
  iso_sf   = walkthrough$iso_sf,
  tract_sf = walkthrough$tract_sf,
  acs_sf   = walkthrough$acs_sf,
  variable = "B17001_002",
  sites_df = walkthrough$sites_df
)

Darker greens mark larger counts. A count describes a whole tract and grows with the tract’s population, so a dark tract need not have a high poverty rate. For a count with no missing tract estimates, as here, the products in the pop-ups add up to the estimate that cacs_run() gives for the drive-time area, the numerator of the poverty rate in Stage 4. A tract adds much to that total only when both its count and its weight are large. Here the two tracts with the largest counts lie mostly outside the drive-time area, as their weights in the Stage 2 map show. The map shows only small parts of them, and they add only small parts of their counts.

Another variable in acs_sf is mapped by changing variable. The argument variable_family only chooses the colors; its values are those of the estimand_family column of a cacs_run() result: "spatial_total" (a count, the default), "median_proxy" (a median), "area_weighted_scalar_proxy" (a per-person value), and "derived_rate" (a rate). The function does not check it against variable. Median household income, for example, is mapped with variable = "B19013_001" and variable_family = "median_proxy".

For a median or a per-person value, the estimate that cacs_run() gives for the drive-time area is an average of the tract estimates weighted by area shares, so the products in the pop-ups do not add up to it. The column estimand_family of the result shows how each variable was combined; vignette("methodology", package = "catchmentACS") lists the ACS tables that are read as medians or per-person values and what happens to a median from another table.

Stage 4: the five rates

cacs_plot_site_rates() draws the site and the outline of its drive-time areas. Clicking the site opens a pop-up with the site’s values in run_result for the five rates of cacs_acs_default_rates: poverty_rate, snap_rate, ssi_rate, unemp_rate, and labor_force_participation. For each rate, the pop-up shows the estimate and its MOE as proportions and the numbers of tracts combined for the numerator and the denominator. It also shows the formula used for the MOE and whether the chosen formula could not be used (moe_fallback).

cacs_plot_site_rates(
  site_id    = one_site,
  iso_sf     = walkthrough$iso_sf,
  run_result = walkthrough$run_result,
  sites_df   = walkthrough$sites_df
)

Here the numerator and the denominator of each rate combine the same tracts, and no rate has a fallback. The MOE of every rate comes from the ratio formula (general_ratio_conservative), the formula for a ratio whose numerator is not part of its denominator. cacs_run() uses it for all five rates by default, although the numerator of each is part of its denominator. vignette("theory-derived-rates", package = "catchmentACS") describes the formula for that case, the proportion formula, and the argument formula_dispatch that chooses between the two. Like every margin of error from catchmentACS, the margins of error of the rates treat the tract estimates as independent and include no error from area weighting or from the drive-time area itself (see the Limitations section of vignette("methodology", package = "catchmentACS")).

A rate with moe_fallback = TRUE has an asterisk before its name in the pop-up, and a value that is NA shows as n/a. The help page of cacs_plot_site_rates() says when the asterisk appears, and that of cacs_derive_rates() lists the cases in which a rate is NA. When run_result has rates for several drive times of the site, the pop-up lists each rate once for each drive time and does not show the drive times. A run_result narrowed to one drive time, such as run_result[run_result$drive_time_min == 10, ], gives one entry for each rate.

All four maps in one call

cacs_plot_site_pipeline() calls the four functions above with the same site and map settings and returns the four maps in a list of class cacs_site_plot_pipeline:

maps <- cacs_plot_site_pipeline(
  site_id    = one_site,
  iso_sf     = walkthrough$iso_sf,
  tract_sf   = walkthrough$tract_sf,
  acs_sf     = walkthrough$acs_sf,
  run_result = walkthrough$run_result,
  sites_df   = walkthrough$sites_df,
  variable   = "B17001_002"
)
names(maps)
#> [1] "isochrone"    "intersection" "weighted"     "rates"

Printing the list at the console gives a short summary and draws no map. Each element is a leaflet map, which is drawn when the element is printed:

# The map of cacs_plot_site_rates(), as in Stage 4
maps$rates

The elements are HTML widgets, so htmlwidgets::saveWidget() can save one as an HTML file, and a Shiny app can show one with leaflet::leafletOutput() and leaflet::renderLeaflet(). ?cacs_plot_site_pipeline describes the arguments, among them drive_time_min, which chooses the drive time of the second and third maps for a site with several drive-time areas.