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.

The Folium Python package creates a Leaflet JavaScript webmap. In the document here, several of these Leaflet maps were live generated. Having several JavaScript interactions from Leaflet might cause some maps to not show correctly. This is a known issue, but for the sake of reproducible code listed for your learning experience we take this disadvantage :-)

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.

import folium

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

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 explicitly with the display() function:

display(m)
Make this Notebook Trusted to load map: File -> Trust Notebook

You can also save it into a html file which we can open in the browser:

# Filepath to the output 
outfp = "../files/data/L6/folium_base_map.html"

# Save the map
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 CartoDB Positron 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'
m = folium.Map(location=[58.37, 26.72], tiles="cartodb positron" ,
    zoom_start=11, control_scale=True, prefer_canvas=True, width=600,
    height=400)

display(m)
Make this Notebook Trusted to load map: File -> Trust Notebook
Tip

Task

Play around with the parameters and save the map and see how those changes affect the look of the map.

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.

import geopandas as gpd
from shapely.geometry import LineString, MultiLineString
import os

# Filepaths
folder = "../files/data/L6"

grid_fp = os.path.join(folder, "population_square_km.shp")
roads_fp = os.path.join(folder, "roads.shp")
schools_fp = os.path.join(folder, "schools_tartu.shp")

# Read files
grid = gpd.read_file(grid_fp)
roads = gpd.read_file(roads_fp)
schools = gpd.read_file(schools_fp)

# Re-project to WGS84, Folium requires all data to be in WGS84
grid = grid.to_crs(epsg=4326)
roads = roads.to_crs(epsg=4326)
schools = schools.to_crs(epsg=4326)

# Make a selection (only data above 0 and below 1000)
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)
grid['geoid'] = grid.index.astype(str)
roads['geoid'] = roads.index.astype(str)
schools['geoid'] = schools.index.astype(str)

# Select data
grid = grid[['geoid', 'Population', 'geometry']]
roads = roads[['geoid', 'TYYP', 'geometry']]
schools = schools[['geoid', 'name', 'geometry']]

# convert the dataframe to geojson
grid_jsontxt = grid.to_json()
roads_jsontxt = roads.to_json()
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.

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

folium.GeoJson(grid_jsontxt).add_to(m)
folium.GeoJson(roads_jsontxt).add_to(m)
folium.GeoJson(schools_jsontxt).add_to(m)

display(m)
Make this Notebook Trusted to load map: File -> Trust Notebook

Let’s again save the file:

# Filepath to the output
outfp = "../files/data/L6/folium_geojson_plain.html"

# Save the map
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.

There are some example Folium notebooks provided by the developers, e.g. for Colormaps:

# create a default tiles base map
m = folium.Map(location=[58.37, 26.72],
               zoom_start=8, control_scale=True,
               prefer_canvas=True, width=600, height=400)

# Create Choropleth map from the polygons where the colors are coming from a column "Population"
# Notice: the '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

folium.Choropleth( geo_data=grid_jsontxt, fill_color='red', fill_opacity=0.3, line_weight=1, ).add_to(m)

folium.LayerControl(collapsed=True).add_to(m)

display(m)
Make this Notebook Trusted to load map: File -> Trust Notebook

And in order to save the file:

# Filepath to the output
outfp = "../files/data/L6/folium_choropleth_plain.html"

# Save the map
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.

import mapclassify as mc

# Initialize the classifier and apply it
classifier = mc.NaturalBreaks.make(k=5)

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

m = folium.Map(location=[58.37, 26.72], tiles='OpenStreetMap',
               zoom_start=8, control_scale=True,
               prefer_canvas=True, width=600, height=400)

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)

# and we are adding a LayerControl widget to the map, so we can
# activate/deactivate the layer
folium.LayerControl(collapsed=True).add_to(m)

display(m)
Make this Notebook Trusted to load map: File -> Trust Notebook

Now, we prepare the road lines:

# define the function to extract the linestring coordinates
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):
        # lets ignore multiline strings for now
        return []
    else:
        list_x = list(row[geom].coords.xy[0])
        list_y = list(row[geom].coords.xy[1])
        # we need lat lon order for the folium map!
        return list(zip(list_y, list_x))
# Calculate x and y coordinates of the line
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 ]

roadpoints = []

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

m = folium.Map(location=[58.37, 26.72],
               tiles='OpenStreetMap', zoom_start=8, control_scale=True,
               prefer_canvas=True, width=600, height=400)

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 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
from shapely.geometry import Point

def getPoints(row, geom):
    """Returns coordinate pair tuples for the point ('lat', 'lon') of a Point geometry"""
    if isinstance(row[geom], Point):
        # we need lat lon order for the folium map!!!
        return (row[geom].y, row[geom].x)
    else:
        return ()

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

m = folium.Map(location=[58.37, 26.72], tiles='Cartodb dark_matter',
               zoom_start=8, control_scale=True,
               prefer_canvas=True, width=600, height=400)

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

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)
display(m)

# Filepath to the output
outfp = "../files/data/L6/folium_better_circle.html"

# Save the map
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.

from folium.plugins import MarkerCluster

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

m = folium.Map(location=[58.37, 26.72],
               tiles='OpenStreetMap', zoom_start=8, control_scale=True,
               prefer_canvas=True, width=600, height=400)

# This function creates clusters for the points that are in the same area
# and then places them on the map
MarkerCluster(locations=latlon, fill_color='#2b8cbe',
              name="Schools", number_of_sides=6, radius=6).add_to(m)

# we also add a layer control to handle the clustered points as a
# single layer.
folium.LayerControl().add_to(m)

display(m)
Make this Notebook Trusted to load map: File -> Trust Notebook

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

from folium.plugins import HeatMap
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.
random_weights = np.random.randint(low=1, high=10, size=len(schools))

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

# Get lat and lon of points, you can do that with or without weights
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
m = folium.Map(location=[58.37, 26.72], tiles='OpenStreetMap',
               zoom_start=8, control_scale=True,
               prefer_canvas=True, width=600, height=400)

# This function creates the heatmap based on the points and weights
# that are in close area and then places them on the map
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)

# and we add the layer control
folium.LayerControl().add_to(m)

display(m)
/tmp/ipykernel_16998/3480995842.py:25: UserWarning: The `max_val` parameter is no longer necessary. The largest intensity is calculated automatically.
  HeatMap(data=heat_data, name="schools density", min_opacity=0.5,
Make this Notebook Trusted to load map: File -> Trust Notebook

For mor edetailed API information, consult the docs.

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

# basemap
m = folium.Map(location=[58.37, 26.72], tiles='OpenStreetMap',
               zoom_start=8, control_scale=True, prefer_canvas=True,
               width=600, height=400)

# coloured polygon layer
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)

# heatmap layer
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)

# initalise a road layer holding object
roads_layer = folium.FeatureGroup(name="roads layer")

# add the roads to the intermediate layer object, and not directly to the map
# 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
roads_layer.add_to(m)

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

# and then places them in the marker cluster
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
marker_cluster.add_to(m)

# create another layer object for the circle markers
circles_layer = folium.FeatureGroup(name="circles layer")

# the yellow school circles as reference
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
circles_layer.add_to(m)

# add the layer control switch, which can now control the separate
# layer holding objects for the single points and roads
folium.LayerControl().add_to(m)
/tmp/ipykernel_16998/1534887674.py:15: UserWarning: The `max_val` parameter is no longer necessary. The largest intensity is calculated automatically.
  HeatMap(data=heat_data, name="schools density",
<folium.map.LayerControl at 0x7f61782d4fd0>

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.

display(m)
Make this Notebook Trusted to load map: File -> Trust Notebook

And save the file:

# Filepath to the output
outfp = "../files/data/L6/folium_full_map.html"

# Save the map
m.save(outfp)

For more example notebooks check the GitHub notebook examples:

Download the notebook:

file:interactive-map-folium.ipynb

Launch in the web/MyBinder:

image