We’ve been making ggplots for a while now. You know how to:
Pass data and aes() to ggplot()
Add a geom like geom_point(), geom_bar(), geom_histogram()
Map variables to x, y, color, fill
Today we’re going to slow down and look at what’s actually happening under the hood.
Tip
The goal of today is to give you the tools to be able to build any plot you can describe.
The grammar of graphics
A vocabulary for plots
The DC chapter introduces five words. These will come up a lot.
Five words to describe any data graphic.
Term
What it is
frame
The relationship between position and data. The coordinate system + the variables on each axis.
glyph
The basic graphical unit. One glyph = one case in your data frame.
aesthetic
Any graphical attribute of a glyph: position, size, color, shape, transparency, …
scale
The rule that translates a variable value into an aesthetic value.
guide
What helps the viewer translate back: axes, tick marks, legends, color bars.
One sentence, five words
Tip
A graphic is a collection of glyphs sitting inside a frame, where each glyph’s aesthetics are controlled by scales that map variables to graphical attributes, and guides help the viewer read the scales backward.
Today we focus on the first three; tomorrow we’ll deal more with scales and guides.
Why this matters
Once you have the vocabulary, debugging gets easier:
“My color isn’t working” \(\rightarrow\) is it a mapping (inside aes()) or a fixed property (outside)?
“I want different lines per group” \(\rightarrow\) I need a discrete variable mapped to an aesthetic that splits the geom (color, linetype, group).
“My bar chart shows counts but I want proportions” \(\rightarrow\) I need to override the stat.
Every confusion you’ve had with ggplot maps onto one of these concepts.
A dataset for today
The storms dataset
Built into the tidyverse (via dplyr). Records of every named Atlantic storm since 1975, drawn from NOAA’s HURDAT2 reanalysis.
storms
# A tibble: 20,778 × 13
name year month day hour lat long status category wind pressure
<chr> <dbl> <dbl> <int> <dbl> <dbl> <dbl> <fct> <dbl> <int> <int>
1 Amy 1975 6 27 0 27.5 -79 tropical d… NA 25 1013
2 Amy 1975 6 27 6 28.5 -79 tropical d… NA 25 1013
3 Amy 1975 6 27 12 29.5 -79 tropical d… NA 25 1013
4 Amy 1975 6 27 18 30.5 -79 tropical d… NA 25 1013
5 Amy 1975 6 28 0 31.5 -78.8 tropical d… NA 25 1012
6 Amy 1975 6 28 6 32.4 -78.7 tropical d… NA 25 1012
7 Amy 1975 6 28 12 33.3 -78 tropical d… NA 25 1011
8 Amy 1975 6 28 18 34 -77 tropical d… NA 30 1006
9 Amy 1975 6 29 0 34.4 -75.8 tropical s… NA 35 1004
10 Amy 1975 6 29 6 34 -74.8 tropical s… NA 40 1002
# ℹ 20,768 more rows
# ℹ 2 more variables: tropicalstorm_force_diameter <int>,
# hurricane_force_diameter <int>
Variables we’ll use
Selected variables in storms.
Variable
Description
name, year, month, day, hour
When the observation was taken
lat, long
Where the storm was
status
tropical depression, tropical storm, hurricane, …
category
Saffir-Simpson scale (1–5), NA for non-hurricanes
wind
Maximum sustained wind speed, knots
pressure
Central pressure, millibars (lower = stronger)
Each row is a 6-hour observation of one storm, so single hurricane shows up many times as it moves and intensifies.
Aesthetic mappings
Mapping vs. setting
The single most common ggplot bug is confusing mapping with setting.
Mapping: a variable controls the aesthetic.
Goes insideaes().
ggplot(storms, aes(x = wind, y = pressure,color = status)) +geom_point()
color varies by status value.
Setting: you fix the aesthetic to a constant.
Goes outsideaes().
ggplot(storms, aes(x = wind, y = pressure)) +geom_point(color ="steelblue")
Every point is steelblue.
The classic mistake
What does this do?
ggplot(storms, aes(x = wind, y = pressure)) +geom_point(aes(color ="steelblue"))
It makes every point a salmon red and adds a legend that says "steelblue".
Why? aes() says “map a variable to color.” We gave it the string "steelblue", which ggplot treated as a one-level categorical variable. It then picked a default color (red-pink) for that one level.
Rule of thumb: if you want a constant color, it goes outsideaes(). If you want it to depend on data, it goes insideaes().
What can you map?
Every geom accepts a subset of these aesthetics. The most common:
Common aesthetics.
Aesthetic
Works on
Notes
x, y
almost everything
position in the frame
color
points, lines, borders
outline of a glyph
fill
bars, areas, filled shapes
interior of a glyph
size
points, lines
numerical only, ideally
alpha
almost everything
transparency, 0–1
shape
points
max 6 discrete values by default
linetype
lines
solid, dashed, dotted, …
group
grouped geoms
splits w/o adding a legend
Discrete vs. continuous aesthetics
ggplot picks a scale automatically based on the type of the variable.
How ggplot scales aesthetics by variable type.
Aesthetic
Discrete variable
Continuous variable
color / fill
qualitative palette (distinct hues)
gradient (e.g., light \(\rightarrow\) dark)
size
warns (order is implied but levels aren’t ordered)
smooth size range
shape
distinct shapes (max 6)
error (shapes don’t have an order)
alpha
warns, same reason as size
smooth
Warning
Mapping a categorical variable like status to size or alpha tells the reader “these categories are ordered”, which is often not actually the case. Stick to color, fill, shape, or facets for categorical splits.
Shape reference
In ggplot, you specify the shape of a point as a number.
Figure 1: R has 26 built-in shapes that are identified by numbers.
Example: status mapped four ways
# Color: good for categoricalggplot(storms, aes(x = wind, y = pressure, color = status)) +geom_point(alpha =0.4)
# Shape: also good, but watch the 6-value limitggplot(storms, aes(x = wind, y = pressure, shape = status)) +geom_point()
Warning: The shape palette can deal with a maximum of 6 discrete values because more
than 6 becomes difficult to discriminate
ℹ you have requested 9 values. Consider specifying shapes manually if you need
that many of them.
Warning: Removed 11048 rows containing missing values or values outside the scale range
(`geom_point()`).
`geom_smooth()` using method = 'gam' and formula = 'y ~ s(x, bs = "cs")'
Only points are colored; the smooth is one curve.
Why local mappings matter
If you put color = status in the global aes(), every layer sees it. geom_smooth() will draw one smoother per status, not one overall.
# One smoother per status (probably not what you want here):ggplot(storms, aes(x = wind, y = pressure, color = status)) +geom_point(alpha =0.2) +geom_smooth()
`geom_smooth()` using method = 'gam' and formula = 'y ~ s(x, bs = "cs")'
Warning: Failed to fit group 1.
Caused by error in `smooth.construct.cr.smooth.spec()`:
! x has insufficient unique values to support 10 knots: reduce k.
Warning: Failed to fit group 5.
Caused by error in `smooth.construct.cr.smooth.spec()`:
! x has insufficient unique values to support 10 knots: reduce k.
Warning: Failed to fit group 6.
Caused by error in `smooth.construct.cr.smooth.spec()`:
! x has insufficient unique values to support 10 knots: reduce k.
Warning: Failed to fit group 7.
Caused by error in `smooth.construct.cr.smooth.spec()`:
! x has insufficient unique values to support 10 knots: reduce k.
Warning: Failed to fit group 8.
Caused by error in `smooth.construct.cr.smooth.spec()`:
! x has insufficient unique values to support 10 knots: reduce k.
Warning: Failed to fit group 9.
Caused by error in `smooth.construct.cr.smooth.spec()`:
! x has insufficient unique values to support 10 knots: reduce k.
# Points colored by status, one global smoother:ggplot(storms, aes(x = wind, y = pressure)) +geom_point(aes(color = status), alpha =0.2) +geom_smooth()
`geom_smooth()` using method = 'gam' and formula = 'y ~ s(x, bs = "cs")'
Where you put the mapping changes the plot. There’s no “right” choice (just be intentional in your choices.)
Different data per layer
Each layer can have its own data. This is how you highlight a subset.
katrina <- storms |>filter(name =="Katrina", year ==2005)ggplot(storms |>filter(year ==2005), aes(x = long, y = lat)) +geom_path(aes(group = name), alpha =0.2) +geom_path(data = katrina, color ="firebrick", linewidth =1.2) +geom_point(data = katrina, color ="firebrick") +labs(title ="2005 Atlantic hurricane season, with Katrina highlighted")
Three layers: all storms (faint), Katrina’s path (red), Katrina’s observations (red dots). Each calls data = locally to override the default.
The group aesthetic
Some geoms — lines, smoothers, polygons — connect multiple rows. They need to know which rows belong together.
If you map a discrete variable to color or linetype, ggplot groups for you automatically. If you don’t, you may need to set group explicitly.
# Bad: one giant line zigzagging between every stormggplot(storms |>filter(year ==2005), aes(x = long, y = lat)) +geom_path()
# Good: one path per stormggplot(storms |>filter(year ==2005), aes(x = long, y = lat, group = name)) +geom_path()
group doesn’t add a legend or change colors, it just splits the geom.
Statistical transformations
Where do bar heights come from?
Here’s a (potentially) tricky question:
ggplot(storms, aes(x = status)) +geom_bar()
We told ggplot about x = status. Where did the y-axis (count) come from?
It was computed.geom_bar() doesn’t just plot raw data. It also transforms it by grouping rows by status and counting them, then plotting the counts.
Every geom has a stat (statistical transformation) it runs under the hood.
Every geom \(\leftrightarrow\) stat pair
Geoms and their default stats.
Geom
Default stat
What it computes
geom_bar()
stat_count()
counts per x value
geom_histogram()
stat_bin()
counts per bin
geom_boxplot()
stat_boxplot()
five-number summary
geom_smooth()
stat_smooth()
fitted curve + CI
geom_density()
stat_density()
kernel density estimate
geom_point()
stat_identity()
nothing — just plots raw
You can use a geom and never think about its stat. But sometimes you will need to think about it.
Three reasons to touch the stat
1. You already pre-computed the values. We can override the default behavior of geom_bar with stat = "identity".
ggplot(storm_counts, aes(x = status, y = n)) +geom_bar(stat ="identity") # or just use geom_col(), it's the same thing
geom_col() is a shortcut for geom_bar(stat = "identity"). You can use it when you have your own y values.
Three reasons to touch the stat
2. You want a computed variable, not the default.
stat_count() actually computes two things: count (default) and prop (proportion). You can ask for the proportion using after_stat():
ggplot(storms, aes(x = status, y =after_stat(prop), group =1)) +geom_bar()
WarningWhy group = 1?
By default, prop is computed within each x group, which means every bar would be 100%. Setting group = 1 tells ggplot “treat all rows as one group when computing proportions” so they sum to 1 across the whole plot.
Three reasons to touch the stat
3. You want to make the transformation explicit. Use a stat_*() function directly.
ggplot(storms, aes(x = status, y = wind)) +stat_summary(fun.min = min,fun.max = max,fun = median )
This shows the min, median, and max wind speed for each status without you having to manually write any dplyr code first. stat_summary() does the aggregation as part of the plot.
after_stat() quick overview
Anywhere you’d write a variable name inside aes(), you can wrap it in after_stat(...) to refer to a computed variable instead of one in the original data.
# Default: bin counts on yggplot(storms, aes(x = wind)) +geom_histogram(binwidth =5)
# Same bins, but density on y insteadggplot(storms, aes(x = wind, y =after_stat(density))) +geom_histogram(binwidth =5)
To see what’s available for a given geom, run ?geom_bar and look for the Computed variables section.
Position adjustments
What’s a position adjustment?
When multiple glyphs would land in the same spot, ggplot has to decide what to do. That decision is the position adjustment.
Each month gets one bar; segments stack by hurricane category.
This is useful for showing total and composition at once, but hard to compare middle segments across bars (their bottoms aren’t aligned).
position = "fill"
Here we have the same code, but every bar is normalized to height 1:
ggplot(hurricanes, aes(x = month, fill =factor(category))) +geom_bar(position ="fill")
Now the y-axis is proportion, not count. Loses information about total volume, but makes the composition easy to compare across months.
position = "dodge"
Here are the bars side-by-side instead of stacked:
ggplot(hurricanes, aes(x = month, fill =factor(category))) +geom_bar(position ="dodge")
This is the easiest version for comparing individual counts directly. The trade-off: lots of bars, can get crowded fast.
TipQuick rule
Stack: show totals, with composition as bonus.
Fill: show composition only, ignore totals.
Dodge: compare individual values, ignore totals.
Jittering — for scatterplots, not bars
A different problem: many storm observations are recorded only every 6 hours, and pressure readings are rounded to whole millibars. Lots of points overlap.
# Lots of overplotting since points are stacked on top of each otherggplot(storms, aes(x = category, y = wind)) +geom_point()
Warning: Removed 15678 rows containing missing values or values outside the scale range
(`geom_point()`).
# Spread them out a bitggplot(storms, aes(x = category, y = wind)) +geom_point(position ="jitter", alpha =0.3)
Warning: Removed 15678 rows containing missing values or values outside the scale range
(`geom_point()`).
# Shorthand for the same thingggplot(storms, aes(x = category, y = wind)) +geom_jitter(alpha =0.3)
Warning: Removed 15678 rows containing missing values or values outside the scale range
(`geom_point()`).
Jitter adds tiny random noise so points stop landing on top of each other. You lose precision but (often) can gain a clear picture of density.
Don’t over-jitter
geom_jitter() takes width and height arguments. Defaults are usually fine, but:
Categorical x: small width (~0.2), height = 0 to keep y meaningful.
Integer x and y: small jitter on both.
Continuous data: you probably don’t need jitter at all, just use alpha or geom_hex()/geom_bin2d() instead.
There are seven slots here. You only want to fill the ones you need to change since ggplot picks sensible defaults for the rest.
That’s why just ggplot() + aes() + geom_*() go a long way on their own. The other slots are there when you need them.
What we covered today
The vocabulary: frame, glyph, aesthetic, scale, guide.
Mapping (inside aes()) vs. setting (outside aes()) and the bug where you mix them up.
Aesthetics: x, y, color, fill, size, shape, alpha, linetype, group, stroke. Discrete vs. continuous behavior.
Geoms: stacking layers, global vs. local mappings, different data per layer, the group aesthetic.
Stats: the hidden transformation in every geom; geom_col() vs geom_bar(); after_stat() for proportions and densities; stat_summary() for ad hoc aggregation.
Position adjustments: identity, stack, fill, dodge, jitter — when to use which.
Part 1: Guided warmup. Five short fill-in-the-blank chunks. Practice each concept from today on a fresh question.
Part 2: Open exploration. Pick one of three guiding questions about the storms data and build the plot you think answers it best. We’ll show a few at the end.
To turn in: Save your .qmd and rendered HTML.
Activity debrief
Things to watch for:
aes(color = "red") instead of color = "red" (and getting confused when nothing turns red.)
A geom_path() that zigzags wildly because group is missing.
geom_bar(stat = "identity") when you already have counts (use geom_col()).
position = "fill" when you actually wanted "dodge" (proportions vs. side-by-side counts.)
Mapping a categorical to size.
Wrap-up & looking ahead
Tomorrow: facets in depth, scales, and color choices, including continuous color scales, sequential vs. diverging palettes, and accessibility.