[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 Americasand animate it to see the changes from 1957 to 2007.The {gapminder} package we are using is from theGapminder foundation, an independenteducational non-profit fighting global misconceptions. The cover issueslike global warming, plastic in the oceans and life satisfaction.First we will load the full dataset from the gapminder package, and seewhat 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 theyears from 1952 to 2007 (in 5-year steps).A shapefile (*.shp) containing the geographical boundaries of eachcountry 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 84head(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 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 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 storesgeographical data in a specialised data-frame structure which allows usto merge our boundary data with the gapminder statistics using the samefunctions that we would use to combine more typical data-frames. Here wejoin the two datasets, matching the entries by country name, using thedplyr 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## ## 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 , iso_3166_1_ , french_shor ,## # geometry Data comes in all shapes and sizes. It can often be difficult to know where to start. Whatever your problem, Jumping Rivers can help.I am going to select the country column and plot that using the base Rplot function for a quick visualisation.joined |> select("country") |> plot()Hmmmmmmm that doesn’t look quite right does it?The issue here is a common one when grabbing a spatial boundaries filefrom the internet. The data sets being joined have different names forsome 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 thatdon’t match. I will use fct_recode from {forcats} to align the worldcountry names with gapminder. In the example below, I am just fixingthe USA but you can see from the plot above that several other countriesneed to be recoded (19 in total), I am doing this behind the scenes toavoid 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()That’s better! Now I’ve got the data I want to plot, I can use ggplot2to start creating the visualisation that I will be animating. Beforethat, I will filter the data to keep only the Americas, then usegeom_sf to plot the geometry data.library(ggplot2)americas = joined |> filter(continent == "Americas")americas_plot = ggplot(americas) + geom_sf()This plot looks good but I’m going to change the coordinate referencesystem (CRS) to one (“EPSG:8858”) that is designed for the Americas. Ifound this CRS on epsg.io, a website I wouldrecommend if you are looking for some different CRS’s. st_transformcan be used to change the CRS to EPSG:8858. This is what it looks likenow:americas = st_transform(americas, "EPSG:8858")new_crs_plot = ggplot(americas) + geom_sf()Okay so now the plot looks right we will start preparing it to beanimated.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))This is the plot we are going to animate now so we’ll use {gganimate}.The transition_states function partitions the data using a statescolumn (here our ‘year’ column), iteratively creating a frame of theanimation for each year value in the input data. The next function isanimate which will convert these frames into a GIF. Note, make sureyou have the dependencies installed or you may end up with 100 PNG filesin 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.")The keener eyed of you will notice some countries don’t have a value forevery year.americas |> st_drop_geometry() |> count(country) |> arrange(n)## # A tibble: 36 × 2## country n## ## 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 rowsSo 25 countries have 12 observations (the max), four have 10 and 8respectively 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 eachyear. The countries with one would continue with one value from from2002.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"))So that is our final animated map, of course we could add more stylingor complexity – maybe in a future blog. If you want to learn more aboutworking the topic, check out our Spatial Data Analysis with Rcourseor another Jumping Rivers blog, Thinking About Maps and IceCreamby Nicola Rennie.For updates and revisions to this article, see the original postTo 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.Continue reading: Animated Maps with {ggplot2} and {gganimate}