Lecture: Graphing Effectively

Boxplots, axis limits, a reusable themes file, multi-panel figures, and saving publication-ready output

Graphing
ggplot2
R
Boxplots with jittered raw points, axis labels with labs(), mapping color and fill by group, setting axis limits honestly with coord_cartesian(), plotting mean ± SE with stat_summary(), sourcing a reusable themes file for publication-ready figures, combining panels with patchwork, and a real ggsave() drill — all on the pine needle data the class collected.
Author

Bill Perry

Where We Left Off

Last time you:

  • Computed mean, median, SD, and SE — by hand and with summarize()
  • Fixed the length() trap with sum(!is.na())
  • Got tidy per-group tables with group_by() + summarize()
Note

✅ Key idea from Describing Your Data

You now have real numbers describing shady vs. sunny needles. Today we make those numbers visible — and save the result properly.

Note

Today’s roadmap

  1. The ggplot grammar, recapped
  2. Boxplots done right — with raw points
  3. Axis labels and titles with labs()
  4. Axis limits with coord_cartesian()
  5. Mapping color by group
  6. Mean ± SE with stat_summary()
  7. A themes file you source() once — publication-ready figures
  8. patchwork — many plots on one page
  9. ggsave(), for real this time

⏱ Histograms, density curves, faceting, and scale_color_manual() are in the notes as time allows — the worksheet has them either way.

Part 1 · The ggplot Grammar, Recapped

The ggplot Grammar, Recapped

Every ggplot is three pieces joined with +:

  1. data — which data frame
  2. aes() — which columns map to x, y, color, fill…
  3. geom — how to draw it
library(tidyverse)
library(readxl)

pine_df <- read_csv("data/pine_data.csv")

📥 Download the data

Note

✅ Key idea

Everything today is the same three pieces — we’re just adding more layers and more polish on top of them.

Note

📖 Reference

Whitlock & Schluter, The Analysis of Biological Data, Ch. 2 — Displaying Data, is the core reference for this whole lecture.

📖 R4DS §1 — Data visualization and §9 — Layers

Tip

🖐 Recap from Getting Started

ggplot(pine_df, aes(x = side, y = needle_length_mm)) +
  geom_point()

Scatterplot of pine needle length in millimeters by side of tree (side), with individual points shown for each side.

Part 2 · Boxplot Anatomy

Boxplot Anatomy

pine_box_plot <- ggplot(pine_df, aes(x = side, y = needle_length_mm, fill = side)) +
  geom_boxplot(alpha = 0.6, outlier.shape = NA) +
  labs(
    title = "Needle Length by Side of Tree",
    x = "Side of tree",
    y = "Needle length (mm)",
    fill = "Side"
  ) +
  theme_minimal(base_size = 9) +
  theme(legend.position = "none")

pine_box_plot

Boxplot of needle length by side of tree (shady vs. sunny), filled by side, with outlier points hidden. Titled 'Needle Length by Side of Tree'.

Boxplot anatomy:

  • Middle line = median
  • Box edges = 25th and 75th percentile (IQR)
  • Whiskers = 1.5 × IQR
  • Dots beyond whiskers = outliers

Whitlock & Schluter, Ch. 2 — Displaying Data, on the boxplot and the five-number summary it draws.

📖 R4DS §9 — Layers

Boxplot + Raw Points

Note

🔮 Predict first: With only 8 needles per side per tree, does a boxplot alone show you everything? What might it hide?

pine_jitter_plot <- ggplot(pine_df, aes(x = side, y = needle_length_mm, fill = side)) +
  geom_boxplot(alpha = 0.5, outlier.shape = NA) +
  geom_point(
    position = position_jitter(width = 0.15),
    alpha = 0.6,
    size = 2
  ) +
  labs(
    title = "Needle Length by Side of Tree",
    x = "Side of tree",
    y = "Needle length (mm)",
    fill = "Side"
  ) +
  theme_minimal(base_size = 9) +
  theme(legend.position = "none")

pine_jitter_plot

Boxplot of needle length by side of tree with individual jittered data points overlaid on top of each box, so every raw observation is visible alongside the summary.

  • position_jitter(width = 0.15) spreads points sideways so they don’t overlap
  • alpha = 0.6 — semi-transparent, so overlapping points are still visible
Tip

Always show the raw points alongside a boxplot when n is small — every point matters, and a box alone can hide a bimodal or lopsided pattern.

Note

📖 Reference

Whitlock & Schluter, Ch. 2 — Displaying Data, “How to draw a good graph,” makes exactly this argument: show the data, not only the summary.

▶ Your Turn — Code It Now

Important

Stop here and write code.

Worksheet Parts 1, 2 and 3 — load the data, build the boxplot, add the raw points.

Come back when it runs. If it does not run, that is the interesting part — bring the error.

Part 2b · Axis Labels and Titles, Done Right

Before labs() — Never Ship This

ggplot(pine_df, aes(x = side, y = needle_length_mm, fill = side)) +
  geom_boxplot(alpha = 0.6, outlier.shape = NA) +
  theme_minimal(base_size = 9) +
  theme(legend.position = "none")

Boxplot of needle length by side of tree with no labs() applied, so the axes are labeled with the raw column names side and needle_length_mm instead of readable text.

Important

The x-axis reads side and the y-axis reads needle_length_mm — the literal column names, not something a reader outside your lab would understand.

Warning

⚠️ Watch out!

A plot with default column-name axes is a plot that only makes sense to you, today. Six months from now, even you may not remember what side meant.

labs() — Every Piece of Text on the Plot

ggplot(pine_df, aes(x = side, y = needle_length_mm, fill = side)) +
  geom_boxplot(alpha = 0.6, outlier.shape = NA) +
  labs(
    title = "Needle Length by Side of Tree",
    subtitle = "Four field teams, four trees",
    x = "Side of tree",
    y = "Needle length (mm)",
    fill = "Side",
    caption = "Data: pine_data.csv"
  ) +
  theme_minimal(base_size = 9) +
  theme(legend.position = "none")

Boxplot of needle length by side of tree with a full title, subtitle, readable axis labels, legend title, and data-source caption added via labs().

One function, five jobs:

Argument Controls
title main title
subtitle smaller text under the title
x / y axis labels
caption small text, bottom-right
color/fill legend title for that aesthetic
Note

✅ Key idea

labs() never touches the data or the statistics — only what a reader sees written on the plot. Change it freely.

Note

labs() — the Legend Title Trap

# Whichever aesthetic name you mapped, use that same name in labs()
ggplot(pine_df, aes(x = side, y = needle_length_mm, color = team)) +
  geom_point(position = position_jitter(width = 0.15)) +
  labs(color = "Field team") # matches aes(color = team)

Jittered scatterplot of needle length by side of tree, colored by field team, with the legend relabeled 'Field team'.

Warning

⚠️ Watch out!

The legend title comes from whatever you write for the aesthetic you mapped — color = "Field team" in labs() relabels a color = mapping. Write fill = "..." instead and it does nothing, because this plot never mapped fill.

▶ Your Turn — Code It Now

Important

Stop here and write code.

Worksheet Part 3b — write real titles, axis labels, and a legend title with labs().

Come back when it runs. If it does not run, that is the interesting part — bring the error.

Part 2c · Setting the Axis Limits — coord_cartesian()

coord_cartesian() — Choosing What the Reader Sees

ggplot(pine_df, aes(x = side, y = needle_length_mm, fill = side)) +
  geom_boxplot(alpha = 0.6, outlier.shape = NA) +
  labs(
    title = "Needle Length by Side of Tree",
    x = "Side of tree",
    y = "Needle length (mm)"
  ) +
  coord_cartesian(ylim = c(0, 25)) +
  theme_minimal(base_size = 9) +
  theme(legend.position = "none")

Boxplot of needle length by side of tree with the y-axis set to run from 0 to 25 mm using coord_cartesian, so the two boxes sit in the upper half of the panel against a zero baseline.

ggplot picks axis limits from your data. That is a fine default for exploring — and the wrong default the moment a reader has to judge how big a difference is.

  • coord_cartesian(ylim = c(0, 25)) — set the y-axis range
  • coord_cartesian(xlim = c(...)) — same for x
  • Both at once: coord_cartesian(xlim = c(...), ylim = c(...))
Note

✅ Key idea

Starting a measurement axis at zero is what keeps a 1 mm difference from looking like a canyon. Decide the limits deliberately; don’t inherit them.

coord_cartesian() — Both Axes at Once

pine_scatter_plot <- ggplot(pine_df, aes(x = needle_length_mm, y = needle_width_mm,
                                         color = side)) +
  geom_point(alpha = 0.6, size = 2) +
  labs(
    title = "Needle Width vs. Length",
    x = "Needle length (mm)",
    y = "Needle width (mm)",
    color = "Side"
  ) +
  coord_cartesian(xlim = c(5, 25), ylim = c(0.4, 1.8)) +
  theme_minimal(base_size = 9)

pine_scatter_plot

Scatterplot of needle width against needle length, colored by side of tree, with both axes set using coord_cartesian to x from 5 to 25 mm and y from 0.4 to 1.8 mm, a window that comfortably contains every point.

Tip

🖐 Notice

Two continuous axes, so both xlim = and ylim = are useful here. On the boxplot, x was a category — there is nothing to zoom.

Note

The Trap — ylim() Deletes Data, coord_cartesian() Does Not

Note

🔮 Predict first: Both plots below ask for a y-axis running 14 to 16 mm. One of them changes where the boxes are drawn. Which one, and why?

# ❌ ylim() THROWS AWAY every row outside the range first
ggplot(pine_df, aes(x = side, y = needle_length_mm, fill = side)) +
  geom_boxplot(alpha = 0.6) +
  ylim(14, 16) +
  labs(title = "ylim() — boxes redrawn from survivors") +
  theme_minimal(base_size = 8) +
  theme(legend.position = "none")

Boxplot of needle length by side with ylim used to restrict the axis to 14 to 16 mm, which silently removes every needle outside that range and redraws the boxes from the surviving rows only.

# ✅ coord_cartesian() keeps every row and just crops the view
ggplot(pine_df, aes(x = side, y = needle_length_mm, fill = side)) +
  geom_boxplot(alpha = 0.6) +
  coord_cartesian(ylim = c(14, 16)) +
  labs(title = "coord_cartesian() — same boxes, zoomed") +
  theme_minimal(base_size = 8) +
  theme(legend.position = "none")

The same boxplot with coord_cartesian used to zoom to 14 to 16 mm, which keeps every row in the calculation and simply crops the view, so the medians and quartiles are unchanged.

Warning

⚠️ Watch out!

ylim(14, 16) and scale_y_continuous(limits = c(14, 16)) filter the data before the geom draws it. Every needle outside 14–16 mm is silently dropped, and the median, the quartiles, and the whiskers are all recomputed from what is left. R warns about “removed rows” and students read past it every time.

coord_cartesian() computes the statistics on all the data and then zooms the camera. The boxes do not move.

Tip

🖐 The rule

Use coord_cartesian() to zoom. Use filter() when you actually mean to remove data — on purpose, in the data step, where you can see it.

Note

📖 Worth knowing — changing the axis scale, not just its limits

Right-skewed data (a few very large values squashing everything else into a corner) is usually better read on a log axis:

+ scale_x_log10()      # the AXIS is logged; the data are untouched

That is different again from logging the data itself with mutate(log_length = log(needle_length_mm)), which changes the numbers every later calculation sees.

Nothing today needs it — our needles are not skewed. Try it on your scatterplot if you are curious, and we will come back to it when a distribution actually calls for it.

▶ Your Turn — Code It Now

Important

Stop here and write code.

Worksheet Part 3c — set your axis limits with coord_cartesian(). The ylim() trap stays on screen; you do not need to type it.

Come back when it runs. If it does not run, that is the interesting part — bring the error.

Part 3 · Mapping Color by Group

Mapping Color by Group

pine_team_plot <- ggplot(pine_df, aes(x = side, y = needle_length_mm, color = team)) +
  geom_point(
    position = position_jitter(width = 0.15),
    size = 2.5,
    alpha = 0.8
  ) +
  labs(
    title = "Needle Length by Side, Colored by Team",
    x = "Side of tree",
    y = "Needle length (mm)",
    color = "Field team"
  ) +
  theme_minimal(base_size = 9)

pine_team_plot

Jittered scatterplot of needle length by side of tree, with points colored by field team, titled 'Needle Length by Side, Colored by Team'.

  • color = team maps a third variable onto the plot — no new geom needed
  • ggplot auto-builds the legend and picks colors for you
  • Mapping inside aes() always means “vary this by data” — not “make everything this color”
Warning

⚠️ Watch out!

color = "darkblue" outside aes() sets one fixed color. color = team inside aes() maps colors to a variable. Mixing these up is one of the most common ggplot mistakes.

Note

📖 Worth knowing — where you put aes() matters too

An aes() inside ggplot() is global: every layer inherits it. An aes() inside a single geom applies to that layer only:

# global — both layers colored by side
ggplot(pine_df, aes(x = side, y = needle_length_mm, color = side)) +
  geom_point() + stat_summary(fun = mean, geom = "point", size = 4)

# per-layer — points colored by team, the mean drawn in plain black
ggplot(pine_df, aes(x = side, y = needle_length_mm)) +
  geom_point(aes(color = team)) +
  stat_summary(fun = mean, geom = "point", size = 4)

Just know it exists for now — we will lean on it later, when a plot has to show raw data and a fitted model at the same time.

▶ Your Turn — Code It Now

Important

Stop here and write code.

Worksheet Part 4 — map color = / fill = to a third variable.

Come back when it runs. If it does not run, that is the interesting part — bring the error.

Part 3b · Histograms and Density — Seeing the Whole Distribution

Note

⏱ If time allows — everything in this part is on the worksheet, so it is safe to work through on your own if we run short in class.

Histograms — the Shape Behind the Summary

Note

🔮 Predict first: In Describing Your Data we found the overall mean(needle_length_mm) ≈ 14.67 and the median ≈ 14.55 — nearly identical. What shape do you expect the full distribution to have — symmetric, or skewed?

ggplot(pine_df, aes(x = needle_length_mm)) +
  geom_histogram(binwidth = 2, fill = "darkblue", color = "white") +
  labs(
    title = "Distribution of Needle Length",
    x = "Needle length (mm)",
    y = "Count"
  ) +
  theme_minimal(base_size = 9)

Histogram of needle length in millimeters across all needles, with 2mm bins, showing the overall shape of the distribution.

  • binwidth sets the width of each bar in data units (here, 2mm)
  • Too wide hides structure; too narrow shows noise — try a few values
Tip

🖐 Try it yourself

Change binwidth = 2 to binwidth = 0.5 and to binwidth = 5. Which tells the real story?

Note

📖 Reference

Whitlock & Schluter, Ch. 2 — Displaying Data, on frequency distributions and choosing bin width.

📖 R4DS §10.3 — Variation

Histograms by Group — fill and facet_wrap() Together

ggplot(pine_df, aes(x = needle_length_mm, fill = side)) +
  geom_histogram(binwidth = 2, color = "white", alpha = 0.7) +
  facet_wrap(~side, ncol = 1) +
  labs(x = "Needle length (mm)", y = "Count", fill = "Side") +
  theme_minimal(base_size = 8) +
  theme(legend.position = "none")

Two stacked histograms of needle length, one panel for the shady side and one for the sunny side, faceted so each group's distribution is shown separately.

Note

✅ Key idea

Overlapping histograms on one panel are hard to read. Faceting stacks them into separate panels sharing an x-axis — same comparison, much clearer.

Note

Density Curves — a Smoothed Alternative

ggplot(pine_df, aes(x = needle_length_mm, fill = side)) +
  geom_density(alpha = 0.5) +
  labs(
    title = "Needle Length Density by Side",
    x = "Needle length (mm)",
    y = "Density",
    fill = "Side"
  ) +
  theme_minimal(base_size = 9)

Overlaid density curves of needle length for the shady and sunny sides, semi-transparent so both distributions are visible on one panel.

Tip

📖 Histogram vs. density

A density curve is a smoothed histogram, rescaled so the area under each curve sums to 1. It overlays cleanly for group comparisons — no faceting required, at the cost of hiding the raw bin counts.

Note

📖 Reference

Gotelli & Ellison, A Primer of Ecological Statistics, Ch. 3 — Summary Statistics, on the shape of a distribution behind any mean and SD you report.

▶ Your Turn — Code It Now

Important

Stop here and write code.

Worksheet Part 4b — histograms and density curves. ⏱ Skip it if we are short; nothing later depends on it.

Come back when it runs. If it does not run, that is the interesting part — bring the error.

Part 4 · Mean ± SE — Plotted Directly

Mean ± SE — Plotted Directly

pine_mean_se_plot <- ggplot(pine_df, aes(x = side, y = needle_length_mm, color = side)) +
  geom_point(
    position = position_jitter(width = 0.15),
    alpha = 0.3,
    size = 2
  ) +
  stat_summary(fun = mean, geom = "point", size = 4) +
  stat_summary(
    fun.data = mean_se,
    geom = "errorbar",
    width = 0.15,
    linewidth = 0.9
  ) +
  labs(
    title = "Mean ± SE Needle Length by Side",
    x = "Side of tree",
    y = "Needle length (mm)"
  ) +
  theme_minimal(base_size = 9) +
  theme(legend.position = "none")

pine_mean_se_plot

Scatterplot of jittered raw needle-length points by side of tree, with a large point marking the mean and an error bar showing ± 1 standard error for each side.

Two stat_summary() layers:

call draws
fun = mean one large point at the mean
fun.data = mean_se error bars for ± 1 SE

stat_summary() computes the mean and SE for you, straight from the raw data — no summarize() step required first.

Warning

⚠️ Convenient, but read the fine print

“Straight from the raw data” means it used all 64 needles. Those are the naive error bars from Describing Your Data — the ones that treat 8 needles from one tree as 8 independent facts.

Note

📖 Reference

Whitlock & Schluter, Ch. 4 — Estimating with Uncertainty, on what an error bar is an estimate of.
Gotelli & Ellison, Ch. 6 — Designing Successful Field Studies, on why independence decides which rows you may average.

The error bar is a claim — make it an honest one

Note

🔮 Predict first: We are about to draw the same two means, but with error bars built from the 4 tree means instead of the 64 needles. Will the dots move? Will the bars?

team_means_df <- pine_df |>
  group_by(team, side) |>
  summarize(team_mean_mm = mean(needle_length_mm), .groups = "drop")

ggplot(team_means_df, aes(x = side, y = team_mean_mm, color = side)) +
  geom_point(size = 2, alpha = 0.6,
             position = position_jitter(width = 0.08)) +
  stat_summary(fun = mean, geom = "point", size = 4) +
  stat_summary(fun.data = mean_se, geom = "errorbar",
               width = 0.15, linewidth = 0.9) +
  labs(title = "Mean ± SE, computed from 4 trees",
       x = "Side of tree", y = "Mean needle length (mm)") +
  theme_minimal(base_size = 9) +
  theme(legend.position = "none")

Mean needle length by side of tree with error bars computed from the four team means rather than from all 64 needles, so the error bars are roughly twice as long as in the naive version.

Important

Same dots. Longer bars.

The means are identical (14.12 and 15.21). The SEs went from 0.49/0.46 to 1.01/0.79 — roughly double.

Each small point here is one tree, not one needle. That is the plot our design actually supports.

Tip

🖐 The habit

Before you draw an error bar, ask what one point in it represents. stat_summary() will happily compute an SE over anything you hand it — including rows that aren’t independent.

▶ Your Turn — Code It Now

Important

Stop here and write code.

Worksheet Part 5 — mean ± SE with stat_summary().

Come back when it runs. If it does not run, that is the interesting part — bring the error.

Part 5 · Faceting — One Panel Per Group

Note

⏱ If time allows — the worksheet walks through this one too.

Faceting — One Panel Per Group

pine_facet_plot <- ggplot(pine_df, aes(x = side, y = needle_length_mm, fill = side)) +
  geom_boxplot(alpha = 0.6, outlier.shape = NA) +
  facet_wrap(~team) +
  labs(
    title = "Needle Length by Side, Faceted by Team",
    x = "Side of tree",
    y = "Needle length (mm)"
  ) +
  theme_minimal(base_size = 7) +
  theme(legend.position = "none")

pine_facet_plot

Boxplots of needle length by side of tree, faceted into a separate small panel for each field team, to compare whether the shady-vs-sunny pattern holds across teams.

  • facet_wrap(~team) gives each field team its own small panel
  • Same x/y scale across panels — easy to compare
  • Useful the moment “does every group show the same pattern?” is the real question
Tip

🖐 Notice

A faceted plot answers a different question than a single colored plot: not just “is there an overall pattern,” but “does the pattern hold for everyone?”

📖 R4DS §9.5 — Facets

▶ Your Turn — Code It Now

Important

Stop here and write code.

Worksheet Part 6 — facet by field team. ⏱ Skip it if we are short.

Come back when it runs. If it does not run, that is the interesting part — bring the error.

Part 6 · Themes — Write It Once, Use It Everywhere

The Built-in Themes

pine_jitter_plot + theme_bw(base_size = 9)

The boxplot-with-jittered-points figure re-rendered using theme_bw, a white background with a black-and-white frame.

  • theme_minimal() — light, few gridlines (what we have used so far)
  • theme_bw() — white background, black-and-white frame
  • theme_classic() — just x/y axis lines, no gridlines

A theme changes appearance only — never the data or the statistics underneath.

Warning

⚠️ The problem with the built-ins

None of them is quite publication-ready, so you end up appending ten lines of theme(axis.title = element_text(...), ...) to every single plot — and retyping them in every script, slightly differently each time.

The Themes File — Three Sizes, Written Once

Download r_themes_for_3_sizes.R from the activity page and save it in your project folder, next to your .Rproj file.

It defines three functions, each sized for a different final output:

Function Built for Base font
theme_small() ~3 × 3 in — a journal panel 9 pt
theme_regular() ~7 × 7 in — a report figure 16 pt
theme_large() ~16 × 16 in — a poster 36 pt

All three give you the same publication look: white background, a black panel border, black axis lines and ticks, bold axis titles, no gridlines.

Important

Two steps, once each

  1. Open the file and run it (Ctrl/Cmd + Shift + Enter, or Source). Look at your Environment pane — theme_small, theme_regular, and theme_large appear as functions. That is all “running it” does: it defines them.
  2. From then on, source() it at the top of any script that makes a figure. One line replaces the whole block of theme code you would otherwise paste into every script.
# At the top of your script,
# with library(tidyverse):
source("r_themes_for_3_sizes.R")
Note

✅ Why source() and not copy-paste

source() runs that file top to bottom, exactly as if you had typed it. Fix a margin in the themes file once and every figure in every script picks up the change the next time you run it.

Copy-pasted theme code gives you fifteen slightly different themes and no way to fix them all.

Warning

⚠️ Watch out!

source() needs the path to be right. With the file in your project root and an .Rproj open, "r_themes_for_3_sizes.R" is enough. Put it in a scripts/ folder and it becomes source("scripts/r_themes_for_3_sizes.R").

Using the Themes

pine_theme_plot <- ggplot(pine_df, aes(x = side, y = needle_length_mm, fill = side)) +
  geom_boxplot(alpha = 0.6, outlier.shape = NA) +
  labs(
    title = "Needle Length by Side of Tree",
    x = "Side of tree",
    y = "Needle length (mm)"
  ) +
  coord_cartesian(ylim = c(0, 25)) +
  theme_small() +
  theme(legend.position = "none")

pine_theme_plot

The needle length boxplot rendered with the custom theme_small function: white background, black panel border, bold axis titles, and no gridlines.

It is one more + layer, exactly like theme_minimal():

+ theme_small()      # 3 × 3 in
+ theme_regular()    # 7 × 7 in
+ theme_large()      # poster
Tip

🖐 Match the theme to the size you will save

Pick the theme for the figure’s final printed size, then pass that same size to ggsave(). A theme_large() plot saved at 3 × 3 in is unreadable, and theme_small() on a poster disappears.

Note

Still your plot

Anything you add after the theme still wins — theme_small() + theme(legend.position = "none") keeps the theme and drops the legend.

▶ Your Turn — Code It Now

Important

Stop here and write code.

Worksheet Parts 7 and 7b — try the built-in themes, then download, run, and source() the themes file.

Come back when it runs. If it does not run, that is the interesting part — bring the error.

Part 7 · Many Plots on One Page — patchwork

patchwork — + for Whole Plots

install.packages("patchwork")   # once per computer
library(patchwork)

Save each plot into an object, then combine them:

plot_a + plot_b     # side by side
plot_a / plot_b     # stacked
Note

✅ Key idea

patchwork reuses the operators you already know. + puts plots beside each other, / puts one over the other — and the result is still a plot object you can save.

pine_jitter_plot + pine_mean_se_plot

Two panels side by side: the boxplot with jittered raw points on the left and the mean plus or minus standard error plot on the right.

patchwork — Building a Real Figure

combined_plot <- (pine_jitter_plot + pine_mean_se_plot) / pine_scatter_plot +
  plot_annotation(tag_levels = "A")

combined_plot

A three-panel figure: the boxplot and the mean plus or minus standard error plot side by side on the top row, with the width-versus-length scatterplot spanning the bottom row, each panel tagged A, B, and C.

  • Parentheses group plots, exactly like arithmetic: (a + b) / c is two on top, one across the bottom
  • plot_annotation(tag_levels = "A") tags the panels A, B, C — what a figure caption refers to
  • combined_plot is one object, so ggsave() saves the whole multi-panel figure in one call
Tip

🖐 Size it for the whole figure

A three-panel figure needs roughly three panels’ worth of space. Give ggsave() a width/height for the combined plot, not for one panel.

▶ Your Turn — Code It Now

Important

Stop here and write code.

Worksheet Part 7c — combine your plots with patchwork.

Come back when it runs. If it does not run, that is the interesting part — bring the error.

Part 8 · Saving a Real Figure

Saving a Real Figure

# One panel, built with theme_small() — save it at the size the theme was written for
ggsave(
  "figures/pine_needle_boxplot.png",
  plot = pine_theme_plot,
  width = 3,
  height = 3,
  units = "in",
  dpi = 300
)

# The three-panel patchwork figure — bigger canvas, one call
ggsave(
  "figures/pine_needle_combined.png",
  plot = combined_plot,
  width = 7,
  height = 6,
  units = "in",
  dpi = 300
)
Warning

⚠️ Watch out!

ggsave() wants the filename first, then plot =. If you skip plot =, it saves whatever plot was drawn last — not necessarily the one you meant.

Note

✅ Key idea

Always set width, height, units, and dpi explicitly. Letting ggsave() guess gives you a plot sized for your screen, not for a report or a poster.

Note

📖 Reference

Whitlock & Schluter, Ch. 2 — Displaying Data, on what a figure has to carry to stand on its own in a report.

📖 R4DS §11 — Communication

▶ Your Turn — Code It Now

Important

Stop here and write code.

Worksheet Part 8 — save both figures with ggsave().

Come back when it runs. If it does not run, that is the interesting part — bring the error.

Part 9 · Custom Colors and Legends — scale_color_manual()

scale_color_manual() — Choosing Your Own Colors

Tip

🚀 Advanced / extra work — ggplot’s default colors are fine for exploring data, but a real figure often needs specific colors (journal style, colorblind-safe palettes, matching a poster). This is how you take control.

ggplot(pine_df, aes(x = side, y = needle_length_mm, color = side)) +
  geom_point(
    position = position_jitter(width = 0.15),
    size = 2.5
  ) +
  stat_summary(fun = mean, geom = "point", size = 4, color = "black") +
  scale_color_manual(
    name = "Side of tree",
    labels = c(shady = "Shady side", sunny = "Sunny side"),
    values = c(shady = "#2c7fb8", sunny = "#d95f0e")
  ) +
  labs(x = "Side of tree", y = "Needle length (mm)") +
  theme_minimal(base_size = 9)

Jittered scatterplot of needle length by side of tree with a black point marking each side's mean, using custom colors (blue for shady, orange for sunny) and a relabeled legend set via scale_color_manual().

Three arguments, matched by name:

Argument Does what
name the legend title
labels text shown for each level
values the actual color for each level
Note

✅ Key idea

labels and values are both named vectors, keyed by the raw values in your data (shady, sunny) — not by the pretty text you want to display. ggplot looks up each raw value and substitutes the label/color you gave it.

Tip

📖 Why these two colors?

#2c7fb8 (blue) and #d95f0e (orange) are a blue–orange pair, the safest two-colour choice for colour-vision deficiency: red–green confusion, which affects roughly 8% of men, leaves the blue–orange axis intact.

Don’t take that on faith — check any pair you invent with a simulator (the colorblindr or dichromat packages), or start from a palette that was built for it: scale_color_viridis_d() or RColorBrewer::brewer.pal(n, "Dark2").

📖 R4DS §11.4 — Scales, including the ColorBrewer palettes.

scale_color_manual() — Getting the Names Wrong

# Typo: "sun" instead of the real value "sunny"
ggplot(pine_df, aes(x = side, y = needle_length_mm, color = side)) +
  geom_point() +
  scale_color_manual(
    values = c(shady = "#2c7fb8", sun = "#d95f0e")
  )
Warning

⚠️ Watch out!

  • Every level that appears in your data must get an entry in values
    • (and in labels, if you supply it), spelled exactly as it appears in the column
    • check with unique(pine_df$side) first if you are not sure
    • A missing or misspelled level draws as grey with a warning.

scale_fill_manual() — the fill Twin

ggplot(pine_df, aes(x = side, y = needle_length_mm, fill = side)) +
  geom_boxplot(alpha = 0.7, outlier.shape = NA) +
  scale_fill_manual(
    name = "Side of tree",
    labels = c(shady = "Shady side", sunny = "Sunny side"),
    values = c(shady = "#2c7fb8", sunny = "#d95f0e")
  ) +
  labs(x = "Side of tree", y = "Needle length (mm)") +
  theme_minimal(base_size = 9)

Boxplot of needle length by side of tree using custom fill colors (blue for shady, orange for sunny) and a relabeled legend set via scale_fill_manual().

Tip

🖐 Notice

Same three arguments, same pattern — scale_fill_manual() is scale_color_manual() for the fill aesthetic instead of color. A boxplot’s box is fill; its jittered points (if you added geom_point(color = ...)) would be color. A plot can need both at once.

▶ Your Turn — Code It Now

Important

Stop here and write code.

Worksheet Part 8b — 🚀 optional bonus: pick your own colors with scale_color_manual(), and check them against a colorblind-safe palette.

Wrap-up

Today you:

  • Built boxplots with raw points overlaid, not hidden
  • Wrote real axis labels, titles, and legend titles with labs() — never shipped a default column name
  • Mapped a third variable with color = / fill = inside aes()
  • Set axis limits deliberately with coord_cartesian() — and learned that ylim() deletes rows while coord_cartesian() only zooms
  • Plotted mean ± SE directly with stat_summary() — and saw that an error bar is only as honest as the rows you feed it
  • source()d a themes file so theme_small() / theme_regular() / theme_large() are one + layer away in every script, forever
  • Combined panels with patchwork — a + b, a / b, (a + b) / c, tagged A/B/C
  • Saved a real, correctly-sized figure with ggsave()
  • (As time allowed) Histograms, density curves, facet_wrap(), and custom colors with scale_color_manual()
Tip

🖐 Before next class

Finish the worksheet: source the themes file, build a two-panel patchwork figure with coord_cartesian() limits you chose on purpose, and save it to figures/ at 300 dpi.

Note

Where this leads

You can now import, wrangle, describe, and visualize a dataset end to end. That full pipeline — raw data → clean → summarize → visualize — is the foundation every later statistical test builds on.

Readings

Before next class — chapter PDFs are posted on Canvas:

  • Whitlock & Schluter, The Analysis of Biological Data — Ch. 2, Displaying Data. Read “How to draw a good graph” closely: it is the standard every figure you hand in this term is held to
  • Gotelli & Ellison, A Primer of Ecological Statistics — Ch. 3, Summary Statistics, for the distributions behind the boxplots and histograms

If you want more:

  • Whitlock & Schluter, Ch. 4 — Estimating with Uncertainty, on what an error bar actually claims
Note

✅ Key idea

W&S tells you which graph is honest. R4DS tells you how to draw it. You need both.

Note

📖 R for Data Science (2e)

Free online at r4ds.hadley.nz:

Getting unstuck

When code breaks — and it will, that is normal:

  1. Read the error message out loud; it usually names the line
  2. Check the usual suspects: library(tidyverse) loaded? Is color/fill inside aes() when it should be?
  3. ?function_name opens the help page
  4. Bring the exact error (copy-paste it) to class or office hours

📖 R4DS §8 — Workflow: getting help

Note

✅ Key idea

Every working scientist googles error messages daily. Getting stuck is not failing — it is the job.