R Bootcamp: Day 3 AM


Data visualization: base R and ggplot2



Yufei Qin

Eric Manning

September 1, 2026



princeton-ddss.github.io/r-bootcamp

This session



1) Base R plots

  • Your first plots: scatter, line, histogram, bar, density
  • The canvas model: layering with lines(), points(), abline()
  • Many plots at once; maps


2) ggplot2

  • Plots as objects, built from layers
  • Geoms, facets, scales, and labels
  • Making and saving publication-ready figures


Conclude with Quarto report exercise

Some sources



Base R plots

Our data, again



The county data from yesterday — income and rent, plus population and region:

counties <- left_join(
    read_csv("data/county_data.csv"),
    read_csv("data/county_population.csv"),
    join_by(GEOID == fips)
)

Our data, again



glimpse(counties)
Rows: 3,144
Columns: 10
$ GEOID      <chr> "01001", "01003", "01005", "01007", "01009", "01011", "01…
$ state      <chr> "Alabama", "Alabama", "Alabama", "Alabama", "Alabama", "A…
$ county     <chr> "Autauga County", "Baldwin County", "Barbour County", "Bi…
$ income     <dbl> 69841, 75019, 44290, 51215, 61096, 36723, 44881, 55826, 4…
$ income_moe <dbl> 5512, 2751, 2762, 6678, 3328, 6836, 4130, 2168, 5005, 489…
$ rent       <dbl> 1200, 1211, 644, 802, 743, 635, 722, 804, 850, 750, 855, …
$ rent_moe   <dbl> 75, 42, 41, 129, 27, 93, 17, 28, 61, 74, 65, 135, 104, 13…
$ region     <chr> "South", "South", "South", "South", "South", "South", "So…
$ division   <chr> "East South Central", "East South Central", "East South C…
$ pop        <dbl> 58805, 231767, 25223, 22293, 59134, 10357, 19051, 116441,…

Your first plot



plot(counties$income, counties$rent)

A bunch of hidden defaults we can fill ourselves



plot(
    counties$income / 1000, counties$rent,
    main = "Rent vs. income",
    xlab = "Median household income (in thousands USD)",
    ylab = "Median gross rent",
    pch = 19, cex = 0.5, col = "steelblue"
)

Useful plot arguments



Argument What it controls Example
main title main = "Rent vs. income"
xlab, ylab axis labels xlab = "Income"
col color col = "steelblue"
pch point shape pch = 19 (filled circle)
cex point size cex = 0.5 (half size)
type points vs. lines type = "l"
lwd, lty line width, line style lwd = 2, lty = "dashed"


The same arguments work across plot(), lines(), points(), hist(), and friends

Menus: ?points draws every pch, ?par covers the rest, colors() lists all 657 names

ggplot2

From canvas to objects



Base R: call functions, ink appears on a device. The plot is a side effect.

ggplot2: builds an object that describes a plot with layers. Gets drawn when you print it.


That means you can assign, modify, combine, save, etc. without printing anything.

p <- ggplot(...) # nothing drawn yet
p # drawn now


ggplot2 is part of the tidyverse you met yesterday:

library(tidyverse)

The gist: (data w/ mapping) + geom + …



ggplot(counties, aes(x = income, y = rent)) +
    geom_point()

(Yuck.)

Aesthetics



Map a third column to color. It’s a variable, so it goes in aes() (constants go in geom_*)

ggplot(counties, aes(x = income, y = rent, color = region)) +
    geom_point(size = 0.7)

Compare: in base R, you subset the data, chose the colors, and drew the legend.

Classic mistake



ggplot(counties, aes(x = income, y = rent, color = "steelblue")) +
    geom_point(size = 0.7)

Inside aes(), "steelblue" isn’t a color. It gets vectorized as data.

Same mapping, different geom



Distributions by group:

ggplot(counties, aes(x = region, y = income)) +
    geom_boxplot()

Composition: stacked bars



Split each region’s counties into high- and low-population halves:

counties <- counties |>
    mutate(pop_level = if_else(
        pop >= median(counties$pop, na.rm = T),
        "high pop", "low_pop"
    ))

counties |>
    filter(!is.na(pop_level)) |>
    ## piping in this data; now different operator!
    ggplot(aes(x = region, fill = pop_level)) +
    geom_bar()

Counts vs. shares: position



position = "fill" rescales every bar to 1

counties |>
    filter(!is.na(pop_level)) |>
    ggplot(aes(x = region, fill = pop_level)) +
    geom_bar(position = "fill")

Side by side: position = "dodge"



counties |>
    filter(!is.na(pop_level)) |>
    ggplot(aes(x = region, fill = pop_level)) +
    geom_bar(position = "dodge")

Three versions of the same bars — stacked, filled, dodged. Which question does each answer best?

Line graphs



ggplot2 ships with practice data: economics is monthly US macro data:

ggplot(economics, aes(x = date, y = unemploy)) +
    geom_line()

Common mistake



head(economics_long, 3)
# A tibble: 3 × 4
  date       variable value  value01
  <date>     <chr>    <dbl>    <dbl>
1 1967-07-01 pce       507. 0       
2 1967-08-01 pce       510. 0.000265
3 1967-09-01 pce       516. 0.000762
ggplot(economics_long, aes(x = date, y = value01)) +
    geom_line()

Tell ggplot what belongs together



Map the series id to color (or group) and each variable gets its own line:

ggplot(economics_long, aes(x = date, y = value01, color = variable)) +
    geom_line()

Reference lines are geoms too



geom_hline(), geom_vline(), and geom_abline() are just more layers:

ggplot(counties, aes(x = income, y = rent)) +
    geom_point(size = 0.5, color = "gray") +
    geom_hline(
        yintercept = median(counties$rent, na.rm = T),
        color = "steelblue", linewidth = 1
    )

Facets



One line splits the plot by a categorical column:

ggplot(counties, aes(x = income, y = rent)) +
    geom_point(size = 0.4) +
    facet_wrap(~region) ## more than one: ~ region + ...

Facets in two directions



facet_grid(rows ~ cols):

counties |>
    filter(!is.na(pop_level)) |>
    ggplot(aes(x = income, y = rent)) +
    geom_point(size = 0.4) +
    facet_grid(pop_level ~ region)

Labels with labs()



ggplot(counties, aes(x = income, y = rent, color = region)) +
    geom_point(size = 0.7) +
    labs(
        title = "Rent rises with income",
        subtitle = "US counties, 2023 five-year ACS",
        x = "Median household income ($)",
        y = "Median gross rent ($)",
        color = "Region"
    )

Scales: how mappings become positions and colors



Every aesthetic has a scale named scale_<aesthetic>_<flavor>() with its own arguments:

ggplot(counties, aes(x = income, y = rent)) +
    geom_point(size = 0.5) +
    scale_x_continuous(
        name = "Median household income",
        breaks = c(50000, 100000, 150000),
        labels = c("$50k", "$100k", "$150k"),
        limits = c(0, 200000)
    )

Zooming vs. dropping



xlim()/ylim()/lims() throw away data outside the limits:

ggplot(counties, aes(x = income, y = rent)) +
    geom_point(size = 0.5) +
    lims(x = c(0, 100000))
Warning: Removed 158 rows containing missing values or values outside the scale range
(`geom_point()`).

Zooming vs. dropping



coord_cartesian() zooms without dropping:

ggplot(counties, aes(x = income, y = rent)) +
    geom_point(size = 0.5) +
    coord_cartesian(xlim = c(0, 100000))

Looks the same here, but anything computed from the data (boxplot stats, smoothers, densities) changes when points are dropped.

Other geom_* functions




You’ll google ggplot geom + what you want to draw (or ask Claude).


There is also a stat_*() family for layers that include on-the-fly statistical transformations. See here.

Setup for next bit



p <- ggplot(counties, aes(x = income, y = rent, color = region)) +
    geom_point(size = 0.6) +
    labs(
        x = "Median household income ($ logged)",
        y = "Median gross rent ($)",
        color = NULL
    ) +
    scale_x_log10()

p

Themes



Complete themes restyle everything at once:

p + theme_bw()

Try theme_minimal(), theme_bw(), theme_classic(), theme_void(), etc. and packages like ggthemes for more.

Scale your fonts to the medium



Every complete theme takes base_size to scale all the text together.

Paper figures ≈ 11, but slides need much more:

p + theme_minimal(base_size = 20)

Foreshadowing: What matters is font size relative to the figure. Save the same plot smaller and every label looks bigger.

Or size each piece separately


theme() targets one element at a time. Elements take an element_*():

p + theme_minimal(base_size = 15) +
    theme(
        axis.title.x = element_text(size = 20, face = "bold"),
        axis.text    = element_text(size = 10),
        legend.text  = element_text(size = 12, family = "Palatino")
    )

The element_*() family


  • element_text(), element_line()
  • element_rect() — backgrounds and borders
  • element_blank() — removes the element
p + theme_bw(base_size = 15) + theme(panel.grid = element_blank())

A complete theme resets everything before it.

Color palettes



Pick non-default colors or it looks like you didn’t try.

p + theme_minimal(base_size = 15) + scale_color_viridis_d()

viridis is colorblind-safe and printer-safe. scale_*_brewer() uses ColorBrewer. There are many packages (paletteer, MetBrewer, etc)

Or make your own



pal <- c(
    "Midwest" = "#E87722", "Northeast" = "steelblue",
    "South" = "firebrick", "West" = "#83876F"
)

p + theme_minimal(base_size = 15) + scale_color_manual(values = pal)

Composing figures: patchwork



A package can define +, |, and / on ggplot objects:

library(patchwork)

p <- p + theme_minimal(base_size = 15)

box <- ggplot(counties, aes(x = region, y = income)) +
    geom_boxplot() +
    theme_minimal(base_size = 15)

p + box

patchwork layouts



| puts plots beside each other; / stacks them:

hist_inc <- ggplot(counties, aes(x = income)) +
    theme_minimal(base_size = 15) +
    geom_histogram(bins = 40)

(p | box) / hist_inc

What’s wrong here?

Publication-ready figures

Figures are programmatic outputs



Your figures are products of your code, same as your tables and estimates

  • Rerun the script, get the figure without clicking through export UI boxes
  • Change the data and every figure updates


Never screenshot a plot. If a figure matters enough to share, save it properly at full resolution using code you can rerun.

Saving plots properly with ggsave()



ggsave("rent_income.pdf", p, width = 6, height = 4)

ggsave() infers the format from the extension; width/height are in inches (change with units arg).


Tip: Set width/height to the actual size in your document and the figures will be proportioned correctly.


For base R plots the equivalent is: pdf("fig.pdf", width = 6, height = 4), plot, then dev.off()

Vector > raster



Formats What’s stored Zoom in and…
Vector pdf, svg, eps shapes and text stays perfectly crisp
Raster png, jpg a grid of pixels gets blurry


Use vector formats whenever you can: LaTeX and Word take pdf; the web takes svg

When you’re forced to raster (some journals, slides software), set dpi = 300 or more in ggsave()

Maps

Maps are just plots



The sf package reads geographic files and gives each row a geometry column:

library(sf)
shapes <- st_read("county_shapes.geojson")


It looks like a data frame with a special column:

head(shapes, 3)
Simple feature collection with 3 features and 3 fields
Geometry type: MULTIPOLYGON
Dimension:     XY
Bounding box:  xmin: 80165.29 ymin: 60653.63 xmax: 1717985 ymax: 527895.3
Projected CRS: USA_Contiguous_Albers_Equal_Area_Conic
  GEOID    state          county                       geometry
1 18087  Indiana LaGrange County MULTIPOLYGON (((851901.1 52...
2 20107   Kansas     Linn County MULTIPOLYGON (((80824.31 10...
3 24029 Maryland     Kent County MULTIPOLYGON (((1689187 389...

Maps, the ggplot way



First attach our county numbers to the shapes:

shapes_data <- shapes |>
    left_join(
        counties |> select(GEOID, income, rent),
        join_by(GEOID)
    )

head(shapes_data, 3)
Simple feature collection with 3 features and 5 fields
Geometry type: MULTIPOLYGON
Dimension:     XY
Bounding box:  xmin: 80165.29 ymin: 60653.63 xmax: 1717985 ymax: 527895.3
Projected CRS: USA_Contiguous_Albers_Equal_Area_Conic
  GEOID    state          county income rent                       geometry
1 18087  Indiana LaGrange County  83741  816 MULTIPOLYGON (((851901.1 52...
2 20107   Kansas     Linn County  59200  682 MULTIPOLYGON (((80824.31 10...
3 24029 Maryland     Kent County  74402 1144 MULTIPOLYGON (((1689187 389...

Maps, the ggplot way



This could not be easier:

shapes_data |>
    ggplot() +
    geom_sf()

Maps, the ggplot way



geom_sf() is a geom like any other — aesthetics, scales, and themes all apply:

ggplot(shapes_data) +
    geom_sf(aes(fill = income), linewidth = 0.05) +
    scale_fill_viridis_c(
        name = "Median income",
        breaks = c(50000, 100000, 150000),
        labels = c("$50k", "$100k", "$150k")
    ) +
    theme_void(base_size = 15)

Pivoting for facet_*()



To facet by variable, the variables must live in one column — tidyr’s pivot_longer() stacks them:

long <- shapes_data |> pivot_longer(c(income, rent))

long |>
    select(county, name, value) |>
    head(4)
Simple feature collection with 4 features and 3 fields
Geometry type: MULTIPOLYGON
Dimension:     XY
Bounding box:  xmin: 80165.29 ymin: 60653.63 xmax: 893059.9 ymax: 527895.3
Projected CRS: USA_Contiguous_Albers_Equal_Area_Conic
# A tibble: 4 × 4
  county          name   value                                        geometry
  <chr>           <chr>  <dbl>                              <MULTIPOLYGON [m]>
1 LaGrange County income 83741 (((851901.1 523582.1, 855179.6 497385.5, 89305…
2 LaGrange County rent     816 (((851901.1 523582.1, 855179.6 497385.5, 89305…
3 Linn County     income 59200 (((80824.31 100142.8, 80165.29 60653.63, 12048…
4 Linn County     rent     682 (((80824.31 100142.8, 80165.29 60653.63, 12048…

Multiple maps



Income dollars dwarf rent dollars, so give each variable its own percentiles, then facet:

long <- long |>
    group_by(name) |>
    mutate(percentile = percent_rank(value))

ggplot(long) +
    geom_sf(aes(fill = percentile), linewidth = 0.02) +
    scale_fill_viridis_c() +
    facet_wrap(~name) +
    theme_void(base_size = 15)

For spatial data, resolution matters



Boundary files often store more detail than we can see. Use st_simplify() for plot files if necessary:

# this renders weirdly sometimes:
rough <- st_simplify(shapes, dTolerance = 25000, preserveTopology = T)


Ours is pre-simplified (1.8 MB); raw Census files run much larger and image files will be too large – and view rendering will take too long.


The tigris package (USA) has cb and resolution arguments for this.

This afternoon



  • Version control with git and GitHub
  • Distributions and random draws
  • Big exercise: a version-controlled, simulated analysis — start to finish


princeton-ddss.github.io/r-bootcamp

Now: plotting exercise



Open the exercise Quarto: princeton-ddss.github.io/r-bootcamp/notes/day-3-viz-ex.qmd

Open the exercise PDF instructions (if you want): princeton-ddss.github.io/r-bootcamp/notes/day-3-viz-ex.pdf


  • A Quarto report with empty chunks — fill them in and render as you go
  • Everything you need is from this morning

More fun with base R

Lines: type = "l"



All 3,135 counties sorted by income:

plot(sort(counties$income), type = "l")

What does the rightmost tail tell you?

Histograms



A better way to visualize the distribution of one variable (binned):

hist(counties$income, breaks = 50) ## number of bins

Bar plots: counting categories



Use barplot() for categorical variables:

table(counties$region) |> barplot()

(Categories appear alphabetically by default.)

Densities: a smooth histogram



density() estimates the distribution; plot() draws the estimate:

density(counties$income, na.rm = TRUE) |> plot()

Densities can smooth past data: misleading



d <- density(counties$income, na.rm = TRUE)

range(counties$income, na.rm = TRUE)
range(d$x)
[1]  25425 178707
[1]  17899.87 186232.13


The curve starts ~$7,000 below the poorest county. Density estimates commonly leak below zero.

The cut argument controls this:

d0 <- density(counties$income, na.rm = TRUE, cut = 0)
range(d0$x)
[1]  25425 178707

Base R plotting is a canvas



plot() (or hist(), barplot(), …) starts a fresh canvas

Then each of these adds ink on top of whatever is there:

  • points() — more points
  • lines() — more lines
  • abline() — straight reference lines
  • legend() — add a legend


The canvas is cumulative. To change something underneath, start over.

Layer by layer



Start the canvas — every county in gray:

nj <- counties[counties$state == "New Jersey", ]

plot(counties$income, counties$rent, pch = 19, cex = 0.4, col = "gray")

Layer by layer



The canvas is live. Add points() on top:

points(nj$income, nj$rent, pch = 19, col = "orange")

Layer by layer



…and abline() draw a reference line:

abline(h = median(counties$rent, na.rm = TRUE), lwd = 2, col = "steelblue")

Every New Jersey county sits above the national median rent

Always label your layers



The same trick compares distributions — start with the Northeast:

plot(density(counties$income[counties$region == "Northeast"], na.rm = TRUE),
    col = "steelblue", lwd = 2, main = "Income by region"
)

Always label your layers



lines() overlays the South:

lines(density(counties$income[counties$region == "South"], na.rm = TRUE),
    col = "firebrick", lwd = 2
)

We had to know ahead of time to set ylim. Otherwise we’d have to start over. A pain that ggplot2 will solve.

Always label your layers



legend() for a legend:

legend("topright",
    legend = c("Northeast", "South"),
    col = c("steelblue", "firebrick"), lwd = 2
)

Many plots at once: par()



par() sets graphics options for the whole device — mfrow makes a grid that fills plot by plot:

par(mfrow = c(1, 2))
hist(counties$income, main = "Income")
hist(counties$rent, main = "Rent")

par() is global



Every plot from now on lands in that grid until you put it back:

par(mfrow = c(1, 1))


par() controls dozens more settings: margins (mar and oma), text size (cex), etc.


Base R plots are side effects that depend on the state of the graphics device. The functions return NULL.

Maps are just plots



plot() knows what to do with a geometry column:

st_geometry(shapes) |> plot()

What happens if you just call plot(shapes)?

State colors



plot(shapes["state"], main = NULL)

Counties to states



shapes |>
    group_by(state) |>
    summarise(geometry = st_union(geometry)) |>
    plot(main = NULL)