counties <- left_join(
read_csv("data/county_data.csv"),
read_csv("data/county_population.csv"),
join_by(GEOID == fips)
)Yufei Qin
Eric Manning
September 1, 2026
lines(), points(), abline()ggplot2
Conclude with Quarto report exercise
ggplot2 documentation
The county data from yesterday — income and rent, plus population and region:
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,…
| 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
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.
(Yuck.)
Map a third column to color. It’s a variable, so it goes in aes() (constants go in geom_*)
Compare: in base R, you subset the data, chose the colors, and drew the legend.
Inside aes(), "steelblue" isn’t a color. It gets vectorized as data.
Distributions by group:
Split each region’s counties into high- and low-population halves:
positionposition = "fill" rescales every bar to 1
position = "dodge"Three versions of the same bars — stacked, filled, dodged. Which question does each answer best?
ggplot2 ships with practice data: economics is monthly US macro data:
# 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
Map the series id to color (or group) and each variable gets its own line:
One line splits the plot by a categorical column:
facet_grid(rows ~ cols):
labs()Every aesthetic has a scale named scale_<aesthetic>_<flavor>() with its own arguments:
xlim()/ylim()/lims() throw away data outside the limits:
Warning: Removed 158 rows containing missing values or values outside the scale range
(`geom_point()`).
coord_cartesian() zooms without dropping:
Looks the same here, but anything computed from the data (boxplot stats, smoothers, densities) changes when points are dropped.
geom_* functionsshape number, linetype name, and colorYou’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.
Complete themes restyle everything at once:
Try theme_minimal(), theme_bw(), theme_classic(), theme_void(), etc. and packages like ggthemes for more.
Every complete theme takes base_size to scale all the text together.
Paper figures ≈ 11, but slides need much more:
Foreshadowing: What matters is font size relative to the figure. Save the same plot smaller and every label looks bigger.
theme() targets one element at a time. Elements take an element_*():
element_*() familyelement_text(), element_line()element_rect() — backgrounds and borderselement_blank() — removes the elementA complete theme resets everything before it.
Pick non-default colors or it looks like you didn’t try.
viridis is colorblind-safe and printer-safe. scale_*_brewer() uses ColorBrewer. There are many packages (paletteer, MetBrewer, etc)
patchworkA package can define +, |, and / on ggplot objects:
patchwork layouts| puts plots beside each other; / stacks them:
What’s wrong here?
Your figures are products of your code, same as your tables and estimates
Never screenshot a plot. If a figure matters enough to share, save it properly at full resolution using code you can rerun.
ggsave()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()
| 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()
The sf package reads geographic files and gives each row a geometry column:
It looks like a data frame with a special column:
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...
First attach our county numbers to the shapes:
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...
This could not be easier:
geom_sf() is a geom like any other — aesthetics, scales, and themes all apply:
facet_*()To facet by variable, the variables must live in one column — tidyr’s pivot_longer() stacks them:
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…
Income dollars dwarf rent dollars, so give each variable its own percentiles, then facet:
Boundary files often store more detail than we can see. Use st_simplify() for plot files if necessary:
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.
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
type = "l"All 3,135 counties sorted by income:
What does the rightmost tail tell you?
A better way to visualize the distribution of one variable (binned):
Use barplot() for categorical variables:
(Categories appear alphabetically by default.)
density() estimates the distribution; plot() draws the estimate:
[1] 25425 178707
[1] 17899.87 186232.13
The curve starts ~$7,000 below the poorest county. Density estimates commonly leak below zero.
plot() (or hist(), barplot(), …) starts a fresh canvas
Then each of these adds ink on top of whatever is there:
points() — more pointslines() — more linesabline() — straight reference lineslegend() — add a legendThe canvas is cumulative. To change something underneath, start over.
Start the canvas — every county in gray:
The canvas is live. Add points() on top:
…and abline() draw a reference line:
Every New Jersey county sits above the national median rent
The same trick compares distributions — start with the Northeast:
lines() overlays the South:
We had to know ahead of time to set ylim. Otherwise we’d have to start over. A pain that ggplot2 will solve.
legend() for a legend:
par()par() sets graphics options for the whole device — mfrow makes a grid that fills plot by plot:
par() is globalEvery plot from now on lands in that grid until you put it back:
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.
plot() knows what to do with a geometry column:
What happens if you just call plot(shapes)?