Lecture 04 — GGPlot II

More geoms, zooming safely, and picking a theme

Bill Perry

2026-09-10

Where we left off (Lecture 03 — GGPlot I)

  • Piping straight into a plot: leaf_df %>% ggplot(...)
  • scale_color_manual() / scale_fill_manual() — colors you choose
  • facet_wrap() and facet_grid() — one panel per group
  • stat_summary() — mean and mean ± SE, drawn from raw data

Note

✅ Key idea from Lecture 03

You can already build a grouped, colored, faceted figure. Today: one more geom, a safer way to zoom, and picking a theme that looks intentional instead of default.

Goals for Today

  • Map shape alongside color, and add a trend line with geom_smooth()
  • Zoom into a plot without silently dropping datacoord_cartesian()
  • Pick a built-in theme and adjust it with theme()
  • Save a figure in the right format for the job

Tip

🖐 Try it yourself

By the end you will recognize the single most common ggplot mistake — zooming with xlim()/ylim() — before you ever make it yourself.

References:

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

Both PDFs are in the course readings/ folder.

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.

Note

✅ Why bother?

xlim() and coord_cartesian() produce plots that look identical for simple scatter plots — the difference only shows up once you compute something (a smooth line, a boxplot). Typing both yourself and comparing outputs is the only way this sticks.

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()

🧩 Chunk 1 of 3 · Shape, Color, and a Trend Line

We will cover: mapping shape alongside color, and geom_smooth().

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

Mapping Shape Alongside Color

Note

🔮 Predict first: We map both color = shade and shape = shade to the same variable. Will ggplot draw one legend or two?

# Map color AND shape to the same variable -------------
leaf_df %>%
  ggplot(aes(x = petiole_mm, y = mass_g, color = shade, shape = shade)) +
  geom_point(size = 2.5, alpha = 0.8) +
  scale_color_manual(values = c("sunny" = "goldenrod", "shady" = "forestgreen")) +
  labs(
    title = "Leaf Mass vs. Petiole Length",
    x = "Petiole Length (mm)", y = "Leaf Mass (g)",
    color = "Shade", shape = "Shade"
  ) +
  theme_minimal()

Scatter plot of leaf mass against petiole length, with sunny leaves shown as gold triangles and shady leaves as green circles.

  • Mapping the same variable to both color and shape merges them into one legend
  • This makes the plot easier to read in black-and-white or for colorblind readers
  • If color and shape legend titles differ in labs(), you get two legends instead — usually not what you want

📖 R4DS §9 — Layers

Adding a Trend Line — geom_smooth()

# geom_smooth() fits a line straight from the data -----
leaf_df %>%
  ggplot(aes(x = petiole_mm, y = mass_g)) +
  geom_point(alpha = 0.6, color = "grey40") +
  geom_smooth(method = "lm", color = "tomato", fill = "tomato", alpha = 0.15) +
  labs(
    title = "Leaf Mass vs. Petiole Length, with Trend Line",
    x = "Petiole Length (mm)", y = "Leaf Mass (g)"
  ) +
  theme_minimal()

Scatter plot of leaf mass against petiole length with a linear trend line and shaded confidence ribbon added.

  • method = "lm" fits a straight line (linear model)
  • The shaded ribbon is the 95% confidence band around the line
  • We’ll fit this same relationship formally with lm() in the Regression unit later this semester

Tip

geom_smooth() is exploration, not a hypothesis test — it shows you whether a line is worth fitting formally before you commit to one.

→ ACTIVITY 4 Parts 1–2 now

🛑 Do Activity Parts 1–2 now

Load the data, map shape and color to shade, then add a geom_smooth() trend line. Predict, type, run.

🧩 Chunk 2 of 3 · Zooming Without Losing Data

We will cover: coord_cartesian() vs. xlim()/ylim() — they look the same until they don’t.

🛑 After this chunk you will do Activity Part 3.

coord_cartesian() — Zoom Without Removing Data

# coord_cartesian() zooms the VIEW, keeps all the data -
leaf_df %>%
  ggplot(aes(x = petiole_mm, y = mass_g)) +
  geom_point(alpha = 0.6, color = "grey40") +
  geom_smooth(method = "lm", color = "tomato", fill = "tomato", alpha = 0.15) +
  coord_cartesian(xlim = c(50, 70), ylim = c(0.4, 0.6)) +
  labs(
    title = "Zoomed with coord_cartesian()",
    x = "Petiole Length (mm)", y = "Leaf Mass (g)"
  ) +
  theme_minimal()

Scatter plot of leaf mass against petiole length zoomed to a narrower window using coord_cartesian, with the same underlying data and trend line as the full plot.

  • Zooms the view — every point still contributes to the trend line
  • The trend line is fit on all 53 leaves, then the plot window is cropped afterward
  • Safe to use anytime you just want a closer look

xlim() / ylim() — Removes Data First

Note

🔮 Predict first: If we drop the leaves outside our zoom window before fitting the trend line, will the line look the same as before, or different?

# xlim()/ylim() DELETE rows outside the range first ----
leaf_df %>%
  ggplot(aes(x = petiole_mm, y = mass_g)) +
  geom_point(alpha = 0.6, color = "grey40") +
  geom_smooth(method = "lm", color = "tomato", fill = "tomato", alpha = 0.15) +
  xlim(50, 70) +
  ylim(0.4, 0.6) +
  labs(
    title = "Zoomed with xlim() / ylim() — different line!",
    x = "Petiole Length (mm)", y = "Leaf Mass (g)"
  ) +
  theme_minimal()

Scatter plot of leaf mass against petiole length using xlim and ylim, showing a trend line refit only on the remaining visible points after out-of-range leaves were silently dropped.

Warning

⚠️ Watch out!

xlim()/ylim() silently delete rows outside the range before anything is computed. The trend line above is fit only on the leaves that remain — a different line than coord_cartesian() produced, with no warning that data went missing.

Rule of thumb: use coord_cartesian() to zoom. Only use xlim()/ylim() when you actually want those rows excluded from the analysis, not just out of view.

→ ACTIVITY 4 Part 3 now

🛑 Do Activity Part 3 now

Zoom the same plot two ways and compare the trend lines. Predict, type, run.

🧩 Chunk 3 of 3 · Themes and Saving

We will cover: picking a built-in theme, theme() tweaks, and choosing a file format with ggsave().

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

Picking a Theme

# theme_classic() — clean, close to journal defaults ---
leaf_df %>%
  ggplot(aes(x = shade, y = mass_g, fill = shade)) +
  geom_boxplot(alpha = 0.6, outlier.shape = NA) +
  scale_fill_manual(values = c("sunny" = "goldenrod", "shady" = "forestgreen")) +
  labs(x = "Shade", y = "Leaf Mass (g)", title = "Leaf Mass by Shade") +
  theme_classic() +
  theme(legend.position = "none")

Boxplot of leaf mass by shade rendered in theme_classic, with a white background and axis lines only.

Five built-in themes to try on the same plot:

Theme Look
theme_grey() default — grey background
theme_bw() white background, grey gridlines
theme_minimal() no background, subtle gridlines
theme_classic() white background, axis lines only
theme_light() light grey lines and border

Tip

theme_classic() is a safe default for scientific figures — clean, uncluttered, close to what most journals expect.

Adjusting a Theme with theme()

# theme() overrides individual elements afterward -----
leaf_df %>%
  ggplot(aes(x = shade, y = mass_g, fill = shade)) +
  geom_boxplot(alpha = 0.6, outlier.shape = NA) +
  scale_fill_manual(values = c("sunny" = "goldenrod", "shady" = "forestgreen")) +
  labs(x = "Shade", y = "Leaf Mass (g)", title = "Leaf Mass by Shade") +
  theme_classic() +
  theme(
    legend.position = "none",
    axis.text = element_text(size = 12),
    axis.title = element_text(size = 13)
  )

The same boxplot of leaf mass by shade, with legend removed and larger axis text via theme adjustments.

  • Pick a built-in theme first (theme_classic()), then add theme() afterward to tweak individual pieces
  • legend.position = "none" — drop a redundant legend
  • axis.text / axis.title — control tick labels and axis titles separately

Note

✅ Key idea

You rarely need to build a theme from scratch. Start from a built-in theme and override only what looks wrong.

Saving in the Right Format

leaf_plot <- leaf_df %>%
  ggplot(aes(x = shade, y = mass_g, fill = shade)) +
  geom_boxplot(alpha = 0.6, outlier.shape = NA) +
  scale_fill_manual(values = c("sunny" = "goldenrod", "shady" = "forestgreen")) +
  labs(x = "Shade", y = "Leaf Mass (g)") +
  theme_classic() +
  theme(legend.position = "none")

# PNG — good for Word docs, slides, web
ggsave("figures/leaf_mass_theme.png",
       plot = leaf_plot, width = 5, height = 5,
       units = "in", dpi = 300)

# PDF — vector, scales to any size, good for print
ggsave("figures/leaf_mass_theme.pdf",
       plot = leaf_plot, width = 5, height = 5, units = "in")
Format Best for Scales?
.png Word docs, slides, web ❌ fixed pixels
.pdf Print, journals ✅ infinite
.tiff Journal submission ❌ fixed pixels

Warning

⚠️ Watch out! ggsave() takes the filename first, then plot =. Always name the plot object explicitly — do not rely on ggsave grabbing “the last plot shown.”

→ ACTIVITY 4 Parts 4–5 now

🛑 Go to Activity 4 — Parts 4–5

Close the slides. Pick a theme, tweak it, and save your plot as both PNG and PDF.

What We Learned Today

  • Map shape alongside color to merge legends
  • geom_smooth(method = "lm") — a quick trend line, not a hypothesis test
  • coord_cartesian() zooms safely; xlim()/ylim() silently deletes rows first
  • Built-in themes (theme_classic(), etc.) + theme() for small overrides
  • .png for documents/slides, .pdf for print/journals

References:

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

Up next — Lecture 05, Wrangling:

  • filter(), select(), mutate(), arrange()
  • Chaining verbs into one pipeline