leaflet() |>
addTiles() |>
addMarkers(lng = -123.117, lat = 49.247, popup = "Vancouver, BC")2 Map Forestry Data with leaflet
This chapter uses the CSRD parks layer (csrd_parks.geojson); the optional air-quality examples reuse (airdata3.csv). Click a file name to download it directly, then save it in your data/ folder. Sources: CSRD Open Data (parks); BC Ministry of Environment (air quality, ground-level O₃).
Chapter goals
By the end of this chapter you will be able to:
- Explain the difference between a static map (Ch 10,
geom_sf) and an interactive web map (this chapter,leaflet), and choose the right tool for each deliverable. - Build a basic leaflet map in three lines:
leaflet() |> addTiles() |> addMarkers(). - Add base tiles from default OpenStreetMap and from alternative providers (terrain, satellite, light/dark).
- Place markers and circle markers on the map, with size and colour driven by data columns.
- Attach popups and labels that show information when a reader clicks or hovers.
- Map quantitative data with
colorNumeric()and categorical data withcolorFactor()palettes, and add a matchingaddLegend(). - Combine multiple layers with a layers-control widget that lets the reader toggle each layer on or off.
- Embed a leaflet map inside a rendered Quarto HTML file — the map remains interactive in the final report.
- Recognize when
leafletdata must be in WGS84 (EPSG:4326) and reproject if needed.
Expected output for this chapter
- A rendered Quarto HTML report (
frst232_ch11_learner.html) containing at least:- A basic leaflet map of the 10 Metro Vancouver monitoring stations with default markers and click-popups showing station names.
- A circle-marker map where circle colour is driven by each station’s mean O₃ (computed from the cleaned air-quality tibble in Chapter 6), with a matching legend.
- A multi-layer map combining the stations and 5 km buffer polygons (from Chapter 10), with a layers-control widget to toggle each layer.
- At least one map using a non-default tile provider (e.g., Esri WorldImagery for satellite or CartoDB.Positron for clean light).
- The corresponding
.qmdsource file. - A short group-lab worksheet (or screenshot) submitted to the course site.
3 Where we are
In Chapter 10 you built stations_sf — a tibble of 10 BC monitoring stations with point geometries. You plotted it with geom_sf() and got a clean static map suitable for a printed report.
This chapter takes the same stations_sf and turns it into an interactive web map. Click any station to see its name. Pan and zoom freely. Switch between street, satellite, and terrain basemaps. The reader does not need R installed — the map lives in the rendered HTML.
This chapter builds on objects created earlier. If you are not running every chapter in one continuous session, recreate these first:
stations_sf— thesfobject of monitoring stations (Chapter 10).- For the optional air-quality examples:
airdata_cleanand a per-station summary (Chapters 6–7).
If one is missing you will see object '...' not found — just re-run the code that created it.
4 Static map vs interactive map — when to use which
| Static (Ch 10) | Interactive (this chapter) | |
|---|---|---|
| Library | ggplot2::geom_sf() |
leaflet |
| Output | PNG, PDF, or SVG | HTML widget |
| Reader experience | Fixed view | Pan, zoom, click, toggle |
| Where it works | Print, slides, papers | Web, dashboards, Quarto HTML |
| File size | Small (KB) | Larger (MB — includes JS) |
| Build time | Fast | Slower |
| Customization | Full ggplot grammar | Many leaflet options, but not everything |
Use geom_sf for any chart that goes into a paper or a PowerPoint slide. Use leaflet for any chart that goes into an HTML report, a Quarto deliverable, or a dashboard your reader will explore.
The two are complementary. A working analyst publishes both — a static map in the printed memo, an interactive map in the linked HTML.
5 A note about Excel
Excel does not have a direct equivalent of leaflet. You could embed a Bing Maps chart in Excel 365 (limited), or paste a screenshot of a web map. Neither is interactive in the leaflet sense. This is one of the places where R does something Excel cannot.
6 Installing leaflet
install.packages("leaflet")
library(leaflet)leaflet is much lighter than sf — no system dependencies, installs in seconds on every platform. If you already have Posit Cloud from Chapter 10, leaflet is pre-installed there too.
7 The leaflet pipeline pattern
Every leaflet map is built with the pipe |> (or %>%) and the add*() family of functions:
Three things to notice:
leaflet()starts an empty map.addTiles()adds the default basemap (OpenStreetMap).addMarkers()drops a pin.
Read each |> as “and also add”. This is the same layering pattern as ggplot’s +, just with the pipe.
|> (not +)
Unlike ggplot2, which connects layers with +, leaflet uses the pipe |>. This makes sense — each add*() call takes a leaflet map object and returns a modified one, so it composes like every other dplyr-style operation.
8 Build the stations sf object (carrying forward from Ch 10)
stations <- tribble(
~location, ~lon, ~lat,
"Burnaby South", -122.985, 49.215,
"Coquitlam Douglas College", -122.798, 49.252,
"Langley Central", -122.660, 49.105,
"Maple Ridge Golden Ears School", -122.620, 49.215,
"New Westminster Sapperton Park", -122.890, 49.235,
"North Delta", -122.910, 49.135,
"North Vancouver Second Narrows", -123.015, 49.295,
"Pitt Meadows Airport", -122.700, 49.215,
"Port Coquitlam North", -122.770, 49.265,
"Port Moody Rocky Point Park", -122.860, 49.290
)
stations_sf <- stations |>
st_as_sf(coords = c("lon", "lat"), crs = 4326)The leaflet JavaScript library expects all coordinates in latitude/longitude (WGS84). If your sf object is in BC Albers or any other projected CRS, reproject before passing to leaflet:
stations_for_leaflet <- stations_sf |> st_transform(4326)leaflet will sometimes warn if you forget; sometimes it just plots your data in the wrong place. Make the reprojection explicit.
9 Your first interactive map
leaflet(data = stations_sf) |>
addTiles() |>
addMarkers(popup = ~location)When this renders, you get:
- A draggable, zoomable map centered on Metro Vancouver.
- 10 default blue teardrop pins, one per station.
- A popup with the station name on each click.
Three new ideas in that small example:
data = stations_sf— leaflet auto-detects the geometry column and finds the lat/lon.popup = ~location— the~(tilde) is leaflet’s formula notation: “the value of the column calledlocation”. You will see this pattern everywhere in leaflet.- The map auto-centres and auto-zooms to fit all the points.
10 Markers, circles, and popups
10.1 addMarkers() — default pins
leaflet(stations_sf) |>
addTiles() |>
addMarkers(popup = ~location, label = ~location)popup: shows on click.label: shows on hover.
10.2 addCircleMarkers() — circles whose size and colour can vary
When the data column matters (not just the location), use circle markers:
leaflet(stations_sf) |>
addTiles() |>
addCircleMarkers(
radius = 8,
color = "#1B4332",
fillColor = "#52B788",
fillOpacity = 0.8,
weight = 2,
popup = ~location
)10.3 Driving size and colour from a data column
Attach a mean-O₃ value to each station (carrying forward from Ch 7) and use it to size/colour the circles:
# Suppose airdata_clean is loaded from Ch 6
station_means <- airdata_clean |>
group_by(location) |>
summarise(mean_o3 = mean(O3, na.rm = TRUE), .groups = "drop")
stations_o3 <- stations_sf |>
left_join(station_means, by = "location")
pal <- colorNumeric(
palette = c("#52B788", "#B7791F", "#B05C50"),
domain = stations_o3$mean_o3,
na.color = "grey50"
)
leaflet(stations_o3) |>
addTiles() |>
addCircleMarkers(
radius = ~ifelse(is.na(mean_o3), 5, mean_o3 + 4),
color = "#1B4332",
fillColor = ~pal(mean_o3),
fillOpacity = 0.85,
weight = 1.5,
popup = ~paste0("<b>", location, "</b><br>",
"Mean O₃: ",
ifelse(is.na(mean_o3), "no data",
paste0(round(mean_o3, 1), " ppb")))
) |>
addLegend(
"bottomright",
pal = pal,
values = ~mean_o3,
title = "Mean O₃ (ppb)",
opacity = 1
)Three new ideas:
colorNumeric(palette, domain)builds a palette function that maps numeric values to colours. Use it insidefillColor = ~pal(column).addLegend()draws a matching legend. The palette function and the data column must match what the markers used.- HTML in popups — you can use
<b>,<br>, links — anything HTML.
11 Tile providers — change the basemap
addTiles() adds the default OpenStreetMap. For other basemaps, use addProviderTiles():
leaflet(stations_sf) |>
addProviderTiles("CartoDB.Positron") |> # clean light theme
addMarkers(popup = ~location)Useful providers:
| Provider | When to use |
|---|---|
OpenStreetMap.Mapnik (default) |
General-purpose, free |
CartoDB.Positron |
Clean light theme — popular for data viz |
CartoDB.DarkMatter |
Dark theme |
Esri.WorldImagery |
Satellite imagery |
Esri.WorldTopoMap |
Topographic with relief |
OpenTopoMap |
Open-source topo, good for forestry |
Stadia.AlidadeSmooth |
Subtle smooth basemap |
Full provider list: https://leaflet-extras.github.io/leaflet-providers/preview/.
12 Polygons — adding the buffers from Ch 10
Polygon layers (like the 5 km buffers from Ch 10) go in with addPolygons():
buffers_sf <- stations_sf |>
st_transform(3005) |>
st_buffer(dist = 5000) |>
st_transform(4326)
leaflet() |>
addProviderTiles("CartoDB.Positron") |>
addPolygons(data = buffers_sf,
fillColor = "#52B788",
fillOpacity = 0.25,
color = "#1B4332",
weight = 1) |>
addCircleMarkers(data = stations_sf,
radius = 5,
color = "#1B4332",
fillColor = "#1B4332",
fillOpacity = 1,
popup = ~location)Note the CRS dance: buffers are computed in BC Albers (3005, metric — required for the buffer distance), then reprojected to WGS84 (4326) for leaflet. Always end in WGS84 before passing to leaflet.
13 Multiple layers + layers control
When a map has multiple groups, addLayersControl() lets the reader toggle them:
leaflet() |>
addProviderTiles("OpenStreetMap.Mapnik", group = "Street") |>
addProviderTiles("Esri.WorldImagery", group = "Satellite") |>
addProviderTiles("OpenTopoMap", group = "Topo") |>
addCircleMarkers(data = stations_sf,
group = "Stations",
popup = ~location) |>
addPolygons(data = buffers_sf,
group = "5 km buffers",
fillColor = "#52B788", fillOpacity = 0.3) |>
addLayersControl(
baseGroups = c("Street", "Satellite", "Topo"),
overlayGroups = c("Stations", "5 km buffers"),
options = layersControlOptions(collapsed = FALSE)
)baseGroupsare mutually exclusive (radio buttons — one basemap at a time).overlayGroupsare independently toggleable (checkboxes).
The reader sees a control box in the top-right of the map.
14 setView() and fitBounds() — control the initial view
By default, leaflet zooms to fit the data. Override with:
# Centre on a specific lat/lon and zoom level
leaflet() |>
addTiles() |>
setView(lng = -122.9, lat = 49.22, zoom = 11)
# Or fit a specific bounding box
leaflet() |>
addTiles() |>
fitBounds(lng1 = -123.1, lat1 = 49.10,
lng2 = -122.6, lat2 = 49.30)Zoom level: 0 (whole world) to ~18 (street level). For a Metro Vancouver overview, zoom 10 or 11 is right.
15 Saving and embedding
When you render a Quarto document, leaflet maps appear inline in the rendered HTML. The map is fully interactive — your reader can pan, zoom, click — without opening R.
To save a leaflet map as a standalone HTML file:
library(htmlwidgets)
saveWidget(my_leaflet_map,
file = here("outputs", "stations_map.html"),
selfcontained = TRUE)selfcontained = TRUE bundles all the JavaScript into a single file — easy to email, share, or open offline.
16 A complete worked example
# All together: stations + buffers + O₃ colour, layered + legend
# 1. Build the buffer layer (compute in metric CRS, reproject for leaflet)
buffers_sf <- stations_sf |>
st_transform(3005) |>
st_buffer(dist = 5000) |>
st_transform(4326)
# 2. Attach O₃ (assume station_means is from Ch 6/7)
stations_o3 <- stations_sf |>
left_join(station_means, by = "location")
# 3. Palette for O₃
pal <- colorNumeric(palette = c("#52B788", "#B7791F", "#B05C50"),
domain = stations_o3$mean_o3,
na.color = "grey60")
# 4. Build the map
leaflet() |>
addProviderTiles("CartoDB.Positron", group = "Light") |>
addProviderTiles("Esri.WorldImagery", group = "Satellite") |>
addPolygons(data = buffers_sf,
group = "5 km buffers",
fillColor = "#52B788", fillOpacity = 0.2,
color = "#1B4332", weight = 1) |>
addCircleMarkers(data = stations_o3,
group = "Stations",
radius = ~ifelse(is.na(mean_o3), 5, mean_o3 + 4),
fillColor = ~pal(mean_o3),
fillOpacity = 0.9,
color = "#1B4332",
weight = 1.5,
popup = ~paste0("<b>", location, "</b><br>",
"Mean O₃: ",
ifelse(is.na(mean_o3), "no data",
paste0(round(mean_o3, 1),
" ppb")))) |>
addLegend("bottomright",
pal = pal, values = stations_o3$mean_o3,
title = "Mean O₃ (ppb)",
na.label = "No data",
opacity = 1) |>
addLayersControl(
baseGroups = c("Light", "Satellite"),
overlayGroups = c("Stations", "5 km buffers"),
options = layersControlOptions(collapsed = FALSE)
) |>
setView(lng = -122.85, lat = 49.22, zoom = 11)This map renders as a single interactive widget. A reader can zoom out to BC, zoom in to a station, click for the popup, toggle the buffers off, switch to satellite. One R block, full dashboard.
17 A real interactive map: CSRD parks
The maps above used the synthetic station tibble so you could see each piece in isolation. Here is the real thing — the Columbia Shuswap Regional District parks layer from Chapter 10, as a live interactive map you can pan, zoom, and click:
parks <- st_read(here("data", "csrd_parks.geojson"), quiet = TRUE)
pal <- colorFactor("viridis", domain = parks$ParkType)
leaflet(parks) |>
addProviderTiles("CartoDB.Positron") |>
addCircleMarkers(
radius = 5,
color = ~pal(ParkType),
fillOpacity = 0.85,
stroke = FALSE,
label = ~ParkName,
popup = ~paste0("<b>", ParkName, "</b><br>",
"Type: ", ParkType, "<br>",
"Status: ", ParkStatus)
) |>
addLegend("bottomright", pal = pal, values = ~ParkType,
title = "Park type", opacity = 1)Map: Columbia Shuswap Regional District park locations, coloured by park type. Source: CSRD Open Data. Non-interactive description: the parks are distributed across the CSRD — clustering near Salmon Arm and following the Shuswap Lake and Columbia River corridors. A static geom_sf version carrying the same information appears in Chapter 10 for readers who cannot use the interactive widget.
This is the interactive counterpart to the static map in Chapter 10 — same data, same question, two presentation choices. Use the interactive version when exploration helps (a reader wants to find a specific park); use the static version when the map must work in print, in a PDF, or for a screen-reader user.
18 Active learning
18.1 Activity 1 — Minimal map
Build a leaflet map showing only one location: your university campus. Use addTiles() and addMarkers() with a popup.
18.2 Activity 2 — All ten stations
Build a leaflet map of stations_sf with default markers and hover labels.
18.3 Activity 3 — Circle markers
Replace the default markers with addCircleMarkers(). Set radius = 7, fillColor = "#2D6A4F", fillOpacity = 0.85.
18.4 Activity 4 — Tile provider experiment
Build the same map three times with three different tile providers (default, CartoDB.Positron, Esri.WorldImagery). Which feels best for a forestry deliverable?
18.5 Activity 5 — Add a polygon layer
Take any one station, build a 5 km buffer, and add it to the map as addPolygons(). Make sure both the buffer and the station markers are in WGS84 before plotting.
18.6 Activity 6 — Save as standalone HTML
Use htmlwidgets::saveWidget() to save your map as outputs/my_map.html. Open it in a browser — confirm it works offline.
19 Group lab activity
Work in groups of 3 or 4. The lab builds the multi-layer interactive map specified in the chapter’s Expected output.
Task 1 — Build stations_sf (everyone confirms 10 rows in WGS84, same as Ch 10).
Task 2 — Compute mean O₃ per station using the Chapter 6 cleaning pipeline + group_by(location) |> summarise(mean_o3 = mean(O3, na.rm = TRUE)). Join the mean back onto stations_sf with left_join(by = "location").
Task 3 — Build a O₃ palette with colorNumeric(). Discuss as a group: which colour scheme makes sense for an air-quality variable? (Many groups choose green→yellow→red.)
Task 4 — Build the map. Add tiles, add circle markers with size/colour driven by mean_o3, add a legend, add informative popups.
Task 5 — Add the buffer overlay. Build 5 km buffers around every station and add them as a separate group with addPolygons(). Add a layers-control widget so the reader can toggle buffers on and off.
Task 6 — Pick the basemap. Discuss which provider tile makes the air-quality colours pop. Add at least two basemap options to the layers control.
Task 7 — Submit. Each group submits the rendered HTML file and a one-sentence caption explaining the headline finding.
This lab fits comfortably inside a single hands-on session.
20 Exercises
Minimal map. Build a leaflet map of
stations_sfwith default tiles and markers. Confirm the popup shows the station name.Circle markers. Replace
addMarkers()withaddCircleMarkers(). Set radius, fill colour, and fill opacity manually.Tile providers. Build the same map three times with three different
addProviderTiles()calls. Embed all three in your.qmd.Colour by O₃. Compute mean O₃ per station (from Ch 6 cleaning). Use
colorNumeric()to colour the circles. AddaddLegend().Popup with HTML. Build popups that include the station name in bold, a line break, and the mean O₃ rounded to one decimal.
Add a polygon layer. Build 5 km buffers around all stations and add them with
addPolygons(). Confirm both layers are in WGS84 before plotting.Layers control. Add an
addLayersControl()with two base groups (street, satellite) and two overlay groups (stations, buffers).Save standalone. Use
htmlwidgets::saveWidget()to save your final map asoutputs/stations_map.html. Open it directly in a browser.
21 Optional, advanced
21.1 Markers with custom icons
fire_icon <- makeIcon(
iconUrl = "https://leafletjs.com/examples/custom-icons/leaf-red.png",
iconWidth = 24, iconHeight = 38
)
leaflet(stations_sf) |>
addTiles() |>
addMarkers(icon = fire_icon, popup = ~location)For forestry use cases, custom icons can distinguish point types (fire ignitions, plot centres, sampling locations).
21.2 Adding a search box
The leaflet.extras add-on package provides extras including search, drawing tools, and measurement widgets:
library(leaflet.extras)
leaflet(stations_sf) |>
addTiles() |>
addMarkers(popup = ~location, group = "stations") |>
addSearchFeatures(targetGroups = "stations",
options = searchFeaturesOptions(zoom = 12))21.3 Heatmaps
For dense point data (e.g., wildfire ignitions over years), leaflet.extras::addHeatmap() produces a smooth density overlay:
library(leaflet.extras)
leaflet(ignition_points) |>
addTiles() |>
addHeatmap(radius = 12)22 A glimpse of Chapter 12: From maps to presentations
In Chapter 12 you take everything from Chapters 4–11 — your cleaned data, summary tables, charts, and maps — and assemble them into a portfolio:
- A Quarto revealjs presentation for an in-class talk.
- A Quarto HTML report showcasing your best analyses.
- A printable PDF for a deliverable submission.
The leaflet maps you built in this chapter become slides in the presentation — fully interactive when displayed live.
23 Self-assessment quiz
- Asking Google Maps
- Generating HTML + JavaScript that wraps the leaflet.js library
- Drawing into a static PNG
- Calling ggplot
- + between calls (like ggplot2)
- |> (or %>%) between leaflet() and the add*() functions
- commas inside a single call
- no chaining
- EPSG:3005 (BC Albers)
- EPSG:4326 (WGS84, lat/lon)
- EPSG:3857 (Web Mercator)
- Any CRS
- addBackground()
- addTiles()
- addBasemap()
- addLayer()
addTiles() with no arguments adds the default OpenStreetMap Mapnik tiles.- addTiles("satellite")
- addProviderTiles("Esri.WorldImagery")
- addTiles(satellite = TRUE)
- setBasemap()
popup = ~location means —- Literally the string "location"
- The value of the column called
locationin the data tibble - The opposite of location
- A function called location
addCircleMarkers() differs from addMarkers() in that —- It only works with raster data
- It draws circles whose size and colour you can drive from data columns; default markers are fixed pins
- It is faster but less accurate
- It is for polygons
- scale_colour_continuous()
- colorNumeric(palette, domain) — and pass the resulting function as fillColor = ~pal(column)
- addColour()
- paint()
addLegend() is —- A required call for every map
- An optional layer that draws a legend matching a palette and data values
- A way to add a title
- A debugging tool
addLegend takes the same palette function and column you used for the markers, and draws a legend box anchored to a corner.addPolygons() is for —- Points
- Lines only
- Polygon geometries (e.g., buffers, forest districts, watersheds)
- Markers
addPolygons draws filled polygon shapes. addPolylines draws line geometries.addLayersControl() with baseGroups and overlayGroups —- Hides the map
- Adds a toggle widget where base groups are radio buttons (one at a time) and overlay groups are checkboxes (any combination)
- Removes all layers
- Saves the map
setView(lng, lat, zoom) —- Adds a marker at the centre
- Sets the initial centre and zoom level when the map first renders
- Saves the map
- Adds an overlay
setView controls the starting view. Without it, leaflet auto-zooms to fit the data.- Renders as a static PNG
- Remains fully interactive in the rendered HTML — the reader can pan, zoom, click, and toggle layers
- Requires R to be installed on the reader's computer
- Cannot be embedded
- ggsave()
- htmlwidgets::saveWidget()
- write_csv()
- save()
saveWidget(my_map, "outputs/map.html", selfcontained = TRUE) bundles all the JS into one shareable file.- A bug in leaflet
- Your sf data is not in WGS84 (EPSG:4326) — you forgot to st_transform(4326) before passing it to leaflet
- Your internet is down
- Your zoom level is wrong
- Use st_buffer in WGS84 with dist = 5000
- Reproject to BC Albers (metric), st_buffer with dist = 5000, then reproject the buffer back to WGS84 for leaflet
- leaflet handles buffers automatically
- It is not possible
- Higher resolution
- Interactivity — pan, zoom, click for popups, toggle layers
- Lower file size
- No CRS required
- A built-in leaflet equivalent
- No direct equivalent — interactive web mapping is one of the places R does something Excel cannot
- Better web mapping
- Identical functionality
popup shows on click; label shows on —- Page load
- Hover
- Double-click
- Right-click
24 AI as a debugging companion
“My leaflet map shows my points in the middle of the Atlantic Ocean. My sf data is in BC Albers. What did I miss?” (Answer: reproject to WGS84 before passing to leaflet.)
“How do I add a legend that matches the colour palette I used for my circles?”
“My popups show the literal word ‘location’ instead of the station name. What’s wrong?” (Answer: missing the
~inpopup = ~location.)
25 Reading
- Leaflet for R — the canonical reference, free: https://rstudio.github.io/leaflet/
- Geocomputation with R — chapter 8 Making maps with R: https://r.geocompx.org/adv-map.html
- Tile providers preview: https://leaflet-extras.github.io/leaflet-providers/preview/
- HTML widgets gallery: https://www.htmlwidgets.org/