My rides, with darker shades indicating higher heart rates

I rode a lot when I lived in Kitchener-Waterloo. The weather sucked for at least half the year, but I had time and I love being on the bike. I recorded almost all my rides with a Garmin 500. Without any extra sensors, the Garmin records GPS coordinates, altitude, and speed. I put sensors on my bike that allowed it to record cadence (pedal turns per minute) and more accurate speed data (by relying on wheel rotations instead of movement). I also wore a heartrate monitor most of the time. Each ride ends up as a .fit file that I copied to my computer and uploaded to Strava.

Tom MacWright wrote a post about creating a map of his runs in Washington D.C., and I wanted to try doing the same with my cycling data.

The fit R package by Alex Cooper does the hard work of parsing each .fit file to give a clean dataframe with cadence, distance, speed, altitude, and heartrate metrics for each timestamp.1 Thanks to Alex, all I have to do is merge the data from all the files while excluding ones that don’t have relevant data.2

First, some library loading and setup.

library(dplyr)
library(fit)
library(ggmap)
library(magrittr)
library(svglite)
library(viridis)

Aside from the fit package that parses the files, I use the ggmap package to overlay the rides on top of the town’s Google Map roads.

There are some limitations to using Google Maps. You have to be careful about hitting a rate limit while developing and rerunning the script, Google watermarks the maps with their logo at the bottom right and left corners, and – and this is the worst – zoom settings are way too coarse. The maps below are at zoom level 10, which is too far and has a lot of unused map area. But zoom level 11 is too close and cuts off many of the routes. I could technically get around both issues by cropping to get rid of empty space and remove the logos, but I suspect that violates some usage policy. Finally, higher resolution maps (i.e, scale = 4) are reserved for business users only.

That said, the ggmap R package is convenient and easy to use. I tried OpenStreetMap and it looks promising, but I still haven’t figured out if I can get the kind of map I want out of it, and how to do it.

Now we read the ride files from _map directory and discard any that don’t have the right shape.3

ride_files <- dir('_map/')

check_vars <- function(fit_file) {
    ride <- read.fit(file.path('_map', fit_file))
    # checking number of columns is a 'bad smell' in the future-proofness of this code
    if (ncol(ride$record) == 9) {
        return(ride$record)
    } else {
        return(NA)
    }
}

rides <- do.call('rbind', lapply(ride_files, function(x) { check_vars(x) }))

points <- rides %>%
    na.omit() %>%
    filter(heart_rate < 190)

centers <- c(mean(points$position_long) + .04, mean(points$position_lat) - .08)

With all the files parsed into one dataframe, and the longitude and latitude centers of the map calculated, we can get the base layer Google Map of the town.

map <- get_googlemap(center = centers,
                     zoom = 10,
                     scale = 2,
                     color = 'bw',
                     style = c(feature = "all", element = "labels", visibility = "off"),
                     maptype = c('roadmap'),
                     messaging = FALSE)

Let’s make some maps! Going forward, color/shade variation indicates heartrate.

Google Maps with color heatmap overlay:

ggmap(map, extent = 'device') +
    geom_path(aes(x = position_long, y = position_lat, color = heart_rate), data = points, size = .7) +
    scale_color_viridis(option = 'inferno', guide = FALSE) +
    theme_void()

plot of chunk gmap-color

Google Maps with greyscale heatmap overlay:

ggmap(map, extent = 'device') +
    geom_path(aes(x = position_long, y = position_lat, color = heart_rate), data = points, size = .7) +
    coord_map("mercator") +
    scale_colour_gradient(low = "white", high = "black", guide = FALSE) +
    theme_void()

plot of chunk gmap-grey

Naked routes with color heatmap:

ggplot(points, aes(x = position_long, y = position_lat, color = heart_rate)) +
    geom_path() +
    coord_map("mercator") +
    scale_color_viridis(option = 'inferno', alpha = .2, guide = FALSE) +
    theme_void()

plot of chunk nomap-color

Naked routes with greyscale heatmap:

ggplot(points, aes(x = position_long, y = position_lat, color = heart_rate)) +
    geom_path() +
    coord_map("mercator") +
    scale_colour_gradient(low = "white", high = "black", guide = FALSE) +
    theme_void()

plot of chunk nomap-grey

… + minimal coordinate information

ggplot(points, aes(x = position_long, y = position_lat, color = heart_rate)) +
    geom_path() +
    coord_map("mercator") +
    scale_color_viridis(option = 'inferno', alpha = .2, guide = FALSE) +
    xlab('long') +
    ylab('lat') +
    theme_minimal() +
    theme(panel.grid.major = element_blank(), panel.grid.minor = element_blank())

plot of chunk nomap-coords

That was fun!

Next steps: save my favorite as an svg file and get a nice-looking print.

  1. I assume it would also give power data if I had it. ↩︎

  2. I record my training on resistance trainers too, but that data is irrelevant because it’s all stationary. ↩︎

  3. As I mention in the code comment, validating a data file by counting columns is a bad smell. I think it hurts the generalizability and reusability of the code. That said, this post idled in draft status for way too long, and given that I don’t foresee myself doing more training rides in Kitchener-Waterloo again, this code is largely custom-written for data that is fully collected. Sometimes you do things the ideal way, and sometimes you ship. ↩︎