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 fromextract_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 withsystem.file("extdata", "segment.gpx", package = "gpx3d")
after installing the packagegpx_segment
, an sf-class data.frame that’s the result of using theextract_gpx3d()
on the built-insegment.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))
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:
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
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.↩︎
Or you can return a simpler data.frame without the sf-class by passing
sf_out = FALSE
toextract_gpx3d()
.↩︎In a place I do not live, so minimal opsec-leaking here.↩︎
You could try using the demo GPX file that’s included in the package, using
file <- system.file("extdata", "segment.gpx", package = "gpx3d")
↩︎I originally wrote ‘mouselick’ instead of ‘mouseclick’.↩︎
I also realised later that Tyler Morgan-Wall already did something like this with {rayshader}. I should have guessed.↩︎