Mountain Race

Animation of my Strava efforts on one of my local climbs

Julian During
2025-12-16

Idea

Every cyclist has a particular important climb. It might not be a big deal to anyone else, but any climb can be important!

My favorite local climb goes by the name of ‘Lochen’. It’s located outside of my local hometown Balingen in the southwest of Germany. It’s about 4.4 kilometers long with an average gradient of 6.9%.

This doesn’t sound like a hard climb. It might not even register as a regular big climb for most cyclists. But for me it’s one of the most iconic climbs.

In the following post, I will let different versions of me race against each other on my favorite local climb!

In order to reproduce the analysis, perform the following steps:

Data

The following libraries are used in this analysis:

The data originates from my personal Strava account. If you have a Strava account and want to query your data like I do here, you can have a look at one of my previous posts.

Download the overview data and data about individual activities here:

g_board <- board_gdrive("strava")
df_act <- filter(select(pin_read(g_board, "df_act_26845822"), 
     id, start_date, type), type == "Ride")
df_meas <- pin_read(g_board, "df_meas_26845822")

Deselect heartrate measurements and restrict the spatial data to a bounding box. Add information about the type and the start date of each activity.

lochen_raw <- function(df_meas, df_act, lat_min, lat_max, lng_min, lng_max) {
  df_meas |>
    filter(
      lat >= lat_min, lat <= lat_max, lng >= lng_min, lng <= lng_max,
    ) |>
    select(-heartrate) |>
    inner_join(df_act, by = join_by(id))
}
df_lochen_raw <- lochen_raw(df_meas, df_act, 48.218171, 48.231383, 
     8.842964, 8.86219)

Take a first look at the raw data:

glimpse(df_lochen_raw)
Rows: 45,186
Columns: 17
$ series_type     <chr> "distance", "distance", "distance", "distanc…
$ original_size   <int> 2152, 2152, 2152, 2152, 2152, 2152, 2152, 21…
$ resolution      <chr> "high", "high", "high", "high", "high", "hig…
$ id              <chr> "16017478862", "16017478862", "16017478862",…
$ distance        <dbl> 3674.6, 3679.0, 3683.6, 3689.0, 3695.0, 3701…
$ time            <int> 806, 807, 808, 809, 810, 811, 812, 813, 814,…
$ altitude        <dbl> 884.0, 884.0, 883.9, 883.8, 883.7, 883.4, 88…
$ velocity_smooth <dbl> 4.56, 4.46, 4.36, 4.56, 4.94, 5.32, 5.86, 6.…
$ moving          <lgl> TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TR…
$ grade_smooth    <dbl> -1.1, -1.1, -1.5, -2.7, -2.8, -3.4, -4.1, -4…
$ lat             <dbl> 48.21818, 48.21821, 48.21825, 48.21829, 48.2…
$ lng             <dbl> 8.852876, 8.852852, 8.852821, 8.852780, 8.85…
$ temp            <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, …
$ watts           <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, …
$ cadence         <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, …
$ start_date      <dttm> 2025-10-03 07:09:40, 2025-10-03 07:09:40, 2…
$ type            <chr> "Ride", "Ride", "Ride", "Ride", "Ride", "Rid…

Further preprocess the raw data. Keep only rows, where I was moving and turn the start date from datetime to date. Adjust the time column so that every activity starts at time 0.

lochen <- function(df_lochen_raw) {
  df_lochen_raw |>
    group_by(id) |>
    mutate(
      time_delta = time - lag(time),
      start_date = as_date(start_date)
    ) |>
    replace_na(list(time_delta = 0)) |>
    filter(moving) |>
    mutate(time = cumsum(time_delta)) |>
    select(-time_delta) |>
    ungroup()
}
df_lochen <- lochen(df_lochen_raw)

Visualisation

Make a first static ggplot visualisation. Keep the plot rather minimal. Use ggplot2::theme_void as a general theme:

vis_lochen <- function(df_lochen) {
  df_lochen |>
    ggplot(
      aes(x = lng, y = lat, group = id)
    ) +
    geom_path(alpha = 0.2)
}
gg_lochen <- vis_lochen(df_lochen)

As you can see there are lot of paths on one road. These are my bike rides on the ‘Lochen’ pass.

To further explore the data, make a first animated visualisation with the gganimate package:

vis_anim_lochen <- function(gg_lochen) {
  gg_lochen +
    transition_reveal(time)
}
gg_anim_lochen <- vis_anim_lochen(gg_lochen)

In this animated version of the plot, you can see that not all bike rides start at the bottom of the climb. Determine these activities:

wrong_direction <- function(df_lochen) {
  df_lochen |>
    group_by(id) |>
    summarise(
      start_altitude = altitude[time == min(time)],
      end_altitude = altitude[time == max(time)]
    ) |>
    filter(end_altitude < start_altitude)
}
df_wrong_direction <- wrong_direction(df_lochen)

Exclude activities that start at the top of the climb:

df_lochen_uphill <- anti_join(df_lochen, df_wrong_direction, by = join_by(id))

With the cleaned up data, we can repeat the animated plot:

gg_lochen_uphill <- vis_lochen(df_lochen_uphill)
gg_anim_lochen_uphill <- vis_anim_lochen(gg_lochen_uphill)

Now it looks much cleaner and the rides are more comparable to one another.

I very much like how the plot turned out. I hope I do more of this type of animation in the future!

Corrections

If you see mistakes or want to suggest changes, please create an issue on the source repository.

Reuse

Text and figures are licensed under Creative Commons Attribution CC BY 4.0. Source code is available at https://codeberg.org/duju211/gif_climb, unless otherwise noted. The figures that have been reused from other sources don't fall under this license and can be recognized by a note in their caption: "Figure from ...".

Citation

For attribution, please cite this work as

During (2025, Dec. 16). Datannery: Mountain Race. Retrieved from https://www.datannery.com/posts/mountain-race/

BibTeX citation

@misc{during2025mountain,
  author = {During, Julian},
  title = {Datannery: Mountain Race},
  url = {https://www.datannery.com/posts/mountain-race/},
  year = {2025}
}