Publishable Stuff

Rasmus Bååth's Blog


Putting the top 100 R packages into a GIF

2023-02-12

You can say what you want about Twitter, but the way animated GIFs are presented on that platform is pretty nice. It’s not so surprising that they play and loop, as one would expect them to do, but the nice thing is that if you click them, they pause. This tiny change in GIF behavior has resulted in a small cottage industry of GIF games (like here or here) and click-the-GIF-and-see-what-you-get animations (like Mario roulette). Here I’ll go through how I made one of the latter in R with gganimate showing the top 100 downloaded R packages. But first the actual GIF! Click to pause it and learn more about a popular R-package:

The recipe to make an animated GIF like this is fairly straightforward:

  1. Compile a data frame with one row per frame. That is, each row needs to include all the info that will go into a single image in the resulting GIF.
  2. Build a ggplot that, given a single row, produces a single frame.
  3. Through the magic of gganimate and the transition_states function, turn this ggplot into an animation.
  4. Render the animation to a GIF using the animate function.

Below, I’ll go through the whole code needed for the “Top 100 R packages”-animation, but first some other animations I’ve done using the same recipe. All can be paused by clicking/touching them.




Code for getting 100 R packages into a GIF

Except for the usual suspects, we’ll need some packages supporting gganimate and we’ll need pkgsearch to pull R package statistics and info.

library(tidyverse) # For ggplot2 etc.
library(glue) # Easy string manipulation
library(lubridate) # Easy date manipulation
library(pkgsearch) # To pull information around R packages
library(gganimate) # Add animation-powers to ggplots
library(ragg) # Graphics devices that enables emojis in ggplots
library(gifski) # Allows exporting gganimate plots to animated GIFs

Here getting the top 100 most downloaded packages from the Rstudio CRAN mirror from last week and some extra metadata on each package.

last_week = floor_date(today() - 7, "week", week_start = 1)
cran_top_100_count <- cran_top_downloaded()

cran_metadata <- cran_packages(cran_top_100_count$package) |> 
  select("Package", "Title", "Version", "Author", "Maintainer", "Description") |> 
  rename_all(tolower)

Now creating a data frame with everything we need to make the animation where each row will become a frame in the animated plot.

cran_top_100_animation_info <- cran_top_100_count |>
  left_join(cran_metadata, by = "package") |> 
  mutate(
    rank = row_number(),
    # The y-coordinate of a 🥳 emoji that's in picture on the No 1 package, 
    # but otherwise "off-camera".
    emoji_y = ifelse(rank == 1, 0.72, 2),
    # The y-coordinate of the little dot that scrolls along as we go from 1 to 100
    dot_x = 0.1 + (rank - 1) / (max(rank) - 1) * 0.8,
    maintainer_info = glue(
      "Maintainer: { str_extract(cran_metadata$maintainer, '^[^<]+(?= )') }"),
    title_info = glue("{ifelse(str_starts(title, '\\\\d'), '.', '')}{title}")  |> 
      str_replace_all("\n", " ") |> 
      str_wrap(width = 35)
  )

cran_top_100_animation_info
## # A data frame: 100 × 12
##    package     count  title   version author maint…¹ descr…²  rank emoji_y dot_x
##    <chr>       <chr>  <chr>   <chr>   <chr>  <chr>   <chr>   <int>   <dbl> <dbl>
##  1 rlang       592905 "Funct… 1.0.6   "Lion… Lionel… "A too…     1    0.72 0.1  
##  2 ggplot2     580724 "Creat… 3.4.1   "Hadl… Thomas… "A sys…     2    2    0.108
##  3 cli         577931 "Helpe… 3.6.0   "Gábo… Gábor … "A sui…     3    2    0.116
##  4 vctrs       550428 "Vecto… 0.5.2   "Hadl… Lionel… "Defin…     4    2    0.124
##  5 lifecycle   511805 "Manag… 1.0.3   "Lion… Lionel… "Manag…     5    2    0.132
##  6 dplyr       426694 "A Gra… 1.1.0   "Hadl… Hadley… "A fas…     6    2    0.140
##  7 ragg        391427 "Graph… 1.2.5   "Thom… Thomas… "Anti-…     7    2    0.148
##  8 textshaping 380763 "Bindi… 0.3.6   "Thom… Thomas… "Provi…     8    2    0.157
##  9 tidyselect  325641 "Selec… 1.2.0   "Lion… Lionel… "A bac…     9    2    0.165
## 10 devtools    308780 "Tools… 2.4.5   "Hadl… Jennif… "Colle…    10    2    0.173
## # … with 90 more rows, 2 more variables: maintainer_info <glue>,
## #   title_info <chr>, and abbreviated variable names ¹​maintainer, ²​description

Next up: Creating the ggplot. This is fairly tedious as all the different elements needs to be separately placed and styled. Then magic comes at the end with transition_states which turns this into an animation.

bg_color <- '#211353'
text_color1 <- "#FFFFFF"
text_color2 <- "#BAC2E6"
main_font <- "Helvetica Neue"
monospace_font <- "Monaco"

rank_plot_theme <- theme_void(base_family = main_font) +
  theme(
    legend.position = 'bottom',
    legend.background = element_rect(fill = bg_color, colour = bg_color),
    panel.background = element_rect(fill = bg_color, color = bg_color),
    plot.background = element_rect(fill = bg_color, color = bg_color),
    plot.title = element_text(
      face = 'bold', colour = text_color1, size = 7, hjust = 0.5
    ),
    plot.subtitle = element_text(colour = text_color2, size = 6, hjust = 0.5),
    plot.margin = unit(c(0, 0.2,0.1,0.2), "cm"),
    plot.caption = element_text(colour = text_color2, hjust = 0, size = 5)
  )

cran_top_100_animation <- ggplot(cran_top_100_animation_info) +
  labs(
    title = "Top 100 most downloaded R packages",
    subtitle = ">> Click to pause. Get no. 1! <<",
    caption = glue(.sep = "\n",
      "Stats from the RStudio CRAN mirror week {last_week}",
      "Made by @rabaath"
    )
  ) +
  geom_point(aes(dot_x), y = 0.95, color = text_color1) +
  geom_text(
    aes(label = rank), x = 0.5, y = 0.80,  size = 6, 
    color = text_color1, family = main_font
  ) +
  geom_text(
    aes(label = package), x = 0.5, y = 0.60, size = 5, 
    color = text_color1, family = monospace_font
  ) +
  geom_text(aes(label = "🥳", y = emoji_y), size = 8, x = 0.95) +
  geom_text(aes(label = "🥳", y = emoji_y), size = 8, x = 0.05) +
  geom_text(
    aes(label = title_info), x = 0, y = 0.40,  size = 2.5, 
    color = text_color1, family = main_font, fontface = "italic",
    hjust = 0, vjust=1, lineheight=1
  ) +
  geom_text(
    aes(label = maintainer_info), x = 0, y = 0.08,  size = 2.5,
    color = text_color1, family = main_font, hjust = 0
  ) +
  scale_y_continuous(limits = 0:1) +
  scale_x_continuous(limits = 0:1) +
  rank_plot_theme +
  transition_states(
    rank, transition_length = 0, state_length = 1, wrap=FALSE
  )

Finally rendering the animation as a GIF. Let’s make two versions: One that’s slower and possible to read as it is and one that’s super fast and made to be a “click to pause” GIF on Twitter.

animate_top_100_gif <- function(fps) {
  animate(
    cran_top_100_animation, nframes = nrow(cran_top_100_animation), fps = fps,
    start_pause = 0, end_pause = 0, width = 600, height = 600, res=300,
    device = "ragg_png", renderer = gifski_renderer()
  ) 
  
  anim_save(glue("top_100_rpackages_{fps}_fps_{last_week}.gif"))
}

# A slower animation, where it's possible to read and contemplate.
animate_top_100_gif(fps = 0.75)
# A quick animation where it's impossible to read, but one can play the
# "click to pause"-game.
animate_top_100_gif(fps = 12)

But nothing happens if I click the GIFs above!?

Turns out, you cannot actually pause an animated GIF. So how does Twitter do that? By sneakily converting your GIF to a video which is something that can be paused. If you want the same effect on your own website you’ll have to emulate Twitter and

  1. Convert your GIF to a video. For example, using ffmpeg on the command line or the ever-dependable online tool EzGIF.
  2. Wrap your video inside a <video>-tag with the following properties:
<video width="400" autoplay loop muted playsinline disablepictureinpicture onclick="this.paused ? this.play() : this.pause();">
  <source src="link/to/my-animated-gif.mp4" />
</video>
Posted by Rasmus Bååth | 2023-02-12 | Tags: R, Python