1. Introduction

The R package ‘animate’ implements a web-based graphics device to let you create animated visualisation easily with the base R syntax, supporting both frame-by-frame and key-frame animations, and share them as GIF images, MP4 videos, ‘Shiny’ apps or web pages. With ‘animate’, users can easily bring their creations to life, creating dynamic and engaging visualisations. Let’s take a quick look at what ‘animate’ is capable of.

Example 1

An animated plot showing the correspondence between the stationary points of a function \(f(x)\) and the roots of the derivative \(f'(x)\).

Example 2

An animated diagram about granting parole to prisoners, replicated from this article

More examples can be found in the gallery here!

2. Installation and device usage

a. Installation

You could install the released version of the package from CRAN or the development version from Github.

# From CRAN
install.packages("animate")

# From Github
remotes::install_github("kcf-jackson/animate")

b. Initialising and using the device

To use the device, load the package and call animate$new with the width and height arguments (in pixel values) to initialise the device. It may take some time for the device to start (about half a second); making function calls before the start-up process completes would result in a warning. This could happen when you source a file, which starts the device and runs the plot functions before the device is ready.

Usage 1

All the plotting functions are stored as the methods of the device.

library(animate)
device <- animate$new(width = 500, height = 300)  # takes ~0.5s

device$plot(1:10, 1:10)
device$points(1:10, 10 * runif(10), bg = "red")
device$lines(1:100, sin(1:100 / 10 * pi / 2))
device$clear()

device$off()  # switch off the device when you are done

Usage 2

Sometimes it can be convenient to attach the device so that the functions of the device can be called directly.

library(animate)
device <- animate$new(500, 300)
attach(device)  # overrides the 'base' primitives

plot(1:10, 1:10)
points(1:10, 10 * runif(10), bg = "red")
lines(1:100, sin(1:100 / 10 * pi / 2))
clear()

off()
detach(device)  # restore the 'base' primitives

Remarks

  • Only one device is supported per R session. If a device fails to initialise, it is usually because there is another device currently occupying the session.
  • In case one forgets to assign the device to a variable and so does not have the handle to call the off function, simply restarting R will close the connection.

3. Creating animated plots

The most important idea of this package is that every object to be animated on the screen must have an ID. These IDs are used to decide which objects need to be modified to create the animation effect.

a. Setup

We first set up the device for the remaining of this section.

device <- animate$new(400, 400)
attach(device)

b. Keyframe animation

A basic plot can be made with the usual syntax plot(x, y) and the additional argument id. id expects a character vector, and its length should match the number of data points.

If we provide a new set of coordinates while keeping the same id, the package will recognise that it should update the points rather than plot new ones.

Setting the argument transition = TRUE creates a transition effect from the old coordinates to the new coordinates. It can handle multiple attributes, and it supports two timing options duration and delay.

x <- c(0.5, 1, 0.5, 0, -0.5, -1, -0.5, 0)
y <- c(0.5, 0, -0.5, -1, -0.5, 0, 0.5, 1)
id <- new_id(x)  # Give each point an ID: c("ID-1", "ID-2", ..., "ID-8")
plot(x, y, id = id)

# Transition (basic)
shuffle <- c(8, 1:7)
plot(x[shuffle], y[shuffle], id = id, transition = TRUE)  # Use transition

# Transition (with multiple attributes and timing option)
shuffle <- c(7:8, 1:6)
plot(x[shuffle], y[shuffle], id = id, 
     cex = 1:8 * 20,                      # another attribute for transition
     transition = list(duration = 2000))  # 2000ms transition duration

# Note: the unit for `cex` is squared pixels, e.g., a value of 400 has dimension 20 x 20. 
# This follows the convention used by 'D3.js' to handle the size of different shapes.

Click to see the transition; click again to reset.

c. Frame-by-frame animation

Some applications require rapidly plotting a sequence of frames; this can be done easily with a loop.

  • There should be pauses between iterations, or else the animation will happen so quickly that only the last key frame can be seen.

  • The lines function treats the entire line as a single unit, even if it contains multiple points, so only one ID is required.

clear()  # Clear the canvas
par(xlim = c(-17, 16), ylim = c(-18, 13))    # Use a static scale

t <- seq(0, 2*pi, length.out = 150)
x <- 16 * sin(t)^3
y <- 13 * cos(t) - 5 * cos(2*t) - 2 * cos(3*t) - cos(4*t)
id <- "line-1"     # a line needs 1 ID only (despite containing multiple points)

for (n in seq_along(t)) {
  plot(x[1:n], y[1:n], id = id, type = 'l')
  Sys.sleep(0.02)  # about 50 frames per second
}

Click to see the animation.

When you are done. Don’t forget to switch-off and detach the device with off(); detach(device).

d. More styling options

The package currently supports the following primitives in addition to the plot function: points, lines, bars, text, image and axis. These primitive functions accept the commonly used graphical parameters such as cex, lwd, and bg. As animate is based on D3.js, it also accepts the attr, style and transition arguments. This is useful for specifying options that are not part of R, such as CSS styles, or that are part of R but have not yet been implemented. For instance, for the text function, the font family can be specified using attr = list("font-family" = "monospace"). More examples can be found in the other vignettes on the package website.

4. Dynamic scale versus static scale

An critical difference between animate and base is that animate uses dynamic scale. Each time any graphics function is called, a new scale specific to the call is created. To see why this is important, consider the following two snippets:

# Plot 1 (Left)
clear()
plot(0:9, 0:9, type = 'l')
lines(-4:0, 4:0, col = "red")  # the `lines` function seems to be wrong

# Plot 2 (Right: notice the changes in the axes)
clear()
plot(0:9, 0:9, type = 'l')
plot(-4:0, 4:0, col = "red", type = 'l')  # showing what had happened when `lines` was called

In the left plot, we saw that the lines function seems to plot at the incorrect location, e.g. the point (-4,4) is located at (0, 9). But if you replace the lines function with the plot function, you will see that the second plot function (as well as the original lines function) actually uses its own scale and the line is in the correct location. This is something to keep in mind when using animate.

In case you want to use a static scale, as in the regular base plot, you can specify it in the call with the xlim and ylim arguments or set the global option via par(xlim = ..., ylim = ...) before making the plot.

# Inline static range (Left)
clear()
plot(0:9, 0:9, type = 'l', xlim = c(-4, 10), ylim = c(0, 9))
lines(-4:0, 4:0, col = "red", xlim = c(-4, 10), ylim = c(0, 9))

# Global static range (Right)
clear()
par(xlim = c(-4, 10), ylim = c(0, 9))
plot(0:9, 0:9, type = 'l')
lines(-4:0, 4:0, col = "red") 
# par(xlim = NULL, ylim = NULL)  # Clear the global setting

5. Three full examples

Lorenz system

\[\begin{aligned} \dfrac{dx}{dt} = \sigma (y - x), \quad \dfrac{dy}{dt} = x (\rho - z) - y, \quad \dfrac{dz}{dt} = xy - \beta z \end{aligned}\]

Simulating and visualising the Lorenz system

# device <- animate$new(500, 300)
# attach(device)

# Lorenz system parameters
sigma <- 10
beta <- 8/3
rho <- 28

# Set up for the Euler method
x <- y <- z <- 1
dx <- dy <- dz <- 0
xs <- x
ys <- y
zs <- z
time_steps <- 1:2000
dt <- 0.015

# Frame-by-frame animation with a for loop
for (t in time_steps) {
  dx <- sigma * (y - x) * dt
  dy <- (x * (rho - z) - y) * dt
  dz <- (x * y - beta * z) * dt
  x <- x + dx
  y <- y + dy
  z <- z + dz  
  xs <- c(xs, x)
  ys <- c(ys, y)
  zs <- c(zs, z) 

  # `animate` plot
  par(xlim = c(-30, 30), ylim = c(-30, 40))
  plot(x, y, id = "ID-1")
  lines(xs, ys, id = "lines-1")
  Sys.sleep(0.025)
}

# Special transition to x-z plane to show the 'bufferfly'
par(xlim = c(-30, 30), ylim = range(zs))   
plot(x, z, id = "ID-1", transition = TRUE)
lines(xs, zs, id = "lines-1", transition = TRUE)

# off()
# detach(device)

Click to begin the animation.

A particle system

\[\begin{aligned} \dfrac{dx_i}{dt} = u_i, \quad \dfrac{dy_i}{dt} = v_i, \quad i = 1, 2, ..., n \end{aligned}\]

Simulating and visualising the particle system

# device <- animate$new(500, 500)
# attach(device)

# Number of particles
n <- 50  

# Data of the particles
ps <- list(
  x = runif(n), y = runif(n),                            # Position
  vx = rnorm(n) * 0.01, vy = rnorm(n) * 0.01,            # Velocity
  color = sample(c("black", "red"), n, replace = TRUE),  # Class
  id = new_id(x)                                         # ID
)

# Simulation of the evolution of one time step
update_one_step <- function(ps) {
    # Turns around when the particle hits the boundary
    x_turn <- ps$x + ps$vx > 1 | ps$x + ps$vx < 0
    ps$vx[x_turn] <- ps$vx[x_turn] * -1
    y_turn <- ps$y + ps$vy > 1 | ps$y + ps$vy < 0
    ps$vy[y_turn] <- ps$vy[y_turn] * -1
    
    # Update position
    ps$x <- ps$x + ps$vx
    ps$y <- ps$y + ps$vy
    ps
}

# Visualising the system
par(xlim = c(0, 1), ylim = c(0, 1))
for (t in 1:1000) {
  points(ps$x, ps$y, id = ps$id, bg = ps$color)
  ps <- update_one_step(ps)
  Sys.sleep(0.02)
}

# off()
# detach(device)

Click to begin the animation.

2 dimensional discrete random walk

Simulating and visualising the random walk

# library(animate)
# device <- animate$new(height = 500, width = 500)
# attach(device)

# Simulation
random_walk <- function(n_steps) {
  steps <- sample(list(c(-1, 0), c(1, 0), c(0, -1), c(0, 1)), n_steps, replace = T)
  coord <- do.call(rbind, steps)
  x <- c(0, cumsum(coord[,1]))
  y <- c(0, cumsum(coord[,2]))
  data.frame(x = x, y = y)
}

# Setup
set.seed(20230105)
n_steps <- 250
n_walkers <- 3
color <- c("black", "orange", "blue", "green", "red")
walkers <- lapply(1:n_walkers, function(ind) random_walk(n_steps))

# Use static range (combine the data frames to find the common range)
xlim <- range(do.call(rbind, walkers)$x)
ylim <- range(do.call(rbind, walkers)$y)
par(xlim = xlim, ylim = ylim)

# Plot looping through time and the walkers
for (i in 1:n_steps) {
  for (ind in 1:n_walkers) {
    x <- walkers[[ind]]$x
    y <- walkers[[ind]]$y
    plot(x[1:i], y[1:i], type = 'l', id = paste0("line-", ind), col = color[ind])
    points(x[i], y[i], id = paste0("point-", ind), bg = color[ind])  
  }
  Sys.sleep(0.02)
}

# off()
# detach(device)

Click to begin the animation.

6. Usage with RMarkdown Document and Shiny

RMarkdown Document

Inline usage

In the code chunk of an R Markdown document,

  • Call animate$new with the virtual = TRUE flag,
  • then at the end of the code chunk, call rmd_animate(device).

Here is an example. (Click to begin the animation.)

library(animate)
device <- animate$new(500, 500, virtual = TRUE)
attach(device)

# Data
id <- new_id(1:10)
s <- 1:10 * 2 * pi / 10
s2 <- sample(s)

# Plot
par(xlim = c(-2.5, 2.5), ylim = c(-2.5, 2.5))
plot(2*sin(s), 2*cos(s), id = id)
points(sin(s2), cos(s2), id = id, transition = list(duration = 2000))

# Render in-line in an R Markdown document
rmd_animate(device, click_to_play(start = 3))  # begin the plot at the third frame

Import from a file

To include an exported visualisation (from device$export) in an R Markdown Document, simply use animate::insert_animate to insert the visualisation in a code chunk.

The function supports several playback options, including the loop, click_to_loop and click_to_play options. Customisation is possible, but it would require some JavaScript knowledge. Interested readers may want to look into the source code of the functions above before deciding to pursue that option.

Shiny

To use the animate plot in a Shiny app,

  • use animateOutput in the ui,
  • pass the shiny session argument to animate$new during initialisation.
  • then use the device function in the server directly inside any of the shiny::observeEvent.

Here is a full example:

library(shiny)
library(animate)

ui <- fluidPage(
  actionButton("buttonPlot", "Plot"),
  actionButton("buttonPoints", "Points"),
  actionButton("buttonLines", "Lines"),
  animateOutput()
)

server <- function(input, output, session) {
  device <- animate$new(500, 300, session = session)
  id <- new_id(1:10)

  observeEvent(input$buttonPlot, {     # Example 1
    device$plot(1:10, 1:10, id = id)
  })

  observeEvent(input$buttonPoints, {   # Example 2
    device$points(1:10, runif(10, 1, 10), id = id, transition = TRUE)
  })

  observeEvent(input$buttonLines, {    # Example 3
    x <- seq(1, 10, 0.1)
    y <- sin(x)
    id <- "line_1"
    device$lines(x, y, id = id)
    for (n in 11:100) {
      x <- seq(1, n, 0.1)
      y <- sin(x)
      device$lines(x, y, id = id)
      Sys.sleep(0.05)
    }
  })
}

shinyApp(ui = ui, server = server)