Apart from being a data scientist, I also spend a lot of time on my bike. It is therefore no surprise that I am a huge fan of all kinds of wearable devices. Lots of the times though, I get quite frustrated with the data processing and data visualizationsoftware that major providers of wearable devices offer. That’s why I have been trying to take things to my own hands. Recently I have started to play around with plotting my bike route from python using Google Maps API. My novice’s guide to all this follows in the post.
![How to plot your own bike/jogging route using Python and Google Maps API]()
Recently I was playing with my sports data and wanted to create aGoogle map with my bike ride like Garmin Connect or Strava is doing.
That let me to Google Map API , specifically to their javascript API. And since I was playing with my data in Python we’ll be creating the map from there.
But first things first. To get the positional data for some of my recent bike rides I downloaded a TCX file from Garmin Connect. Parsing the TCX is easy but more about that some other time. For now let me just show a basic Python 3.x snippet that parses latitude and longitude from my TCX file and stores them in a pandas data frame.
from lxml import objectify
import pandas as pd
# helper function to handle missing data in my file
def add_trackpoint(element, subelement, namespaces, default=None):
in_str = './/' + subelement
try:
return float(element.find(in_str, namespaces=namespaces).text)
except AttributeError:
return default
# activity file and namespace of the schema
tcx_file = 'activity_1485936178.tcx'
namespaces={'ns': 'http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2'}
# get activity tree
tree = objectify.parse(tcx_file)
root = tree.getroot()
activity = root.Activities.Activity
# run through all the trackpoints and store lat and lon data
trackpoints = []
for trackpoint in activity.xpath('.//ns:Trackpoint', namespaces=namespaces):
latitude_degrees = add_trackpoint(trackpoint, 'ns:Position/ns:LatitudeDegrees', namespaces)
longitude_degrees = add_trackpoint(trackpoint, 'ns:Position/ns:LongitudeDegrees', namespaces)
trackpoints.append((latitude_degrees,
longitude_degrees))
# store as dataframe
activity_data = pd.DataFrame(trackpoints, columns=['latitude_degrees', 'longitude_degrees'])
Now we can focus on the Google Map JavaScript. The documentation is really great so there is no point in rewriting it myself. This tutorial got me started. In a nutshell, I was about to create a html file that would source Google Map JavaScript API and use its syntax to create a map and plot the route on it.
Following javascript code initializes a new map.
var map;
function show_map() {{
map = new google.maps.Map(document.getElementById("map-canvas"), {{
zoom: {zoom},
center: new google.maps.LatLng({center_lat}, {center_lon}),
mapTypeId: 'terrain'
}});
What we need to solve is where to centre the map and what should be the zoom. The first task is easy as you can simply take an average of minimal and maximal latitude and longitude. Zoom is where things get a bit tricky.
Zoom is documented here plus I found an extremely useful answer on stackoverflow . The trick is to get the extreme coordinates of the route and deal with the Mercator projection Google Maps is using to get the zoom needed to show the whole route on one screen. This is done by functions _get_zoom and _lat_rad as shown further down in a code with Map class I used.
Once we have a map that is correctly centered and zoomed we can start plotting the route. This step is done by using simple polylines . Such polyline is initialised by following javascript code.
var activity_route = new google.maps.Polyline({{
path: activity_coordinates,
geodesic: true,
strokeColor: '#FF0000',
strokeOpacity: 1.0,
strokeWeight: 2
}});
Where activity_coordinates contains the coordinates of my route.
I wrapped all this into a Python class called Map that looks as follows
from __future__ import print_function
import math
class Map(object):
def __init__(self):
self._points = []
def add_point(self, coordinates):
"""
Adds coordinates to map
:param coordinates: latitude, longitude
:return:
"""
# add only points with existing coordinates
if not ((math.isnan(coordinates[0])) or (math.isnan(coordinates[1]))):
self._points.append(coordinates)
@staticmethod
def _lat_rad(lat):
"""
Helper function for _get_zoom()
:param lat:
:return:
"""
sinus = math.sin(math.radians(lat + math.pi / 180))
rad_2 = math.log((1 + sinus) / (1 - sinus)) / 2
return max(min(rad_2, math.pi), -math.pi) / 2
def _get_zoom(self, map_height_pix=900, map_width_pix=1900, zoom_max=21):
"""
Algorithm to derive zoom from the activity route. For details please see
- https://developers.google.com/maps/documentation/javascript/maptypes#WorldCoordinates
- http://stackoverflow.com/questions/6048975/google-maps-v3-how-to-calculate-the-zoom-level-for-a-given-bounds
:param zoom_max: maximal zoom level based on Google Map API
:return:
"""
# at zoom level 0 the entire world can be displayed in an area that is 256 x 256 pixels
world_heigth_pix = 256
world_width_pix = 256
# get boundaries of the activity route
max_lat = max(x[0] for x in self._points)
min_lat = min(x[0] for x in self._points)
max_lon = max(x[1] for x in self._points)
min_lon = min(x[1] for x in self._points)
# calculate longitude fraction
diff_lon = max_lon - min_lon
if diff_lon < 0:
fraction_lon = (diff_lon + 360) / 360
else:
fraction_lon = diff_lon / 360
# calculate latitude fraction
fraction_lat = (self._lat_rad(max_lat) - self._lat_rad(min_lat)) / math.pi
# get zoom for both latitude and longitude
zoom_lat = math.floor(math.log(map_height_pix / world_heigth_pix / fraction_lat) / math.log(2))
zoom_lon = math.floor(math.log(map_width_pix / world_width_pix / fraction_lon) / math.log(2))
return min(zoom_lat, zoom_lon, zoom_max)
def __str__(self):
"""
A Python wrapper around Google Map Api v3; see
- https://developers.google.com/maps/documentation/javascript/
- https://developers.google.com/maps/documentation/javascript/examples/polyline-simple
- http://stackoverflow.com/questions/22342097/is-it-possible-to-create-a-google-map-from-python
:return: string to be stored as html and opened in a