Lecture 03 — GGPlot I

Grouped summaries, facets, and the pipe into ggplot

Bill Perry

2026-09-10

Where we left off (Lecture 02 — Meeting R)

  • Project structuredata/, scripts/, figures/
  • Packagesinstall.packages() once, library() every session
  • Loading dataread_excel(), clean_names() into leaf_df
  • The pipe%>% reads as “then”
  • Our first plot — a bare geom_point()

Note

✅ Key idea from Lecture 02

You can already load data and make one honest point on a page. Today that one geom becomes a whole toolkit: custom colors, side-by-side panels, and a proper mean ± SE summary.

Goals for Today

  • Start every plot with the pipe: leaf_df %>% ggplot(...)
  • Assign colors on purpose with scale_color_manual()
  • Split one plot into panels with facet_wrap() and facet_grid()
  • Build a mean ± SE plot with stat_summary()

Tip

🖐 Try it yourself

By the end you will make a publication-style figure: raw leaf data, grouped and colored on purpose, with the mean and its uncertainty layered on top.

References:

  • 📖 Whitlock & Schluter, Ch. 2 — Displaying Data
  • 📖 R4DS Ch 1 — Data Visualization
  • 📖 R4DS Ch 9 — Layers

Both PDFs are in the course readings/ folder.

Naming conventions:

  • data frames → _df
  • plots → _plot

How to Use These Slides — Predict · Type · Run

This lecture runs in three short chunks. After each chunk you switch to the activity and type the code yourself into your R script.

For every code block, do three things:

  1. Predict — before it runs, say what you think the output will be
  2. Type it out by hand — do not copy-paste
  3. Run it and compare to your prediction

Note

✅ Why bother?

  • facet_wrap() and facet_grid() look interchangeable until you’ve typed both and watched one wrap into rows while the other insists on a strict grid.
  • Typing scale_color_manual(values = c(...)) yourself is what makes you notice the color names have to match your group names exactly.

Load Libraries and Data

# Load all packages at the top of every script ----------
library(readxl) # reading Excel files
library(tidyverse) # data wrangling + ggplot2
library(janitor) # cleans up messy column names
# Read the leaf data from the data folder ---------------
leaf_df <- read_excel("data/2026_09_03_data_sci_leaf_area.xlsx") %>%
  clean_names()

glimpse(leaf_df)
Rows: 53
Columns: 8
$ twig_id      <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, "twig_1",…
$ leaf_id      <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, "l07", "l…
$ teams        <chr> "12345", "12345", "12345", "12345", "12345", "12345", "12…
$ shade        <chr> "sunny", "sunny", "sunny", "sunny", "sunny", "sunny", "sh…
$ mass_g       <dbl> 0.4000, 0.4800, 0.3400, 0.6500, 0.2700, 0.4300, 0.3900, 0…
$ petiole_mm   <dbl> 79.0000, 63.0000, 68.0000, 35.0000, 34.0000, 40.0000, 40.…
$ thickness_mm <dbl> 0.15, 0.14, 0.14, 0.15, 0.11, 0.16, 0.14, 0.15, 0.12, 0.1…
$ paper_mass_g <dbl> 0.2100, 0.2100, 0.2100, 0.2400, 0.1500, 0.3400, 0.2900, 0…

Positions — Define Once, Reuse on Every Layer

# a "position" is HOW a layer nudges its shapes sideways
# define each one once, up top, then reuse it on every layer

# raw points: scatter them so they don't stack on one line
jitter_pos <- position_jitter(width = 0.15, seed = 42)

# grouped summaries / lines: shift the groups side by side
dodge_pos <- position_dodge(width = 0.2)
  • position_jitter(width = 0.15) — random sideways nudge so overlapping points become visible; seed = 42 freezes that nudge so the figure looks the same every render
  • position_dodge(width = 0.2) — shifts groups apart; does nothing with one group per x, but the moment you add a second grouping variable (color = sex) it fans the groups out
  • Reusing one dodge_pos on geom_point(), geom_errorbar(), and geom_line() is what keeps a grouped mean, its error bar, and its connecting line perfectly aligned

🧩 Chunk 1 of 3 · Piping into ggplot, on Purpose

We will cover: starting a plot with the pipe, and choosing colors with scale_color_manual().

🛑 After this chunk you will do Activity Parts 1–2.

Every Plot Starts the Same Way

# Pipe the data frame straight into ggplot() -----------
leaf_df %>%
  ggplot(aes(x = shade, y = mass_g, fill = shade)) +
  geom_boxplot(alpha = 0.6, outlier.shape = NA) +
  geom_point(position = jitter_pos, alpha = 0.6) +
  labs(
    title = "Leaf Mass by Shade",
    x = "Shade",
    y = "Leaf Mass (g)",
    fill = "Shade"
  ) +
  theme_minimal()

Boxplot of leaf mass in grams by shade, with jittered points overlaid, using default ggplot colors.

  • leaf_df %>% ggplot(...) reads as “take leaf_df, then plot it” — the same pipe you’ve already used
  • Everything after ggplot() is added with +, not %>%ggplot layers use plus, pipelines use the pipe
  • fill = shade maps a column to color — ggplot chooses the colors for you

Warning

⚠️ Watch out!

%>% connects data-wrangling steps. + connects plot layers. Mixing them up is the single most common ggplot error.

Choosing Colors on Purpose — scale_color_manual()

Note

🔮 Predict first: scale_fill_manual() needs one color per group. We have two groups (sunny, shady). How many colors do we need to supply?

# scale_fill_manual() assigns exact colors to exact groups
leaf_df %>%
  ggplot(aes(x = shade, y = mass_g, fill = shade)) +
  geom_boxplot(alpha = 0.6, outlier.shape = NA) +
  geom_point(position = jitter_pos, alpha = 0.6) +
  scale_fill_manual(values = c(
    "sunny" = "goldenrod",
    "shady" = "forestgreen"
  )) +
  labs(
    title = "Leaf Mass by Shade",
    x = "Shade",
    y = "Leaf Mass (g)",
    fill = "Shade"
  ) +
  theme_minimal() +
  theme(legend.position = "none")

Boxplot of leaf mass in grams by shade with jittered points, using custom gold and forest-green colors for sunny and shady respectively.

  • scale_fill_manual() — for fill = aesthetics (boxes, bars, columns)
  • scale_color_manual() — for color = aesthetics (points, lines)
  • The names inside c() must exactly match the values in your data ("sunny", "shady") — case and spelling both count

Warning

⚠️ Watch out!

If a name in values = c(...) doesn’t match a value in your data exactly, that group’s color falls back to grey and ggplot prints a warning.

→ ACTIVITY 3 Parts 1–2 now

🛑 Do Activity Parts 1–2 now

Pipe into ggplot(), then assign sunny/shady their own colors with scale_fill_manual(). Predict, type, run.

🧩 Chunk 2 of 3 · Small Multiples with Facets

We will cover: facet_wrap() and facet_grid() — splitting one plot into a panel per group.

🛑 After this chunk you will do Activity Part 3.

facet_wrap() — One Panel per Group

# facet_wrap() splits into one panel per level ---------
leaf_df %>%
  ggplot(aes(x = mass_g, fill = shade)) +
  geom_histogram(binwidth = 0.05, color = "white") +
  facet_wrap(~shade) +
  scale_fill_manual(values = c("sunny" = "goldenrod", "shady" = "forestgreen")) +
  labs(x = "Leaf Mass (g)", y = "Count", title = "Mass Distribution by Shade") +
  theme_minimal() +
  theme(legend.position = "none")

Two histograms of leaf mass in grams, one panel per shade, arranged side by side by facet_wrap.

  • ~shade reads as “by shade” — the ~ is required
  • Each panel gets its own copy of the plot, filtered to that group
  • facet_wrap(~shade, scales = "free_y") lets each panel’s y-axis rescale independently

📖 R4DS §9 — Layers

facet_grid() — a Strict Rows-and-Columns Grid

# facet_grid(rows ~ cols) — use "." for "no grouping" --
leaf_df %>%
  ggplot(aes(x = mass_g, fill = shade)) +
  geom_histogram(binwidth = 0.05, color = "white") +
  facet_grid(shade ~ .) +
  scale_fill_manual(values = c("sunny" = "goldenrod", "shady" = "forestgreen")) +
  labs(x = "Leaf Mass (g)", y = "Count") +
  theme_minimal() +
  theme(legend.position = "none")

Two histograms of leaf mass stacked in one column, one row per shade, arranged by facet_grid.

  • facet_grid(shade ~ .) stacks panels in rows; facet_grid(. ~ shade) arranges them in columns
  • The dot . means “nothing on this side of the grid”
  • facet_grid() really shines with two grouping variables (rows ~ cols) — we only have one (shade) today, but you’ll use the two-variable form the moment your data has a second grouping column

Note

💡 facet_wrap vs facet_grid

Use facet_wrap() for one grouping variable — it wraps panels into a grid automatically. Use facet_grid() when you have two variables and want rows and columns to each mean something specific.

→ ACTIVITY 3 Part 3 now

🛑 Do Activity Part 3 now

Build both a facet_wrap() and a facet_grid() version of the histogram. Predict, type, run.

🧩 Chunk 3 of 3 · Mean ± SE with stat_summary()

We will cover: layering a group mean and its standard error directly on top of raw data — no summary table needed.

🛑 After this chunk you will do Activity Parts 4–5.

stat_summary() — the Mean as a Layer

# stat_summary() computes the mean straight from the data
leaf_df %>%
  ggplot(aes(x = shade, y = mass_g, color = shade)) +
  geom_point(position = jitter_pos, alpha = 0.4, size = 2) +
  stat_summary(fun = mean, geom = "point", size = 4, color = "black",
               position = dodge_pos) +
  scale_color_manual(values = c("sunny" = "goldenrod", "shady" = "forestgreen")) +
  labs(
    title = "Leaf Mass with Group Means",
    x = "Shade", y = "Leaf Mass (g)"
  ) +
  theme_minimal() +
  theme(legend.position = "none")

Jittered points of leaf mass by shade in gold and green, with a larger black point marking the mean for each group.

  • stat_summary() computes a statistic from the raw data and draws it — no pre-built summary table needed
  • fun = mean — any function that returns one number (mean, median, max, …)
  • geom = "point" — draw that number as a point
  • Put the raw geom_point() layer before stat_summary() so the mean sits on top of the raw points, not buried under them

stat_summary() — Mean ± SE

Note

🔮 Predict first: fun.data = mean_se returns three numbers per group instead of one (the mean, and the bar’s top and bottom). What do you think those three are called?

# fun.data = returns THREE values: y, ymin, ymax -------
leaf_mean_se_plot <- leaf_df %>%
  ggplot(aes(x = shade, y = mass_g, color = shade)) +
  geom_point(position = jitter_pos, alpha = 0.35, size = 2) +
  stat_summary(fun = mean, geom = "point", size = 4,
               position = dodge_pos) +
  stat_summary(
    fun.data = mean_se,
    geom = "errorbar",
    width = 0.15,
    linewidth = 0.9,
    position = dodge_pos
  ) +
  scale_color_manual(values = c("sunny" = "goldenrod", "shady" = "forestgreen")) +
  labs(
    title = "Mean ± SE Leaf Mass by Shade",
    x = "Shade", y = "Leaf Mass (g)"
  ) +
  theme_minimal() +
  theme(legend.position = "none")

leaf_mean_se_plot

Jittered points of leaf mass by shade in gold and green with mean plus or minus one standard error error bars overlaid for each group.

Two stat_summary() layers, stacked:

call what it draws
fun = mean a point at the mean
fun.data = mean_se error bars for ± 1 SE
  • Raw points in the background (alpha = 0.35)
  • Mean as a larger foreground point
  • Error bars = ± 1 SE
  • The mean point and the error bar share one position = dodge_pos, so they always line up. With one group per x it has no visible effect yet — add color = sex and the same dodge_pos fans the two groups apart, and you’d hang a geom_line(position = dodge_pos) off it to connect them

📖 Whitlock & Schluter, Ch. 3 — Describing Data

→ ACTIVITY 3 Parts 4–5 now

🛑 Go to Activity 3 — Parts 4–5

Close the slides. Build your own mean ± SE plot, then save it to figures/.

What We Learned Today

  • Pipe straight into a plot: leaf_df %>% ggplot(...)
  • scale_color_manual() / scale_fill_manual() — colors you choose, not colors ggplot guesses
  • facet_wrap(~var) — one panel per group, wraps automatically
  • facet_grid(rows ~ cols) — strict grid, best with two grouping variables
  • stat_summary() — mean and mean ± SE, drawn directly from raw data
  • Positions defined once at the top (jitter_pos, dodge_pos) and reused on every layer

References:

  • 📖 Whitlock & Schluter, Ch. 2 — Displaying Data
  • 📖 R4DS Ch 1 — Data Visualization
  • 📖 R4DS Ch 9 — Layers

Up next — Lecture 04, GGPlot II:

  • More geoms
  • coord_cartesian() vs. xlim()
  • Themes