I ❤️ Leaflet: ‘Shading out’ regions

Shading out the rest of the world (kind of!) to create regions of focus in a leaflet map.

leaflet
Author

Jack Davison

Published

September 16, 2023

This is a blog post in the “I love leaflet” series, where I share tips and tricks I’ve discovered over time working with the leaflet R package. You’re therefore best off loading leaflet before we get cracking!

Starting Off

I’m going to start by creating a map of towns and cities in Oxfordshire with markers roughly sized by population. I’ve scraped from citipedia; if you’re interested in doing so yourself, do expand the box below to see the code.

links <-
  rvest::read_html("https://www.citipedia.info/province/general/United+Kingdom_Oxfordshire") |>
  rvest::html_elements("a")

get_city_info <- function(x) {
  rvest::read_html(stringr::str_glue("https://www.citipedia.info{x}")) |>
    rvest::html_table() |>
    dplyr::bind_rows()
}

tbl <-
  data.frame(
    city = rvest::html_text2(links),
    link = rvest::html_attr(links, "href")
  ) |>
  dplyr::filter(grepl("city/", link)) |>
  dplyr::distinct(city, link) |>
  dplyr::arrange(city) |>
  dplyr::mutate(data = purrr::map(link, get_city_info, .progress = TRUE)) |>
  tidyr::unnest(data) |>
  tidyr::pivot_wider(names_from = X1, values_from = X2) |>
  janitor::clean_names() |>
  dplyr::mutate(population = readr::parse_number(population))

This is as initially simple as reading in a CSV and doing some transformations to it; first to transform it into a simple features object, and then to bin the population values so Oxford doesn’t totally eclipse the other settlements. My actual leaflet map doesn’t have many bells or whistles here; I’ve got a minimalist tile set from CartoDB, and then add the towns as circle markers. I often prefer circle markers over the standard markers (i.e., using addMarkers()) because you can much more easily control things like colour, size, and so on. Give it a look in Figure 1.

# read in "paths" data
towns <-
  # read data from file
  readr::read_csv(path_to_towns_csv) |>
  # format as sf object
  dplyr::select(city, population, lat = latitude_in_decimal_degrees, lng = longitude_in_decimal_degrees) |>
  sf::st_as_sf(crs = 4326, coords = c("lng", "lat")) |>
  # cut into bins - makes for a nicer map
  dplyr::mutate(pop = ggplot2::cut_interval(population, 8))

# plot leaflet map
map <-
  leaflet() |>
  addProviderTiles(providers$CartoDB.Positron) |>
  addCircleMarkers(
    data = towns,
    radius = ~ as.numeric(pop) * 2 + 2,
    color = "white",
    fillColor = "royalblue",
    weight = 2,
    opacity = 1,
    fillOpacity = .8,
    label = ~ stringr::str_glue("{city} ({scales::label_comma()(population)})")
  )

# view map
map
Figure 1: A simple map of towns and cities in Oxfordshire

Now, Figure 1 looks really lovely, but what strikes me is how “uncontained” it appears. International readers may be totally unfamiliar with what Oxfordshire is (a ceremonial county of the UK), let alone where its borders are, so it would be useful to properly illustrate the area we’re interested in. After all, you may not think “wow, that’s a lot of towns and cities!” if the actual area of Oxfordshire extends to Gloucester on one side and St Albans to the other (don’t worry, it doesn’t).

Adding an outline

The UK Office for National Statistics provides shapefiles for a lot of different ways to chop up the UK, including counties. We can easily use this to define the borders of Oxfordshire using addPolygons().

oxfordshire <-
  sf::read_sf(path_to_polygon_sf) |>
  dplyr::filter(ctyua19nm == "Oxfordshire") |>
  sf::st_transform(crs = 4326)

map |>
  addPolygons(
    data = oxfordshire,
    fillOpacity = 0,
    color = "black",
    weight = 2
  )
Figure 2: Our map, now with the border of Oxfordshire drawn on it.

Now, again Figure 2 looks decent. However, leaflet maps cover the entire world and I’d love to make it clear that we’re really, really focusing on Oxfordshire here. We also have the issue that the polygon is clickable and gets in the way of the marker labels, which we’d want to avoid. So my aim now - and the overall purpose of this post - is to “shade out” and de-emphasise the great land of Not-Oxfordshire to signal to viewers they should disregard anything outside of the border.

Shading the outside

Initially, this may seem like a simple task. Can’t we just invert the polygon? If we were writing in JavaScript directly we could use the snogylop (get it?) library to do so, but - as far as I can see - this functionality has not yet reached R. However, it is very possible for us to approximate it using sf and some clever leaflet magic!

First, we’re going to create a polygon containing the vast, vast land of Not-Oxfordshire using sf::st_buffer() (which creates a circular buffer around a shape) and sf::st_difference(). Think of that second step as similar to making gingerbread men; you’ve got your rolled out piece of gingerbread, and you’re using the cutter to stamp out the little men from it. We’ve got the large surface of not-Oxfordshire, and we’re stamping out the Oxfordshire shape, leaving us with a negative of it.

notxfordshire <- sf::st_buffer(oxfordshire, dist = 5E5) |>
  sf::st_difference(oxfordshire)

leaflet(notxfordshire) |>
  addTiles() |>
  addPolygons()
Figure 3: Not Oxfordshire… kind of.

You may notice in Figure 3 that I’ve not actually encapsulated the entire world of not-Oxfordshire. New Zealand isn’t in Oxfordshire, yet its not covered by my big blue shape. This is where we do an extra sneaky step - we’re going to limit the minimum zoom level and map boundaries. Now in Figure 4, our viewers will think we’ve somehow shaded the whole world even though we actually haven’t.

In more detail, the minimum zoom is defined in the leaflet() options, and stops users from zooming out loads and loads and breaking our illusion. setMaxBounds() is stopping users from panning too far away from Oxfordshire, and I’ve used the bounding box of a slightly buffered Oxfordshire as a bit of a cheat for getting good values for its arguments. I’m also using setView() to centre the map on Oxfordshire to begin with.

bounds <- sf::st_bbox(sf::st_buffer(oxfordshire, 5E4))

leaflet(notxfordshire, options = leafletOptions(minZoom = 9)) |>
  addTiles() |>
  addPolygons() |>
  setMaxBounds(
    lng1 = bounds[["xmin"]],
    lng2 = bounds[["xmax"]],
    lat1 = bounds[["ymin"]],
    lat2 = bounds[["ymax"]]
  ) |>
  setView(
    lng = mean(bounds[c("xmin", "xmax")]),
    lat = mean(bounds[c("ymin", "ymax")]),
    zoom = 9
  )
Figure 4: Now we can’t leave Oxfordshire, no matter how hard we try!

Now we can combine our sneaky new hack with our original map and see how it looks! See now in Figure 5 how our towns and cities really pop on the map of Oxfordshire as the rest of the world becomes de-emphasised.

leaflet(options = leafletOptions(minZoom = 9)) |>
  addProviderTiles(providers$CartoDB.Positron) |>
  addPolygons(
    data = notxfordshire,
    color = "black",
    weight = 2,
    opacity = 1,
    fillOpacity = 1 / 2
  ) |>
  addCircleMarkers(
    data = towns,
    radius = ~ as.numeric(pop) * 2 + 2,
    color = "white",
    fillColor = "royalblue",
    weight = 2,
    opacity = 1,
    fillOpacity = .8,
    label = ~ stringr::str_glue("{city} ({scales::label_comma()(population)})")
  ) |>
  setMaxBounds(
    lng1 = bounds[["xmin"]],
    lng2 = bounds[["xmax"]],
    lat1 = bounds[["ymin"]],
    lat2 = bounds[["ymax"]]
  ) |>
  setView(
    lng = mean(bounds[c("xmin", "xmax")]),
    lat = mean(bounds[c("ymin", "ymax")]),
    zoom = 9
  )
Figure 5: Bringing everything together in one place.

Using different tiles

I find that this sort of effect can be equally effective in lots of different map styles, whether that’s light (as already demonstrated), dark, or even with a satellite view. I’ve demonstrated this in Figure 6. As this is our final map, I’ve popped it in a bslib card so you can make it full screen for a closer look.

# function to quickly add tiles & shaded out zone
add_theme <- function(map, group, provider, color, fillColor) {
  for (i in provider) {
    map <- addProviderTiles(map, provider = i, group = group)
  }
  map <- addPolygons(
    map = map,
    data = notxfordshire,
    color = color,
    fillColor = fillColor,
    weight = 2,
    opacity = 1,
    fillOpacity = 1 / 2,
    group = group
  )
  return(map)
}

bigmap <-
  leaflet(options = leafletOptions(minZoom = 9)) |>
  # Dark Mode
  add_theme(
    "Dark Mode",
    c(
      providers$CartoDB.DarkMatterNoLabels,
      providers$CartoDB.PositronOnlyLabels
    ),
    "white",
    "white"
  ) |>
  # Light Mode
  add_theme(
    "Light Mode",
    c(providers$CartoDB.Positron),
    "black", "black"
  ) |>
  # Satellite View
  add_theme(
    "Satellite",
    c(
      providers$Esri.WorldImagery,
      providers$CartoDB.PositronOnlyLabels
    ),
    "white",
    "black"
  ) |>
  # and the rest...
  addCircleMarkers(
    data = towns,
    radius = ~ as.numeric(pop) * 2 + 2,
    color = "white",
    fillColor = "royalblue",
    weight = 2,
    opacity = 1,
    fillOpacity = .8,
    label = ~ stringr::str_glue("{city} ({scales::label_comma()(population)})")
  ) |>
  setMaxBounds(
    lng1 = bounds[["xmin"]],
    lng2 = bounds[["xmax"]],
    lat1 = bounds[["ymin"]],
    lat2 = bounds[["ymax"]]
  ) |>
  setView(
    lng = mean(bounds[c("xmin", "xmax")]),
    lat = mean(bounds[c("ymin", "ymax")]),
    zoom = 9
  ) |>
  addLayersControl(
    baseGroups = c("Light Mode", "Dark Mode", "Satellite"),
    options = layersControlOptions(collapsed = FALSE)
  )

bslib::card(bigmap, full_screen = TRUE, height = 500)
Figure 6: Our ‘final’ map, which includes options to switch between Light, Dark and Satellite Views.