Interactive maps with Bokeh¶
Our goal in this lesson is to learn few concepts how we can produce nice looking interactive maps using Geopandas and Bokeh.
Simple interactive point plot¶
First, we learn the basic logic of plotting in Bokeh by making a simple interactive plot with few points.
Import necessary functionalities from bokeh.
from bokeh.plotting import figure, output_file, output_notebook, show
First we need to initialize our plot by calling the figure
object.
# Initialize the plot (p) and give it a title
In [1]: p = figure(title="My first interactive plot!")
# Let's see what it is
p
Next we need to create lists of x and y coordinates that we want to plot.
# Create a list of x-coordinates
In [2]: x_coords = [0,1,2,3,4]
# Create a list of y-coordinates
In [3]: y_coords = [5,4,1,2,0]
Note
In Bokeh drawing points, lines or polygons are always done using list(s) of x and y coordinates.
Now we can plot those as points using a .circle()
-object. Let’s give it a red color and size of 10.
# Plot the points
p.circle(x=x_coords, y=y_coords, size=10, color="red")
Finally, we can save our interactive plot into the disk with output_file
-function that we imported in the beginning. All interactive plots are typically saved as html
files which you can open in a web-browser.
# Give output filepath
outfp = r"L5_Data\points.html"
# Save the plot by passing the plot -object and output path
output_file(outfp)
show(p)
Now open your interactive points.html
plot by double-clicking it which should open it in a web browser.
This is how it should look like:
It is interactive. You can drag the plot by clicking with left mouse and dragging. There are also specific buttons on the right side of the plot by default which you can select on and off:
Pan button allows you to switch the dragging possibility on and off (on by default).
BoxZoom button allows you to zoom into an area that you define by left dragging with mouse an area of your interest.
Save button allows you to save your interactive plot as a static low resolution .png
file.
WheelZoom button allows you to use mouse wheel to zoom in and out.
Reset button allows you to use reset the plot as it was in the beginning.
Creating interactive maps using Bokeh and Geopandas¶
If some of us run into some library incompatibilities, then following actions may resolve the problem. Uninstall and re-install Bokeh and the offending underlying Pillow library:
# make sure you are in the anaconda prompt with your geopython-environment activated
conda uninstall pillow bokeh
conda install bokeh pillow=5.2.0
Now we now khow how to make a really simple interactive point plot using Bokeh. What about creating such a map from a Shapefile of points? Of course we can do that, and we can use Geopandas for achieving that goal which is nice!
Creating an interactive Bokeh map from Shapefile(s) contains typically following steps:
- Read the Shapefile into GeoDataFrame
- Calculate the x and y coordinates of the geometries into separate columns
- Convert the GeoDataFrame into a Bokeh DataSource
- Plot the x and y coordinates as points, lines or polygons (which are in Bokeh words: circle, multi_line and patches)
Let’s practice these things and see how we can first create an interactive point map, then a map with lines, and finally a map with polygons where we also add those points and lines into our final map.
Point map¶
Let’s first make a map out schools address points in Tartumaa. That Shapefile is provided for you in the data folder that you downloaded.
Read the data using geopandas which is the first step.
import geopandas as gpd
# File path
points_fp = r"L5_Data\schools_tartu.shp"
# Read the data
points = gpd.read_file(points_fp)
Let’s see what we have.
In [4]: points.head()
Out[4]:
id name \
0 13376 Aakre Lasteaed-Algkool
1 13290 Alatskivi Lasteaed
2 13396 Anna Haava nim Pala Kool
3 13202 Elva Gümnaasium
4 13298 Elva Huviala-ja Kultuurikeskus Sinilind
Aadress X \
0 Valga maakond, Puka vald, Aakre küla, Mõisa te... 629862.0000
1 Tartu maakond, Alatskivi vald, Alatskivi alevi... 682186.9651
2 Jõgeva maakond, Pala vald, Pala küla, Koolimaja 675030.0000
3 Tartu maakond, Elva linn, Tartu mnt 3 641493.0000
4 Tartu maakond, Elva linn, Kesk tn 30 641973.9625
y geometry
0 6441779.000 POINT (629862 6441779)
1 6499629.999 POINT (682186.9651 6499629.999)
2 6507478.000 POINT (675030 6507478)
3 6456152.000 POINT (641493 6456152)
4 6456196.951 POINT (641973.9625 6456196.951)
Okey, so we have several columns plus the geometry column as attributes.
Now, as a second step, we need to calculate the x and y coordinates of those points. Unfortunately there is not a ready made function in geopandas to do that.
Thus, let’s create our own function called getPointCoords()
which will return the x or y coordinate of a given geometry. It shall have two parameters: geom
and coord_type
where the first one should be a Shapely geometry object and coord_type should be either 'x'
or 'y'
.
def getPointCoords(row, geom, coord_type):
"""Calculates coordinates ('x' or 'y') of a Point geometry"""
if coord_type == 'x':
return row[geom].x
elif coord_type == 'y':
return row[geom].y
Okey great. Let’s then use our function in a similar manner as we did before when classifying data using .apply()
function.
# Calculate x coordinates
In [5]: points['x'] = points.apply(getPointCoords, geom='geometry', coord_type='x', axis=1)
# Calculate y coordinates
In [6]: points['y'] = points.apply(getPointCoords, geom='geometry', coord_type='y', axis=1)
# Let's see what we have now
In [7]: points.head()
Out[7]:
id name \
0 13376 Aakre Lasteaed-Algkool
1 13290 Alatskivi Lasteaed
2 13396 Anna Haava nim Pala Kool
3 13202 Elva Gümnaasium
4 13298 Elva Huviala-ja Kultuurikeskus Sinilind
Aadress X \
0 Valga maakond, Puka vald, Aakre küla, Mõisa te... 629862.0000
1 Tartu maakond, Alatskivi vald, Alatskivi alevi... 682186.9651
2 Jõgeva maakond, Pala vald, Pala küla, Koolimaja 675030.0000
3 Tartu maakond, Elva linn, Tartu mnt 3 641493.0000
4 Tartu maakond, Elva linn, Kesk tn 30 641973.9625
y geometry x
0 6441779.000 POINT (629862 6441779) 629862.0000
1 6499629.999 POINT (682186.9651 6499629.999) 682186.9651
2 6507478.000 POINT (675030 6507478) 675030.0000
3 6456152.000 POINT (641493 6456152) 641493.0000
4 6456196.951 POINT (641973.9625 6456196.951) 641973.9625
Okey great! Now we have the x and y columns in our GeoDataFrame.
The third step, is to convert our DataFrame into a format that Bokeh can understand. Thus, we will convert our DataFrame into ColumnDataSource which is a Bokeh-specific way of storing the data.
Note
Bokeh ColumnDataSource
does not understand Shapely geometry -objects. Thus, we need to remove the geometry
-column before convert our DataFrame into a ColumnDataSouce.
Let’s make a copy of our points GeoDataFrame where we drop the geometry column.
# Make a copy and drop the geometry column
In [8]: p_df = points.drop('geometry', axis=1).copy()
# See head
In [9]: p_df.head(2)
Out[9]:
id name \
0 13376 Aakre Lasteaed-Algkool
1 13290 Alatskivi Lasteaed
Aadress X \
0 Valga maakond, Puka vald, Aakre küla, Mõisa te... 629862.0000
1 Tartu maakond, Alatskivi vald, Alatskivi alevi... 682186.9651
y x
0 6441779.000 629862.0000
1 6499629.999 682186.9651
Now we can convert that pandas DataFrame into a ColumnDataSource.
In [10]: from bokeh.models import ColumnDataSource
# Point DataSource
In [11]: psource = ColumnDataSource(p_df)
# What is it?
psource
Okey, so now we have a ColumnDataSource object that has our data stored in a way that Bokeh wants it.
Finally, we can make a Point map of those points in a fairly similar manner as in the first example. Now instead of passing the coordinate lists,
we can pass the data as a source
for the plot with column names containing those coordinates.
# Initialize our plot figure
p = figure(title="A map of school location points from a Shapefile")
# Add the points to the map from our 'psource' ColumnDataSource -object
p.circle('x', 'y', source=psource, color='red', size=10)
Great it worked. Now the last thing is to save our map as html file into our computer.
# Output filepath
outfp = r"L5_Data\point_map.html"
# Save the map
output_file(outfp)
show(p)
Now you can open your point map in the browser. Your map should look like following:
Adding interactivity to the map¶
In Bokeh there are specific set of plot tools that you can add to the plot. Actually all the buttons that you see on the right side of the plot are exactly such tools. It is e.g. possible to interactively show information about the plot objects to the user when placing mouse over an object as you can see from the example on top of this page. The tool that shows information from the plot objects is an inspector called HoverTool that annotate or otherwise report information about the plot, based on the current cursor position.
Let’s see now how this can be done.
First we need to import the HoverTool from bokeh.models
that includes .
In [12]: from bokeh.models import HoverTool
Next, we need to initialize our tool.
In [13]: my_hover = HoverTool()
Then, we need to tell to the HoverTool that what information it should show to us. These are defined with tooltips like this:
In [14]: my_hover.tooltips = [('Address of the point', '@address')]
From the above we can see that tooltip should be defined with a list of tuple(s) where the first item is the name or label for the information that will be shown, and the second item
is the column-name where that information should be read in your data. The @
character in front of the column-name is important because it tells that the information should be taken
from a column named as the text that comes after the character.
Lastly we need to add this new tool into our current plot.
In [15]: p.add_tools(my_hover)
Great! Let’s save this enhanced version of our map as point_map_hover.html
and see the result.
# File path
outfp = r"L5_Data\point_map_hover.html"
output_file(outfp)
show(p)
As you can see now the plot shows information about the points and the content is the information derived from column address.
Hint
Of course, you can show information from multiple columns at the same time. This is achieved simply by adding more tooltip variables when defining the tooltips, such as:
my_hover2.tooltips = [('Label1', '@col1'), ('Label2', '@col2'), ('Label3', '@col3')]
Line map¶
Okey, now we have made a nice point map out of a Shapefile. Let’s see how we can make an interactive map out of a Shapefile that represents roads lines in Tartumaa. We follow the same steps than before, i.e. 1) read the data, 2) calculate x and y coordinates, 3) convert the DataFrame into a ColumnDataSource and 4) make the map and save it as html.
Read the data using geopandas which is the first step.
import geopandas as gpd
# File path
roads_fp = r"L5_Data\roads.shp"
# Read the data
roads = gpd.read_file(roads_fp)
Let’s see what we have.
In [16]: roads.head()
Out[16]:
TYYP geometry
0 Kõrvalmaantee LINESTRING (628395.967023925 6437374.940890101...
1 Kõrvalmaantee LINESTRING (627016.0000000595 6438859.77045114...
2 Kõrvalmaantee LINESTRING (630462.5180000596 6439009.70945115...
3 Põhimaantee LINESTRING (630059.6421779364 6438844.37815871...
4 Kõrvalmaantee LINESTRING (630092.690000058 6439015.520451145...
Okey, so we have a road type plus the geometry column as attributes.
Second step is where calculate the x and y coordinates of the nodes of our lines.
Let’s create our own function called getLineCoords()
in a similar manner as previously but now we need to modify it a bit so that we can get coordinates out of the Shapely LineString object.
from shapely.geometry import LineString, MultiLineString
def getLineCoords(row, geom, coord_type):
"""Returns a list of coordinates ('x' or 'y') of a LineString geometry"""
if isinstance(row[geom], MultiLineString):
empty_l = []
return empty_l
else:
if coord_type == 'x':
return list( row[geom].coords.xy[0] )
elif coord_type == 'y':
return list( row[geom].coords.xy[1] )
Note
Wondering about what happens here? Take a tour to our earlier materials about LineString attributes.
By default Shapely returns the coordinates as a numpy array of the coordinates. Bokeh does not understand arrays, hence we need to convert the array into a list which is why we
apply list()
-function.
Let’s now apply our function in a similar manner as previously.
# Calculate x coordinates of the line
In [17]: roads['x'] = roads.apply(getLineCoords, geom='geometry', coord_type='x', axis=1)
# Calculate y coordinates of the line
In [18]: roads['y'] = roads.apply(getLineCoords, geom='geometry', coord_type='y', axis=1)
# Let's see what we have now
In [19]: roads.head()
Out[19]:
TYYP geometry \
0 Kõrvalmaantee LINESTRING (628395.967023925 6437374.940890101...
1 Kõrvalmaantee LINESTRING (627016.0000000595 6438859.77045114...
2 Kõrvalmaantee LINESTRING (630462.5180000596 6439009.70945115...
3 Põhimaantee LINESTRING (630059.6421779364 6438844.37815871...
4 Kõrvalmaantee LINESTRING (630092.690000058 6439015.520451145...
x \
0 [628395.967023925, 628230.7000000704, 628170.1...
1 [627016.0000000595, 626942.160000056]
2 [630462.5180000596, 630496.7935589477]
3 [630059.6421779364, 630092.690000058]
4 [630092.690000058, 630462.5180000596]
y
0 [6437374.940890101, 6437599.020451155, 6437663...
1 [6438859.770451147, 6438884.15045115]
2 [6439009.709451153, 6438938.423092961]
3 [6438844.378158718, 6439015.520451145]
4 [6439015.520451145, 6439009.709451153]
Yep, now we have the x and y columns in our GeoDataFrame.
The third step. Convert the DataFrame (without geometry column) into a ColumnDataSource which, as you remember, is a Bokeh-specific way of storing the data.
# Make a copy and drop the geometry column
In [20]: m_df = roads.drop('geometry', axis=1).copy()
# Point DataSource
In [21]: msource = ColumnDataSource(m_df)
Finally, we can make a map of the roads line and save it in a similar manner as earlier but now instead of plotting circle
we need to use a .multiline()
-object. Let’s define the line_width
to be 3.
# Initialize our plot figure
p = figure(title="A map of Tartumaa Roads")
# Add the lines to the map from our 'msource' ColumnDataSource -object
p.multi_line('x', 'y', source=msource, color='red', line_width=3)
# Output filepath
outfp = "L5_Data\roads_map.html"
# Save the map
output_file(outfp)
show(p)
Now you can open your point map in the browser and it should look like following:
Todo
Task:
As you can see we didn’t apply HoverTool for the plot. Try to apply it yourself and use a column called TYYP
from our data as the information.
Polygon map with Points and Lines¶
It is of course possible to add different layers on top of each other. Let’s visualize a map showing Population per km2 in Tartumaa compared to Road network in Tartu, and locations of schools.
1st step: Import necessary modules and read the Shapefiles.
from bokeh.plotting import figure, show, output_file, output_notebook
from bokeh.models import ColumnDataSource, HoverTool, LogColorMapper
import geopandas as gpd
import pysal as ps
# File paths
grid_fp = r"L5_Data\population_square_km.shp"
roads_fp = r"L5_Data\roads.shp"
schools_fp = r"L5_Data\schools_tartu.shp"
# Read files
grid = gpd.read_file(grid_fp)
roads = gpd.read_file(roads_fp)
schools = gpd.read_file(schools_fp)
As usual, we need to make sure that the coordinate reference system is the same in every one of the layers. Let’s use the CRS of the grid layer and apply it to our schools and roads line.
# Get the CRS of our grid
In [22]: CRS = grid.crs
In [23]: print(CRS)
{'lon_0': 24, 'y_0': 6375000, 'lat_0': 57.51755393055556, 'x_0': 500000, 'no_defs': True, 'proj': 'lcc', 'units': 'm', 'lat_2': 59.33333333333334, 'ellps': 'GRS80', 'lat_1': 58}
# Convert the geometries of roads line and schools into that one
schools['geometry'] = schools['geometry'].to_crs(crs=CRS)
roads['geometry'] = roads['geometry'].to_crs(crs=CRS)
Okey now, the geometries should have similar values:
In [24]: schools['geometry'].head(1)
Out[24]:
0 POINT (629862.0000000078 6441778.999999761)
Name: geometry, dtype: object
In [25]: roads['geometry'].head(1)