Personal Heatmaps

J Evans
strava-engineering
Published in
7 min readOct 28, 2022

This post discusses the algorithm behind Personal Heatmap, one of Strava’s most acclaimed subscriber features.

Product Overview

The Personal Heatmap feature has been around in some form since 2015, but we gave it a major overhaul in 2020. For those unfamiliar, the heatmap is an aggregated view of an athlete’s GPS-enabled activities on Strava. Activity data is rendered onto a map according to path traversal frequency; the more an athlete runs on a road, the brighter — i.e. “hotter” — that road shows up on the map. The result is a beautiful color gradient displaying the intricate web of past activities, unique to each athlete.

The Personal Heatmap is displayed on an interactive world map, also known as a slippy map. Athletes can seamlessly zoom in and out or pan around to arbitrary views of the map, and their activity heat stays displayed the entire time. To enable this, we must provide a scheme for rendering and serving subsections of the world map on the fly. Enter map tiling.

Mercator Projection & Map Tiling

Map tiling is the practice of subdividing a map into many discrete images that can be stitched together and rendered as a single image in real-time during map navigation. The first step in choosing a tiling scheme is picking what world map to use. Enter, the Mercator Projection, the most commonly used projection of Earth as a flat, rectangular surface. We use this projection in all of our mapping products, including heatmaps.

Now, imagine a grid overlaying the Mercator projection. Each grid section is known as a tile, a pixel image with resolution 256x256. Every tile has an associated zoom level, and tiles are defined recursively according to this zoom level. A tile at a given zoom level can be subdivided into four equal sized tiles at a next zoom level. For example, Zoom level 0 displays the entire planet in a single 256x256 pixel image. Zoom level 1 displays the world in four 256x256 pixel images. The recursion continues until Zoom level 20, at which point the 256x256 pixel image displays an area roughly the size of a house.

This table approximates of the landmass covered by tiles at each of the 21 zoom levels

We use a schema introduced by Google Maps to uniquely identify each tile with an <x, y> coordinate and associated zoom level. Moving forward, we’ll refer to zoom levels as <x,y,z> tuples. Use this online tool to get familiar with the standard.

So, how do we transform an athlete’s activity data into this map?

Generating Map & Heat Images

There are two components to a Personal Heatmap, the map image and the activity heat data overlaying that image. We will call the latter the heat images.

Generating Map Images

Instead of generating map images ourselves, we rely on Mapbox. They’ve done a fantastic job providing beautiful, accurate, and well-designed map images for all of our mapping products. Their map images adhere to the Mapbox Vector Tile specification, a standard that enables efficient, high-resolution, client-side renderings of map displays.

Generating Heat Images

The activity “heat” is what makes this experience unique to each athlete. Here’s how we create heat images:

  1. Enumerate all activities for an athlete
  2. For each activity, produce a list of tiled activity segments. (We’ll define below)
  3. Aggregate these lists of tile activity segments into a dictionary, where the key is a tile and the value is the list of tile activity segments that traverse the tile.
  4. For each tile in this dictionary, sum the tile activity segments and render a 256x256 resolution raster image of the heat for the tile.

Step 1: Enumerate all activities

We store each uploaded activity as a list of <latitude, longitude> points. For any athlete, we fetch this set of lists of <lat, lng> points.

case class Point(latitude: Double, longitude: Double)
val athleteActivities: Set[List[Point]] = fetchActivities(athleteId)

Step 2: Produce Tile Activity Segments

Once we’ve fetched an athletes activities, we transform them into ActivitySegments . An ActivitySegment is a data structure representing the portion of an activity that traverses a tile. Specifically, the ActivitySegment is a list of pixels traversed by the segment of an activity in a given tile.

Consider a simplified tiling scheme with four zoom levels and a single activity. In this scheme, tiles are images with 10x10 pixel resolution.

Now we focus on the activity in zoom level 2, taking care to annotate each tile with the <x,y,z> tile coordinates.

projection at zoom level 2

The activity traverses seven of the 16 tiles. Thus, there are seven ActivitySegments for this activity at zoom level 2 of this projection; one for each traversed tile. Each ActivitySegment encodes a list of pixels and associated tile. Let’s drive this home by focusing on tile <0,0,2> as a 10x10 pixel image.

Pixels in a 10x10 resolution image for activity segment that traverses tile <0,0,2>

Tile <0,0,2> contains the beginning and ending segments of the activity. The shaded-in pixels constitute the list of pixels for the this ActivitySegment.

ActivitySegment(
Tile(0,0,2),
List(
Pixel(2,3), Pixel(3,3), Pixel(4,3), Pixel(5,3),
Pixel(6,3), Pixel(7,3), Pixel(8,3), Pixel(9,2),
Pixel(9,2), Pixel(2,5), Pixel(2,6), Pixel(2,7),
Pixel(2,8), Pixel(3,8), Pixel(3,9)
)
)

We repeat this process for each activity, for every tile traversed by those activities, for multiple zoom levels.

Step 3: Aggregate Activity Segments

Once we’ve repeated step 2 for every activity, we organize the ActivtySegments into a dictionary, keyed by tile. Consider the following scenario of an athlete that has completed four activities:

Focusing on zoom level 2 again, we can group ActivitySegments by on tile.

ActivitySegments grouped by tile for tile <0,0,2> and tile <3,1,2>

Tile <0,0,2> has two associated ActivitySegments, one from activity 1 and one from activity 2. Meanwhile, tile <3,1,2> has three associated ActivitySegments, one for activity 2, activity 3, and activity 4. Here, we’ve only shown two mappings, but with the entire mapping, we can quickly determine two things about the athlete:

  1. All tiles traversed during the athlete’s activities
  2. All pixels within the tiles traversed by the athlete

Moreover, we can use this dictionary to count how many times an athlete has traversed any given pixel in a given tile. We have everything we need to render heat images.

Step 4: Render raster images

With the tile dictionary, we can calculate a traversal frequency for each pixel in a tile. Upon request, we loop through each pixel of a tile, counting how many ActivitySegments for the given tile include the pixel. After looping through and counting the traversal frequency for each pixel, we can color the pixels on a gradient. Below is an example of tile <3,1,2> from the projection described above.

tile <x=3,y=1,zoom=2>

Pixels that aren’t traversed aren’t shaded in; pixels traversed fewer times are shaded in darker; pixels traversed more often are shaded in brighter.

Putting it All Together

An athlete opens the Heatmap feature in the Strava mobile app. The map in view is tiled, and this set of tiles get passed down two request paths in parallel:

  1. Requests to Mapbox servers for vector tile map images
  2. Requests to our own server for the raster tile heat images

Once both sets of requests complete, the client overlays the raster images of an Athlete’s heat atop the vector tiles and presto! You have a heatmap.

Future Improvements

We’re happy with the heatmaps product, but there’s always room for improvement. In a future release, we would consider rendering heat using vector images instead of raster images for a few reasons:

  1. The resolution of raster images is static. Unfortunately, they look pixelated the more you zoom in. In contrast, vector tile resolution resizes, so they remain sharp regardless of how zoom level.
  2. As mentioned previously, Mapbox serves our map images in a vector format. Serving heat as vector images would provide consistency between our map and heat image layers.
  3. Vector image files size are typically smaller then raster file sizes, so data transfer from server to client is reduced when using vector tiles. This can make a significant difference when operating at Strava’s scale.

Thanks for reading! Subscribe to Strava if you want to explore your own personal heatmap, and be sure to check out our other engineering blog posts.

--

--