I've always been a fan of maps, especially old maps, and the sense of exploration and history that comes with hovering over them and zooming in on the fine details. Despite this I've never really tried to make any maps of my own. I recently discovered the nifty Python package, Folium, which can make visually stunning interactive maps within a matter of minutes. Since my PhD research is concerned more with the digital world than the real world I decided to look to my love of cycling and plot some of the top cycling climbs in the UK.

I've published the final map here on the Top100 Climbs Project Page, where (if you're interested) you can find more information on the climbs themselves. The focus of this post is to see how it was made.

The most basic Python package for plotting geographical data is Basemap which uses matplotlib. However, like matplotlib, it can require a lot of work to get something that looks reasonable. By contrast, Folium is simple, intuitive, and utilises the Javascript mapping library leaflet.js to create interactive maps on the fly.

To install you can use pip install folium (there is not current a Conda package).

In [1]:
import folium

A Basic Example

The following example highlights how we can generate an embeded map in just four lines of code.

The first line generates the map object. The location and zoom_start keywords sets the latitude and longitude of the centre of the map (we can of course move around), and how zoomed in we are initially. The tiles keyword describes which tileset to want to use. There are lots of different options here, from the geographically detailed, to the more artistic representations of the world. You can see all the available sets here. For this example we'll use the Open Street Map which is on the detailed side of the spectrum.

The second line defines a Marker object. This has a specific location specifed by a (lat,lon) pair, some pop-up text, and an icon to be placed onto the map.

Finally we add the marker to the map (in a very OOP fashion) and view the map object which embeds nicely in a Jupyter notebook.

In [2]:
map_osm = folium.Map(location=[40.7128, -74.0059],
                     tiles='Open Street Map',
                     zoom_start=12)

marker = folium.Marker([40.7484, -73.9857], 
                       popup='Empire State Building', 
                       icon=folium.Icon())

map_osm.add_child(marker)

map_osm
Out[2]:

This might qualify as one of the most useless maps ever created - but if you didn't know where the Empire State Building was, you do now.

Using the Strava API to Acquire Data

We first need some data to plot. We use the requests package to query the Strava API. If you're unfamilar with using an RESTful API, all that this really entails is visiting a specific URL and receiving JSON back. Of course there's much more to it than that but since we're only trying to access data it's not that much more complicated. For the specifics of the Strava API, click here.

To access the API we need a token to identify ourselves. I've omitted mine below for obvious reasons.

In [3]:
import requests as rq
import pandas as pd
import json

token = '<Put your own token here>'
segment_payload = {'access_token': token, 'per_page': '200'}
headers = {'content-type': 'application/json'}

I've previously catalogued a list of all the segment ids for the climbs I want to map from Strava, and saved them in a .csv. Loading these in with Pandas we get

In [4]:
segment_ids = pd.read_csv('./strava_segments.csv', index_col='id')
segment_ids.head()
Out[4]:
segment_name segment_id
id
1 Cheddar Gorge 6665302
2 Weston Hill 6665281
3 Crowcombe Combe 6665343
4 Porlock 6665361
5 Dunkery Beacon 6665334

At the moment we have no further information about these segments - where they are, how steep they are, and how long they go on for. Thankfully we can query the Strava API for each segement (using the id) and save that information into a DataFrame.

For each segment in the table above, we generate an API call, receive the JSON, and parse the columns (dictionary keys) that we want to keep before finally creating a DataFrame.

In [5]:
required_fields = ['athlete_count',  'average_grade', 'city', 
                   'climb_category', 'distance', 'effort_count', 
                   'elevation_low', 'end_latlng', 'id',
                   'maximum_grade', 'name', 'start_latlng', 
                   'total_elevation_gain', 'updated_at']

full_segment_info = {}
for ix, row in segment_ids.iterrows():
    r = rq.get('https://www.strava.com/api/v3/segments/{}'.format(row.segment_id), headers=headers, params=segment_payload)
    data = r.json()
    data = {key: val for key, val in data.items() if key in required_fields}
    full_segment_info[ix] = data
    
full_segment_info = pd.DataFrame(full_segment_info).T
full_segment_info.index = full_store.index + 1

Now, looking at the top of the table, we can see we have a multitude of new information:

In [6]:
full_segment_info.head()
Out[6]:
athlete_count average_grade city climb_category distance effort_count elevation_low end_latlng id maximum_grade name start_latlng total_elevation_gain updated_at
1 15129 4.2 Cheddar 2 4165.7 43064 37.2 [51.278704, -2.738877] 6665302 28.4 OFFICIAL 100Climbs No1 Cheddar Gorge [51.27987, -2.77271] 173.2 2016-10-06T08:04:39Z
2 1682 10.3 Bath, UK 2 1583.3 3968 66.6 [51.412288, -2.394196] 6665281 22.5 OFFICIAL 100Climbs No2 Weston Hill [51.398412, -2.395843] 163.8 2016-10-06T08:04:39Z
3 1304 14.8 Taunton, UK 2 1260.7 2047 147.8 [51.129597, -3.216476] 6665343 26.6 OFFICIAL 100Climbs No3 Crowcombe Combe [51.122484, -3.22928] 186.8 2016-10-06T08:04:39Z
4 387 6.3 Porlock 3 6087.4 448 43.2 [51.208239, -3.672401] 6665361 34.8 OFFICIAL 100Climbs No4 Porlock [51.208793, -3.59699] 395.2 2016-10-06T08:04:39Z
5 2264 10.3 Minehead TA24, UK 2 3116.5 3569 130.2 [51.166943, -3.568937] 6665334 25.2 OFFICIAL 100Climbs No5 Dunkery Beacon [51.192036, -3.566595] 323.4 2016-10-06T08:04:39Z

Since querying the API takes time and there are rate limits on how often we can do this we'll make sure this new data is saved locally.

In [7]:
full_segment_info.to_csv('./top200climbs-full.csv')

I had originally planned to use all this information this information in the map however Strava provide a widget (or small piece of website) which summarises the climb in a nice an succinct fashion. We will however need to know where each climb starts (it's lat and lon).

A Little Fix (Skippable)

We want to embed a widget in our markers, which we can do in the form of an IFrame. An IFrame renders a website within a website in a smaller frame. For simplicity we'll use the Strava widget for each climb, like the one below.

The default class for the IFrame in the folium package includes big border and the ability to scroll. We want to disable that by default, so we define a new subclass and overwrite the render method:

In [8]:
import base64
# Check HTML on building blog
class InvisibleIFrame(folium.element.IFrame):

    def render(self, **kwargs):
        """Renders the HTML representation of the element."""
        html = super(folium.element.IFrame, self).render(**kwargs)
        html = "data:text/html;base64," + base64.b64encode(html.encode('utf8')).decode('utf8')

        if self.height is None:
            iframe = (
            '<div style="width:{width};">'
            '<div style="position:relative;width:100%;height:0;padding-bottom:{ratio};">'
            '<iframe src="{html}" style="position:absolute;width:100%;height:100%;left:0;top:0; "'
            'frameBorder="0" scrolling="no">'
            '</iframe>'
            '</div></div>').format
            iframe = iframe(html=html,
                            width=self.width,
                            ratio=self.ratio)
        else:
            iframe = ('<iframe src="{html}" width="{width}" '
                      'frameBorder="0" scrolling="no"'
                      'height="{height}"></iframe>').format
            iframe = iframe(html=html, width=self.width, height=self.height)
        return iframe

Taking the code from Github, we copy the method we want to overwrite. On lines 15 and 23 we've added the HTML to set the border size to 0, and disable scrolling. We leave the rest of the code as is.

Building the Map

We only want people looking at the UK, so we introduce some rough bounds on the lat/lon values.

In [9]:
# Bounds
min_lat, max_lat = 48.77, 60
min_lon, max_lon = -9.05, 5

We can now define our map object. The only other new argument here is min_zoom, which prevents people zooming out into space.

In [10]:
# Make the map
m = folium.Map(location=[54.584797 , -3.438721],
               tiles='Stamen Terrain', 
               zoom_start=6,
               min_lat=min_lat, 
               max_lat=max_lat,
               min_lon=min_lon, 
               max_lon=max_lon,
               max_zoom=18, 
               min_zoom=5)

Finally before the main loop we define a MarkerCluster object. This prevents too many markers from appearing at once and overlapping by grouping them up by location. You can then click on the cluster or zoom in to expand them.

In [11]:
# Define a cluster of markers.
mc = folium.MarkerCluster()

We're now ready to plot our climbs. For added style, I made a couple of custom markers too, combining the UK road signs for cyclists and steep gradients.


Our main loop is just like the basic example with a few more things thrown in: we define something to be displayed on a marker, create a marker, and add it to our marker cluster.

In [12]:
for ix, row in full_segment_info.iterrows():
    
    # The encodes the Strava segment widget
    html = r"""<center><iframe height='405' width='590' frameborder='0' 
               allowtransparency='true' scrolling='no' 
               src='https://www.strava.com/segments/{}/embed'></iframe></center>""".format
    html = html(row.id)
    iframe = InvisibleIFrame(html, width=600, height=370)
    
    # We create the popup thats going to appear when we click a marker.
    # Previously this was just text but now we're adding an IFrame.
    popup = folium.map.Popup(iframe, max_width=2650)
    
    # This defines our CustomIcon, using the icons I created
    if ix <= 100:
        icon_url='https://andrewmellor.co.uk/static/markerr_post.png'
    else:
        icon_url='https://andrewmellor.co.uk/static/markerb_post.png'
    icon = folium.features.CustomIcon(icon_image=icon_url,
                                      icon_size=(56,56),
                                      icon_anchor=(28,56))

    # We create our marker with our custom icon and popup 
    # at the location of the start of each climb
    lat, lon = row.start_latlng
    marker = folium.map.Marker([lat, lon], 
                               icon=icon,
                               popup=popup)
    
    # Add the marker to the cluster.
    mc.add_child(marker)

All that is left is to add the marker cluster to the map object.

In [13]:
m.add_child(mc);

Then we can display the map inline in the notebook.

In [14]:
m
Out[14]:

Finally, we can save the map as a .html file so we can access it outside of Python/Notebook (note that it won't work offline as it needs the leaflet.js scripts) or embed it into another website.

In [15]:
m.save(outfile='full-terrain.html')

...and we're done.

Having experimented with other Python packages to plot geographical data and struggled I was surprised at how easy, intuitive, and quick it was to create an interactive map and add data. Folium isn't just limited to markers either, there is support for geojson and other complex data structures which can be overlaid like weather forecasts.

Data visualisation at its simplest.



Comments

comments powered by Disqus