Lecture 9

Interactive Visualization and Animated Plots

Byeong-Hak Choe

SUNY Geneseo

April 27, 2026

πŸ–±οΈ Interactive ggplot

🎯 Why Make a Plot Interactive?

Interactive graphics are useful when viewers need to explore details that would clutter a static chart.

  • Hovering can reveal exact values or labels.
  • Zooming can help viewers inspect dense regions.
  • Clicking or selecting can support dashboards and Shiny apps.
  • The tradeoff is that interactive graphics are usually less suitable for printed reports.

🧭 Two Main Options for Today

πŸͺ„ plotly::ggplotly()

Best when you already have a regular ggplot and want a quick interactive version.

# One-line conversion from ggplot 
# to interactive plot
p <- ggplot() + geom_ ... 

ggplotly(p)

πŸ¦’ ggiraph::girafe()

Best when you want more control over tooltips, hover effects, and clickable elements.

# Interactive geoms + girafe()

p <- ggplot() + 
    geom_*_interactive(...)
girafe(p)

πŸͺ„ plotly::ggplotly()

  • plotly can create interactive figures directly.
  • In this class, we focus on plotly::ggplotly().
  • ggplotly() converts many existing ggplot objects into interactive HTML widgets.
set.seed(310)

dat <- data.frame(
  group = rep(c("A", "B"), each = 10),
  xvar = 1:20 + rnorm(20, sd = 3),
  yvar = 1:20 + rnorm(20, sd = 3)
)

p_small <- ggplot(dat, aes(x = xvar, y = yvar, color = group)) +
  geom_point(size = 3, alpha = 0.75) +
  labs(x = "X variable", y = "Y variable", color = "Group")

p_small
ggplotly(p_small)

πŸ›οΈ Example: CCES Data

  • We use Cooperative Congressional Election Study data.
  • seniority measures how long a member has served in Congress.
  • les measures legislative effectiveness.
  • party indicates whether the member is a Democrat or Republican.
cces <- read_csv("https://bcdanl.github.io/data/cces.csv") |>
  mutate(
    party = if_else(dem == 1, "Democrat", "Republican"),
    tooltip_text = paste0(
      "Party: ", party,
      "<br>Seniority: ", seniority,
      "<br>Legislative effectiveness: ", round(les, 2)
    )
  )
p_cces <- ggplot(
  cces,
  aes(
    x = seniority,
    y = les,
    color = party,
    text = tooltip_text
  )
) +
  geom_point(alpha = 0.65) +
  scale_color_manual(values = party_colors) +
  labs(
    x = "Seniority",
    y = "Legislative effectiveness",
    color = "Party"
  )

p_cces
p_cces_interactive <- ggplotly(p_cces, tooltip = "text")
p_cces_interactive

htmlwidgets::saveWidget() saves an interactive plot as an HTML file.

# Save the interactive figure
saveWidget(p_cces_interactive, "cces_interactive_plot.html")

πŸ¦’ ggiraph

ggiraph adds interactivity through special ggplot geoms.

  • tooltip: text displayed when the viewer hovers over an element.
  • data_id: an ID used for hover and click behavior.
  • onclick: JavaScript action when an element is clicked.

To create a ggiraph plot, replace a regular geom with an interactive version.

  • geom_point() becomes geom_point_interactive().
  • geom_col() becomes geom_col_interactive().
  • geom_sf() becomes geom_sf_interactive().
  • Then use girafe(ggobj = p) to display the result.
car_data <- mtcars |>
  rownames_to_column("car_name") |>
  mutate(
    tooltip_text = str_c(
      car_name,
      "\nWeight: ", wt,
      "\nQuarter-mile time: ", qsec,
      "\nDisplacement: ", disp
    )
  )
gg_point <- car_data |> 
  ggplot() +
  geom_point_interactive(
    aes(
      x = wt,
      y = qsec,
      color = disp,
      tooltip = tooltip_text,
      data_id = car_name
    ),
    size = 3, alpha = 0.85
  ) +
  scale_color_viridis_c() +
  labs(x = "Weight", y = "Quarter-mile time", color = "Displacement") +
  theme_minimal()
gg_point
girafe_fig <- girafe(gg_point)
girafe_fig
saveWidget(girafe_fig, "mtcars_ggiraph_plot.html")

🎞️ Animated ggplot

🎬 Why Animate a Plot?

Animated plots are useful when the story involves change over time, stages, or categories.

  • Animation can show a sequence of changes.
  • It can make time-series patterns easier to follow.
  • It can also distract viewers if the movement is not tied to the main story.

🧭 Core Idea of gganimate

gganimate starts with a regular ggplot and adds a transition.

# Basic pattern
p + transition_time(time_variable)
p + transition_states(category_variable)
p + transition_reveal(along_variable)

πŸ“½οΈ gganimate

Consider the relationship between cylinders and miles per gallon in mtcars.

mtcars_data <- datasets::mtcars |>
  mutate(
    cyl = factor(cyl),
    gear = factor(gear)
  )

p_mtcars <- ggplot(mtcars_data, aes(x = cyl, y = mpg)) +
  geom_boxplot() +
  labs(x = "Number of cylinders", y = "Miles per gallon")

p_mtcars

Facets show all gear groups at once.

p_mtcars + facet_wrap(~ gear)

transition_states() moves through categories one at a time.

p_mtcars +
  transition_states(gear) +
  labs(title = "Gear: {closest_state}")

πŸ”„ Common Transition Functions

🧩 transition_states()

  • Use transition_states() for categorical variables.
  • transition_length controls how long it takes to move between states.
  • state_length controls how long the animation pauses on each state.
p_mtcars +
  transition_states(
    gear,
    transition_length = 3,
    state_length = 2
  ) +
  labs(title = "Gear: {closest_state}")

πŸ›οΈ CCES Animation

Here we summarize the number of Democratic and Republican House members by year.

cong_dat <- cces |>
  mutate(year = as.integer(year)) |> 
  group_by(year, party) |>
  summarise(seats = n()) |> 
  ungroup()
p_seats <- ggplot(cong_dat, aes(x = party, y = seats, fill = party)) +
  geom_col(show.legend = FALSE) +
  geom_hline(yintercept = 217, linewidth = 1) +
  scale_fill_manual(values = party_colors) +
  labs(
    x = NULL,
    y = "Number of seats",
    title = "House seats by party"
  )

p_seats

Use transition_time() when the animation variable is time-like.

p_seats +
  transition_time(year) +
  labs(title = "House seats by party, Year: {frame_time}")

🧱 transition_layers()

This plot compares seniority and bills passed, with separate layers for each party.

cces_115 <- cces |>
  filter(congress == 115)

p_layers <- ggplot() +
  geom_jitter(
    data = filter(cces_115, party == "Democrat"),
    aes(x = seniority, y = all_pass, color = party),
    alpha = 0.65
  ) +
  geom_jitter(
    data = filter(cces_115, party == "Republican"),
    aes(x = seniority, y = all_pass, color = party),
    alpha = 0.65
  ) +
  geom_smooth(
    data = filter(cces_115, party == "Democrat"),
    aes(x = seniority, y = all_pass, color = party),
    se = FALSE
  ) +
  geom_smooth(
    data = filter(cces_115, party == "Republican"),
    aes(x = seniority, y = all_pass, color = party),
    se = FALSE
  ) +
  scale_color_manual(values = party_colors) +
  labs(
    x = "Seniority",
    y = "Bills passed",
    color = "Party"
  )

p_layers
p_layers + transition_layers()

🌫️ Entering and Exiting Effects

enter_*() and exit_*() control how elements appear and disappear.

anim_fade <- ggplot(mtcars_data, aes(x = cyl, y = mpg)) +
  geom_boxplot() +
  transition_states(cyl) +
  labs(title = "Cylinders: {closest_state}") +
  enter_fade() +
  exit_fade()

anim_fade

πŸ‘» Shadow Effects

shadow_*() keeps some information from previous or future frames.

  • shadow_wake() shows preceding frames with gradual fading.
  • shadow_mark() keeps previous frames visible.
  • shadow_trail() leaves a trail of earlier positions.
  • shadow_null() removes shadows.
p_seats_time <- cong_dat |> 
  ggplot(aes(x = year, y = seats, 
             fill = party)) +
  geom_col() +
  geom_hline(yintercept = 217) +
  scale_fill_manual(
    values = party_colors) +
  labs(
    x = "Year", y = "Number of seats",
    color = "Party"
  ) +
  theme_minimal()

p_seats_time +
  transition_time(year) +
  shadow_wake(wake_length = .4) +
  labs(title = "Year: {frame_time}")
p_seats_time <- cong_dat |> 
  ggplot(aes(x = year, y = seats, 
             color = party)) +
  geom_line(linewidth = 1.2) + geom_point(size = 3) +
  geom_hline(yintercept = 217) +
  scale_color_manual(
    values = party_colors) +
  labs(
    x = "Year", y = "Number of seats",
    color = "Party"
  ) +
  theme_minimal()

p_seats_time +
  transition_time(year) +
  shadow_wake(wake_length = .4) +
  labs(title = "Year: {frame_time}")

🌍 Example: Gapminder

p_gap <- gapminder |>
  ggplot(aes(x = gdpPercap, y = lifeExp, color = continent, size = pop)) +
  geom_point(alpha = 0.75) +
  scale_x_log10(labels = scales::dollar_format()) +
  scale_color_tableau() +
  guides(size = "none") +
  labs(
    x = "GDP per capita",
    y = "Life expectancy",
    color = "Continent"
  ) +
  theme_minimal() +
  theme(legend.position = "top")

p_gap
p_gap +
  transition_time(year) +
  labs(title = "Year: {frame_time}")
p_gap +
  geom_text(
    aes(
      x = 10^4,
      y = min(lifeExp),
      label = as.factor(year)
    ),
    hjust = -0.2,
    vjust = -0.2,
    alpha = 0.25,
    color = "gray30",
    size = 18
  ) +
  transition_states(as.factor(year), state_length = 0) +
  labs(title = "Year: {closest_state}")

πŸ“ˆ transition_reveal()

transition_reveal() is useful for showing a line or path over time.

gapminder |>
  filter(country == "United States") |>
  ggplot(aes(x = year, y = pop/10^6)) +
  geom_line(linewidth = 1.2) +
  geom_point(size = 2.5) +
  labs(
    x = "Year",
    y = "Population (in million)",
    title = "U.S. population over time"
  ) +
  theme_minimal() +
  transition_reveal(year)

πŸ” view_follow()

view_follow() lets the viewing window adjust as the animation moves.

anim_us <- gapminder |>
  filter(country == "United States") |>
  ggplot(aes(x = year, y = pop/10^6)) +
  geom_line(linewidth = 1.2) +
  geom_point(size = 2.5) +
  labs(
    x = "Year",
    y = "Population (in million)",
    title = "Year: {frame_along}"
  ) +
  theme_minimal(base_size = 14) +
  transition_reveal(year) +
  view_follow()

anim_us

πŸ’Ύ animate() and anim_save()

Use animate() when you want more control over output size, speed, and duration.

p_anim <- animate(
  anim_us,
  width = 700,
  height = 432,
  fps = 20,
  duration = 12,
  rewind = FALSE
)
anim_save("us_population_animation.gif", animation = p_anim)
  • rewind: Controls what happens at the end of the sequence.
    • FALSE: Jumps back to the first frame and repeats from the beginning
    • TRUE: After reaching the last frame it’ll play the animation in reverse back to the start