library(gpx)
raw_gpx <- read_gpx("strathearn-marathon.gpx")Create custom GPS route maps in R
Ever thought about using R to make a poster? This blog post is going to walk through the process of creating a custom GPS route map that you can print out and hang on the wall. So whether you’re a runner or cyclist looking to make some new home decor (or you need a gift idea for someone who is) or just looking to learn a bit more about the things you can do with spatial data in R, then read on!
It’s currently November which means it’s the month of the #30DayMapChallenge! This map is a recreation of one I created for the “My Data” prompt on day 4 of the 2025 edition of the challenge.
Data
We’re going to be using two different sources of spatial data for this map:
- A GPS file of a run route or race that you have recorded or downloaded; and
- Background data from OpenStreetMap that we’ll be accessing through the
osmdataR package.
I’ll be talking about plotting a run throughout this blog post, but it works for any GPS data. Substitute the word run for cycle, walk, drive, or something else.
Loading GPS data
If you use a GPS watch and/or app to track your runs, you should be able to download a .gpx file for a specific run. If you don’t, many races will have a .gpx file for the route somewhere on their website that you can use instead.
To make it easier for you to run the code in the examples below, we’ll use a GPS file for the route of the Strathearn Marathon that you can dowload from the race website. Assuming that you’ve managed to obtain a .gpx file of the route you want to plot, we can load it into R using the gpx package:
Processing GPS data
We need to do a little bit of processing on the GPS data to get it into the right shape for plotting on a map. Let’s start by loading in the tidyverse and sf packages for data processing and working with spatial data, respectively.
library(tidyverse)
library(sf)If you inspect the raw_gpx object, you’ll see that it’s a nested list of different R objects. We want to extract the complete race route from the tracks element, which we can do using the $ operator. We’ll then convert it to a tibble to make it more pleasant to work with, before making it into a spatial data object using st_as_sf(). Note that we set the crs (coordinate reference system) as 4326 which is the code for the World Geodetic System 1984 (WGS 84), used as standard in global GPS systems.
points_data <- raw_gpx$tracks$`Strathearn Marathon Complete` |>
as_tibble() |>
st_as_sf(coords = c("Longitude", "Latitude"), crs = 4326)The raw data is provided as a series of points, but we’re planning to plot the route that connects those points. Though we could do that within the plotting, it’s easier if we convert it to the correct type of spatial instead. We combine all of the points into one object, and then cast it to a line using st_combine() and st_cast().
line_data <- points_data |>
st_combine() |>
st_cast("LINESTRING")Background map data
Optionally, we might want to provide a little bit of context for the race route by including a minimal background map. Here, we’re going to add all of the other roads and footpaths that exist around the map in the background.
The first thing we need to do is get the bounding box for the race route i.e. the minimum and maximum latitude and longitude of the route. We can do this using the st_bbox() function from the sf package.
bbx <- st_bbox(line_data)When we’re getting the background map data, we probably don’t want to just go exactly to the edges of the bounding box. Instead, it’s going to look much nicer if we add a little bit of a buffer around the edges. When working with distances, it’s often better to work in a local coordinate reference system rather than a global one. We’re going to convert our route data into the British National Grid (BNG) coordinate reference system (27700), apply the buffer, and then convert it back.
Let’s transform the data, and get an updated bounding box:
line_proj <- st_transform(line_data, crs = 27700)
bbx <- st_bbox(line_proj)Now we can add a buffer of 500 metres so the north, south, east, and west of the bounding box:
buffer <- 500
bbx_expanded <- bbx
bbx_expanded[c("xmin", "ymin")] <- bbx_expanded[c("xmin", "ymin")] - buffer
bbx_expanded[c("xmax", "ymax")] <- bbx_expanded[c("xmax", "ymax")] + bufferBefore converting the new, expanded bounding box back to the original CRS:
bbx_expanded <- st_bbox(st_as_sfc(bbx_expanded), crs = 27700)
bbx_expanded <- st_transform(st_as_sfc(bbx_expanded), crs = 4326)Now, we’re ready to use the osmdata package to access OpenStreetMap to get the background data. We pass our bounding box into the opq() function to build an Overpass query, and then specify which features we want to return. Here I’ve returned objects which are classed as a "highway", which includes both roads and footpaths. You can be more specific in your query depending on what type and how many roads you want to return. The area in this example is fairly rural, so I want all of the smaller paths too. If you’re plotting a much more urban, or larger area, you might only want to return the larger roads.
We also use the osmdata_sf() function to return an sf object to make it easier for plotting later on.
library(osmdata)
highways <- bbx_expanded |>
opq() |>
add_osm_feature(
key = "highway",
value = c(
"primary", "secondary", "tertiary", "residential",
"living_street", "service", "unclassified",
"pedestrian", "footway", "track", "path"
)
) |>
osmdata_sf()OpenStreetMap returns any road that exists inside our bounding box, but it doesn’t trim the road to the size of the bounding box itself. For example, if a road extends beyond the edges, it still returns the whole road. To make our map look nice, we want to trim off the straggly roads around the edges, and we can do that by using st_crop() to trim the lines returned to the edges of the bounding box.
roads_cropped <- st_crop(highways$osm_lines, bbx_expanded)Making the map
Now that we have all of the data we need, we’re ready to start mapping!
Setting up chart variables
When you’re designing with the main purpose of making something aesthetically pleasing, it can take a little bit of trial and error to find the right colours and fonts. To make it easier to switch between different colours and fonts, I usually start by defining variables for them. This means that if I want to change the colour to something different, I only need to edit it in one place in my code.
Let’s start by loading and setting up our fonts. I often use the showtext package for fonts in R, because it gives easy access to Google Fonts. This means I never have to worry about me (or a collaborator) having the correct fonts installed on my laptop. We’re making a poster, so I’m going to pick a nice bold, display font. Here. I’ll use Oswald.
We run showtext_auto() to tell R that we’re using showtext for fonts, and then showtext_opts() to set our desired resolution. This helps to make sure that our fonts are sized correctly when we later save an image. Optionally, we also add a variable, body_font, to store the name of the font.
library(showtext)
font_add_google("Oswald")
showtext_auto()
showtext_opts(dpi = 300)
body_font <- "Oswald"We’re also going to set up variables for the colours of the map background, the route line, and the text using hex codes to specify the colours. The map_route_col variable will also serve as the main background colour, but you might choose to define another colour if you want to.
map_bg_col <- "#33658A"
map_route_col <- "grey90"
text_col <- "grey10"I want to add one more colour that will be used to draw the lines for roads and paths in the background of the map. When adding those lines, I want them to be similar to the map_bg_col colour, but slightly darker. One tool I often use when trying to find lighter or darker shades of a colour is coolors.co.
However, we can also do it programmatically using the monochromeR package from Cara Thompson. Let’s start with the map_bg_col colour and see what some of the darker shades look like:
library(monochromeR)
generate_palette(
colour = map_bg_col,
modification = "go_darker",
n_colours = 7,
view_palette = TRUE
)[1] "#33658A" "#2C5777" "#254A65" "#1E3C52" "#172F40" "#11212E" "#0A141B"
We can set the line colour to be the third of these values:
map_line_col <- generate_palette(
colour = map_bg_col,
modification = "go_darker",
n_colours = 7
)[3]Plotting an initial map
Let’s get started …
If you’re planning to print your map and hang it on the wall, it’s a good idea to preview it at the same size and resolution that you’ll be printing it at. This helps to make sure the fonts and lines look goodwhen saved as a PNG file. If you’re an RStudio user, then either camcorder or ggview can help with those pesky differences between plot previews and the version saved with ggsave().
The example below uses a height of 8 inches and a width of 6 inches.
Although we already have our colours set up, I tend not to add them on the first draft as the aim of that is just to check that the building blocks of the code work. Here, we start with ggplot() as we normally would, though leaving the data and mapping arguments blank as we’re using multiple different data sources.
We can use geom_sf() to add in the roads data from OpenStreetMap, noting that we don’t need to specify a mapping with aes() here because ggplot2 knows how to interpret sf objects already. We then use geom_sf() again to add the data for the route on top. Finally, we take the first and last points of the route data and again plot them using geom_sf() to show the start and end points of the route.
ggplot() +
geom_sf(
data = roads_cropped
) +
geom_sf(
data = line_data
) +
geom_sf(data = head(points_data, 1)) +
geom_sf(data = tail(points_data, 1))Arguably, since the start and end points are (almost) the same place, there’s a perhaps not much use in adding both so you might choose not to add them.
Styling your map
All of the lines are quite prominent on the initial draft, and it’s very hard to see the actual route on the first version since it sits directly on top of the background roads with the same colour and linewidth. Let’s start styling our map by updating the colours and linewidths for all of the basic sf objects.
We’ll also save the updated plot as a variable to keep our code tidy as we add more styling later on. To make sure that our roads go the edge of the plotting area, without the default padding around it, we set expand = FALSE inside coord_sf():
base_map <- ggplot() +
geom_sf(
data = roads_cropped,
colour = map_line_col,
linewidth = 0.5
) +
geom_sf(
data = line_data,
colour = map_route_col,
linewidth = 1.5
) +
geom_sf(
data = head(points_data, 1),
colour = map_route_col,
size = 3
) +
geom_sf(
data = tail(points_data, 1),
colour = map_route_col,
size = 3
) +
coord_sf(expand = FALSE)
base_mapThis is already looking a lot neater than the first draft, though it’s still hard to see the route since it’s so close in colour to the background. Sometimes it’s more helpful to choose a bold, ugly colour for a first draft to check everything is working!
We have two more things to do before we’re finished making this map:
- Add some text with the race name and date (for example);
- Apply the colours we’ve selected for the background.
Let’s start with the text. Here, I’m going to set the title as the name of the race, and the subtitle as the date of the race. For both, I use str_to_upper() to format all of the text in uppercase, to give it more of a poster look.
For the caption, I want to include two different pieces of information: my name, and my race number. To make it clear they are separate pieces of information, I’m going to make one larger and darker. To apply formatting to only part of the text, we can add HTML <span></span> tags within the character string. Later, we’ll use the ggtext package to make sure the HTML formats correctly. Alternatively, you can use the marquee package in a similar way.
We also use the glue package here to pull in the variable we set earlier for the text colour.
library(glue)
text_map <- base_map +
labs(
title = str_to_upper("Strathearn Marathon"),
subtitle = str_to_upper("12 June 2016"),
caption = glue(
"<span style='color: {text_col}; font-size:16pt;'>**NICOLA RENNIE**</span> #168"
)
)The last step is to tell ggplot2 to use ggtext for the caption, remove the extra parts of the theming we don’t want, and apply our colours. We can remove most of the default theme parts using theme_void(), setting the default font and size at the same time.
We then adjust the plot (default white) and panel (default grey) backgrounds to our colour variables, adjust the margins, and style the text. Here, I use a slight transparency on the subtitle and caption to get a lighter shade.
library(ggtext)
text_map +
theme_void(base_family = body_font, base_size = 13) +
theme(
panel.background = element_rect(
fill = map_bg_col, colour = map_bg_col
),
plot.background = element_rect(
fill = map_route_col, colour = map_route_col
),
plot.margin = margin(5, 20, 5, 20),
plot.title = element_text(
colour = text_col,
hjust = 0,
lineheight = 1,
face = "bold",
size = rel(1.6)
),
plot.subtitle = element_text(
colour = alpha(text_col, 0.7),
hjust = 0,
margin = margin(b = 10, t = 5),
lineheight = 1
),
plot.caption = element_textbox_simple(
colour = alpha(text_col, 0.7),
hjust = 1,
halign = 1,
margin = margin(b = 10, t = 10),
lineheight = 1
)
)Finally, we can save our map as a PNG file (or your preferred format) ready for printing! Since we’re using spatial data which has it’s own fixed aspect ratio this sometimes means there’s a little bit of extra white space either at the top/bottom or left/right of the saved chart. Setting the bg element equal to our chosen background colour fixes this.
ggsave("strathearn-map.png",
height = 8, width = 6,
bg = map_route_col
)Now you’re ready to get it printed and framed!
There’s a whole section on visualizing spatial data in The Art of Visualization with ggplot2 where you can learn how to make choropleth maps, plot world maps with custom legends, and create small multiple grid maps.
Reuse
Citation
@online{rennie2025,
author = {Rennie, Nicola},
title = {Create Custom {GPS} Route Maps in {R}},
date = {2025-11-23},
url = {https://nrennie.rbind.io/blog/gps-route-map-r/},
langid = {en}
}



