How to build a travel map in R (Updated 2024)

I used to catalogue my holidays using Polarsteps. It's a great app for tracking your travels, sharing photos and keeping a journal as you go. But my organising tendencies cannot abide the minor but regular inaccuracies of the Polarsteps travel tracker.

Instead, I built my own map in R that drops pins on the cities I've visited and colours in the flags of countries I've passed through. Here's the code I used to create this in R.

If you want to create something similar for yourself, read on.

Requisite files

You can update the destination list with your own travel destinations.

Setup

# Get github packages
devtools::install_github(
  repo = c(
    "ropensci/rnaturalearthhires",
    "ropensci/rnaturalearthdata"
  ),
  quiet = TRUE
)

# Load packages
pacman::p_load(
  char = c(
    "rnaturalearth",
    "countrycode",
    "tidyverse", # For everything
    "patchwork", # For better plot layout
    "here",
    "sf"
  )
)

# Pick the colours for the graph
graph_colours <- c(
  "Visited"        = "#F07C51",
  "Planned"        = "#FFCF66",
  "Travel_outline" = "#000000",
  "Country_fill"   = "#D4CEC4",
  "Border_line"    = "#FFFFFF"
)

Trips

Create trip data

df_locations <-
  googlesheets4::read_sheet(
    paste0(
      "https://docs.google.com/spreadsheets/",
      "d/1Ia0IhKe3eP0LEZnJn388VjA7dCtuVeC37WLFUPhhpLg/",
      "edit#gid=833795961"
    )
  ) %>%
  janitor::clean_names() %>%
  mutate(
    place_country = ifelse(
      is.na(place_country),
      paste(place, country, sep = ", "),
      place_country
    )
  )

Geocode trips

# Select any locations missing a lat or lon coordinate
df_geocodes <-
  df_locations %>%
  filter(is.na(lon) | is.na(lat)) %>%
  select(-starts_with(c("lat", "lon")))

# If there are any missing, then geocode them
if (nrow(df_geocodes) > 0) {
  df_geocodes <-
    df_geocodes %>%
    bind_cols(
      tmaptools::geocode_OSM(
        q = .$place_country,
        as.data.frame = T
      )[, c("lon", "lat")]
    )
}

# Join the two
df_locations <-
  df_locations %>%
  dplyr::rows_update(x = ., y = df_geocodes, by = "place_country") %>%
  arrange(status, continent, country)

# Clear the environment
rm(df_geocodes)

Save trip data

if (TRUE == FALSE) {
  write.csv(
    x = df_locations,
    file = paste0(here::here(), "/Output/Destinations.csv"),
    row.names = FALSE
  )
}

Flags

Read flags data

# Load dataframe I created earlier
df_flags <-
  read_csv(
    paste0(
      here::here(),
      "/Input/Flag Names.csv"
    )
  ) %>%
  janitor::remove_empty(
    which = c("rows", "cols"),
    quiet = TRUE
  )

# Remove countries, and put Serbia in alphabetical order
df_flags <-
  df_flags %>%
  filter(keep == TRUE) %>%
  select(-keep) %>%
  mutate(name = if_else(name == "Republic of Serbia", "Serbia", name))

Colour in visited flags

# Create columns
df_flags <-
  df_flags %>%
  mutate(
    visited = if_else(
      name %in% unlist(
        df_locations %>%
          filter(status %in% c("Visited", "Planned")) %>%
          select(country)
      ),
      TRUE,
      FALSE
    ),
    file_path = paste0(
      here::here(),
      "/Input/Flags/",
      if_else(visited == TRUE, "colour", "grey"), # Colour based on visited
      "/",
      file_name
    )
  )

Create grid references

# Create columns
df_flags <-
  df_flags %>%
  mutate(
    continent = if_else(
      str_detect(continent, "America"),
      "Americas",
      continent
    )
  ) %>%
  arrange(continent, name) %>%
  mutate(
    .by = continent,
    n_flags = n(),
    n_cols = n_flags / 3,
    x = rep(1:max(n_cols), 3),
    y = 3 - cumsum(n_cols == lag(x, default = 1)),
    y = if_else(continent %in% c("Europe", "Asia"), y + 22, y)
  ) %>%
  mutate(
    .by = y,
    row_fraction = 39 / (n() + n_distinct(continent) - 1),
    x = x * row_fraction - row_fraction,
    x = case_when(
      continent == "Asia" ~ x + row_fraction * 2 + 17.727273,
      continent == "Africa" ~ x + row_fraction * 2 + 11.289474,
      continent == "Oceania" ~ x + row_fraction * 4 + 11.289474 + 17.447368,
      TRUE ~ x
    )
  )

# Create df of continent names
df_names <-
  df_flags %>%
  summarise(
    .by = continent,
    x = if_else(
      continent %in% c("Asia", "Oceania"),
      max(x) + row_fraction / 2,
      min(x) - row_fraction / 2
    ),
    y = if_else(first(y) < 4, 4, 22),
    hjust = if_else(continent %in% c("Asia", "Oceania"), 1, 0)
  ) %>%
  ungroup() %>%
  filter(duplicated(.) == FALSE)

# Create a df of lines and text
df_lines <-
  df_flags %>%
  summarise(
    .by = continent,
    x = if_else(
      continent %in% c("Americas", "Africa", "Europe"),
      max(x) + row_fraction,
      NA
    ),
    xend = x,
    y = min(y),
    yend = y + 2
  ) %>%
  filter(duplicated(.) == FALSE)

Map

Download map data

# Download map data
df_world <-
  rnaturalearth::ne_countries(
    type = "countries",
    scale = "large",
    returnclass = "sf"
  ) %>%
  sf::st_make_valid()

# Remove Antarctica, Chile, Kazakhstan and small military bases
df_world <-
  df_world %>%
  filter(
    !name %in% c("Antarctica", "Chile", "Kazakhstan", "Baikonur"),
    !brk_a3 %in% c("B69", "CLP", "SGS", "HMD", "ATF", "SHN")
  )

# Restore removed map sections in the manner we need
df_world <-
  df_world %>%
  bind_rows(
    .,
    # Restore Chile with a lower resolution coastline
    rnaturalearth::ne_countries(
      type = "countries",
      country = "Chile",
      scale = "medium",
      returnclass = "sf"
    ) %>%
      sf::st_make_valid(),
    # Combine Baikonur with Kazakhstan
    rnaturalearth::ne_countries(
      type = "countries",
      geounit = c("Baykonur Cosmodrome", "Kazakhstan"),
      scale = "large",
      returnclass = "sf"
    ) %>%
      sf::st_make_valid() %>%
      mutate(geometry = sf::st_union(geometry)) %>%
      filter(name != "Baikonur")
  )

Download state borders

shapefile_dir <- here::here("Input", "States")

if (!dir.exists(shapefile_dir)) dir.create(shapefile_dir)

shapefile_dir_empty <- length(list.files(shapefile_dir)) == 0L

if (shapefile_dir_empty) {
  shapefile_zip <- here::here(
    shapefile_dir,
    "shapefile.zip"
  )

  download.file(
    paste0(
      "https://www.naturalearthdata.com/http//",
      "www.naturalearthdata.com/download/",
      "10m/",
      "cultural/",
      "ne_10m_admin_1_states_provinces_lines.zip"
    ),
    shapefile_zip
  )

  unzip(
    shapefile_zip,
    exdir = shapefile_dir
  )

  file.remove(shapefile_zip)
}

df_states <-
  sf::st_read(shapefile_dir) %>%
  janitor::clean_names() %>%
  filter( # Top 10 countries by area
    adm0_a3 %in% c(
      "CAN", "USA", "RUS", "KAZ", "CHN",
      "IND", "BRA", "AUS", "DZA", "ARG"
    )
  ) %>%
  select(name = adm0_name, geometry) %>%
  sf::st_make_valid()

Download great lakes

shapefile_dir <- here::here("Input", "Lakes")

if (!dir.exists(shapefile_dir)) dir.create(shapefile_dir)

shapefile_dir_empty <- length(list.files(shapefile_dir)) == 0L

if (shapefile_dir_empty) {
  shapefile_zip <- here::here(
    shapefile_dir,
    "shapefile.zip"
  )

  download.file(
    paste0(
      "https://www.naturalearthdata.com/http//",
      "www.naturalearthdata.com/download/",
      "10m/",
      "physical/",
      "ne_10m_lakes.zip"
    ),
    shapefile_zip
  )

  unzip(
    shapefile_zip,
    exdir = shapefile_dir
  )

  file.remove(shapefile_zip)
}

df_lakes <-
  sf::st_read(shapefile_dir) %>%
  filter(
    scalerank < 1 |
      name %in% c("Lake Chad", "Lago de Nicaragua", "Lake Onega")
  ) %>%
  select(-c(name_abb:name_zht)) %>%
  sf::st_make_valid()

Adjust projection

# Specify projection
target_crs <-
  sf::st_crs(
    paste0(
      "+proj=longlat ", # Projection
      "+lon_0=0 ",
      "+x_0=0 ",
      "+y_0=0 ",
      "+datum=WGS84 ",
      "+units=m ",
      "+pm=10 ", # Prime meridian
      "+no_defs"
    )
  )

# Specify 180 - the lon the map is centered on
offset <- 180 - 10 # lon 10

# Create thin polygon to cut the adjusted border
polygon <-
  sf::st_polygon(
    x = list(rbind(
      c(-0.0001 - offset,  90),
      c( 0.0000 - offset,  90),
      c( 0.0000 - offset, -90),
      c(-0.0001 - offset, -90),
      c(-0.0001 - offset,  90)
    ))
  ) %>%
  sf::st_sfc() %>%
  sf::st_set_crs(4326)

# Remove overlapping part of world and change projection
df_world <-
  df_world %>%
  sf::st_difference(polygon) %>%
  sf::st_transform(crs = target_crs)

df_states <-
  df_states %>%
  sf::st_difference(polygon) %>%
  sf::st_transform(crs = target_crs)

df_lakes <-
  df_lakes %>%
  sf::st_difference(polygon) %>%
  sf::st_transform(crs = target_crs)

# Clear environment
rm(polygon, target_crs, offset)

Graph

Create Travel Map

# Create map
graph_world <-
  ggplot() +
  ggplot2::geom_sf( # Countries
    data = df_world,
    fill = graph_colours["Country_fill"],
    colour = graph_colours["Border_line"],
    linewidth = 0.25
  ) +
  ggplot2::geom_sf( # State borders
    data = df_states,
    colour = graph_colours["Border_line"], # State border
    linewidth = 0.1
  ) +
  ggplot2::geom_sf( # Lakes
    data = df_lakes,
    fill = "#FFFFFF",
    colour = graph_colours["Border_line"],
    linewidth = 0.1
  )

# Add travel dots
graph_world <-
  graph_world +
  geom_point( # Back dots
    data = df_locations %>% filter(!status %in% c("Wish-list")),
    aes(x = lon, y = lat),
    colour = graph_colours["Travel_outline"],
    size = 1.25
  ) +
  geom_point( # Front dots
    data = df_locations %>% filter(!status %in% c("Wish-list")),
    aes(x = lon, y = lat, colour = status),
    size = 1
  ) +
  scale_color_manual(
    name = "",
    values = c(
      "Visited" = graph_colours[["Visited"]],
      "Planned" = graph_colours[["Planned"]]
    )
  )

# Crop the map
graph_world <-
  graph_world +
  ggplot2::coord_sf(
    expand = FALSE,
    default_crs = sf::st_crs(4326),
    xlim = c(-172, -169),
    ylim = c(90, -60)
  ) +
  theme(
    axis.text = element_blank(),
    axis.title = element_blank(),
    axis.ticks = element_blank(),
    panel.grid = element_blank(),
    panel.background = element_blank(),
    plot.background = element_blank(),
    legend.position = "none"
  )

Create Flag Backdrop

graph_flags <-
  ggplot() +
  nflplotR::geom_from_path(
    data = df_flags,
    aes(
      x = x,
      y = y,
      path = file_path
    ),
    height = 0.04
  ) +
  geom_segment(
    data = df_lines,
    aes(
      x = x,
      xend = xend,
      y = y,
      yend = yend
    ),
    colour = "grey25"
  ) +
  geom_label(
    data = df_names,
    aes(
      label = continent,
      x = x,
      y = y,
      hjust = hjust
    ),
    colour = "grey25",
    label.size = 0
  ) +
  theme(
    axis.text = element_blank(),
    axis.title = element_blank(),
    axis.ticks = element_blank(),
    panel.grid = element_blank(),
    panel.background = element_blank()
  )

Combine Graphs

graph_world_travel_flags <-
  graph_flags +
  patchwork::inset_element(
    p = graph_world,
    on_top = F,
    left = 0,
    right = 1,
    top = 0.9,
    bottom = 0.1
  )

Export

ggsave(
  plot = graph_world_travel_flags,
  path = paste0(here::here(), "/Output"),
  filename = "graph_travel_and_flags_A4.png",
  device = "png",
  dpi = 600,
  units = "cm",
  width = 36.6,
  height = 19.0
)