Mapping the Evolution of Scottish Munros

Spatial analysis with {sf}, visualization with {ggplot2}, and enhanced text styling with {ggtext}

#TidyTuesday
Maps
{ggtext}
Data Wrangling
Author

Aditya Dahiya

Published

August 23, 2025

About the Data

This dataset contains information about Scottish Munros, mountains with elevations exceeding 3,000 feet (914 meters), sourced from The Database of British and Irish Hills v18.2 and made available under a Creative Commons Attribution 4.0 International Licence. Originally catalogued by Sir Hugh Munro in 1891, these peaks differ from other Scottish mountain classifications by lacking rigid prominence requirements, making the list subject to periodic revisions as new surveys are conducted. The dataset tracks the classification status of each peak across multiple revisions from 1891 to 2021, documenting whether summits were designated as full Munros, subsidiary Munro Tops, or excluded entirely during different time periods. Each entry includes precise coordinates using the British National Grid projection system, enabling geographic analysis of these iconic Scottish highlands. This data was curated by Nicola Rennie as part of the TidyTuesday weekly data project, providing researchers and enthusiasts with a comprehensive historical record of how Scotland’s most celebrated peaks have been classified over more than a century.

Figure 1: This map displays changes to Scotland’s official Munro list—mountains exceeding 3,000 feet in elevation—across nine survey revisions from 1921 to 1997. Each dot represents a Munro peak: green dots indicate mountains newly added to the list in that survey year, red dots show peaks that will be dropped in the next revision, and gray dots represent mountains that remained unchanged between consecutive surveys. The shifting classifications reflect improvements in surveying techniques and measurement accuracy over more than a century of Scottish mountaineering history.

How the Graphic Was Created

This visualization was created using R’s powerful mapping and data visualization ecosystem. I started by loading the Scottish Munros dataset and transforming it using the tidyverse suite of packages, particularly dplyr for data manipulation and tidyr for reshaping the data from wide to long format with pivot_longer(). The coordinate data was converted into spatial objects using the sf package’s st_as_sf() function, specifying the British National Grid (OSGB36) projection (CRS 27700). To categorize each Munro’s status across survey years, I developed custom helper functions to identify previous and next years in the chronological sequence, then used purrr’s map2_lgl() function to determine whether each peak was newly added, unchanged, or dropped in subsequent surveys. The Scotland boundary data was obtained using geodata::gadm() and transformed to match the coordinate system. The final visualization was built with ggplot2, using geom_sf() to plot both the country boundary and Munro locations, facet_wrap() to create separate panels for each survey year, and ggtext::geom_richtext() to add formatted summary statistics. Custom fonts were integrated using showtext, while the color palette and styling were enhanced with paletteer and extensive theme() customizations to create a clean, professional cartographic presentation.

Loading required libraries

Code
pacman::p_load(
  tidyverse,            # All things tidy
  
  scales,               # Nice Scales for ggplot2
  fontawesome,          # Icons display in ggplot2
  ggtext,               # Markdown text support for ggplot2
  showtext,             # Display fonts in ggplot2
  colorspace,           # Lighten and Darken colours

  patchwork,            # Composing Plots
  sf
)

# Read in the Data
scottish_munros <- readr::read_csv('https://raw.githubusercontent.com/rfordatascience/tidytuesday/main/data/2025/2025-08-19/scottish_munros.csv') |> 
  janitor::clean_names()

Visualization Parameters

Code
# Font for titles
font_add_google("Saira",
  family = "title_font"
) 

# Font for the caption
font_add_google("Saira Condensed",
  family = "body_font"
) 

# Font for plot text
font_add_google("Saira Extra Condensed",
  family = "caption_font"
) 

showtext_auto()

# A base Colour
bg_col <- "white"
seecolor::print_color(bg_col)

# Colour for highlighted text
text_hil <- "grey20"
seecolor::print_color(text_hil)

# Colour for the text
text_col <- "grey20"
seecolor::print_color(text_col)

line_col <- "grey30"

# Custom Colours for dots
custom_dot_colours <- paletteer::paletteer_d("nbapalettes::cavaliers_retro")

# Custom size for dots
size_var <- 14

# Define Base Text Size
bts <- 80

# Caption stuff for the plot
sysfonts::font_add(
  family = "Font Awesome 6 Brands",
  regular = here::here("docs", "Font Awesome 6 Brands-Regular-400.otf")
)
github <- "&#xf09b"
github_username <- "aditya-dahiya"
xtwitter <- "&#xe61b"
xtwitter_username <- "@adityadahiyaias"
social_caption_1 <- glue::glue("<span style='font-family:\"Font Awesome 6 Brands\";'>{github};</span> <span style='color: {text_hil}'>{github_username}  </span>")
social_caption_2 <- glue::glue("<span style='font-family:\"Font Awesome 6 Brands\";'>{xtwitter};</span> <span style='color: {text_hil}'>{xtwitter_username}</span>")
plot_caption <- paste0(
  "**Data:** The Database of British and Irish Hills", 
  " |  **Code:** ", 
  social_caption_1, 
  " |  **Graphics:** ", 
  social_caption_2
  )
rm(github, github_username, xtwitter, 
   xtwitter_username, social_caption_1, 
   social_caption_2)

# Add text to plot-------------------------------------------------
plot_title <- "Scottish Munros: A Century of Reclassification"

plot_subtitle <- "Mountains over 3,000 feet have been <span style='color:#088158FF'>**added**</span> and <span style='color:#BA2F2AFF'>**dropped**</span> from Scotland's official Munro<br>list across multiple surveys. Gray peaks remained unchanged between consecutive revisions."

str_view(plot_subtitle)

Exploratory Data Analysis and Wrangling

Code
# How many distinct munors are there
# scottish_munros |> 
#   distinct(do_bih_number)

# scottish_munros |> 
#   distinct(name)

# Convert into sf object for British National Grid (OSGB36) projection
# df1 <- scottish_munros |> 
#   distinct(do_bih_number, name, xcoord, ycoord, height_m) |> 
#   drop_na() |> 
#   st_as_sf(coords = c("xcoord", "ycoord"), crs = 27700)
# 
# scottish_munros |> 
#   select(height_m) |> 
#   ggplot(aes(height_m)) +
#   geom_boxplot()
# 
# df1 |> 
#   ggplot() +
#   geom_sf(
#     aes(colour = height_m),
#     size = 0.8
#   ) +
#   paletteer::scale_colour_paletteer_c(
#     "grDevices::Purple-Yellow",
#     direction = -1
#   )

df2 <- scottish_munros |> 
  filter(!is.na(xcoord) & !is.na(ycoord)) |> 
  st_as_sf(coords = c("xcoord", "ycoord"), crs = 27700) |> 
  st_drop_geometry() |> 
  pivot_longer(
    cols = starts_with("x"),
    names_to = "year",
    values_to = "value"
  ) |> 
  mutate(
    year = parse_number(year)
  ) |> 
  filter(value == "Munro")


# Code help : Clause Sonnet 4

# Define the chronological order of years
year_order <- c(1891, 1921, 1933, 1953, 1969, 1974, 1981, 1984, 1990, 1997, 2021)

# Create a helper function to get previous and next years
get_prev_year <- function(year) {
  idx <- which(year_order == year)
  if (idx == 1) return(NA) else return(year_order[idx - 1])
}

get_next_year <- function(year) {
  idx <- which(year_order == year)
  if (idx == length(year_order)) return(NA) else return(year_order[idx + 1])
}

# Create the label_var
plotdf <- df2 |> 
  arrange(do_bih_number, year) |>
  group_by(do_bih_number) |>
  mutate(
    # Get the years this munro appears in
    munro_years = list(year),
    # For each year, check previous and next
    prev_year = map_dbl(year, get_prev_year),
    next_year = map_dbl(year, get_next_year)
  ) |>
  ungroup() |>
  mutate(
    # Check if munro was present in previous year
    was_in_prev = case_when(
      is.na(prev_year) ~ FALSE, # First year, so can't be continued
      TRUE ~ map2_lgl(do_bih_number, prev_year, ~{
        .x %in% (df2 |> filter(year == .y) |> pull(do_bih_number))
      })
    ),
    # Check if munro will be present in next year
    will_be_in_next = case_when(
      is.na(next_year) ~ TRUE, # Last year, so can't be dropped
      TRUE ~ map2_lgl(do_bih_number, next_year, ~{
        .x %in% (df2 |> filter(year == .y) |> pull(do_bih_number))
      })
    ),
    # Create the label
    label_var = case_when(
      !was_in_prev ~ "Newly added in this Survey",
      !will_be_in_next ~ "Dropped in the next Survey",
      TRUE ~ "Unchanged"
    )
  ) |>
  # Clean up helper columns
  select(-munro_years, -prev_year, -next_year, -was_in_prev, -will_be_in_next)

plotdf1 <- plotdf |> 
  left_join(
    scottish_munros |> 
      select(do_bih_number, xcoord, ycoord) |> 
      filter(!is.na(xcoord) & !is.na(ycoord)) |> 
      st_as_sf(coords = c("xcoord", "ycoord"), crs = 27700)
  ) |> 
  st_as_sf() |> 
  filter(!(year %in% c(1891, 2021)))

# A summary table for labels in the facet
plotdf2 <- plotdf |>
  count(year, label_var) |>
  pivot_wider(names_from = label_var, values_from = n, values_fill = 0) |>
  arrange(year) |> 
  filter(year != 1891)
label_vector <- colnames(plotdf2)
plotdf2 <- plotdf2 |> 
  janitor::clean_names() |>
  filter(year != 2021)

scotland <- geodata::gadm(
  country = "United Kingdom",
  level = 1,
  path = tempdir(),
  resolution = 2
  ) |> 
  janitor::clean_names() |> 
  st_as_sf() |> 
  filter(name_1 == "Scotland") |> 
  st_transform(crs = 27700)
  
scotland
plotdf1
plotdf2

The Plot

Code
g <- plotdf1 |> 
  ggplot() +
  geom_sf(
    data = scotland,
    fill = "grey80",
    linewidth = 0.3,
    colour = "grey60"
  ) +
  geom_sf(
    data = plotdf1,
    mapping = aes(colour = label_var),
    size = 0.75,
    alpha = 0.8
  ) +
  geom_sf(
    data = plotdf1 |> filter(label_var != "Unchanged"),
    mapping = aes(colour = label_var),
    size = 2.5,
    pch = 21,
    fill = "transparent",
    stroke = 2
  ) +
  geom_text(
    data = plotdf2,
    mapping = aes(
      x = -8,
      y = 59.5,
      label = year
    ),
    size = bts / 2,
    colour = text_hil,
    family = "body_font",
    hjust = 0,
    vjust = 1,
    fontface = "bold"
  ) +
  geom_richtext(
    data = plotdf2,
    mapping = aes(
      x = -8,
      y = 59,
      label = paste0(
        "<span style='color:#088158FF'>Newly added in this survey = ",
        newly_added_in_this_survey,
        "</span><br><span style='color:#BA2F2AFF'>Dropped in next survey = ",
        dropped_in_the_next_survey,
        "</span>"
      )
    ),
    size = bts / 4.5,
    family = "caption_font",
    fill = "transparent",
    hjust = 0,
    vjust = 1,
    lineheight = 0.4,
    label.size = NA,
    label.padding = unit(0, "mm")
  ) +
  facet_wrap(
    ~year, ncol = 3, nrow = 3
  ) +
  scale_colour_manual(
    values = c("#BA2F2AFF", "#088158FF", "grey30"),
    labels = str_wrap(label_vector[c(3,2,4)], 14)
  ) +
  guides(
    colour = guide_legend(
      override.aes = list(
        size = 20,
        pch = 21,
        fill = "transparent",
        stroke = 4
      )
    )
  ) +
  coord_sf(
    crs = 27700,
    ylim = c(54.5, 59.9),
    default_crs = "EPSG:4326",
    clip = "off",
    expand = FALSE
  ) +
  labs(
    title = plot_title,
    subtitle = plot_subtitle,
    x = NULL,
    y = NULL,
    caption = plot_caption
  ) +
  theme_minimal(
    base_family = "body_font",
    base_size = bts
  ) +
  theme(
    
    # Legend
    legend.position = "bottom",
    legend.margin = margin(-20,0,0,0, "mm"),
    legend.box.margin = margin(0,0,0,0, "mm"),
    legend.text = element_text(
      margin = margin(0,0,0,2, "mm"),
      lineheight = 0.3,
      size = 1.2 * bts
    ),
    legend.title = element_blank(),
    panel.grid = element_line(
      colour = alpha(text_col, 0.3),
      linewidth = 0.3,
      linetype = 3
    ),
    
    # Overall
    text = element_text(
      margin = margin(0,0,0,0, "mm"),
      colour = text_col,
      lineheight = 0.3
    ),
    
    # Labels and Strip Text
    plot.title = element_text(
      margin = margin(5,0,5,0, "mm"),
      hjust = 0.5,
      vjust = 0.5,
      colour = text_hil,
      size = 2.2 * bts,
      family = "body_font",
      face = "bold"
      ),
    plot.subtitle = element_textbox(
      margin = margin(2,0,2,0, "mm"),
      fill = bg_col,
      vjust = 0.5,
      valign = 0.5,
      halign = 0.5,
      colour = text_hil,
      size =  bts * 1.2,
      hjust = 0.5,
      family = "body_font",
      lineheight = 0.3
      ),
    plot.caption = element_textbox(
      margin = margin(8,0,5,0, "mm"),
      hjust = 0.5,
      halign = 0.5,
      colour = text_hil,
      size = bts * 1.2,
      family = "caption_font"
    ),
    plot.caption.position = "plot",
    plot.title.position = "plot",
    plot.margin = margin(5,0,5,5, "mm"),
    
    # Axes Lines, Ticks, Text and labels
    axis.ticks = element_blank(),
    axis.ticks.length = unit(0, "mm"),
    axis.text.x = element_text(
      margin = margin(1,1,1,1, "mm"),
      colour = text_hil,
      size = 0.5 * bts
    ),
    axis.text.y = element_text(
      margin = margin(1,1,1,1, "mm"),
      colour = text_hil,
      size = 0.5 * bts
    ),
    
    # Panels
    panel.spacing.y = unit(0.5, "mm"),
    panel.spacing.x = unit(5, "mm"),
    strip.text = element_blank(),
    panel.background = element_rect(
      fill = "transparent",
      colour = "transparent"
    ),
    plot.background = element_rect(
      fill = "transparent",
      colour = "transparent"
    )
  )

ggsave(
  filename = here::here(
    "data_vizs",
    "tidy_scottish_munros.png"
  ),
  plot = g,
  width = 400,
  height = 550,
  units = "mm",
  bg = bg_col
)

Savings the thumbnail for the webpage

Code
# Saving a thumbnail

library(magick)

# Saving a thumbnail for the webpage
image_read(here::here("data_vizs", 
                      "tidy_scottish_munros.png")) |> 
  image_resize(geometry = "x400") |> 
  image_write(
    here::here(
      "data_vizs", 
      "thumbnails", 
      "tidy_scottish_munros.png"
    )
  )

Session Info

Code
pacman::p_load(
  tidyverse,            # All things tidy
  
  scales,               # Nice Scales for ggplot2
  fontawesome,          # Icons display in ggplot2
  ggtext,               # Markdown text support for ggplot2
  showtext,             # Display fonts in ggplot2
  colorspace,           # Lighten and Darken colours

  patchwork             # Composing Plots
)

sessioninfo::session_info()$packages |> 
  as_tibble() |> 
  dplyr::select(package, 
         version = loadedversion, 
         date, source) |> 
  dplyr::arrange(package) |> 
  janitor::clean_names(
    case = "title"
  ) |> 
  gt::gt() |> 
  gt::opt_interactive(
    use_search = TRUE
  ) |> 
  gtExtras::gt_theme_espn()
Table 1: R Packages and their versions used in the creation of this page and graphics

Links