Mastering ggplot2


36-315: Statistical Graphics and Visualization, Summer 2026

Learning outcomes

Pick graphs that are most appropriate for particular data types

  • Requires a working knowledge of different graph types and need to appropriately distinguish different data types

  • For any graph, need to know what information is visible vs hidden

Characterize distributions (visually and quantitatively)

  • Notice key attributes (e.g., center, shape, spread for 1D quantitative data) and choose graphs that showcase distributional characteristics (e.g., boxplots suck)

  • Choose graph specifications that showcase distribution quantities (e.g., binwidth, bandwidth)

Perform statistical inference to complement graphs

  • Validate what a graph shows to determine if that trend/difference is significant

  • Requires a working knowledge of statistical inference (e.g., tests, confidence intervals)

  • Interpret inferential results in context

MAKE HIGH-QUALITY GRAPHICS

Topics

  • Plot composition

  • Colors

  • Themes

  • Annotations

  • Animations

  • Interactive figures

Plot composition

  • Create the same type of plot many times

    • e.g., use faceting with facet_wrap() or facet_grid()
  • Combine several distinct plots into one cohesive display

library(tidyverse)
theme_set(theme_light())

Faceting by one variable

Code
penguins |>
  ggplot(aes(x = flipper_len, y = body_mass)) +
  geom_point(alpha = 0.5) +
  facet_wrap(~ species)

Faceting by multiple variables

Code
penguins |>
  ggplot(aes(x = flipper_len, y = body_mass)) +
  geom_point(alpha = 0.5) +
  facet_grid(island ~ species)

Arrange plots with cowplot

  • Example: shot attempts by the Sabrina Ionescu in the 2023 WNBA season

  • Plot density curve and ECDF for shot distance together

ionescu_shots <- read_csv("https://raw.githubusercontent.com/qntkhvn/36-315-summer26/refs/heads/master/data/ionescu-shots.csv")

ionescu_shot_density <- ionescu_shots |>
  ggplot(aes(x = shot_distance)) + 
  geom_density() +
  geom_rug(alpha = 0.3) +
  labs(x = "Shot distance (in feet)",
       y = "Number of shot attempts")

ionescu_shot_ecdf <- ionescu_shots |>
  ggplot(aes(x = shot_distance)) + 
  stat_ecdf() +
  geom_rug(alpha = 0.3) +
  labs(x = "Shot distance (in feet)",
       y = "Proportion of Ionescu shot attempts")

library(cowplot)
plot_grid(ionescu_shot_density, ionescu_shot_ecdf, labels = c("A", "B"), label_size = 15)

Arrange plots with cowplot

Arrange plots with patchwork

Code
ionescu_shots <- read_csv("https://raw.githubusercontent.com/qntkhvn/36-315-summer26/refs/heads/master/data/ionescu-shots.csv")

ionescu_scoring_density <- ionescu_shots |>
  ggplot(aes(x = shot_distance, color = scoring_play)) + 
  geom_density() +
  geom_rug(alpha = 0.3) +
  labs(x = "Shot distance (in feet)",
       y = "Number of shot attempts")

ionescu_scoring_ecdf <- ionescu_shots |>
  ggplot(aes(x = shot_distance, color = scoring_play)) + 
  stat_ecdf() +
  geom_rug(alpha = 0.3) +
  labs(x = "Shot distance (in feet)",
       y = "Proportion of Ionescu shot attempts")

library(patchwork)
ionescu_scoring_density + ionescu_scoring_ecdf + plot_layout(guides = "collect")

Arrange plots with patchwork

Code
p1 <- penguins |> 
  ggplot() + 
  geom_point(aes(x = flipper_len, y = bill_len))

p2 <- penguins |> 
  ggplot() + 
  geom_histogram(aes(flipper_len))

p3 <- penguins |> 
  ggplot() + 
  stat_ecdf(aes(bill_len, color = species))

p1 | (p2 / p3) & theme_minimal() 

Colors

  • Discrete: used to represent distinct categories with no ordering (nominal categorical). Colors should be distinct clearly with no single category emphasized unless intentionally highlighting a specific group

    • Do NOT use a discrete scale on a continuous variable
  • Sequential: used when values are mapped to a single color gradient (e.g. choropleth maps), or for an ordered categorical variable or low to high continuous variable

    • Do NOT use a sequential scale on an unordered variable
  • Divergent: used when data are centered around a meaningful midpoint and diverges in two directions from it (e.g., 0 for positive/negative values or 50% for percentages)

    • Do NOT use a divergent scale on data without natural midpoint

Colors in R

  • Specify a color in R using

    • color name (see all with colors())

    • hex code (see, e.g., here)

    • rgb(): build a color using a quantity of red, green, and blue (+ transparency)

Options for ggplot2 color

  • Change discrete color palette with scale_color_brewer()
Code
penguins |> 
  ggplot(aes(x = body_mass, y = bill_len, color = species)) +
  geom_point(alpha = 0.7) +
  scale_color_brewer(palette = "Set2")

Options for ggplot2 color

  • Change continuous color palette using the viridis scales (also color-blind friendly)
Code
penguins |> 
  ggplot(aes(x = body_mass, y = bill_len, color = flipper_len)) +
  geom_point(alpha = 0.7) +
  scale_color_viridis_c()

ggplot2 themes

Code
penguins |> 
  ggplot(aes(x = body_mass, y = bill_len)) +
  geom_point(alpha = 0.7) +
  theme_minimal()

Code
penguins |> 
  ggplot(aes(x = body_mass, y = bill_len)) +
  geom_point(alpha = 0.7) +
  theme_bw()

Code
penguins |> 
  ggplot(aes(x = body_mass, y = bill_len)) +
  geom_point(alpha = 0.7) +
  theme_light()

Code
penguins |> 
  ggplot(aes(x = body_mass, y = bill_len)) +
  geom_point(alpha = 0.7) +
  theme_classic()

Code
penguins |> 
  ggplot(aes(x = body_mass, y = bill_len)) +
  geom_point(alpha = 0.7) +
  theme_gray()

Themes: ink, paper, accent

  • ink affects all foreground elements and parts of layers

  • paper affects all background elements and parts of layers

  • accent has niche application in geom_smooth() or geom_contour()

Code
penguins |> 
  ggplot(aes(x = body_mass, y = bill_len)) +
  geom_point(alpha = 0.7) +
  geom_smooth(method = "lm") +
  facet_wrap(~ island) +
  theme_classic(paper = "cornsilk", ink = "navy", accent = "red")

Theme components

  • For more customization, see here
Code
penguins |> 
  ggplot(aes(x = body_mass, y = bill_len, color = island)) +
  geom_point(alpha = 0.5) +
  facet_wrap(~ island) +
    labs(
    title = "Penguin Body Mass vs Bill Length",
    x = "Body Mass (g)",
    y = "Bill Length (mm)",
    color = "Island"
  ) +
  theme_bw(base_size = 18) +
  theme(
    # plot components
    plot.title = element_text(face = "bold.italic", size = 20, hjust = 0.5),
    axis.title = element_text(face = "bold"),
    axis.text = element_text(color = "navy"),
    axis.ticks = element_blank(),

    # panel/grid components
    panel.grid.major = element_line(color = "lightgray"),
    panel.grid.minor = element_blank(),
    panel.border = element_rect(color = "darkgray"),

    # facet components
    strip.background = element_rect(fill = "lightblue", color = NA),
    strip.text = element_text(face = "bold"),

    # legend components
    legend.position = "bottom",
    legend.title = element_text(face = "italic")
  )

Themes from other packages

library(ggthemes)
penguins |> 
  ggplot(aes(x = body_mass, y = bill_len)) +
  geom_point(alpha = 0.7) +
  theme_gdocs()

Thinking about themes

  • Default choices tend to treat each element with equal weight

    • e.g., axes stand out as much as the data or background elements look the same as the points of emphasis
  • Design plot with the visual hierarchy in mind

    • Make elements of that are more important look more important

      • i.e., customize so that the data is the focus, not the axes and grid lines
    • Match visual weight to the focus of the graphic for communication

Annotation

  • Use annotate() to manually add geoms (text, lines, rectangles, etc.)
Code
mtcars |>
  ggplot(aes(x = wt, y = mpg)) +
  geom_point() +
  annotate(geom = "text", x = 4, y = 23, label = "Some text") +
  annotate(geom = "rect", xmin = 3, xmax = 4, ymin = 15, ymax = 20, alpha = 0.2) +
  annotate(geom = "curve", linewidth = 0.5, x = 4, y = 22, xend = 3.5, yend = 20, curvature = -0.3,
           arrow = arrow(type = "closed", length = unit(10, "pt")))

Storytelling with animation

  • Static version
Code
f1_constructors <- read_csv("https://raw.githubusercontent.com/qntkhvn/36-315-summer26/refs/heads/master/data/f1-constructors.csv") |>
  filter(name %in% c("McLaren", "Renault", "Racing Point"), year == 2020)

f1_constructors |>
  ggplot(aes(x = round, y = points, color = name)) +
  geom_line(linewidth = 2) +
  scale_x_continuous(breaks = seq(1, 17, 1)) +
  labs(x = NULL,
       y = "Accumulated points",
       color = "Team",
       title = "The race for third place in the 2020 F1 season")

Animation with gganimate

  • Add a transition on top of the static plot
Code
library(gganimate)

f1_constructors |>
  ggplot(aes(x = round, y = points, color = name)) +
  geom_line(linewidth = 2) +
  scale_x_continuous(breaks = seq(1, 17, 1)) +
  labs(x = NULL,
       y = "Accumulated points",
       color = "Team",
       title = "The race for third place in the 2020 F1 season") +
  transition_reveal(round)

Key gganimate concepts

  • transition_*(): defines how data should be spread out and how it relates to itself across time

  • view_*(): defines how positional scales should change along the animation

  • shadow_*(): defines how data from other points in time should be presented in the given point in time

  • enter_*()/exit_*(): defines how new data should appear and how old data should disappear

  • ease_aes(): defines how different aesthetics should be eased during transitions

See here and here for great tutorials

Considerations in making effective animations

  • Think about pace: speed of animation (too fast: hard to follow; too slow: boring and tedious)

  • Think about perplexity: amount of information; avoid information overload

  • Think about purpose: usefulness of animation (needed at all? providing additional value?)

  • Always make and describe the static version first

  • Never add animation just because it looks cool

  • When presenting, make sure to explain what is being displayed in the animation and
    which elements should be emphasized

    • This helps determine whether including animation is actually worthwhile

Interactive plots with plotly

  • Apply ggplotly() to a static plot
Code
penguins_plot <- penguins |> 
  ggplot(aes(x = body_mass, y = bill_len, color = species)) +
  geom_point(alpha = 0.5, size = 2) +
  labs(x = "Body Mass (g)", 
       y = "Bill Length (mm)",
       color = NULL)

library(plotly)
penguins_plot |> 
  ggplotly()

Infographics versus figures in papers/reports

  • Infographics should stand alone: relevant labels, titles and captions within the plot

Infographics versus figures in papers/reports

  • Figures in papers/reports should have captions explaining the information provided in standalone labels

Figure 1: Corruption and human development. The most developed countries experience the least corruption. Data sources: Transparency International & UN Human Development Report.

Resources