Urban to Forest Gradient

Annual NLCD land cover, impervious surface, and leaf-off NDVI along the transect

Published

November 4, 2026

Code
library(sf)
library(dplyr)
library(tidyr)
library(ggplot2)
library(leaflet)
library(terra)
library(here)

source(here("R", "functions.R"))
derived <- here(cfg$paths$derived)

line    <- load_transect()
aoi     <- load_aoi()
windows <- load_windows()
nlcd    <- read.csv(file.path(derived, "nlcd_windows.csv"))
classes <- read.csv(file.path(derived, "nlcd_window_classes.csv"))
lc      <- rast(here(cfg$nlcd$dir, sprintf("LndCov_%d.tif", cfg$nlcd$year)))

ndvi_path <- file.path(derived, "ndvi_windows.csv")
ndvi <- if (file.exists(ndvi_path)) read.csv(ndvi_path) else NULL

# Join on the window id only; the distance columns are floats and may not match exactly
windows <- left_join(windows, select(nlcd, -u_start_km, -u_end_km, -u_mid_km, -area_km2), by = "window")
if (!is.null(ndvi)) windows <- left_join(windows, select(ndvi, -u_mid_km), by = "window")

# Grouped cover, ordered from most to least urban; colors follow the NLCD legend
cover_cols <- c(
  developed      = "#eb0000",
  developed_open = "#dec5c5",
  barren         = "#b3ac9f",
  agriculture    = "#dcd939",
  shrub_grass    = "#ccb879",
  forest         = "#38814e",
  wetland        = "#b8d9eb",
  water          = "#466b9f"
)
cover_labels <- c(
  developed = "Developed (low to high)", developed_open = "Developed, open space",
  barren = "Barren", agriculture = "Agriculture", shrub_grass = "Shrub and grassland",
  forest = "Forest", wetland = "Wetland", water = "Water"
)

Purpose

A first look at how land use changes from downtown Richmond to Pocahontas State Park. The study area is cut into non-overlapping 1 km windows along the transect, each spanning the full 4 km buffer width, and each window is summarized from the Annual NLCD 2025 land cover and fractional impervious surface layers (30 m) and from NDVI computed from the 4-band (RGB + near-infrared) 12-inch imagery.

Step Script Output
5. Transect windows R/04_transect_windows.R data/derived/transect_windows.gpkg
6. NLCD download and window summaries R/05_nlcd.R data/raw/nlcd/*.tif, data/derived/nlcd_windows.csv, nlcd_window_classes.csv
9. NDVI window summaries R/07_ndvi_windows.R data/derived/ndvi_10ft.tif, ndvi_windows.csv, ndvi_calibration.csv

NLCD is kept on its native grid (EPSG:5070). Windows are projected to it and summarized with exactextractr, which weights each 30 m cell by the fraction of it inside the window.

Windows

Code
n_win <- nrow(windows)
  • Windows: 25, each 1 km long by 4 km wide (the last is 0.99 km)
  • Area covered: 99.9 of 112.5 km². The round end caps beyond the endpoints are not part of any window.
  • Window 1 is at the Richmond end. Distance along the transect (u, km) is measured from the Richmond endpoint; transect_position() in R/functions.R returns u, the signed lateral offset v, and the window id for any point.

Use the layer control (top right) to choose which value colors the windows; hover a window for all of its values.

Code
to_ll <- function(x) st_transform(x, 4326)
win_ll <- to_ll(windows)
win_ll$label <- sprintf(
  "<b>Window %d</b> (%.0f–%.0f km)<br>Impervious: %.1f%%<br>Developed: %.1f%%<br>
   Developed, open: %.1f%%<br>Forest: %.1f%%<br>Wetland + water: %.1f%%",
  win_ll$window, win_ll$u_start_km, win_ll$u_end_km, win_ll$imperv_mean, win_ll$developed_pct,
  win_ll$developed_open_pct, win_ll$forest_pct, win_ll$wetland_pct + win_ll$water_pct
)
win_ll$wet_water <- win_ll$wetland_pct + win_ll$water_pct
if (!is.null(ndvi)) {
  win_ll$label <- paste0(win_ll$label, sprintf("<br>Evergreen (NDVI): %.1f%%", win_ll$evg_pct))
}

# One single-hue sequential scale per metric, all on a shared 0-100% domain
metrics <- list(
  "Impervious surface" = list(col = "imperv_mean",   pal = "Purples"),
  "Developed"          = list(col = "developed_pct", pal = "Reds"),
  "Forest"             = list(col = "forest_pct",    pal = "Greens"),
  "Wetland + water"    = list(col = "wet_water",     pal = "Blues")
)
if (!is.null(ndvi)) metrics[["Evergreen (NDVI)"]] <- list(col = "evg_pct", pal = "BuGn")

m <- leaflet(win_ll) |>
  addProviderTiles(providers$Esri.WorldGrayCanvas) |>
  addProviderTiles(providers$Esri.WorldImagery, group = "Imagery")
for (nm in names(metrics)) {
  pal <- colorNumeric(metrics[[nm]]$pal, domain = c(0, 100))
  m <- m |>
    addPolygons(color = "white", weight = 1, fillOpacity = 0.7,
                fillColor = pal(win_ll[[metrics[[nm]]$col]]),
                label = lapply(win_ll$label, htmltools::HTML),
                highlightOptions = highlightOptions(color = "black", weight = 2, bringToFront = TRUE),
                group = nm) |>
    addLegend(pal = pal, values = c(0, 100), title = paste(nm, "(%)"),
              position = "bottomright", className = "info legend metric-legend",
              layerId = nm)
}
m |>
  addPolylines(data = to_ll(line), color = "black", dashArray = "6", weight = 2) |>
  addLayersControl(baseGroups = names(metrics), overlayGroups = "Imagery",
                   options = layersControlOptions(collapsed = FALSE)) |>
  hideGroup("Imagery") |>
  # Leaflet does not hide legends for radio (base) layers; show only the selected metric's
  htmlwidgets::onRender(sprintf("
    function(el, x) {
      var map = this;
      function show(name) {
        el.querySelectorAll('.metric-legend').forEach(function(lg) {
          lg.style.display = lg.textContent.trim().startsWith(name + ' (%%)') ? '' : 'none';
        });
      }
      show(%s);
      map.on('baselayerchange', function(e) { show(e.name); });
    }", jsonlite::toJSON(names(metrics)[1], auto_unbox = TRUE)))

Land cover

Code
nlcd_names <- c(
  `11` = "Open water", `21` = "Developed, open space", `22` = "Developed, low",
  `23` = "Developed, medium", `24` = "Developed, high", `31` = "Barren",
  `41` = "Deciduous forest", `42` = "Evergreen forest", `43` = "Mixed forest",
  `52` = "Shrub/scrub", `71` = "Grassland", `81` = "Pasture/hay", `82` = "Cultivated crops",
  `90` = "Woody wetlands", `95` = "Emergent wetlands"
)
aoi_lc <- vect(st_transform(aoi, crs(lc)))
lc_aoi <- mask(crop(lc, aoi_lc), aoi_lc)
present <- sort(unique(values(lc_aoi, na.rm = TRUE)[, 1]))
levels(lc_aoi) <- data.frame(value = present, class = nlcd_names[as.character(present)])
plot(lc_aoi, mar = c(0.5, 0.5, 0.5, 12), axes = FALSE,
     plg = list(cex = 0.8))
plot(vect(st_transform(windows, crs(lc))), add = TRUE, border = "grey20", lwd = 0.5)
plot(vect(st_transform(line, crs(lc))), add = TRUE, col = "black", lty = 2)

Gradient

The charts below are interactive: hover to read every value at one distance, click a legend entry to hide it (double-click to show it alone), and drag to zoom (double-click to reset). Lines connect window midpoints, so values between midpoints are interpolated.

Code
library(plotly)

# Shared x axis and landmarks
river_km  <- nlcd$u_mid_km[which.max(nlcd$water_pct)]
end_km    <- max(nlcd$u_end_km)
x_axis <- list(title = "Distance from Richmond (km)", range = c(0, end_km),
               dtick = 5, zeroline = FALSE, showgrid = FALSE)
landmarks <- list(
  list(x = 0, y = 1.02, yref = "paper", text = "Richmond", showarrow = FALSE,
       xanchor = "left", yanchor = "bottom", font = list(size = 12)),
  list(x = end_km, y = 1.02, yref = "paper", text = "Pocahontas State Park", showarrow = FALSE,
       xanchor = "right", yanchor = "bottom", font = list(size = 12)),
  list(x = river_km, y = 1.02, yref = "paper", text = "James River", showarrow = FALSE,
       yanchor = "bottom", font = list(size = 12, color = "#466b9f"))
)
river_line <- list(type = "line", x0 = river_km, x1 = river_km, y0 = 0, y1 = 1,
                   yref = "paper", line = list(color = "#466b9f", width = 1, dash = "dot"))
plotly_cfg <- function(p) {
  config(p, displaylogo = FALSE,
         modeBarButtonsToRemove = c("lasso2d", "select2d", "autoScale2d"))
}

Land cover composition

Share of each window in each grouped cover type. Developed land is stacked from the bottom and forest above it, so the urban-to-forest transition reads as the green band widening toward Pocahontas State Park.

Code
p <- plot_ly()
for (k in names(cover_cols)) {
  p <- add_trace(p, data = nlcd, x = ~u_mid_km, y = nlcd[[paste0(k, "_pct")]],
                 type = "scatter", mode = "lines", stackgroup = "cover",
                 name = cover_labels[[k]], fillcolor = cover_cols[[k]],
                 line = list(color = "white", width = 0.5),
                 hovertemplate = paste0(cover_labels[[k]], ": %{y:.1f}%<extra></extra>"))
}
p |>
  layout(xaxis = c(x_axis, list(hoverformat = ".1f")),
         yaxis = list(title = "Share of window (%)", range = c(0, 100), ticksuffix = "%"),
         hovermode = "x unified",
         legend = list(traceorder = "reversed"),
         annotations = landmarks, shapes = list(river_line),
         margin = list(t = 40)) |>
  plotly_cfg()

Impervious surface and forest

Mean fractional impervious surface and forest cover per window. The shaded band is ± 1 SD of impervious surface among the 30 m cells in each window, a measure of how mixed the window is.

Code
imp_col <- "#5e3c99"
for_col <- "#38814e"
band <- nlcd |> mutate(lo = pmax(imperv_mean - imperv_sd, 0), hi = pmin(imperv_mean + imperv_sd, 100))

plot_ly(band, x = ~u_mid_km) |>
  add_ribbons(ymin = ~lo, ymax = ~hi, name = "Impervious ± 1 SD",
              fillcolor = "rgba(94, 60, 153, 0.15)", line = list(width = 0),
              hovertemplate = "Impervious ± 1 SD: %{customdata}<extra></extra>",
              customdata = ~sprintf("%.0f–%.0f%%", lo, hi)) |>
  add_trace(y = ~imperv_mean, type = "scatter", mode = "lines+markers", name = "Impervious surface",
            line = list(color = imp_col, width = 2), marker = list(color = imp_col, size = 8),
            hovertemplate = "Impervious surface: %{y:.1f}%<extra></extra>") |>
  add_trace(y = ~forest_pct, type = "scatter", mode = "lines+markers", name = "Forest",
            line = list(color = for_col, width = 2), marker = list(color = for_col, size = 8),
            hovertemplate = "Forest: %{y:.1f}%<extra></extra>") |>
  layout(xaxis = c(x_axis, list(hoverformat = ".1f")),
         yaxis = list(title = "Percent", range = c(0, 100), ticksuffix = "%"),
         hovermode = "x unified",
         legend = list(orientation = "h", y = -0.2),
         annotations = landmarks, shapes = list(river_line),
         margin = list(t = 40)) |>
  plotly_cfg()

Evergreen signal (NDVI)

Code
cat(sprintf(
"The imagery was flown leaf-off, so near-infrared greenness mostly marks evergreen foliage
(pine, cedar, holly, magnolia, evergreen shrubs) and green winter lawns. *Evergreen* is the
share of 1 ft pixels in a window with brightness-adjusted NDVI above %s, the same threshold
used to measure evergreen surroundings for holly candidates (plain NDVI is inflated in shadow;
see the [holly page](03_holly_classification.qmd#shadows)); open water is excluded. The forest-only line
uses the same measure within NLCD forest (classes 41–43) and reads as the evergreen
share of the forest canopy. NLCD forest cover is shown for reference.",
cfg$holly$thresholds$ndvi_adj$context))

The imagery was flown leaf-off, so near-infrared greenness mostly marks evergreen foliage (pine, cedar, holly, magnolia, evergreen shrubs) and green winter lawns. Evergreen is the share of 1 ft pixels in a window with brightness-adjusted NDVI above 1, the same threshold used to measure evergreen surroundings for holly candidates (plain NDVI is inflated in shadow; see the holly page); open water is excluded. The forest-only line uses the same measure within NLCD forest (classes 41–43) and reads as the evergreen share of the forest canopy. NLCD forest cover is shown for reference.

Code
nd <- left_join(ndvi, select(nlcd, window, forest_pct), by = "window")
evg_col <- "#01665e"
plot_ly(nd, x = ~u_mid_km) |>
  add_trace(y = ~evg_pct, type = "scatter", mode = "lines+markers", name = "Evergreen, all land",
            line = list(color = evg_col, width = 2), marker = list(color = evg_col, size = 8),
            hovertemplate = "Evergreen, all land: %{y:.1f}%<extra></extra>") |>
  add_trace(y = ~evg_forest_pct, type = "scatter", mode = "lines+markers",
            name = "Evergreen, within forest",
            line = list(color = evg_col, width = 2, dash = "dash"),
            marker = list(color = evg_col, size = 8, symbol = "circle-open"),
            hovertemplate = "Evergreen, within forest: %{y:.1f}%<extra></extra>") |>
  add_trace(y = ~forest_pct, type = "scatter", mode = "lines", name = "NLCD forest cover",
            line = list(color = "#38814e", width = 1.5, dash = "dot"),
            hovertemplate = "NLCD forest: %{y:.1f}%<extra></extra>") |>
  layout(xaxis = c(x_axis, list(hoverformat = ".1f")),
         yaxis = list(title = "Percent", range = c(0, 100), ticksuffix = "%"),
         hovermode = "x unified",
         legend = list(orientation = "h", y = -0.2),
         annotations = landmarks, shapes = list(river_line),
         margin = list(t = 40)) |>
  plotly_cfg()

Evergreen cover follows the urban-to-forest gradient only loosely. It is lowest in downtown Richmond (about 7–9% at km 0–2), sits between about 12% and 22% through most of the transect, and peaks at 43% at km 21–22, where pine stands are common. In the developed windows part of the signal is green winter lawn and street trees rather than evergreen canopy; the adjusted index is calibrated on forest, so it is least reliable on bright urban surfaces.

Within forest the evergreen share does not follow the urban gradient. The small forest patches near km 9–11 are pine-rich (41–46% evergreen), the forests at km 12–20 are mostly deciduous (18–28%), km 21–22 is pine-rich again (54%), and km 23–24 is hardwood-dominated (about 19%). The amount of forest (NLCD) and its composition (NDVI) change at different places along the transect. That matters for holly: an isolated evergreen crown is easiest to see, and most likely to be holly, in deciduous forest. Windows 1–4 have no NLCD forest, so they have no forest-only value.

Window table

Code
nlcd |>
  transmute(Window = window,
            `Distance (km)` = sprintf("%.1f–%.1f", u_start_km, u_end_km),
            `Impervious (%)` = round(imperv_mean, 1),
            `Developed (%)` = round(developed_pct + developed_open_pct, 1),
            `Forest (%)` = round(forest_pct, 1),
            `Wetland (%)` = round(wetland_pct, 1),
            `Water (%)` = round(water_pct, 1)) |>
  left_join(if (is.null(ndvi)) data.frame(Window = integer()) else
              transmute(ndvi, Window = window, `NDVI mean` = round(ndvi_mean, 2),
                        `Evergreen (%)` = round(evg_pct, 1)),
            by = "Window") |>
  knitr::kable()
Window Distance (km) Impervious (%) Developed (%) Forest (%) Wetland (%) Water (%) NDVI mean Evergreen (%)
1 0.0–1.0 64.9 99.1 0.0 0.9 0.0 0.17 8.8
2 1.0–2.0 69.0 100.0 0.0 0.0 0.0 0.14 7.1
3 2.0–3.0 53.9 100.0 0.0 0.0 0.0 0.21 12.7
4 3.0–4.0 49.9 100.0 0.0 0.0 0.0 0.24 19.1
5 4.0–5.0 23.1 80.1 6.5 1.2 11.8 0.28 20.4
6 5.0–6.0 12.9 58.7 5.5 13.7 22.1 0.24 12.5
7 6.0–7.0 18.7 85.3 5.6 4.3 4.7 0.33 18.3
8 7.0–8.0 29.4 93.5 4.0 2.5 0.0 0.30 17.2
9 8.0–9.0 37.2 94.3 4.3 1.5 0.0 0.27 16.3
10 9.0–10.0 44.2 95.1 2.6 2.3 0.0 0.24 15.7
11 10.0–11.0 37.0 86.6 12.6 0.8 0.0 0.25 17.7
12 11.0–12.0 22.2 73.8 22.9 2.8 0.0 0.31 20.5
13 12.0–13.0 24.7 87.5 11.4 1.1 0.0 0.26 17.1
14 13.0–14.0 29.5 95.0 4.2 0.2 0.6 0.26 17.2
15 14.0–15.0 20.6 77.9 15.7 5.9 0.4 0.30 17.9
16 15.0–16.0 16.8 75.5 23.0 1.0 0.1 0.31 20.6
17 16.0–17.0 12.2 68.0 20.9 10.7 0.4 0.32 19.3
18 17.0–18.0 12.5 66.9 24.6 7.3 0.8 0.34 21.5
19 18.0–19.0 10.3 53.5 37.4 7.4 0.6 0.32 20.5
20 19.0–20.0 8.4 45.1 44.1 2.2 0.4 0.31 22.2
21 20.0–21.0 11.5 40.0 51.5 4.4 0.0 0.36 28.6
22 21.0–22.0 8.3 30.9 62.9 5.2 0.0 0.43 42.6
23 22.0–23.0 0.5 3.1 94.6 0.1 0.2 0.42 35.6
24 23.0–24.0 0.3 1.9 81.2 5.7 8.8 0.32 17.7
25 24.0–25.0 1.2 8.3 86.8 2.2 0.5 0.37 24.9

Notes

  • Data: Annual NLCD Collection 1, 2025 land cover (LndCov) and fractional impervious surface (FctImp), read for the AOI bounding box from the USGS web coverage service (https://dmsdata.cr.usgs.gov/geoserver). No resampling or reprojection of the rasters.
  • Developed combines NLCD classes 22–24 (low, medium, high intensity). Developed open space (21) is mostly lawns, parks, and road verges and is kept separate. Forest is classes 41–43; wetland is 90 and 95.
  • NDVI: computed at full 1 ft resolution from the 4-band VBMP tiles and summarized into 10 ft cells (mean NDVI and share of pixels above the evergreen threshold) before the window summaries. NLCD is resampled to the 10 ft grid (nearest neighbour) only to mask water and select forest. The NIR band is not radiometrically calibrated, so NDVI values are relative to this imagery, and leaf-off timing means NDVI says little about deciduous canopy.
  • Windows are not replicated samples. Adjacent windows are spatially autocorrelated, so the curves describe the gradient but should not be treated as 25 independent observations.
  • The 4 km width averages across very different ground on either side of the line (e.g. the James River crossing near 5–7 km). A narrower center band would test how much that matters.