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”):
leaflet() creates an empty map widget.addTiles() or addProviderTiles() adds the
background map.add*() functions place your data on top.install.packages(c("leaflet", "leaflet.extras", "sf", "dplyr", "htmlwidgets"))
# Only needed to knit to PDF:
install.packages(c("webshot2", "tinytex"))
tinytex::install_tinytex()
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.library(leaflet)
library(leaflet.extras)
library(dplyr)
library(sf)
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.
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.
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.| 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 |
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.
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.places on its own line prints the table so you
can check it.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.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.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.
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 |
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.makeIcon(iconUrl = "pin.png", iconWidth = 25, iconHeight = 40)
with addMarkers(icon = ...).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.
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.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.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 © 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.
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).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.)colorFactor() and add a legend.nc, map
BIR74 with colorQuantile(). How does the
picture differ from colorBin()?