1 Learning objectives

  1. Create an interactive map and choose a base map.
  2. Add markers, popups, labels and circles from a data frame.
  3. Cluster dense points and group layers with toggles.
  4. Draw polygons and build a choropleth with a colour legend.
  5. Save a map as a standalone HTML file.

2 What is Leaflet?

Leaflet is a popular open-source JavaScript library for interactive maps. The R package leaflet lets you use it without writing JavaScript. The result is an HTML widget that you can pan, zoom and click.

A Leaflet map is built in layers with the pipe operator %>% (read it as “and then”):

  1. leaflet() creates an empty map widget.
  2. addTiles() or addProviderTiles() adds the background map.
  3. add*() functions place your data on top.

3 Setup

3.1 Install the packages (run once)

install.packages(c("leaflet", "leaflet.extras", "sf", "dplyr", "htmlwidgets"))

# Only needed to knit to PDF:
install.packages(c("webshot2", "tinytex"))
tinytex::install_tinytex()
  • What each package is for:
    • leaflet: the mapping engine.
    • leaflet.extras: extra layers such as heatmaps.
    • sf: reads and handles spatial data (shapefiles, GeoJSON).
    • dplyr: data wrangling (filter(), mutate() and so on).
    • htmlwidgets: saves a map as an HTML file.
    • webshot2: takes pictures of the maps for the PDF (needs Google Chrome, Chromium or Edge installed).
    • tinytex: a small LaTeX engine that turns the document into a PDF.

3.2 Load the packages

library(leaflet)
library(leaflet.extras)
library(dplyr)
library(sf)

3.3 The datasets used

This tutorial uses two datasets that ship with R packages, so nothing needs downloading:

  • quakes: 1,000 earthquakes near Fiji, with columns lat, long, depth, mag and stations.
  • nc: county boundaries for North Carolina, bundled with sf.
head(quakes)
##      lat   long depth mag stations
## 1 -20.42 181.62   562 4.8       41
## 2 -20.62 181.03   650 4.2       15
## 3 -26.00 184.10    42 5.4       43
## 4 -17.97 181.66   626 4.1       19
## 5 -20.42 181.96   649 4.0       11
## 6 -19.68 184.31   195 4.0       12

head() prints the first six rows of a data frame. Always look at your data before mapping it: you need to know the column names (here long and lat) because the map functions refer to them.

4 Your first map

leaflet() %>%
  addTiles() %>%
  setView(lng = 76.2673, lat = 9.9312, zoom = 11)
  • leaflet(): creates an empty map widget. On its own it shows a blank grey box.
  • addTiles(): adds the default background tiles from OpenStreetMap. Tiles are small square map images that the browser fetches as you pan and zoom.
  • setView(lng, lat, zoom): sets the starting centre and zoom level. The values here centre the map on Kochi, Kerala. Zoom runs from about 1 (whole world) to 18 (street level).

Leaflet wants longitude first, then latitude. This is the opposite of how coordinates are usually spoken (“lat, long”). Swapping them puts your points in the wrong country.

5 Choosing a base map

leaflet() %>%
  addProviderTiles(providers$Esri.WorldGrayCanvas) %>%
  setView(lng = 76.2673, lat = 9.9312, zoom = 11)
  • addProviderTiles() lets you choose from many free tile styles, instead of only the default one.
  • providers$Esri.WorldGrayCanvas is a pale, light-grey style made for data maps. The providers object is a list of named options, and $ picks one.
  • A quiet, light base map is a good habit for data maps because your coloured data stands out against it.
Provider Look
providers$OpenStreetMap Standard street map
providers$Esri.WorldGrayCanvas Light grey, best for data overlays
providers$Esri.WorldStreetMap Detailed street map
providers$Esri.WorldImagery Satellite imagery
providers$OpenTopoMap Terrain and contours

5.0.1 Using any tile URL

addTiles() also accepts a custom urlTemplate, which is how the heatmap section below gets a dark map. {z}, {x} and {y} are filled in by Leaflet. Always keep the attribution text.

6 Markers, popups and labels

First, build a small data frame of places.

places <- data.frame(
  name = c("Fort Kochi", "Marine Drive", "Lulu Mall", "Kochi Metro Hub"),
  lat  = c(9.9658, 9.9816, 10.0261, 9.9726),
  lng  = c(76.2423, 76.2760, 76.3084, 76.2999),
  type = c("Heritage", "Promenade", "Shopping", "Transport")
)

places
##              name     lat     lng      type
## 1      Fort Kochi  9.9658 76.2423  Heritage
## 2    Marine Drive  9.9816 76.2760 Promenade
## 3       Lulu Mall 10.0261 76.3084  Shopping
## 4 Kochi Metro Hub  9.9726 76.2999 Transport
  • data.frame() builds a table. Each name = c(...) becomes a column, and the values are matched by position (the first name goes with the first lat, and so on).
  • <- assigns the table to an object called places.
  • Typing places on its own line prints the table so you can check it.
  • The columns are named lat and lng on purpose. Leaflet automatically looks for columns with names like these.

Now plot them.

leaflet(places) %>%
  addProviderTiles(providers$Esri.WorldGrayCanvas) %>%
  addMarkers(
    lng = ~lng,
    lat = ~lat,
    popup = ~paste0("<b>", name, "</b><br>Type: ", type),
    label = ~name
  )
  • leaflet(places): hands the data frame to the map. All later layers can use its columns.
  • addMarkers(): draws a standard blue pin for each row.
  • lng = ~lng and lat = ~lat: the tilde ~ (formula syntax) means “take this column from the data frame”. Without the ~, R would look for a variable in your workspace instead and fail.
  • popup = ~paste0(...): the text shown when you click a marker. paste0() glues text pieces together with no spaces.
  • label = ~name: the text shown when you hover. Use popups for detail and labels for quick identification.

7 Circle markers that encode data

Circles can show a number through their size and colour.

leaflet(quakes) %>%
  addProviderTiles(providers$Esri.WorldGrayCanvas) %>%
  addCircleMarkers(
    lng = ~long,
    lat = ~lat,
    radius = ~mag * 1.5,
    color = "#d7301f",
    fillOpacity = 0.4,
    stroke = FALSE,
    popup = ~paste0("Magnitude: ", mag, "<br>Depth: ", depth, " km")
  )
  • addCircleMarkers() draws circles instead of pins. They are lighter and faster than pins when you have many points.
  • radius = ~mag * 1.5: the circle size is calculated from the mag column. The * 1.5 is a scaling factor so the circles are neither tiny nor huge. Bigger earthquakes get bigger circles.
  • color = "#d7301f": a red given as a hex code.
  • fillOpacity = 0.4: 40% opaque, so overlapping circles show through each other.
  • stroke = FALSE: removes the circle outline.
  • Note that the quakes data uses long, not lng, so we state the column explicitly.

addCircleMarkers() sets the radius in pixels, so circles stay the same size as you zoom. addCircles() sets it in metres, so circles grow and shrink with the map.

7.1 Colour by a variable and add a legend

pal <- colorNumeric(palette = "YlOrRd", domain = quakes$depth)

leaflet(quakes) %>%
  addProviderTiles(providers$Esri.WorldGrayCanvas) %>%
  addCircleMarkers(
    lng = ~long,
    lat = ~lat,
    radius = 4,
    stroke = FALSE,
    fillOpacity = 0.7,
    color = ~pal(depth)
  ) %>%
  addLegend(
    position = "bottomright",
    pal = pal,
    values = ~depth,
    title = "Depth (km)"
  )
  • colorNumeric(palette, domain) creates a function called pal. Give it a number and it returns a colour.
    • palette = "YlOrRd" is a yellow-orange-red ColorBrewer scheme.
    • domain = quakes$depth tells it the range of values (minimum to maximum) to stretch the colours across.
  • color = ~pal(depth): for each row, pass the depth to pal() and use the colour it returns.
  • addLegend() draws the colour key. It needs the same pal and the same values, otherwise the legend will not match the map.

Choose the palette function to suit your data:

Function Use for
colorNumeric() Smooth continuous scale
colorBin() Equal-width classes
colorQuantile() Equal-count classes
colorFactor() Categories

8 Custom icons

icons <- awesomeIcons(
  icon = "info-sign",
  library = "glyphicon",
  markerColor = ifelse(places$type == "Heritage", "red", "blue")
)

leaflet(places) %>%
  addProviderTiles(providers$Esri.WorldGrayCanvas) %>%
  addAwesomeMarkers(lng = ~lng, lat = ~lat, icon = icons, label = ~name)
  • awesomeIcons() defines how the pins look. It does not draw anything yet; it only builds the icon specification.
    • icon = "info-sign" chooses a symbol from the glyphicon icon library.
    • markerColor = ifelse(...) is a vectorised if/else: for each place, use "red" if its type is Heritage, otherwise "blue". This gives you one colour per row.
  • addAwesomeMarkers(icon = icons) draws the pins using that specification.
  • To use your own picture, use makeIcon(iconUrl = "pin.png", iconWidth = 25, iconHeight = 40) with addMarkers(icon = ...).

9 Clustering many points

leaflet(quakes) %>%
  addProviderTiles(providers$Esri.WorldGrayCanvas) %>%
  addMarkers(
    lng = ~long,
    lat = ~lat,
    popup = ~paste("Mag:", mag),
    clusterOptions = markerClusterOptions()
  )

Plotting 1,000 individual pins makes a map cluttered and slow. Passing clusterOptions = markerClusterOptions() merges nearby points into numbered bubbles. Zoom in and the bubbles split apart until you reach single markers. Click a bubble to zoom to its members.

10 Layer groups and a layer control

strong <- quakes %>% filter(mag >= 5)
weak   <- quakes %>% filter(mag < 5)

leaflet() %>%
  addProviderTiles(providers$Esri.WorldGrayCanvas, group = "Light") %>%
  addProviderTiles(providers$Esri.WorldImagery, group = "Satellite") %>%
  addCircleMarkers(
    data = strong, lng = ~long, lat = ~lat,
    color = "red", radius = 6, group = "Strong (5+)"
  ) %>%
  addCircleMarkers(
    data = weak, lng = ~long, lat = ~lat,
    color = "steelblue", radius = 3, group = "Weak (<5)"
  ) %>%
  addLayersControl(
    baseGroups = c("Light", "Satellite"),
    overlayGroups = c("Strong (5+)", "Weak (<5)"),
    options = layersControlOptions(collapsed = FALSE)
  )
  • filter(mag >= 5) (from dplyr) keeps only the rows that meet the condition, so we get two smaller data frames.
  • leaflet() is called without data this time, because each layer brings its own through the data = argument.
  • group = "..." gives each layer a name so the control can switch it on and off.
  • addLayersControl() adds the toggle box:
    • baseGroups are mutually exclusive (radio buttons): only one background at a time.
    • overlayGroups are independent (checkboxes): show or hide each data layer.
    • collapsed = FALSE keeps the box open rather than hiding it behind an icon.

11 Polygons and choropleth maps

A choropleth shades regions according to a value.

nc <- st_read(system.file("shape/nc.shp", package = "sf"), quiet = TRUE) %>%
  st_transform(4326)

nc$rate <- nc$SID74 / nc$BIR74 * 1000

summary(nc$rate)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##   0.000   1.084   1.855   2.046   2.604   9.554
  • system.file("shape/nc.shp", package = "sf") finds the path of the example shapefile inside the installed sf package.
  • st_read(..., quiet = TRUE) reads the shapefile into an sf object (a data frame with a geometry column). quiet = TRUE suppresses the progress message.
  • st_transform(4326) converts the coordinates to WGS84 (EPSG:4326), the longitude/latitude system Leaflet requires. Skipping this is the most common reason polygons fail to appear.
  • nc$rate <- ... creates a new column: sudden infant deaths (SID74) per 1,000 births (BIR74). Use rates, not raw counts, otherwise large counties will dominate the map simply because they are big.
  • summary() shows the minimum, quartiles and maximum, which helps you pick sensible colour classes.
pal_nc <- colorBin("Blues", domain = nc$rate, bins = 5)

leaflet(nc) %>%
  addProviderTiles(providers$Esri.WorldGrayCanvas) %>%
  addPolygons(
    fillColor = ~pal_nc(rate),
    fillOpacity = 0.8,
    color = "white",
    weight = 1,
    highlightOptions = highlightOptions(
      weight = 3, color = "#333", bringToFront = TRUE
    ),
    label = ~paste0(NAME, ": ", round(rate, 2)),
    popup = ~paste0("<b>", NAME, "</b><br>Rate: ", round(rate, 2))
  ) %>%
  addLegend(
    pal = pal_nc,
    values = ~rate,
    title = "Rate per 1,000 births",
    position = "bottomright"
  )
  • colorBin("Blues", domain, bins = 5) builds a palette that sorts values into 5 equal-width classes.
  • addPolygons() draws each county.
    • fillColor = ~pal_nc(rate) colours the inside by the county’s rate.
    • fillOpacity = 0.8, color = "white" and weight = 1 set the fill transparency, the border colour and the border thickness.
    • highlightOptions() thickens and darkens the border while the mouse hovers over a county (HTML only). bringToFront = TRUE makes sure the highlighted outline is not hidden by neighbours.
    • label and popup work exactly as they did for markers.
  • addLegend() reuses pal_nc so legend colours match the map.

12 Heatmaps

dark_url <- paste0(
  "https://server.arcgisonline.com/ArcGIS/rest/services/",
  "Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}"
)

leaflet(quakes) %>%
  addTiles(urlTemplate = dark_url, attribution = "Tiles &copy; Esri") %>%
  addHeatmap(
    lng = ~long, lat = ~lat,
    intensity = ~mag,
    blur = 20, max = 0.05, radius = 15
  )

addHeatmap() (from leaflet.extras) shows density rather than individual points, which is useful when there are too many points to read separately.

  • dark_url <- paste0(...): the long tile address is built in two pieces so it stays readable (and fits on a PDF page).
  • intensity = ~mag: stronger earthquakes count for more heat.
  • radius = 15: how far each point spreads its heat.
  • blur = 20: how soft the edges are.
  • max = 0.05: the value at which the colours reach their maximum. Lower values make the map hotter overall. You usually have to adjust these three by trial and error.

A dark base map suits heatmaps because the glow stands out. Here we use addTiles() with a custom urlTemplate (Esri’s dark grey canvas, which needs no key) instead of addProviderTiles(), because that style is not in the providers list. The attribution argument credits the tile source, which its licence requires.

13 Extra map controls

leaflet(places) %>%
  addProviderTiles(providers$Esri.WorldGrayCanvas) %>%
  addMarkers(lng = ~lng, lat = ~lat, label = ~name) %>%
  addScaleBar(position = "bottomleft") %>%
  addMiniMap(toggleDisplay = TRUE) %>%
  addMeasure(primaryLengthUnit = "kilometers")
  • addScaleBar(): a distance scale in the corner.
  • addMiniMap(): a small overview map. toggleDisplay = TRUE adds a button to collapse it.
  • addMeasure(): a ruler tool. Users click points to measure distances, shown in kilometres here (HTML only; in the PDF you see just the button).

14 Saving and sharing

m <- leaflet(places) %>%
  addTiles() %>%
  addMarkers(~lng, ~lat, label = ~name)

out_file <- file.path(tempdir(), "kochi_map.html")
htmlwidgets::saveWidget(m, out_file, selfcontained = TRUE)

out_file
## [1] "/tmp/RtmpF87Cjl/kochi_map.html"
  • m <- leaflet(...): the map is stored in an object instead of being drawn at once. Printing m later draws it.
  • file.path(tempdir(), "kochi_map.html") builds a path inside a temporary folder, so this example does not litter your project. Change it to a real path such as "kochi_map.html" to keep the file.
  • htmlwidgets::saveWidget(..., selfcontained = TRUE) writes a single HTML file that bundles all required JavaScript. It can be emailed, double-clicked to open in a browser, or hosted on a website. (The :: calls a function from a package without loading it.)
  • The last line prints the file location.

15 Practice exercises

  1. Warm-up. Map five places in your district with popups and hover labels.
  2. Style it. Colour markers by category using colorFactor() and add a legend.
  3. Switch layers. Offer three base maps and two toggleable overlay groups.
  4. Choropleth. Using nc, map BIR74 with colorQuantile(). How does the picture differ from colorBin()?