Your workout route (in three dimensions!)

Screenshot of a 3D linechart that represents a workout route. X, Y and Z dimensions are latitude, longitude and elevation. All chart decoration has been removed. The route is circular; the southern horizontal portion has low elevation and there is a great deal of elevation to the north. The angle of the chart has been changed to show the view from the south-west. A small emoji runner is placed at the highest point.

tl;dr

You can use R to extract coordinate and elevation data from a GPX file and then plot it as an interactive 3D object. I put some functions in the tiny R package {gpx3d} to help do this.

Elevate to accumulate

I’ve seen recently on Twitter some people using Marcus Volz’s {strava} R package to create pleasing visualisations of their running routes as small-multiples.

I don’t use Strava, but I downloaded my Apple Health data this week and it contained a folder of GPX files; one for each ‘workout’ activity recorded via my Apple Watch.1 GPX files are basically just a type of XML used for storing GPS-related activity.

But rather than try to emulate {strava}, I thought it might be ‘fun’ to incorporate the elevation data from a GPX as a third dimension. I’ve also had mikefc’s {ggrgl} package—‘a 3D extension to ggplot’—on my to-do list for a while now.

An alternate dimension

Cut to the chase: I made a tiny package called {gpx3d}. For now it does what I want it to do and it works on my machine.

You can download it from GitHub with help from the {remotes} package.

install.packages("remotes")  # if not yet installed
remotes::install_github("matt-dray/gpx3d")

There are a number of dependencies, including many that are not available on CRAN; see the README for {ggrgl} for details. You must also install XQuartz, if you haven’t already.

The package does two things and has two exported functions:

  • extract_gpx3d() gets the data out of a GPX file (i.e. it reads a GPX file; parses the XML; extracts datetime, latitude, longitude and elevation; converts to sf-class; and calculates the distance covered)
  • plot_gpx3d() plots the data as an interactive 3D object (i.e. it takes the output from extract_gpx3d(), generates a ‘3D ggplot’ using {ggrgl} and renders it as an interactive object to an external device)

There are also two demo datasets:

  • segment.gpx, a GPX file containing a shorter, edited version of the route used in this blogpost, which you can access with system.file("extdata", "segment.gpx", package = "gpx3d") after installing the package
  • gpx_segment, an sf-class data.frame that’s the result of using the extract_gpx3d() on the built-in segment.gpx file

Read on for an explanation and examples.

Extract

There are already functions that can help read GPX files into R, like gpx::read_gpx() and plotKML::readGPX(), but I decided to do it by hand with {xml2} to get a custom output format (and to practice handling XML).

In short, the extract_gpx3d() function uses read_xml() to read the GPX file, then as_list() to convert it to a deeply nested list. A little wrangling is then required to create a data.frame: datetime and elevation can be hoisted out of the list okay, but the longitude and latitude are actually extracted from the attributes.

After this, the data.frame is converted to the ‘geography-aware’ sf-class.2 I’ve done this for two reasons: (1) the output object can be taken away and will play nicely with various {sf} functions, letting you create various maps and perform further processing, and (2) it allowed me to calculate the distance between each recorded point, which could be summed for total distance.

To use extract_gpx3d(), simply pass a path to a GPX file. I’ve chosen a 10 km run I took on Christmas morning,3 which I downloaded from Apple Health and stored locally.4

file <- "~/Downloads/apple_health_export/workout-routes/route_2021-12-25_9.31am.gpx"
route <- gpx3d::extract_gpx3d(file)
route[2000:2004, ]
## Simple feature collection with 5 features and 5 fields
## Geometry type: POINT
## Dimension:     XY
## Bounding box:  xmin: 0.559015 ymin: 50.85109 xmax: 0.559273 ymax: 50.85109
## Geodetic CRS:  WGS 84
##                     time      ele      lon      lat                  geometry
## 2000 2021-12-25 09:13:29 8.406136 0.559273 50.85109 POINT (0.559273 50.85109)
## 2001 2021-12-25 09:13:30 8.498508 0.559209 50.85109 POINT (0.559209 50.85109)
## 2002 2021-12-25 09:13:31 8.599027 0.559144 50.85109 POINT (0.559144 50.85109)
## 2003 2021-12-25 09:13:32 8.721706 0.559079 50.85109 POINT (0.559079 50.85109)
## 2004 2021-12-25 09:13:34 8.858613 0.559015 50.85109 POINT (0.559015 50.85109)
##          distance
## 2000 4.564465 [m]
## 2001 4.494285 [m]
## 2002 4.564465 [m]
## 2003 4.564465 [m]
## 2004 4.492909 [m]

You can see the rows are basically a measurement per second (time) of the coordinates (lon and lat) and elevation (ele), and that the sf-class metadata and geometry column are present, along with the distance in metres from the previous to current point.

You can take this dataset away and do other stuff with it, like create a lat-long plot of the route (below left), or the elevation over time (below right).

par(mfrow = c(1, 2), mar = rep(0, 4))
with(route, plot(lon, lat, type = "l", axes = FALSE))
with(route, plot(time, ele, type = "l", axes = FALSE))

Two plots: to the left a line showing the route of the run; to the right a line showing the elevation over time. The route is a single loop, roughly rectanglular but with several kinks. The elevation rises before dropping steeply to a plateau, then sharply rising again.

If you’re wondering about the little ‘tail’ in the bottom right of the route, I accidentally joined the back of a Parkrun, so quickly did a hairpin turn to escape. Except the Parkrun route is a ‘there-and-back’ course, so the confused stewards thought I was now in the lead with a pace of about two minutes per kilometre. Whoops!

The elevation plot is pretty dramatic: roughly, it goes downhill to a small plateau, down again to a flatter plateau, then the inevitable (steep!) climb. The lowest plateau is along the seafront, so basically sea level.

But boo! Only two dimensions? You can instead use the plotting function built in to {gpx3d} for something a bit more exciting.

Plot

All the hard work of plotting is done primarily by {ggplot2} and {ggrgl}. The former is probably well-known to readers; the latter is an extension written by mikefc to introduce a third dimension to ggplot objects. In other words, you can extrude your plot along some third variable to generate a z-axis.

There’s a whole bunch of specialised 3D geoms in {ggrgl}. For my purposes, I wanted to extend a geom_path() line plot into the third dimension. This is achieved by adding a z argument to the aes() call of the geom_path_3d() function, where z is our elevation data.

The function renders the plot as an interactive 3D object with {rgl} to an external devoutrgl::rgldev() graphics device. I’ve managed to embed it in the blog below after peeking at mikefc’s vignettes for {ggrgl}, though it may take a moment to load and there’s no guarantees it will work on mobile.

gpx3d::plot_gpx3d(route_sf)

[Mouseclick and drag the object below, or zoom with your scrollwheel]5

Again, you can see why I chose this particular route for this demo; it really shows off the power of the elevation data. I ran anti-clockwise downhill to the seafront, where it was almost entirely flat, before running back up a relatively sharp ascent.

If you think there’s too much ‘junk’ in the plot, you can also set the argument route_only to TRUE to get rid of all the chart elements and leave behind the path only.

gpx3d::plot_gpx3d(route, route_only = TRUE)

This time I’ve inserted a gif of what the output would look like:

Gif showing an RGL device with a 3D interactive object representing a workout route. The z dimension is elevation and the route rises from low to high elevation. The object is spun and zoomed into to highglight the elevation change.

Wish I’d got a 3D printer for Christmas now.

A romance of many dimensions

I’ve made {gpx3d} entirely for my own amusement, so your kilometreage may vary. At this point I can’t make any guarantees about whether it will even work on your machine, but hopefully I’ll find time in future to make sure it does. It might also be nice to include more user options for adjusting the output so you aren’t stuck with ‘ggplot grey’ and the same defaults mikefc used in a vignette showing a {ggrgl} version of Minard’s famous visulisation of Napoleon’s march.6

I’ll also be thinking about developing {gpx4d} and functions like geom_tesseract(), but I might need physics to catch up first.


Session info
## ─ Session info ───────────────────────────────────────────────────────────────
##  setting  value                       
##  version  R version 4.1.0 (2021-05-18)
##  os       macOS Big Sur 10.16         
##  system   x86_64, darwin17.0          
##  ui       X11                         
##  language (EN)                        
##  collate  en_GB.UTF-8                 
##  ctype    en_GB.UTF-8                 
##  tz       Europe/London               
##  date     2021-12-31                  
## 
## ─ Packages ───────────────────────────────────────────────────────────────────
##  package     * version    date       lib
##  assertthat    0.2.1      2019-03-21 [1]
##  blogdown      1.4        2021-07-23 [1]
##  bookdown      0.23       2021-08-13 [1]
##  bslib         0.3.1      2021-10-06 [1]
##  cachem        1.0.6      2021-08-19 [1]
##  class         7.3-19     2021-05-03 [1]
##  classInt      0.4-3      2020-04-07 [1]
##  cli           3.1.0      2021-10-27 [1]
##  colorspace    2.0-2      2021-06-24 [1]
##  crayon        1.4.2      2021-10-29 [1]
##  cryogenic     0.1.0      2021-12-29 [1]
##  DBI           1.1.2      2021-12-20 [1]
##  decido        0.3.0      2020-05-19 [1]
##  devout        0.2.9      2021-12-29 [1]
##  devoutrgl     0.1.0      2021-12-29 [1]
##  digest        0.6.29     2021-12-01 [1]
##  dplyr         1.0.7      2021-06-18 [1]
##  e1071         1.7-9      2021-09-16 [1]
##  ellipsis      0.3.2      2021-04-29 [1]
##  evaluate      0.14       2019-05-28 [1]
##  extrafont     0.17       2014-12-08 [1]
##  extrafontdb   1.0        2012-06-11 [1]
##  fansi         0.5.0      2021-05-25 [1]
##  fastmap       1.1.0      2021-01-25 [1]
##  fs            1.5.0      2020-07-31 [1]
##  gdtools       0.2.3      2021-01-06 [1]
##  generics      0.1.1      2021-10-25 [1]
##  ggplot2       3.3.5      2021-06-25 [1]
##  ggrgl         0.1.0      2021-12-29 [1]
##  glue          1.6.0      2021-12-17 [1]
##  gpx3d         0.0.0.9002 2021-12-30 [1]
##  gtable        0.3.0      2019-03-25 [1]
##  highr         0.9        2021-04-16 [1]
##  htmltools     0.5.2      2021-08-25 [1]
##  htmlwidgets   1.5.4      2021-09-08 [1]
##  jquerylib     0.1.4      2021-04-26 [1]
##  jsonlite      1.7.2      2020-12-09 [1]
##  KernSmooth    2.23-20    2021-05-03 [1]
##  knitr         1.37       2021-12-16 [1]
##  labeling      0.4.2      2020-10-20 [1]
##  lifecycle     1.0.1      2021-09-24 [1]
##  magick        2.7.3      2021-08-18 [1]
##  magrittr      2.0.1      2020-11-17 [1]
##  memoise       2.0.1      2021-11-26 [1]
##  mime          0.12       2021-09-28 [1]
##  munsell       0.5.0      2018-06-12 [1]
##  pillar        1.6.4      2021-10-18 [1]
##  pkgconfig     2.0.3      2019-09-22 [1]
##  pkgdown       1.6.1      2020-09-12 [1]
##  polyclip      1.10-0     2019-03-14 [1]
##  proxy         0.4-26     2021-06-07 [1]
##  purrr         0.3.4      2020-04-17 [1]
##  R6            2.5.1      2021-08-19 [1]
##  Rcpp          1.0.7      2021-07-07 [1]
##  rgl           0.108.3    2021-11-21 [1]
##  rlang         0.4.12     2021-10-18 [1]
##  rmarkdown     2.10       2021-08-06 [1]
##  rstudioapi    0.13       2020-11-12 [1]
##  RTriangle     1.6-0.10   2018-01-31 [1]
##  Rttf2pt1      1.3.8      2020-01-10 [1]
##  s2            1.0.7      2021-09-28 [1]
##  sass          0.4.0      2021-05-12 [1]
##  scales        1.1.1      2020-05-11 [1]
##  sessioninfo   1.1.1      2018-11-05 [1]
##  sf            1.0-4      2021-11-14 [1]
##  snowcrash     0.1.4      2021-12-29 [1]
##  stringi       1.7.6      2021-11-29 [1]
##  stringr       1.4.0      2019-02-10 [1]
##  systemfonts   1.0.3      2021-10-13 [1]
##  tibble        3.1.6      2021-11-07 [1]
##  tidyselect    1.1.1      2021-04-30 [1]
##  triangular    0.1.8      2021-12-29 [1]
##  units         0.7-2      2021-06-08 [1]
##  utf8          1.2.2      2021-07-24 [1]
##  vctrs         0.3.8      2021-04-29 [1]
##  withr         2.4.3      2021-11-30 [1]
##  wk            0.5.0      2021-07-13 [1]
##  xfun          0.29       2021-12-14 [1]
##  xml2          1.3.2      2020-04-23 [1]
##  yaml          2.2.1      2020-02-01 [1]
##  source                                    
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  Github (coolbutuseless/cryogenic@3cfb51b) 
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  Github (coolbutuseless/devout@7bd337a)    
##  Github (coolbutuseless/devoutrgl@a861efc) 
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  Github (coolbutuseless/ggrgl@27ba63c)     
##  CRAN (R 4.1.0)                            
##  local                                     
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  Github (coolbutuseless/snowcrash@9b14727) 
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  Github (coolbutuseless/triangular@a90deee)
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
##  CRAN (R 4.1.0)                            
## 
## [1] /Library/Frameworks/R.framework/Versions/4.1/Resources/library

  1. I wrote earlier in the year about wrangling my Nike Run Club data via Apple Health. It seems as though NRC doesn’t pass geographic information to Health, but now I also record my runs via the Workout app on the watch, which does regurgitate the geo-related data.↩︎

  2. Or you can return a simpler data.frame without the sf-class by passing sf_out = FALSE to extract_gpx3d().↩︎

  3. In a place I do not live, so minimal opsec-leaking here.↩︎

  4. You could try using the demo GPX file that’s included in the package, using file <- system.file("extdata", "segment.gpx", package = "gpx3d")↩︎

  5. I originally wrote ‘mouselick’ instead of ‘mouseclick’.↩︎

  6. I also realised later that Tyler Morgan-Wall already did something like this with {rayshader}. I should have guessed.↩︎