Skip to main content

Official websites use .gov
A .gov website belongs to an official government organization in the United States.

Secure .gov websites use HTTPS
A lock ( ) or https:// means you’ve safely connected to the .gov website. Share sensitive information only on official, secure websites.

Easy hydrology mapping with nhdplusTools, geoconnex, and ggplot2

Plotting hydrography data has never been easier with the latest versions of nhdplusTools and ggplot2. This blog demonstrates how to fetch and map National Hydrography Dataset (NHD) rivers and waterbodies with several reproducible examples.

Date Posted November 28, 2025 Last Updated November 28, 2025
Author Althea Archer
Elmera Azadpour
Reading Time 16 minutes Share
Go from a default ggplot2 map with a bunch of indiscernable black river lines that fill the map to a final map with a title, labels, annotations, and inset map in a few easy steps.

Go from hard-to-read default visuals to easy-to-read river maps in a few easy steps!

Summary

This blog demonstrates how to create maps of USGS National Hydrography Dataset (NHD) lakes and streams using ggplot2 for mapping, USGS packages nhdplusTools and dataRetrieval, and the USGS API service geoconnex. Readers should only need a beginner-level understanding of R to follow along with these reproducible examples.

Background

National Hydrography Data (NHD)

The National Hydrography Dataset (NHD) is a digital vector dataset used by Geographic Information Systems (GIS) to define the spatial locations of surface waters and is designed to provide comprehensive coverage of surface water data for the U.S. The NHD contains features such as lakes, ponds, streams, rivers, canals, dams, and stream gages in a relational database model system.

In 2024, the USGS shifted from maintaining the National Hydrography Dataset (NHD), the Watershed Boundary Dataset (WBD), and the NHDPlus High-Resolution to production of 3D Hydrography Program products. While these legacy products are no longer updated, they are provided as downloadable products and are useful for general mapping purposes.

Map of the Potomac river watershed showing all the rivers.

Example map of NHD waterbodies in the Potomac River watershed.

Highlights about the main packages we will use for these demos:

  • nhdplusTools : We are using v1.3.1, which uses the new Water Data APIs services .
  • dataRetrieval : We are using v2.7.21, which also uses the new Water Data APIs services . The new services default to downloading the gage data as a spatial feature, which is a great improvement for easy ggplot2 mapping.
  • ggplot2 : We are using v4.0 , which was just released to CRAN. We use the new features in this latest release to improve some of the mapping steps.
    • Note: ggplot2 is automatically loaded when you load the tidyverse (e.g., using library(tidyverse)).
  • cowplot : We are using v1.2.0. We use this package to combine maps and annotations into one visual.
  • sysfonts : We are using v0.8.9. This package allows us to add custom fonts to the map. In our examples, we use some free fonts available through Google Fonts .
    • On Macs, you may need to install the fonts Source Code Pro and Bradley Hand manually to your Font Book. As of the writing of this blog, you will need to navigate to Google Fonts, search for the fonts listed above, click “get font” in the upper right corner of the page, download the font file, and then pull the .ttf file into your Font Book application.
  • geoconnex : This is not an R package, but is an API service hosted by USGS that allows sf to read a hydrologic unit (HUC ) shapefile based on a query from R. This is nice if you are downloading only one or two hydrologic units.
    • If you want to instead download the entire hydrologic unit network for the whole United States, you can download the geodatabase from ScienceBase

Note: to check your package version, run packageVersion("[package_name]") in R.

A full list of packages needed to reproduce these demonstrations is provided in the next section.

Get prepared to map

How to reproduce these maps: The following examples build on each other, so when you see continued from example above you’ll need to run the code in the previous chunk for the subsequent one to work correctly.

For the code in this post to run, you’ll need to install and load the following packages.

library(tidyverse) # For using general "tidy" syntax throughout + ggplot2
library(sf) # For using spatial features
library(nhdplusTools) # For downloading National Hydrography Datasets
library(dataRetrieval) # For locating USGS streamgages
library(tigris) # For adding state lines and boundaries
library(cowplot) # For plot composition
library(grid) # For adding custom arrow annotation to plot
library(geomtextpath) # For adding curved text to river on plot
library(showtext) # For custom fonts
library(sysfonts) # For custom fonts
library(stringr) # Used to extract text for annotations

Next, we always like to globally define some of our aesthetics, such as colors we’ll be using often, and our plotting parameters for the ggsave() function. Then, we retrieve these settings for the examples below. Run this chunk of code if you’re planning to follow along.

# colors
river_blue <- "#4783A9" # for waterbodies and river lines
png_bkg <- "#EDEEEE" # an off-white background
text_color <- "black" 

# ggsave parameters
png_width <- 5 #inches
png_height <- 5 #inches
dpi <- 300 #dots per inch

# For example 3b with cowplot
plot_margin <- 0.05

Exporting the maps: Run the following ggsave() call after any example to save your plots as a png.

# example of ggsave
ggsave(filename = "out/your_plot_name_here.png",
       plot = plot_name, # leave blank to print last map created
       width = png_width, height = png_height, 
       dpi = dpi, units = "in")

Example 1: Map rivers around a point of interest based on coordinates

Map of a bunch of black river lines and lake polygons that fill the map with little discernable pattern.

Example 1a: Default ggplot2 map of lakes and streams from the National Hydrography Dataset

Map of rivers showing main downstream areas with thicker blue lines, upstream areas with thinner blue lines. Lakes are filled with the same blue.

Example 1b: Map with lakes and streams from the National Hydrography Dataset shown in blue using line widths to represent stream order.

Example 1a: Basic map

In this example, we begin with a point of interest based on latitude and longitude coordinates. A square buffer is added around the point to define the mapping extent, area of interest. Using the nhdplusTools package, we retrieve the NHD waterbodies and rivers for the entire area of interest, with the get_nhdplus() and the get_waterbodies() functions. Finally, we use ggplot2::ggplot() to create the map.

Map of a bunch of black river lines and lake polygons that fill the map with little discernable pattern.

Wait, I downloaded data in a square buffer, so why isn’t the edge a perfect square?

Any NHD features, whether hydrolines or polygons, that fall within your buffer will be fetched. In this example, there are some small features that are partially in the buffer and partially outside it. We're plotting all the fetched data in this example for simplicity. However, you can change your plotting parameters to cut those off the map or even use geospatial functions in R to remove them from your data if you'd like.

# example lat long coordinates in EPSG 4326
simple_coords <- tibble::tibble(
  lat = -104.450226,
  lon = 39.365151
)

# convert coordinates to spatial feature
location_sf <- sf::st_as_sf(simple_coords,
                            coords = c("lat", "lon"),
                            crs = 4326) |>
  # make sure it's projected nicely for plotting
  sf::st_transform(crs = 5070)

# add buffer
location_buffer <- sf::st_buffer(location_sf, 
                                 endCapStyle = "SQUARE",
                                 dist = 130000) # distance in meters

# query NHD for flowlines (non high resolution) in buffered area
location_nhd <- nhdplusTools::get_nhdplus(AOI = location_buffer, 
                                          realization = "flowline")
location_nhd_wb <- nhdplusTools::get_waterbodies(AOI = location_buffer)

# default ggplot2 map
ggplot(data = location_nhd) +
  geom_sf() +
  geom_sf(data = location_nhd_wb) +
  geom_sf(data = location_sf, color = "white", fill = "black",
  shape = 21, stroke = 1, size = 3) 
  

Example 1b: Mapping streamorder

In this example, we begin with the same steps as above and update it to enhance the appearance of the river network. To make larger, downstream rivers more visually prominent and upstream tributaries smaller, we map the stream order attribute from the NHD dataset to flowline width. We also use color to tie the rivers and waterbodies together while improving the visual hierarchy so that the streamgage location is easier to see.

Note: NHD geometries are fairly high resolution and may be more complex than is needed, depending on the scale and resolution of your map output. Simplifying geometries before plotting can help reduce file sizes and speed up plotting or processing time without any noticeable visual effect. The rmapshaper::ms_simplify() can be used for topologically-aware geometry simplification.

Map of rivers showing main downstream areas with thicker blue lines, upstream areas with thinner blue lines. Lakes are filled with the same blue.

# ... code continued from above (example 1a) ...


# update map by adding nice colors and line thicknesses
ggplot() +
  # hydrolines: map stream order categories to factor labels
  geom_sf(
    data = location_nhd,
    aes(
      linewidth = factor(case_when(
        streamorde >= 5 ~ "major",   
        streamorde == 4 ~ "large",
        streamorde == 3 ~ "medium",
        TRUE ~ "small"))
      ),
    color = river_blue) +
  # assign linewidth values using `scale_linewidth_manual()`
  scale_linewidth_manual(
    values = c(
      major = 0.3,
      large = 0.2,
      medium = 0.1,
      small = 0.04),
  # hide legend
    guide = "none") +
  # water bodies
  geom_sf(data = location_nhd_wb, color = river_blue,
    fill  = river_blue, linewidth = 0.01) +
  # dot in the middle for our main location of interest
  geom_sf(data = location_sf, color = "white", fill = "black",
  shape = 21, stroke = 1, size = 3) +
  theme_void()

Example 2: Map rivers around a streamgage

Map of the downloaded National Hydrography Dataset river with variable line widths scaled to stream order so larger rivers appear more visually prominent. The streamgage is plotted in the center with a black dot.

Example 2a: Map of the downloaded National Hydrography Dataset rivers and waterbodies plotted with variable line thicknesses mapped to stream order.

Map of rivers showing main downstream areas with thicker blue lines, upstream areas with thinner blue lines. The four adjacent states, Utah, Colorado, New Mexico, and Arizona are all labeled.

Example 2b: Map of the downloaded National Hydrography Dataset rivers and waterbodies plotted with variable line thicknesses mapped to stream order. The map is updated to include state boundaries, labels, and a zoomed in view.

Example 2a: Basic map

In this example, we start by obtaining a USGS streamgage location in lieu of a set of coordinates. Using the dataRetrieval package, we can download the gage as a simple feature object and transform point into a projection that works well for mapping purposes. Next, we create a square buffer around the gage using a set distance value, which then defines our area of interest. Within this buffered area, we use nhdplusTools to retrieve both flowlines (rivers and streams) and waterbodies (lakes and reservoirs). Finally, we can compile these data layers with ggplot2 with the rivers mapped by stream order so larger downstream channels appear thicker.

Map of the downloaded National Hydrography Dataset river that has the rivers plotting in blue and with variable line thicknesses to appear cartographic and natural. The streamgage is plotted in the center with a black dot.
# Retrieve a USGS streamgage as an sf
#     This gage is San Juan River at Four Corners, CO
# !! note: if this errors, make sure you have the latest version of dataRetrieval
gage_sf <- dataRetrieval::read_waterdata_monitoring_location(
  monitoring_location_id = "USGS-09371010") |>
  sf::st_transform(crs = 5070)

# add buffer
gage_buffer <- sf::st_buffer(gage_sf, 
                             endCapStyle = "SQUARE",
                             dist = 120000) # distance in meters

# query NHD for flowlines (non high resolution) in buffered area
gage_nhd <- nhdplusTools::get_nhdplus(AOI = gage_buffer, 
                                      realization = "flowline")
gage_nhd_wb <- nhdplusTools::get_waterbodies(AOI = gage_buffer)

ggplot() +
   # hydrolines: map stream order categories to factor labels
  geom_sf(
    data = gage_nhd,
    aes(
      linewidth = factor(case_when(
        streamorde >= 5 ~ "major",   
        streamorde == 4 ~ "large",
        streamorde == 3 ~ "medium",
        TRUE ~ "small"))
      ),
    color = river_blue) +
  # assign linewidth values using `scale_linewidth_manual()`
  scale_linewidth_manual(
    values = c(
      major  = 0.3,
      large  = 0.2,
      medium = 0.1,
      small  = 0.04
    ),
  # hide legend
   guide = "none"
  ) +
  # waterbodies
  geom_sf(data = gage_nhd_wb, color = river_blue,
    fill  = river_blue, linewidth = 0.01) +
  geom_sf(data = gage_sf, color = "white", fill = "black",
  shape = 21, stroke = 1, size = 3) +
  theme_void()

Example 2b: Mapping rivers across state boundaries

In this example, we build on the map above by defining the cartographic elements to be more readable and informative. We start by adding state boundaries, using the tigris package, clipped to our buffered extent so that only relevant states appear on the map. We then label each state using a custom loaded font. Finally, we use coord_sf to zoom in closer to the buffered area, reducing empty margins and keeping the focus on the hydrography and gage location. Together, these additions produce a cleaner, more informative map that situates the streamgage within its broader geographic context.

Note: If your type face appears different on your map, or if you get errors related to the font_title, you may need to install the "Source Code Pro" font manually.

Map of rivers showing main downstream areas with thicker blue lines, upstream areas with thinner blue lines. The four adjacent states, Utah, Colorado, New Mexico, and Arizona are all labeled.

# ... code continued from above (example 2a) ...

# Updated plot with states and labels
# add TIGER/Line states as sf with matching crs
states <- tigris::states(cb = TRUE, 
                         resolution = "20m", 
                         progress_bar = FALSE) |>
  sf::st_transform(crs = 5070)

# keep only states that touch buffered map 
#   (this helps ensure the labels land on the map)
states_clip <- sf::st_intersection(x = states, y = gage_buffer)

# load custom font
font_title <- 'Source Code Pro'
sysfonts::font_add_google(font_title)
showtext::showtext_opts(dpi = dpi, regular.wt = 200, bold.wt = 700)
showtext::showtext_auto(enable = TRUE)

ggplot() +
  # state outlines
  geom_sf(data = states_clip, fill = png_bkg) +
  # hydrolines: map stream order categories to factor labels
   geom_sf(
    data = gage_nhd,
    aes(
      linewidth = factor(case_when(
        streamorde >= 5 ~ "major",   
        streamorde == 4 ~ "large",
        streamorde == 3 ~ "medium",
        TRUE ~ "small"))
      ),
    color = river_blue) +
  # assign linewidth values using `scale_linewidth_manual()`
  scale_linewidth_manual(
    values = c(
      major  = 0.3,
      large  = 0.2,
      medium = 0.1,
      small  = 0.04
    ),
  # hide legend
    guide = "none"
  ) +
  # waterbodies
  geom_sf(data = gage_nhd_wb, color = river_blue,
    fill  = river_blue, linewidth = 0.01) + 
  # gage location
  geom_sf(data = gage_sf, color = "white", fill = "black",
  shape = 21, stroke = 1, size = 3) +
  # state labels 
  geom_sf_text(data = states_clip, aes(label = NAME),
               size = 5, color = "gray20", 
               family = font_title, fontface = "bold") +
  # set up zoom to move in on the plot *without* dropping any geometries
  #   we want to zoom in just into the bounding box
  coord_sf(xlim = c(sf::st_bbox(gage_buffer)["xmin"] +12000,
                    sf::st_bbox(gage_buffer)["xmax"] -12000),
           ylim = c(sf::st_bbox(gage_buffer)["ymin"] +12000,
                    sf::st_bbox(gage_buffer)["ymax"] -12000)) +
  theme_void() +
  # use new border feature with ggplot2 v4.0
  theme(
    geom = element_geom(
      borderwidth = 0.5
    )
  )

Example 3: Map a watershed

Map of the Duchesne River HUC-8 watershed in Utah showing rivers and waterbodies in blue. The HUC-8 watershed boundary is plotted with a slightly darker grey than the background.

Example 3a: Map of the Duchesne River HUC-8 Watershed boundary and river network.

Map of the Duchesne River HUC-8 Watershed in Utah showing rivers and waterbodies in blue. The HUC-8 watershed boundary is plotted with a slightly darker grey than the background. The Duchesne River, the HUC-8 Watershed (14060003), and the USGS Streamgage (09302000) are called out with annotation labels and arrows. There is also an inset map of the lower 48 United States showing where this map location sits in Utah (northeast corner).

Example 3b: Map of the Duchesne River, Utah HUC-8 Watershed boundary and river network with annotations and an inset map.

Example 3a: Basic map

In this example, we expand from plotting rivers and waterbodies around a streamgage to the larger watershed that drains to it. Using the geoconnex service, we query and download the HUC8 basin that contains our gage location, then create a buffered bounding box around that watershed to use as the area of interest. With this extent, we then pull the NHD flowlines and waterbodies along with state boundaries. Finally, we plot the rivers, lakes, watershed boundary, and streamgage. This provides a clear view of how the gage sits within its broader hydrologic unit.

Map of the Duchesne River HUC-8 watershed in Utah showing rivers and waterbodies in blue. The HUC-8 watershed boundary is plotted with a slightly darker grey than the background.
# Retrieve a USGS streamgage as an sf
#     This gage is Duchesne River near Randlett, UT
# !! note: if this errors, make sure you have the latest version of dataRetrieval
gage_sf <- dataRetrieval::read_waterdata_monitoring_location(
  monitoring_location_id = "USGS-09302000") 

# Retrieve a USGS river basin (HUC8 level) from geoconnex
# Build URL to query https://geoconnex.us
base_url <- "https://reference.geoconnex.us"
collection <- sprintf("/collections/%s/items?f=json&filter=", 
                      "hu08")
query <- sprintf("INTERSECTS(geometry, POINT(%f %f))", 
                 sf::st_coordinates(gage_sf)[1, "X"], 
                 sf::st_coordinates(gage_sf)[1, "Y"]) |>
  URLencode()
url <- paste0(base_url, collection, query)

read_huc8_sf <- sf::st_read(url, quiet = TRUE)

# fetch sf from url & project to 5070 for buffering/plotting 
huc8_sf <- sf::st_transform(read_huc8_sf, crs = 5070)

# buffer for downloading nhd. This time, we want to extract a square
#   around the whole watershed, so need to create bbox first
huc8_bbox <- sf::st_bbox(huc8_sf)
huc8_buffer <- huc8_bbox |>
  # convert to sf 
  sf::st_as_sfc() |>
  sf::st_buffer(
    endCapStyle = "SQUARE",
    dist = 24000, # distance in meters
  )

# NHD flowlines in buffer 
huc8_nhd <- nhdplusTools::get_nhdplus(AOI = huc8_buffer,
                                      realization = "flowline")
huc8_nhd_wb <- nhdplusTools::get_waterbodies(AOI = huc8_buffer)
# add TIGER/Line states as sf 
states <- tigris::states(cb = TRUE, 
                         resolution = "20m", 
                         progress_bar = FALSE) |>
  sf::st_transform(crs = 5070)

# keep only states that touch buffered map 
states_clip <- sf::st_intersection(x = states, y = huc8_buffer)
state_labels <- sf::st_point_on_surface(states_clip)

(riv_basin_zoom <- ggplot() +
  # state outlines
  geom_sf(data = states_clip, fill = png_bkg, color = NA) +
  # state boundary lines
  geom_sf(data = sf::st_boundary(states_clip),
          linewidth = 0.3, color = "gray40") + 
  # hydrolines: map stream order categories to factor labels
  geom_sf(
    data = huc8_nhd,
    aes(
      linewidth = factor(case_when(
        streamorde >= 5 ~ "major",   
        streamorde == 4 ~ "large",
        streamorde == 3 ~ "medium",
        TRUE ~ "small"))
      ),
    color = river_blue) +
  # assign linewidth values using `scale_linewidth_manual()`
  scale_linewidth_manual(
    values = c(
      major  = 0.3,
      large  = 0.2,
      medium = 0.1,
      small  = 0.04
    ),
  # hide legend
    guide = "none"
  ) + 
  # waterbodies
  geom_sf(
    data = huc8_nhd_wb,
    color = river_blue,
    fill  = river_blue,
    linewidth = 0.01
  ) +
  # gage location
  geom_sf(data = gage_sf, color = "white", fill = "black",
  shape = 21, stroke = 1, size = 3) +
  geom_sf(data = huc8_sf, fill = alpha("grey50", 0.08), 
          color = "grey40", 
          linewidth = 0.4) +
  # set up zoom to move in on the plot *without* dropping any geometries
  #   we want to zoom in just into the bounding box
  coord_sf(xlim = c(huc8_bbox["xmin"] + 1000,
                    huc8_bbox["xmax"] - 1000),
           ylim = c(huc8_bbox["ymin"] + 1000,
                    huc8_bbox["ymax"] - 1000)) +
  theme_void())

Example 3b: Map a watershed with customization

In this example, we turn the basic watershed map into a final figure composition that contains multiple elements using the cowplot package. A curved label is added for the Duchesne River that follows the shape of the river (using geomtextpath). To show where the watershed is located, we add a conterminous United States (CONUS; lower 48 states) inset map to with the buffered region outlined. Finally, focal features are annotated, including hand-drawn style labels and arrow (built with geom_curve).

Map of the Duchesne River HUC-8 Watershed in Utah showing rivers and waterbodies in blue. The HUC-8 watershed boundary is plotted with a slightly darker grey than the background. The Duchesne River is labeled with curved text that follows the river. The HUC-8 Watershed (14060003), and the USGS Streamgage (09302000) are called out with annotation labels and arrows. There is also an inset map of the lower 48 United States showing where this map location sits in Utah (northeast corner).

Plotting a curved label: Here we only want to label the mainstem, so we are going to filter out most of the other hydrolines before adding the curved label. First, we filter the segments that we want to label. To do this, we will choose the two longest lengths of river, which are in the mainstem. Second, we reproject those linesegments to prep them for plotting. Third, we add a geometry layer to the original map with geomtextpath::geom_textpath(). The settings for this function include text_only, which is logical, and text_smoothing, which is numerical. We want only the text, since the river segments are already mapped, and we want the text to be slightly smoother than the actual segment for readability. We thus set text_only = TRUE and text_smoothing = 40 (0 is no smoothing, 100 is complete smoothing). The vjust and hjust allow us to tweak exactly where the text is plotted in relationship to the river segments.


# ... code continued from above (example 3a) ...

# create mainstem line as sf for Duchesne river text path
duch_main <- huc8_nhd |>
  filter(stringr::str_detect(stringr::str_to_lower(gnis_name), "duchesne river")) |>
  st_union() |>
  st_line_merge() |>
  st_cast("LINESTRING") |>
  (\(x) st_sf(geometry = x, crs = 5070))() |> 
  mutate(len = st_length(geometry)) |>
  # Keep the 2 longest LINESTRING since the path is closer to gage
  slice_max(len, n = 2) |>
  select(-len)

# convert to coordinates for geom_textpath (more text control) and force a single group
crds <- duch_main |>
  st_coordinates() |>
  as.data.frame() |>
  mutate(group = 1)

# add curved label onto main plot
(riv_basin_path <- riv_basin_zoom +
  geomtextpath::geom_textpath(
    data = crds,
    aes(x = X, y = Y, label = "Duchesne River", group = group),
    family = font_annotations,
    size = 4,
    text_smoothing = 40,
    text_only = TRUE, # remove the path, show text only
    vjust  = -0.1,
    hjust  = 0.63,
    inherit.aes = FALSE,
    color = text_color))

Now that we have updated the main map, we can compose the map, an inset map, and annotations with cowplot.

# ... code continued from previous chunk ...

# Filter states for CONUS inset
states_conus <- states |> 
  dplyr::filter(!STUSPS %in% c("AK","HI","PR","VI",
                               "GU","AS","MP"))

# Create conus inset map with gage buffer region for cowplot
(conus_inset <- ggplot() +
  # CONUS
  geom_sf(data = states_conus) +
  # Buffer region
  geom_sf(data = huc8_buffer,  fill = NA, 
          color = "blue", linewidth = 0.4) +
  theme_void() +
  # ggplot2 v4.0 allows you to set aesthetics at once for sf objects
  #   (this is rather than setting for each geom_sf layer above)
  theme(
    geom = element_geom(
      linewidth = 0.15,
      color = "grey40",
      fill = "white"
    )
  ))

# Get codes for pasting with cowplot
gage_code <- str_extract(gage_sf$monitoring_location_id[1], "\\d+")
huc8_code <- stringr::str_extract(paste0(huc8_sf$id[1],
                                         huc8_sf$uri[1]), "\\d{8}")

# Load custom fonts
font_title <- 'Source Code Pro'
sysfonts::font_add_google(font_title)
font_annotations <-"Patrick Hand"
sysfonts::font_add_google(font_annotations)
showtext::showtext_opts(dpi = dpi, regular.wt = 200, bold.wt = 700)
showtext::showtext_auto(enable = TRUE)
text_size <- 13

(map <- cowplot::ggdraw(xlim = c(0, 1), ylim = c(0, 1)) +
  # title text 
  cowplot::draw_label("Duchesne River, Utah\nHUC-8 Watershed",
                      x = plot_margin,
                      y = 1.01 - plot_margin,
                      fontfamily = font_title,
                      color = text_color,
                      size = 18,
                      hjust = 0, vjust = 1,
                      fontface = "bold") +  
  # main watershed and flowlines map and Duchesne River 
  cowplot::draw_plot(riv_basin_path,
                     x = 0 + plot_margin,
                     y = 0 - 0.8*plot_margin,
                     width = 1 - 2*plot_margin) +
  # inset map for orientation
  cowplot::draw_plot(conus_inset,
                     x = plot_margin,
                     y = -0.02,
                     width = 0.25,
                     height = 0.25,
                     hjust = 0, vjust = 0) +
  # streamgage arrow
  geom_curve(aes(x = 0.755, y = 0.222,
               xend = 0.83, yend = 0.286),
             curvature = 0.25, ncp = 20,
             arrow = grid::arrow(length = unit(0.5, "lines")),
             color = text_color, linewidth = 1) +
  # streamgage text
  cowplot::draw_label(paste0("USGS Streamgage\n(", gage_code, ")"),
                      fontfamily = font_annotations,
                      x = 0.73,   
                      y = 0.18,
                      size = text_size,
                      color = text_color) +
  # Watershed text
  cowplot::draw_label(paste0("HUC-8 Watershed\n(", huc8_code, ")"),
                      fontfamily = font_annotations,
                      x =  0.29,   
                      y = 0.59,
                      size = 11,
                      color = text_color))

From data access (nhdplusTools and geoconnex), to composition (ggplot2 and cowplot), we highlight reproducible steps that turn hydrographic data into readable and user-friendly maps. The examples show how small choices, likes projection, stream order styling, and annotations, produce clear mapping results at both site and basin scales.

Further resources and reading

  • More ggplot2 customization:Jazz up your ggplots! ” — tips for extension packages, fonts, and finishing touches
  • Explore more water-data visualizations: USGS Vizlab gallery of interactive examples and inspiration

Data citation:

  • Blodgett, D.L., 2023, Mainstem Rivers of the Conterminous United States (ver. 2.0, February 2023): U.S. Geological Survey data release, https://doi.org/10.5066/P92U7ZUT .

Any use of trade, firm, or product names is for descriptive purposes only and does not imply endorsement by the U.S. Government.

Share:

Related Posts

  • Reproducible Data Science in R: Writing functions that work for you

    May 14, 2024

    A smiling raindrop in a hardhat works on a pipe assembly line beneath the blog series title, Reproducible Data Science with R and to the left of the blog post title, Writing functions that work for you.

    Overview

    This blog post is part of a series that works up from functional programming foundations through the use of the targets R package to create efficient, reproducible data workflows.

  • Charting 'tidycensus' data with R

    June 24, 2025

    In January, 2025, the organizers of the tidytuesday challenge highlighted data that were featured in a previous blog post and data visualization website . Some of us in the USGS Vizlab wanted to participate by creating a series of data visualizations showing these data, specifically the metric “households lacking plumbing.” This blog highlights our data visualizations inspired by the tidytuesday challenge as well as the code we used to create them, based on our previous software release on GitHub .

  • Reproducible Data Science in R: Flexible functions using tidy evaluation

    December 17, 2024

    A smiling raindrop in a hardhat works on a pipe assembly line beneath the blog series title, Reproducible Data Science with R and to the left of the blog post title, Flexible functions using tidy evaluation.

    Overview

    This blog post is part of a series that works up from functional programming foundations through the use of the targets R package to create efficient, reproducible data workflows.

  • TopoRivBlender - Create custom 3D images of Topography and Hydrography

    September 18, 2025

    Introduction

    A rendered 3D image of the contours around the Columbia River in Washington, USA.

    This blog will introduce you to an automated workflow that downloads geospatial data and then processes it to be used by 3D graphic software, Blender, to make images like the ones above and below. After reading through this blog, you should be able to use the example workflows to make your own images with just some basic coding knowledge. For Python users, this repository will also act as a starting point to make your own Blender workflows.

  • Tutorial of dataRetrieval's newest features in R

    November 26, 2025

    This article will describe the R-package dataRetrieval , which simplifies the process of finding and retrieving water from the U.S. Geological Survey (USGS) and other agencies. We have recently released a new version of dataRetrieval to work with the modernized Water Data APIs . The new version of dataRetrieval has several benefits for new and existing users: