A heatmap with many ways to look at your activity data
After a few years of running, cycling and hiking you end up with hundreds of activities in your tracker. Most apps show them as a list of dates and numbers, with one small map per activity. What you don’t really get, even if you pay for it, is a customizable visualization of your whole training history.
I’ve been wanting to build something like that but never really got around to it. Recently I was recommended a video on youtube about a heatmap project and ended up rewriting and extending it into something that does that, running locally on my laptop.
The map #
The interface has three controls: a base map picker (dark, light, topo, …), an activity filter with date and distance ranges and a layer picker for the heatmap itself. The routes are pre-rendered, so the filter doesn’t redraw them. A small stats panel does react to it, showing count, distance, time and ascent for the currently selected subset.
The heatmap layers are grouped into five families: frequency, pace, heart rate, elevation and time. Each layer is a tile pyramid built once at export time, so switching between them is instant. This is important to allow zooming from the world view down to the street view without recomputing the routes all while keeping visualization quality high.
Four questions about your own data #
Frequency #
The frequency layer counts GPS samples per pixel, which is closer to time spent on each road than number of passes. So a regular loop you’ve done eighty times glows. On the other hand, a detour you took once stays relatively dim. There’s a linear and a log version. The linear one is dominated by favorite routes while the log one is more useful to also show less frequent routes.
Pace and heart rate #
The pace layer averages speed across every visit to each pixel. Fast segments brighter, slower darker, about what you’d expect. The heart rate layer works the same way on a separate layer. Both flatten out over years of data, since one hard effort gets diluted by many easier ones, so the peak on the map sits well below your actual peak HR. That’s also what makes the long-term view useful and prevents one spike to dominate the map.
Elevation #
The hill-training layer only colors where the grade goes above 2.5 percent. On mostly flat terrain and to filter GPS altitude noise nothing gets highlighted. You end up with a nice visualization of the elevation gain in your training. There are a couple of other elevation variants in the layer panel, but this is the one I’ve looked at most so far.
Recency #
The recency layer colors each pixel by the date of the most recent activity that crossed it. Brightest streaks are routes recently completed, darker ones are routes from months or years ago. These are a bit compressed to highlight the recent ones more while still allowing to distinguish between new and fairly new. There’s also a freshness variant that filters for visits within the last twelve or thirty-six months, same idea but counts rather than dates.
How it works #
Everything gets pre-computed. On a full build the pipeline walks each GPS track once and paints the tiles. This happens for every zoom level and each zoom level is normalized on its own, so a continent-wide view stays visually distinct from a city-level one. This allows the previously mentioned zooming like you know it from other map apps without recomputing the routes. It’s also the reason why live filters are not possible, since everything is pre-rendered.
The viewer is a static HTML file and a folder of PNG tiles. Sliders update the stats panel locally; switching layers or basemaps just swaps which tiles the browser shows. There is no backend.
The pipeline can currently read two sources: a Strava bulk export and the intervals.icu API. Cross-source duplicates are deduplicated automatically on day, start coordinates and distance. A small admin UI handles the cases where the match is ambiguous, usually because one source has a broken GPS track and you want the other to win.
How to run it yourself #
The repo is at github.com/akanz1/running-heatmap. It’s based on moresamwilson/running-heatmap, which started as a notebook and a YouTube walkthrough. I’ve added some project scaffolding, admin UI and zooming plus some extra data handling and the intervals.icu support.
Building this was a great way to try different coding models. Prototype a layer, see how it looks, throw it away, try another, all on the side. The ideas and general structure are separated from writing the majority of the code which the models are already really good at. The end result is something I couldn’t easily find, even as a paid app. Years of multi-source activities, different ways to look at them and a viewer that builds and runs entirely on a laptop, giving me full control to change and extend it as I want.
Note: To produce screenshots for this post I used a synthetic data generator at
scripts/generate_demo_data.pythat writes a Strava-export-shaped folder with ~150 fake activities around Munich.
Setting up your data #
Two ways to feed the heatmap real activities, in any combination.
Strava bulk export (no API key needed)
Strava lets you download your entire activity archive as a single zip. Request it via Settings → My Account → Download or Delete Your Account → Request Your Archive. The download arrives by email minutes to at most a few hours later. Unzip the contents into strava_export/ at the project root and run make run. The pipeline reads the FIT, GPX and TCX files directly, no live API needed.
The first build for a few hundred activities takes a couple of minutes. Subsequent rebuilds reuse the parse cache and finish in seconds.
intervals.icu sync (my preferred source) (API key needed, Settings -> Developer Settings)
A great tool for analyzing your data! I use it myself and sync activities from Garmin Connect and other apps. It can run as the sole source for the heatmap so you dont need Strava at all. Since it uses the API you dont need to export data manually after every activity to get it into the heatmap.
cp .env.example .env- Then set
INTERVALS_ICU_API_KEYandINTERVALS_ICU_ATHLETE_IDfrom https://intervals.icu/settings (Developer section). make syncto populatecache/intervals_icu/, thenmake runto rebuild.make runwill also sync first if the env vars are set.
If both sources are present, cross-source duplicates are merged on day, start coordinates and distance bucket. For cases where the match is ambiguous, the admin UI allows you to manually select the source for an activity.