Animated Maps with {ggplot2} and {gganimate}

[This article was first published on The Jumping Rivers Blog, and kindly contributed to R-bloggers]. (You can report issue about the content on this page here)
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

In this blog post, we are going to use data from the {gapminder} R package, along with global spatial boundaries from ‘opendatasoft’. We are going to plot the life expectancy of each country in the Americas and animate it to see the changes from 1957 to 2007.

The {gapminder} package we are using is from the Gapminder foundation, an independent educational non-profit fighting global misconceptions. The cover issues like global warming, plastic in the oceans and life satisfaction.

First we will load the full dataset from the gapminder package, and see what is contained within it.

data("gapminder_unfiltered", package = "gapminder")
names(gapminder_unfiltered)

## [1] "country" "continent" "year" "lifeExp" "pop" "gdpPercap"

Then we will filter the dataset to keep life expectancy data for the years from 1952 to 2007 (in 5-year steps).

A shapefile (*.shp) containing the geographical boundaries of each country can be imported using the {sf} R package.

library(sf)
library(dplyr)
if (getwd() == "/home/osheen/corporate-website"){
 world = st_read("content/blog/2025-animated-map/data/world-administrative-boundaries.shp") |>
 select(-"continent")
} else {
 world = st_read("data/world-administrative-boundaries.shp") |>
 select(-"continent")

}

## Reading layer `world-administrative-boundaries' from data source
## `/home/osheen/corporate-website/content/blog/2025-animated-map/data/world-administrative-boundaries.shp'
## using driver `ESRI Shapefile'
## Simple feature collection with 256 features and 8 fields
## Geometry type: MULTIPOLYGON
## Dimension: XY
## Bounding box: xmin: -180 ymin: -58.49861 xmax: 180 ymax: 83.6236
## Geodetic CRS: WGS 84

head(world)

## Simple feature collection with 6 features and 7 fields
## Geometry type: MULTIPOLYGON
## Dimension: XY
## Bounding box: xmin: -58.43861 ymin: -34.94382 xmax: 148.8519 ymax: 51.09111
## Geodetic CRS: WGS 84
## iso3 status color_code name
## 1 MNP US Territory USA Northern Mariana Islands
## 2 <NA> Sovereignty unsettled RUS Kuril Islands
## 3 FRA Member State FRA France
## 4 SRB Member State SRB Serbia
## 5 URY Member State URY Uruguay
## 6 GUM US Non-Self-Governing Territory GUM Guam
## region iso_3166_1_ french_shor
## 1 Micronesia MP Northern Mariana Islands
## 2 Eastern Asia <NA> Kuril Islands
## 3 Western Europe FR France
## 4 Southern Europe RS Serbie
## 5 South America UY Uruguay
## 6 Micronesia GU Guam
## geometry
## 1 MULTIPOLYGON (((145.6333 14...
## 2 MULTIPOLYGON (((146.6827 43...
## 3 MULTIPOLYGON (((9.4475 42.6...
## 4 MULTIPOLYGON (((20.26102 46...
## 5 MULTIPOLYGON (((-53.3743 -3...
## 6 MULTIPOLYGON (((144.7094 13...

One of the nice things about the {sf} package is that it stores geographical data in a specialised data-frame structure which allows us to merge our boundary data with the gapminder statistics using the same functions that we would use to combine more typical data-frames. Here we join the two datasets, matching the entries by country name, using the dplyr left_join function.

joined = left_join(gapminder_unfiltered,
 world,
 by = c("country" = "name")) |>
 st_as_sf()
head(joined)

## Simple feature collection with 6 features and 12 fields
## Geometry type: MULTIPOLYGON
## Dimension: XY
## Bounding box: xmin: 60.50417 ymin: 29.40611 xmax: 74.91574 ymax: 38.47198
## Geodetic CRS: WGS 84
## # A tibble: 6 × 13
## country continent year lifeExp pop gdpPercap iso3 status color_code
## <chr> <fct> <int> <dbl> <int> <dbl> <chr> <chr> <chr>
## 1 Afghanistan Asia 1952 28.8 8425333 779. AFG Membe… AFG
## 2 Afghanistan Asia 1957 30.3 9240934 821. AFG Membe… AFG
## 3 Afghanistan Asia 1962 32.0 10267083 853. AFG Membe… AFG
## 4 Afghanistan Asia 1967 34.0 11537966 836. AFG Membe… AFG
## 5 Afghanistan Asia 1972 36.1 13079460 740. AFG Membe… AFG
## 6 Afghanistan Asia 1977 38.4 14880372 786. AFG Membe… AFG
## # ℹ 4 more variables: region <chr>, iso_3166_1_ <chr>, french_shor <chr>,
## # geometry <MULTIPOLYGON [°]>

I am going to select the country column and plot that using the base R plot function for a quick visualisation.

joined |>
 select("country") |>
 plot()
Map of the World with some countries missing.

Hmmmmmmm that doesn’t look quite right does it?

The issue here is a common one when grabbing a spatial boundaries file from the internet. The data sets being joined have different names for some of the countries. For example, in the world data we have USA as ‘United States’ where as in gapminder it’s ‘United States of America’. The dplyr::anti_join function can be helpful finding countries that don’t match. I will use fct_recode from {forcats} to align the world country names with gapminder. In the example below, I am just fixing the USA but you can see from the plot above that several other countries need to be recoded (19 in total), I am doing this behind the scenes to avoid clogging up the page.

library(forcats)
world = world |>
 mutate(name = fct_recode(.data$name,
 "United States" =
 "United States of America"))

Okay, lets see what this looks like now.

joined |>
 select("country") |>
 plot()
Map of the World with all countries.

That’s better! Now I’ve got the data I want to plot, I can use ggplot2 to start creating the visualisation that I will be animating. Before that, I will filter the data to keep only the Americas, then use geom_sf to plot the geometry data.

library(ggplot2)

americas = joined |>
 filter(continent == "Americas")

americas_plot = ggplot(americas) +
 geom_sf()
Map of The Americas.

This plot looks good but I’m going to change the coordinate reference system (CRS) to one (“EPSG:8858”) that is designed for the Americas. I found this CRS on epsg.io, a website I would recommend if you are looking for some different CRS’s. st_transform can be used to change the CRS to EPSG:8858. This is what it looks like now:

americas = st_transform(americas, "EPSG:8858")

new_crs_plot = ggplot(americas) +
 geom_sf()
Map of The Americas with EPSG:8858 CRS.

Okay so now the plot looks right we will start preparing it to be animated.

library(ggplot2)

plot = americas %>%
 filter(year == 2007) %>%
 ggplot() +
 geom_sf(aes(fill = lifeExp)) +
 labs(title = "Year: 2007",
 fill = "Life Expectancy") +
 theme_void() +
 ggplot2::scale_fill_viridis_b() +
 theme(legend.position = c("inside"),
 legend.position.inside = c(0.23, 0.23),
 plot.title = element_text(size = 15,
 hjust = 0.5),
 panel.border = element_rect(color = "black",
 fill = NA))
Map of The Americas to be animated.

This is the plot we are going to animate now so we’ll use {gganimate}. The transition_states function partitions the data using a states column (here our ‘year’ column), iteratively creating a frame of the animation for each year value in the input data. The next function is animate which will convert these frames into a GIF. Note, make sure you have the dependencies installed or you may end up with 100 PNG files in your working directory rather than a GIF!

library(gganimate)

animation = plot +
 ggtitle("Year: {closest_state}") +
 transition_states(states = year)

animate(animation,
 renderer = gifski_renderer("img/map.gif"),
 alt = "Animation with missing values.")
Animation with missing values.

The keener eyed of you will notice some countries don’t have a value for every year.

americas |>
 st_drop_geometry() |>
 count(country) |>
 arrange(n)

## # A tibble: 36 × 2
## country n
## <chr> <int>
## 1 French Guiana 1
## 2 Guadeloupe 1
## 3 Martinique 1
## 4 Aruba 8
## 5 Grenada 8
## 6 Netherlands Antilles 8
## 7 Suriname 8
## 8 Bahamas 10
## 9 Barbados 10
## 10 Belize 10
## # ℹ 26 more rows

So 25 countries have 12 observations (the max), four have 10 and 8 respectively and three have 1. To fill in these blanks, I’m going to use {tidyr} to compute some mock values using the dataset mean for each year. The countries with one would continue with one value from from 2002.

library(tidyr)

completed = americas |>
 mutate(country = forcats::fct_drop(country)) |>
 complete(year, country) |>
 select(country, lifeExp, year) |>
 group_by(year) |>
 mutate(lifeExp =
 replace_na(lifeExp,
 replace = mean(lifeExp,
 na.rm = TRUE)))

geoms = americas |>
 select(country) |>
 distinct()

plot = left_join(completed,
 geoms,
 by = "country") |>
 st_as_sf() |>
 st_transform("EPSG:8858") |>
 ggplot() +
 geom_sf(aes(fill = lifeExp)) +
 labs(title = "Year: {closest_state}",
 fill = "Life Expectancy") +
 theme_void() +
 ggplot2::scale_fill_viridis_b() +
 theme(legend.position = c("inside"),
 legend.position.inside = c(0.23, 0.23),
 plot.title = element_text(size = 15,
 hjust = 0.5),
 panel.border = element_rect(color = "black",
 fill = NA))

animation = plot +
 transition_states(states = year)

animate(animation,
 renderer = gifski_renderer("img/map2.gif"))
Final animation with all countries.

So that is our final animated map, of course we could add more styling or complexity – maybe in a future blog. If you want to learn more about working the topic, check out our Spatial Data Analysis with R course or another Jumping Rivers blog, Thinking About Maps and Ice Cream by Nicola Rennie.

For updates and revisions to this article, see the original post

To leave a comment for the author, please follow the link and comment on their blog: The Jumping Rivers Blog.

R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

Never miss an update!
Subscribe to R-bloggers to receive
e-mails with the latest R posts.
(You will not see this message again.)

Click here to close (This popup will not appear again)