Creating Interactive Maps with R

Introduction

For this tutorial, we will be using R create an interactive map using the leaflet package. This map will shows how different governments around the world have responded to the spread of COVID-19. Here’s what that map will look like:

The foundation for this chart, including how to download a shapefile and set up the foundational pieces of our map using ggplot2 are covered in the “Creating Static Maps with R” tutorial. For brevity, I will not explain code used in this tutorial that was covered in that one. Put another way, I encourage you to read the “Creating Static Maps with R” tutorial first.

Loading the Data

Our data are derived from The Oxford COVID-19 Government Response Tracker, which provides regularly updated data files about different policy responses governments have adopted.

In particular, we are going to focus on their overall “Stringency Index,” which is a number from 0 to 100 that describes the number and strength of 20 different policy indicators, such as school closures, travel restrictions, events cancellations. More detail about those indicators is available here.

We will be working with a subset of their dataset that includes the country’s name (CountryName) and code (CountryCode), the date (Date) of measurement, the number of confirmed COVID-19 cases (ConfirmedCases) and deaths (ConfirmedDeaths), and the Stringency Index (StringencyIndex).

You can load the dataset into an object called covid19_stringency by using the readr::read_csv() function to read data from this CSV file.

library(tidyverse)
covid19_stringency <- read_csv("https://dds.rodrigozamith.com/files/covid19_stringency_20210402.csv")
## Rows: 85374 Columns: 6

## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (2): CountryName, CountryCode
## dbl (4): Date, ConfirmedCases, ConfirmedDeaths, StringencyIndex

##
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.

This is what our dataset looks like:

head(covid19_stringency)
CountryName CountryCode Date ConfirmedCases ConfirmedDeaths StringencyIndex
Aruba ABW 20200101 NA NA 0
Aruba ABW 20200102 NA NA 0
Aruba ABW 20200103 NA NA 0
Aruba ABW 20200104 NA NA 0
Aruba ABW 20200105 NA NA 0
Aruba ABW 20200106 NA NA 0

Each observation refers to a country’s index and case/death count on a particular date. For example, the first observation tells us that on January 1, 2020, Aruba had a Stringency Index of 0, and we did not have information about its confirmed case and death counts.

Filtering and Joining the Data

Our dataset contains more information than we need. In particular, we only need the observations from April 1, 2021 and for just three of our variables (CountryName, CountryCode, and StringencyIndex).

Here’s how we could get our desired data (and store it in an object called covid19_stringency_mini):

covid19_stringency_mini <- covid19_stringency %>%
  filter(Date<="20210401" & !is.na(StringencyIndex)) %>%
  group_by(CountryCode) %>%
  top_n(1, Date) %>%
  ungroup() %>%
  select(CountryName, CountryCode, Date, StringencyIndex)

Then, we’ll need to join the data with our shapefile information. We’ll start by downloading this shapefile and saving it to our project directory. Then, we can load the shapefile data into an object called world_map by using the following code:

library(sf)
world_borders_file <- tempfile() # Use this line if you're loading the data _remotely_
download.file("https://dds.rodrigozamith.com/files/TM_WORLD_BORDERS-0.3.zip", world_borders_file, mode="wb") # Use this line if you're loading the data _remotely_
unzip(world_borders_file, exdir=tempdir()) # Use this line if you're loading the data _remotely_
#unzip("TM_WORLD_BORDERS-0.3.zip", exdir=tempdir()) # Use this line if you're loading the data _directly from your computer_
world_map <- st_read(paste0(tempdir(), "/", "TM_WORLD_BORDERS-0.3.shp"))

Finally, we can join the two datasets into an object called map_data with the following code:

map_data <- left_join(x=world_map, y=covid19_stringency_mini, by=c("iso3"="CountryCode"))

Creating the Map

To create our interactive map, we’ll be using the leaflet package. This allows R to generate leaflet.js maps.

Leaflet.js is a free, open-source library used by leading news organizations like The New York Times and The Washington Post to power their mapping functionality. However, those organizations also tend to add a lot more customization than we’ll include here—​though we theoretically could match their work with the tools we have!

We’ll begin by loading the leaflet package.

library(leaflet)

You will need to install the leaflet package before you can run it for the first time.

Adding a Base Layer

We’ll start by setting our base layer with the leaflet() function. Here, we specify our data source (map_data) and use the options argument to customize a few features via the leaflet::leafletOptions() function.

The first feature is to disable the buttons that allow zooming in an out, as they take up space (and there’s no real value to zooming in an out). People can still zoom in by pinching or using the scroll-wheel on their mouse, should they want to. We can set that via the zoomControl argument.

The second feature is to set a minimum zoom level. As our world map represents the globe, it wraps around, and at too small a zoom level, you see the continents multiple times. We can prevent that by setting the minZoom argument to 1.5.

leaflet(map_data, options=leafletOptions(zoomControl=FALSE, minZoom=1.5))

Even though it looks like there’s nothing there, note the ‘Leaflet’ label on the bottom right of the frame. This tells us that our base layer is set.

Adding Map Background

Next, we’ll add the images (tiles) that cover our mapping range by adding a new layer with the leaflet::addTiles() function.

This function detects the spatial range of our data (maximum and minimum longitude and latitude) and pulls the best corresponding projection from OpenStreetMap.

leaflet(map_data, options=leafletOptions(zoomControl=FALSE, minZoom=1.5)) %>%
  addTiles()

Unlike the ggplot() function, we will add layers to our Leaflet map by using the piping operator (%>%).

Changing the Default View

Next, we’ll set the default view for our map (the central point in terms of longitude and latitude coordinates), as well as the default zoom level. We can do this via the leaflet::setView() function.

leaflet(map_data, options=leafletOptions(zoomControl=FALSE, minZoom=1.5)) %>%
  addTiles() %>%
  setView(lng=10, lat=17, zoom=1.5)
Adding Choropleth Information

Next, we’re going to create an object (pal) that contains the color palette information for our map.

We will use the leaflet::colorBin() function to color our map, using just a few colors rather than a continuous gradient. That will make it easier for the viewer to see differences in the map. (Some designers prefer a continuous gradient, though, because it is more precise.)

With colorBin(), we’ll select a color palette (I like "YlOrRd") that is easy to read and accessible to folks with visual impairments. Then, we will select the object and variable that contains the values we want our choropleth shading to be based on (domain). Finally, we will the cut-off points for the bins (bins) that we want. We’ll set the cut-off points of 0, 20, 40, 60, 80, and Infinity (Inf), which will give us five bins.

pal <- colorBin("YlOrRd", domain=map_data$StringencyIndex, bins=c(0, 20, 40, 60, 80, Inf))

We won’t see anything new just yet because the plan is to supply this palette information to another leaflet function in just a bit. However, you should see a new pal object appear in your Environment tab in RStudio (under “Functions”).

Adding Tooltip Information

We can also use this time to set up the tooltip that will appear when a user hovers over a country, and assign that into an object called labels.

Specifically, we will use the base::sprintf() function to allow us to drop in HTML tags (like <strong>, for bolding). These tags will be dynamically filled in with information from our variables when we create our visualization. We’ll pipe that to the base::lapply() function, which will translate that into the HTML code that we can feed into our dynamic map, for every row in our map_data data frame.

labels <- sprintf("<strong>%s</strong><br/>Index: %g", map_data$name, map_data$StringencyIndex) %>%
  lapply(htmltools::HTML)

The %s and %g labels are placeholders. You can select which variables to pull information from by altering the sprintf() arguments.

Again, We won’t see the result of this yet, but you should see a new object (labels) that has the same number of observations as our map_data object, with each row corresponding with its equivalent in map_data and containing an HTML-formatted column (Value).

Adding Country Polygons

Now, we can add the country boundaries to our map by using the leaflet::addPolygons() function. This function provides a lot of styling options, which you can play around with to color the country boundaries, highlight countries, provide a label when the user hovers over them, and so on.

I encourage you to review the documentation for the leaflet package to see all of the different options available to you. However, here are some helpful settings for our map:

pal <- colorBin("YlOrRd", domain=map_data$StringencyIndex, bins=c(0, 20, 40, 60, 80, Inf))
labels <- sprintf("<strong>%s</strong><br/>Index: %g", map_data$name, map_data$StringencyIndex) %>%
  lapply(htmltools::HTML)

leaflet(map_data, options=leafletOptions(zoomControl=FALSE, minZoom=1.5)) %>%
  addTiles() %>%
  setView(lng=10, lat=17, zoom=1.5) %>%
  addPolygons(
    fillColor=~pal(StringencyIndex),
    weight=1,
    opacity=1,
    color="white",
    dashArray="3",
    fillOpacity=0.7,
    highlight=highlightOptions(
      weight=5,
      color="#666",
      dashArray="",
      fillOpacity=0.7,
      bringToFront=TRUE),
    label=labels,
    labelOptions=labelOptions(
      style=list("font-weight"="normal", padding="3px 8px"),
      textsize="15px",
      direction="auto")
  )

Doesn’t that look nice already? If you hover over a country, you’ll already see the label.

Adding a Legend

The last thing we’ll want to add is the legend, so the users can see what each color corresponds to.

To accomplish this, we will use the leaflet::addLegend() function. We will need to supply it with a few arguments:

  • The first argument (pal) draws the the palette information from the object we created earlier on (also called pal).

  • The second argument (values) draws the corresponding legend values from the StringencyIndex variable (which we also used for drawing our palette).

  • The third argument (opacity) maintains the same level of transparency as the country shapes.

  • The fourth argument (title) disables the title label, so it is not placed atop the map—​though you could give it a title there if you like. (More on that shortly.)

  • The fifth argument (position) places the legend on the bottom-left corner of the map.

  • The sixth argument (na.label) allows us to name the gray (missing data) through the more human-readable “No Data” label.

Here’s what the code looks like after those additions:

pal <- colorBin("YlOrRd", domain=map_data$StringencyIndex, bins=c(0, 20, 40, 60, 80, Inf))
labels <- sprintf("<strong>%s</strong><br/>Index: %g", map_data$name, map_data$StringencyIndex) %>%
  lapply(htmltools::HTML)

leaflet(map_data, options=leafletOptions(zoomControl=FALSE, minZoom=1.5)) %>%
  addTiles() %>%
  setView(lng=10, lat=17, zoom=1.5) %>%
  addPolygons(
    fillColor=~pal(StringencyIndex),
    weight=1,
    opacity=1,
    color="white",
    dashArray="3",
    fillOpacity=0.7,
    highlight=highlightOptions(
      weight=5,
      color="#666",
      dashArray="",
      fillOpacity=0.7,
      bringToFront=TRUE),
    label=labels,
    labelOptions=labelOptions(
      style=list("font-weight"="normal", padding="3px 8px"),
      textsize="15px",
      direction="auto")
  ) %>%
  addLegend(pal=pal, values=~StringencyIndex, opacity=0.7, title=NULL, position="bottomleft", na.label="No Data")
Adding Scaffolding Information

Finally, you may be wondering about the scaffolding information—​where is the map title, the subtitle, the caption, and so on?

Leaflet is designed to be embedded straight into a document (e.g., a news story). Thus, it doesn’t provide very convenient functionality for that because it is assumed that such scaffolding will be inserted into the story itself.

For those of you who have web-design experience, you would typically just enclose the map within a <div> tag and create your typical headings (e.g., <h1>), where you would insert that information. If you have experience with CSS, you can create some really well-integrated maps!

Saving the Map

There are multiple ways to save your map.

Knitted R Notebook

If you are producing your map within an R Notebook, the leaflet() function will embed the interactive map directly into the knitted notebook. Additionally, you can tell it to occupy the entire width of the notebook (which looks nicer) by passing the width argument to the leaflet() function. Thus, we’d change our initial line when we were creating the map to:

leaflet(map_data, width="100%", options=leafletOptions(zoomControl=FALSE, minZoom=1.5)) %>%
HTML File

If you want to produce an HTML file that is independent of your notebook (and can be easily embedded on another page, like the ones you saw in this tutorial), you can use the htmlwidgets package and its htmlwidgets::saveWidget() function.

That function simply takes the map object (widget) and the filename you wish to save it to (file) as arguments. Alternatively, you could just pipe the code for your map directly into the saveWidget() function like so:

library(htmlwidgets)
pal <- colorBin("YlOrRd", domain=map_data$StringencyIndex, bins=c(0, 20, 40, 60, 80, Inf))
labels <- sprintf("<strong>%s</strong><br/>Index: %g", map_data$name, map_data$StringencyIndex) %>%
  lapply(htmltools::HTML)

leaflet(map_data, options=leafletOptions(zoomControl=FALSE, minZoom=1.5)) %>%
  addTiles() %>%
  setView(lng=10, lat=17, zoom=1.5) %>%
  addPolygons(
    fillColor=~pal(StringencyIndex),
    weight=1,
    opacity=1,
    color="white",
    dashArray="3",
    fillOpacity=0.7,
    highlight=highlightOptions(
      weight=5,
      color="#666",
      dashArray="",
      fillOpacity=0.7,
      bringToFront=TRUE),
    label=labels,
    labelOptions=labelOptions(
      style=list("font-weight"="normal", padding="3px 8px"),
      textsize="15px",
      direction="auto")
  ) %>%
  addLegend(pal=pal, values=~StringencyIndex, opacity=0.7, title=NULL, position="bottomleft", na.label="No Data") %>%
  saveWidget(file="map.html")

This will save the HTML file in your working directory. If you are using an RStudio project, that will typically be your project folder.

Static Image

If you wish to turn it into a static image, you can also do that with the mapview package—​though you may need to install additional software for it to work—​and its mapview::mapshot() function which takes the same arguments.

library(mapview)
mapshot(p, file="map.png") # As a PNG file, for easy sharing
mapshot(p, file="map.pdf") # Or, as a PDF file that Illustrator can work with

This code assumes your leaflet map is stored in an object called p. It will save the image file to your working directory. If you are using an RStudio project, that will typically be your project folder. As you would expect, all of the interactive features in your leaflet map will be lost if you save it as an image.

Adding More Functionality

It is possible to add even more functionality to this map by using additional leaflet functions and customization arguments.

However, if you want to go beyond the functionality offered by the leaflet package, the next step would be to learn how to program in JavaScript and fiddle with the main leaflet.js library that this package is based on. That would allow you to create fully-customized maps and incorporate advanced plugins that would allow you to create animations, timelines, and so on.

Additionally, the beauty of working with open technologies like this is that you could add your own custom JavaScript functions to add functionality that the leaflet.js library does not support. In short, there is a lot that you can do with this particular tool.