National mapping for small areas: visualizing 85,000+ Census tracts with mapgl

r
gis
census
mapping
Author

Kyle Walker

Published

February 28, 2025

This February, I gave a series of workshops on Census data and R with the University of Michigan’s Social Science Data Analysis Network (SSDAN). One of my favorite examples walked participants through how to map all 85,000+ Census tracts across the United States using the mapgl R package and MapLibre. Small-area national maps for topics like Census or election results can be quite powerful but can be tricky to get right even with the capability of modern web browsers. In this post, I’ll walk you through how to create these visualizations effectively while addressing some key technical challenges.

Getting national tract data with tidycensus

Let’s start by loading the required libraries and getting our income data for all US Census tracts. For this example, we’ll map median household income (variable B19013_001) from the American Community Survey, with data acquired with the tidycensus R package. If you don’t have the shapefiles previously cached, this operation may take a few minutes.

library(tidycensus)
library(mapgl)
options(tigris_use_cache = TRUE)

us_income <- get_acs(
  geography = "tract",
  variables = "B19013_001",
  state = c(state.abb, "DC", "PR"),
  year = 2023,
  geometry = TRUE,
  resolution = "5m"
)

us_income
Simple feature collection with 85381 features and 5 fields (with 336 geometries empty)
Geometry type: MULTIPOLYGON
Dimension:     XY
Bounding box:  xmin: -179.1467 ymin: 17.88328 xmax: 179.7785 ymax: 71.38782
Geodetic CRS:  NAD83
# A tibble: 85,381 × 6
   GEOID       NAME            variable estimate   moe                  geometry
   <chr>       <chr>           <chr>       <dbl> <dbl>        <MULTIPOLYGON [°]>
 1 01089003100 Census Tract 3… B19013_…    89297 16748 (((-86.59811 34.74094, -…
 2 01089000501 Census Tract 5… B19013_…    46643 13010 (((-86.65703 34.77881, -…
 3 01089011021 Census Tract 1… B19013_…    82528 38647 (((-86.78678 34.67045, -…
 4 01095031200 Census Tract 3… B19013_…    55106  9618 (((-86.17402 34.23036, -…
 5 01073012401 Census Tract 1… B19013_…    71213 11788 (((-86.90739 33.57447, -…
 6 01073003400 Census Tract 3… B19013_…    32125 20693 (((-86.91104 33.50274, -…
 7 01073003001 Census Tract 3… B19013_…    56917 26119 (((-86.87246 33.51363, -…
 8 01073010402 Census Tract 1… B19013_…    28684 26530 (((-86.99094 33.37425, -…
 9 01073005101 Census Tract 5… B19013_…    30222  6679 (((-86.83959 33.49663, -…
10 01073010603 Census Tract 1… B19013_…    38750  7625 (((-86.9295 33.47472, -8…
# ℹ 85,371 more rows

Our dataset contains over 85,000 Census tracts, each with their estimated median household income. This is a substantial amount of data to visualize on a single map!

Creating our initial national tracts map

Let’s create a first map using the mapgl package with MapLibre as our mapping engine. It will take a few moments for MapLibre to render the map.

maplibre(
  style = carto_style("positron"),
  center = c(-98.5795, 39.8283),
  zoom = 3
) |>
  set_projection("globe") |> 
  add_fill_layer(
    id = "fill-layer",
    source = us_income,
    fill_color = interpolate(
      column = "estimate",
      values = c(10000, 75000, 250000),
      stops = c("#edf8b1", "#7fcdbb", "#2c7fb8"),
      na_color = "lightgrey"
    ),
    fill_opacity = 0.7,
    tooltip = "estimate"
  )

If you look closely at the map, you’ll notice something strange: there appear to be “holes” in the map, particularly in large cities where Census tracts tend to be smaller. What’s going on here?

The issue stems from how MapLibre (and other web mapping libraries) handle geometry simplification. When we’re zoomed out, the mapping engine uses the Douglas-Peucker simplification algorithm to reduce the complexity of geometries, making the map render faster. At zoom level 3, the default tolerance value (0.375) equates to roughly 5.6 kilometers on the ground.

This means that Census tracts smaller than this threshold simply disappear from our map! This is particularly problematic in cities where Census tracts are often quite small.

Disabling simplification to fix the holes

One solution is to disable the automatic simplification by setting the simplification tolerance to 0. We can do this by adding our data as a source with add_source() directly, using the option tolerance = 0:

maplibre(
  style = carto_style("positron"),
  center = c(-98.5795, 39.8283),
  zoom = 3
) |>
  set_projection("globe") |> 
  add_source( 
    id = "us-tracts",
    data = us_income,
    tolerance = 0
  ) |> 
  add_fill_layer(
    id = "fill-layer",
    source = "us-tracts",
    fill_color = interpolate(
      column = "estimate",
      values = c(10000, 75000, 250000),
      stops = c("#edf8b1", "#7fcdbb", "#2c7fb8"),
      na_color = "lightgrey"
    ),
    fill_opacity = 0.7,
    tooltip = "estimate"
  )

The holes are gone, and we can now see all Census tracts, including the tiny ones in dense urban areas.

Understanding the trade-offs

While disabling simplification solves our visual problem, it introduces some performance challenges:

  1. Slower loading times: The browser now has to process and render all 85,000+ tract geometries at their full complexity.
  2. Reduced map performance: Panning and zooming may become sluggish due to the increased data load.
  3. Unnecessary detail: When zoomed out, do we really need to see individual tract boundaries?

A better approach: Zoom-dependent layering

Rather than forcing the browser to render every Census tract at all zoom levels, a more sophisticated approach is to use different geographic levels depending on the zoom level:

  • At low zoom levels (zoomed out): Show county-level data
  • At high zoom levels (zoomed in): Switch to tract-level data

This approach gives users a smooth experience while still providing detailed data when they need it.

Let’s get the same income data but at the county level:

us_county_income <- get_acs(
  geography = "county",
  variables = "B19013_001",
  year = 2023,
  geometry = TRUE,
  resolution = "5m"
) 

Implementing zoom-dependent layers

Now we’ll create a map that transitions between county and tract data depending on the zoom level. We’ll set tracts to appear when zoomed in (minimum zoom level of 8) and counties to disappear just before that (maximum zoom level of 7.99):

maplibre(
  style = carto_style("positron"),
  center = c(-98.5795, 39.8283),
  zoom = 3
) |>
  set_projection("globe") |> 
  add_fill_layer(
    id = "fill-layer",
    source = us_income,
    fill_color = interpolate(
      column = "estimate",
      values = c(10000, 75000, 250000),
      stops = c("#edf8b1", "#7fcdbb", "#2c7fb8"),
      na_color = "lightgrey"
    ),
    fill_opacity = 0.7,
    min_zoom = 8,
    tooltip = "estimate"
  ) |> 
  add_fill_layer(
    id = "county-fill-layer",
    source = us_county_income,
    fill_color = interpolate(
      column = "estimate",
      type = "linear",
      values = c(10000, 75000, 250000),
      stops = c("#edf8b1", "#7fcdbb", "#2c7fb8"),
      na_color = "lightgrey"
    ),
    fill_opacity = 0.7,
    max_zoom = 7.99,
    tooltip = "estimate"
  ) |>
  add_continuous_legend(
    "Median household income",
    values = c("$10k", "$75k", "$250k"),
    colors = c("#edf8b1", "#7fcdbb", "#2c7fb8")
  )

With this approach, you’ll see counties when zoomed out, which provides a good overview of income patterns across the country. When you zoom in past level 8, the map automatically transitions to showing the more detailed tract-level data. This gives users the best of both worlds: good performance at low zoom levels and detailed data at high zoom levels.

I’ve also added a continuous legend to help users interpret the colors on the map, which represents median household income from $10,000 to $250,000.

Next steps

Give this approach a try with other variables from tidycensus, or your own national datasets! The technique works well for any scenario where you need to balance performance with detail, such as election results, demographic data, or economic indicators.

Interested in learning more advanced mapping techniques with mapgl? Check out my mapgl workshop series for in-depth training on these and other visualization methods.