---
title: "Animating taichi diagrams"
author: |
Youzhi Yu
University of Chicago
output: rmarkdown::html_vignette
vignette: >
%\VignetteIndexEntry{Animating taichi diagrams}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
---
```{r setup, include = FALSE}
knitr::opts_chunk$set(
collapse = TRUE,
comment = "#>",
warning = FALSE,
message = FALSE,
fig.width = 7,
fig.height = 5,
fig.align = "center",
dpi = 120
)
# The gganimate package is in Suggests. When it is not available the code
# chunks below are still displayed (so the recipe is readable) but not
# evaluated, so this vignette builds on systems without gganimate.
has_gganimate <- requireNamespace("gganimate", quietly = TRUE)
has_gifski <- requireNamespace("gifski", quietly = TRUE)
knitr::opts_chunk$set(eval = has_gganimate)
library(ggplot2)
library(ggtaichi)
if (has_gganimate) library(gganimate)
set.seed(2026)
# Every animate() call in this vignette is really executed.
#
# Until 0.3.0 they were all commented out (gifski is not installed on every
# check machine), and because building a `gganim` object succeeds whether or
# not the transition works, nothing here ever noticed that the geom was
# collapsing every animation to a single frame. A demonstration that is never
# run is not a demonstration.
#
# file_renderer() writes PNG frames and needs no gifski, no ffmpeg and no
# system libraries, so the frames are always rendered and counted; a GIF is
# emitted on top of that only where gifski exists.
show_animation <- function(p, nframes = 24, fps = 10,
width = 480, height = 360) {
frames <- gganimate::animate(
p, nframes = nframes, width = width, height = height,
units = "px", res = 100,
renderer = gganimate::file_renderer(tempfile("taichi"), overwrite = TRUE)
)
message(length(frames), " frames rendered")
if (has_gifski) {
gganimate::animate(p, nframes = nframes, fps = fps,
width = width, height = height,
units = "px", res = 100,
renderer = gganimate::gifski_renderer())
} else {
knitr::include_graphics(frames[[ceiling(length(frames) / 2)]])
}
}
```
> **Every animation on this page is really rendered when the vignette is
> built.** Before 0.3.0 the `animate()` calls here were commented out for CI,
> which is how a bug that collapsed every transition to a single frame went
> unnoticed through two releases. See `NEWS.md`.
## Why animate?
A taichi (yin-yang) symbol is *cyclical*, so motion is unusually on-brand for
this geom. Instead of forcing a third variable (e.g. `week`) onto the x-axis
and shrinking every glyph, we can turn it into an **animation frame**. Each
frame is then a clean `category` x `state` grid of large, readable taichi.
This vignette shows how to combine `geom_taichi()` with
[gganimate](https://gganimate.com). `gganimate` is a *Suggested* dependency;
install it with `install.packages("gganimate")` if you have not already.
```r
library(ggplot2)
library(ggtaichi)
library(gganimate)
```
## How `geom_taichi()` composes with gganimate
`geom_taichi()` returns a pair of layers separated by
`ggnewscale::new_scale_fill()`, so each fish keeps its own fill scale and
legend. gganimate works at the *layer* level: it splits every layer's data
by the transition variable and builds one frame per state. Because the two
fish layers are ordinary `ggplot2` layers, gganimate treats them independently
and the two fill scales continue to apply frame-by-frame. In short: **the
ggnewscale layer stack composes cleanly with gganimate**, so no special
wrapper is needed. (This is not just theory: every recipe below has been
rendered frame-by-frame against gganimate 1.0.11, with both legends and the
per-fish fills intact in every frame.)
## Animating over a third variable
Use `transition_states()` when the frame variable is discrete (e.g. week
number), or `transition_time()` when it is a continuous time. Both drive the
underlying fish layers. The bundled `states_tg` data is a perfect showcase:
instead of squeezing its 31 weeks onto the x-axis, keep a `category` x
`state` grid of large glyphs and let the weeks play out as frames. Because
the fills are continuous, `tweenr` interpolates the values smoothly between
consecutive weeks.
```{r}
states_small <- subset(states_tg, week <= 8)
p <- ggplot(states_small, aes(x = category, y = state)) +
geom_taichi(yin = Twitter, yang = Google) +
theme_taichi() +
labs(title = "Week {closest_state}") +
transition_states(week, transition_length = 1, state_length = 1)
show_animation(p, nframes = 24)
# and to save it: anim_save("taichi.gif", p)
```
## Keeping glyphs round
Taichi symbols are always drawn round (they are sized in square units, like
`grid::circleGrob()`), but `coord_fixed()` keeps the *cells* square too, so
the glyphs fill them evenly. Match the animation dimensions to the grid:
```{r}
p_fixed <- ggplot(states_small, aes(x = category, y = state)) +
geom_taichi(yin = Twitter, yang = Google) +
coord_fixed() +
theme_taichi() +
transition_states(week, transition_length = 1, state_length = 1)
show_animation(p_fixed, nframes = 24, width = 560, height = 420)
```
## Spin animation
With the `angle` argument (see `?geom_taichi`) you can rotate each glyph.
Mapping `angle` to an expression of the frame variable and animating produces
the iconic "turning taichi", and with `eyes = TRUE` each eye rides around in
its own fish's head:
```{r}
spin <- data.frame(
x = 1, y = 1, yin = 5, yang = 5,
frame = 1:36
)
p_spin <- ggplot(spin, aes(x, y)) +
geom_taichi(yin = yin, yang = yang, angle = frame * 10,
eyes = TRUE) +
coord_fixed() +
theme_taichi() +
transition_states(frame, transition_length = 0, state_length = 1)
show_animation(p_spin, nframes = 36, fps = 12, width = 300, height = 300)
```
Note the `state_length = 1` with `transition_length = 0`: each frame *is* a
state, so the rotation advances in crisp steps (at least one of the two
lengths must be positive, or gganimate cannot allocate frames).
A cell that appears or disappears between states pops in and out whole.
`enter_grow()` and `exit_shrink()` have no visible effect, because they
scale a size the glyph does not have, and `enter_fade()` and
`enter_recolour()` stop with an error on any plot that uses a
`ggnewscale::new_scale_fill()` break, which every `geom_taichi()` plot does.
`enter_drift()` does move an entering glyph in.
## Export helpers
`gganimate::anim_save()` writes the animation to a file. For MP4 output use
`ffmpeg_renderer()` (requires a system `ffmpeg`); for GIF use
`gifski_renderer()` (requires the `gifski` package). Aim for 10--15 fps and
enough frames that the motion is smooth; `coord_fixed()` keeps every glyph
round in the rendered video.