Interactive maps on Leaflet

Whenever you go into a website that has some kind of interactive map, it is quite probable that you are wittnessing a map that has been made with a JavaScipt library called Leaflet (the other popular one that you might have wittnessed is called OpenLayers).

There is also a Python module called Folium that makes it possible visualize data that’s been manipulated in Python on an interactive Leaflet map.

Creating a simple interactive web-map

Let’s first see how we can do a simple interactive web-map without any data on it. We just visualize OpenStreetMap on a specific location of the a world.

First thing that we need to do is to create a Map instance. There are few parameters that we can use to adjust how in our Map instance that will affect how the background map will look like. We should already be able to see what our map looks like. More details can be found in module API documentation.

In [1]: import folium

# Create a Map instance
In [2]: m = folium.Map(location=[58.37, 26.72], zoom_start=11, control_scale=True, prefer_canvas=True, width=600, height=450)

The first parameter location takes a pair of lat, lon values as list as an input which will determine where the map will be positioned when user opens up the map. zoom_start -parameter adjusts the default zoom-level for the map (the higher the number the closer the zoom is). control_scale defines if map should have a scalebar or not.

Now we can check how it looks, by either displaying it directly in the Jupyter Notebook:

# To display it in a Jupyter notebook, simply ask for the object representation
m

or by saving it into a html file which we can open in the browser:

# Filepath to the output
In [3]: outfp = "source/_static/img/folium_base_map.html"

# Save the map
In [4]: m.save(outfp)

It will now just show the basemap in such a way that we initialized it.

Take a look at the map by clicking it with right mouse and open it with Google Chrome which then opens it up in a web browser.

  • Let’s change the basemap style to Stamen Toner and change the location of our map slightly. The tiles -parameter is used for changing the background map provider and map style (see here for all possible ones).

# Let's change the basemap style to 'Stamen Toner'
In [5]: m = folium.Map(location=[58.37, 26.72], tiles='Stamen Toner', zoom_start=11, control_scale=True, prefer_canvas=True, width=600, height=450)
# To display it in a Jupyter notebook, simply ask for the object representation
m
  • And in order to save the file:

# Filepath to the output
In [6]: outfp = "source/_static/img/folium_base_map_toner.html"

# Save the map
In [7]: m.save(outfp)

Adding layers to the map

Adding layers to a web-map is fairly straightforward with Folium and similar procedure as with Bokeh and we can use familiar tools to handle the data, i.e. Geopandas. Our ultimate aim is to create a plot like this where population in Tartumaa, road network and the schools are plotted on top of a web-map.

First we need to prepare the data.

In [8]: import geopandas as gpd

In [9]: from fiona.crs import from_epsg

In [10]: from shapely.geometry import LineString, MultiLineString

 # Filepaths
In [11]: grid_fp = "source/_static/data/L6/population_square_km.shp"

In [12]: roads_fp = "source/_static/data/L6/roads.shp"

In [13]: schools_fp = "source/_static/data/L6/schools_tartu.shp"

 # Read files
In [14]: grid = gpd.read_file(grid_fp)

In [15]: roads = gpd.read_file(roads_fp)

In [16]: schools = gpd.read_file(schools_fp)

 # Re-project to WGS84, Folium requires all data to be in WGS84
In [17]: grid['geometry'] = grid['geometry'].to_crs(epsg=4326)

In [18]: roads['geometry'] = roads['geometry'].to_crs(epsg=4326)

In [19]: schools['geometry'] = schools['geometry'].to_crs(epsg=4326)

 # Make a selection (only data above 0 and below 1000)
In [20]: grid = grid.loc[(grid['Population'] > 0)]

 # Create a Geo-id which is needed by the Folium (it needs to have a unique identifier for each row)
In [21]: grid['geoid'] = grid.index.astype(str)

In [22]: roads['geoid'] = roads.index.astype(str)

In [23]: schools['geoid'] = schools.index.astype(str)

 # Select data
In [24]: grid = grid[['geoid', 'Population', 'geometry']]

In [25]: roads = roads[['geoid', 'TYYP', 'geometry']]

In [26]: schools = schools[['geoid', 'name', 'geometry']]

 # convert the dataframe to geojson
In [27]: grid_jsontxt = grid.to_json()

In [28]: roads_jsontxt = roads.to_json()

In [29]: schools_jsontxt = schools.to_json()

Now we have our data stored in the grid_jsontxt etc. variables as GeoJSON format which basically contains the data as text in a similar way that it would be written in a .geojson -file.

Now we can start visualizing our data with Folium.

In [30]: m = folium.Map(location=[58.37, 26.72], zoom_start=11, control_scale=True, prefer_canvas=True, width=600, height=450)

In [31]: folium.GeoJson(grid_jsontxt).add_to(m)
Out[31]: <folium.features.GeoJson at 0x29c39303408>

In [32]: folium.GeoJson(roads_jsontxt).add_to(m)
Out[32]: <folium.features.GeoJson at 0x29c393037c8>

In [33]: folium.GeoJson(schools_jsontxt).add_to(m)
Out[33]: <folium.features.GeoJson at 0x29c39a1c288>
# To display it in a Jupyter notebook, simply ask for the object representation
m
  • And in order to save the file:

# Filepath to the output
In [34]: outfp = "source/_static/img/folium_geojson_plain.html"

# Save the map
In [35]: m.save(outfp)

While we can see the geometries, shapes etc, it is not really a helpful map. The roads are barely visible, and the school point markers are fancy, but there are too many on top of each other.

So let’s prepare our visualisation step by step. At first we want to make a choropleth map. You remember, the classic map with coloured polygons based on an attribute value.

colormaps: https://nbviewer.jupyter.org/github/python-visualization/folium/blob/master/examples/Colormaps.ipynb

# create the base map
In [36]: m = folium.Map(location=[58.37, 26.72], tiles='Stamen terrain', zoom_start=8, control_scale=True, prefer_canvas=True, width=600, height=450)

# Create Choropleth map from the polygons where the colors are coming from a column "Population".
# Notice: 'geoid' column that we created earlier needs to be assigned always as the first column
# create a basic choropleth map, just polygons with some style information
In [37]: folium.Choropleth(
   ....:     geo_data=grid_jsontxt,
   ....:     fill_color='red',
   ....:     fill_opacity=0.3,
   ....:     line_weight=1,
   ....: ).add_to(m)
   ....: 
Out[37]: <folium.features.Choropleth at 0x29c39a70b08>

In [38]: folium.LayerControl(collapsed=True).add_to(m)
Out[38]: <folium.map.LayerControl at 0x29c39a707c8>
# To display it in a Jupyter notebook, simply ask for the object representation
m
  • And in order to save the file:

# Filepath to the output
In [39]: outfp = "source/_static/img/folium_choropleth_plain.html"

# Save the map
In [40]: m.save(outfp)

Lets take it a bit further, by classifiying the population column again with Natural Breaks from PySAL. Create Choropleth map where the colors are now related to the column “pop_km2”.

Notice, we also need the ‘geoid’ column that we created earlier. And it needs to be assigned always as the first column. And we are adding a LayerControl widget to the map, so we can activate/deactivate layers.

For more infoprmation and configuration examples, you can check Folium GeoJSON and Choropleth examples here.

In [41]: import pysal.viz.mapclassify as mc

# Initialize the classifier and apply it
In [42]: classifier = mc.NaturalBreaks.make(k=5)

In [43]: grid['pop_km2'] = grid[['Population']].apply(classifier)

In [44]: m = folium.Map(location=[58.37, 26.72],
   ....:             tiles='Stamen terrain',
   ....:             zoom_start=8,
   ....:             control_scale=True,
   ....:             prefer_canvas=True,
   ....:             width=600,
   ....:             height=450)
   ....: 

# Create Choropleth map where the colors are coming from a column "Population".
# Notice: 'geoid' column that we created earlier needs to be assigned always as the first column
In [45]: folium.Choropleth(
   ....:     geo_data=grid_jsontxt,
   ....:     data=grid,
   ....:     columns=['geoid', 'pop_km2'],
   ....:     key_on="feature.id",
   ....:     fill_opacity=0.5,
   ....:     line_opacity=0.2,
   ....:     line_color='white',
   ....:     line_weight=0,
   ....:     legend_name='Population classified Natural Breaks in Tartu',
   ....:     name='Population Grid',
   ....:     highlight=False,
   ....:     fill_color='RdBu'
   ....: ).add_to(m)
   ....: 
Out[45]: <folium.features.Choropleth at 0x29c4289e088>

# and we are adding a LayerControl widget to the map, so we can activate/deactivate the layer
In [46]: folium.LayerControl(collapsed=True).add_to(m)
Out[46]: <folium.map.LayerControl at 0x29c428a6a08>
# To display it in a Jupyter notebook, simply ask for the object representation
m
  • And in order to save the file:

# Filepath to the output
In [47]: outfp = "source/_static/img/folium_choropleth_nb1.html"

# Save the map
In [48]: m.save(outfp)

Now, we prepare the road lines:

# define the function to extract the linestring coordinates
In [49]: def getLinesAsPointList(row, geom):
   ....:     """Returns a list of coordinate pair tuples for the line ('lat', 'lon') of a LineString geometry"""
   ....:     if isinstance(row[geom], MultiLineString):
   ....:         return []
   ....:     else:
   ....:         list_x = list(row[geom].coords.xy[0])
   ....:         list_y = list(row[geom].coords.xy[1])
   ....:         return list(zip(list_y, list_x))
   ....: 
# Calculate x and y coordinates of the line
In [50]: roads['points_list'] = roads.apply(getLinesAsPointList, geom='geometry', axis=1)

# list of lat lon coordinate pair tuples
# roadpoints = [a for a in roads['points_list'].tolist() if len(a) >=2 ]
In [51]: roadpoints = []

In [52]: for a in roads['points_list'].tolist():
   ....:     if len(a) >=2:
   ....:         roadpoints.append(a)
   ....: 

In [53]: m = folium.Map(location=[58.37, 26.72],
   ....:             tiles='Stamen toner',
   ....:             zoom_start=8,
   ....:             control_scale=True,
   ....:             prefer_canvas=True,
   ....:             width=600,
   ....:             height=450)
   ....: 

In [54]: for road in roadpoints:
   ....:     folium.PolyLine(locations=road, color="red", weight=2.5, opacity=1).add_to(m)
   ....: 
# To display it in a Jupyter notebook, simply ask for the object representation
m
  • And in order to save the file:

# Filepath to the output
In [55]: outfp = "source/_static/img/folium_choropleth_roads.html"

# Save the map
In [56]: m.save(outfp)

And finally let’s see how we can put points on a map with a bit more control:

# define the function to extract the linestring coordinates
In [57]: from shapely.geometry import Point

In [58]: def getPoints(row, geom):
   ....:     """Returns coordinate pair tuples for the point ('lat', 'lon') of a Point geometry"""
   ....:     if isinstance(row[geom], Point):
   ....:         return (row[geom].y, row[geom].x)
   ....:     else:
   ....:         return ()
   ....: 

Then we create a fresh new map instance and add the schools programmatically:

In [59]: m = folium.Map(location=[58.37, 26.72],
   ....:             tiles='Stamen terrain',
   ....:             zoom_start=8,
   ....:             control_scale=True,
   ....:             prefer_canvas=True,
   ....:             width=600,
   ....:             height=450)
   ....: 

# Calculate x and y coordinates of the line
In [60]: schools['points_tuple'] = schools.apply(getPoints, geom='geometry', axis=1)

In [61]: for idx, school in schools.iterrows():
   ....:     folium.CircleMarker(location=school['points_tuple'], popup=school['name'], color="yellow", radius=2.5, opacity=0.9).add_to(m)
   ....: 
# To display it in a Jupyter notebook, simply ask for the object representation
m
  • And in order to save the file:

# Filepath to the output
In [62]: outfp = "source/_static/img/folium_better_circle.html"

# Save the map
In [63]: m.save(outfp)

This works ok. But now let’s try something new to reduce the clutter of many points. For this we add a “clustering” functionality, so that you see how many points are in an area, without seeing each point. When you zoom in, this display adapts and shows more spatial details. This way you can provide summary overviews and drill down to each point when desired.

In [64]: from folium.plugins import MarkerCluster

# Get lat and lon of points
In [65]: latlon = [[tup[0], tup[1]] for tup in schools['points_tuple'].tolist()]

In [66]: m = folium.Map(location=[58.37, 26.72],
   ....:             tiles='Stamen terrain',
   ....:             zoom_start=8,
   ....:             control_scale=True,
   ....:             prefer_canvas=True,
   ....:             width=600,
   ....:             height=450)
   ....: 

# This function creates clusters for the points that are in the same area
# and then places them on the map
In [67]: MarkerCluster(locations=latlon, fill_color='#2b8cbe', name="Schools", number_of_sides=6, radius=6).add_to(m)
Out[67]: <folium.plugins.marker_cluster.MarkerCluster at 0x29c43343b48>

# we also add a layer control to handle the clustered points as a single layer.
In [68]: folium.LayerControl().add_to(m)
Out[68]: <folium.map.LayerControl at 0x29c4330c8c8>
# To display it in a Jupyter notebook, simply ask for the object representation
m
  • And in order to save the file:

# Filepath to the output
In [69]: outfp = "source/_static/img/folium_marker_cluster.html"

# Save the map
In [70]: m.save(outfp)

We can also visualise dense point concentrations on a map with a heatmap. Folium provides various plugins for extended functionality

In [71]: from folium.plugins import HeatMap

In [72]: import numpy as np

# you can use weights for the heatmap, in order to make points more important. To demonstrate I use random values, though.
In [73]: random_weights = np.random.randint(low=1, high=10, size=len(schools))

# we add lat, lon, and also weights, into each data point tuple
In [74]: heat_data = []

# Get lat and lon of points, you can do that with or without weights
In [75]: for idx, row in schools.iterrows():
   ....:     tup = row['points_tuple']
   ....:     elem = [tup[0], tup[1], int(random_weights[idx])]
   ....:     heat_data.append(elem)
   ....: 

# create the base map
In [76]: m = folium.Map(location=[58.37, 26.72],
   ....:             tiles='Stamen toner',
   ....:             zoom_start=8,
   ....:             control_scale=True,
   ....:             prefer_canvas=True,
   ....:             width=600,
   ....:             height=450)
   ....: 

# This function creates the heatmap based on the points and weights that are in close area
# and then places them on the map
In [77]: HeatMap(data=heat_data,
   ....:         name="schools density",
   ....:         min_opacity=0.5,
   ....:         max_zoom=18,
   ....:         max_val=1.0,
   ....:         radius=25,
   ....:         blur=15,
   ....:         overlay=True,
   ....:         control=True).add_to(m)
   ....: 
Out[77]: <folium.plugins.heat_map.HeatMap at 0x29c43405c48>

# and we add the layer control
In [78]: folium.LayerControl().add_to(m)
Out[78]: <folium.map.LayerControl at 0x29c43401a08>
# To display it in a Jupyter notebook, simply ask for the object representation
m
  • And in order to save the file:

# Filepath to the output
In [79]: outfp = "source/_static/img/folium_heatmap.html"

# Save the map
In [80]: m.save(outfp)

For mor edetailed API information, consult the docs.

And now we can put it all together in one map:

# basemap
In [81]: m = folium.Map(location=[58.37, 26.72],
   ....:             tiles='Stamen toner',
   ....:             zoom_start=8,
   ....:             control_scale=True,
   ....:             prefer_canvas=True,
   ....:             width=600,
   ....:             height=450)
   ....: 

# coloured polygon layer
In [82]: folium.Choropleth(
   ....:     geo_data=grid_jsontxt,
   ....:     data=grid,
   ....:     columns=['geoid', 'pop_km2'],
   ....:     key_on="feature.id",
   ....:     fill_color='RdBu',
   ....:     fill_opacity=0.5,
   ....:     line_opacity=0.2,
   ....:     line_color='white',
   ....:     line_weight=0,
   ....:     legend_name='Population in Tartu',
   ....:     name='Population Grid',
   ....:     highlight=False
   ....: ).add_to(m)
   ....: 
Out[82]: <folium.features.Choropleth at 0x29c43358488>

# heatmap layer
In [83]: HeatMap(data=heat_data,
   ....:         name="schools density",
   ....:         min_opacity=0.5,
   ....:         max_zoom=18,
   ....:         max_val=1.0,
   ....:         radius=25,
   ....:         blur=15,
   ....:         overlay=True,
   ....:         control=True).add_to(m)
   ....: 
Out[83]: <folium.plugins.heat_map.HeatMap at 0x29c43358d48>

# initalise a road layer holing object
In [84]: roads_layer = folium.FeatureGroup(name="roads layer")

# add the roads to the intermediate layer object, and not directly to the map
In [85]: for road in roadpoints:
   ....:     folium.PolyLine(locations=road, color="grey", weight=2.5, opacity=1).add_to(roads_layer)
   ....: 

# then we add the roads layer to the map
In [86]: roads_layer.add_to(m)
Out[86]: <folium.map.FeatureGroup at 0x29c4342b288>

# This function creates clusters for the points that are in the same area
In [87]: marker_cluster = MarkerCluster(name="Schools marker cluster", number_of_sides=6, radius=6)

# and then places them in the marker cluster
In [88]: for idx, school in schools.iterrows():
   ....:     folium.Marker(location=school['points_tuple'],
   ....:                         popup=school['name'],
   ....:                         color="yellow",
   ....:                         radius=5,
   ....:                         opacity=0.9).add_to(marker_cluster)
   ....: 

# and add the marker cluster to the map
In [89]: marker_cluster.add_to(m)
Out[89]: <folium.plugins.marker_cluster.MarkerCluster at 0x29c43358fc8>

# create another layer object for the circle markers
In [90]: circles_layer = folium.FeatureGroup(name="circles layer")

# the yellow school circles as reference
In [91]: for idx, school in schools.iterrows():
   ....:     folium.CircleMarker(location=school['points_tuple'],
   ....:                         popup=school['name'],
   ....:                         color="yellow",
   ....:                         radius=2.5,
   ....:                         opacity=0.9).add_to(circles_layer)
   ....: 

# and add the circle layer to the map
In [92]: circles_layer.add_to(m)
Out[92]: <folium.map.FeatureGroup at 0x29c43a59148>

# add the layer control switch, which can now control the separate layer holding objects for the single points and roads
In [93]: folium.LayerControl().add_to(m)
Out[93]: <folium.map.LayerControl at 0x29c43b19b88>

That’s it! Now we have a cool interactive map with markers, clustered markers, roads, a heatmap and a choropleth grid showing the population in the Tartumaa Region on top of a basemap. You can save it and open it with your browser and see the result.

# To display it in a Jupyter notebook, simply ask for the object representation
m
  • And in order to save the file:

# Filepath to the output
In [94]: outfp = "source/_static/img/folium_full_map.html"

# Save the map
In [95]: m.save(outfp)

Launch in the web/MyBinder:

https://mybinder.org/badge_logo.svg