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. .. code:: python from bokeh.plotting import figure, output_file, show from bokeh.io import output_notebook output_notebook() .. ipython:: python :suppress: from bokeh.plotting import figure, output_file, show First we need to initialize our plot by calling the ``figure`` object. .. ipython:: python # Initialize the plot (p) and give it a title p = figure(title="My first interactive plot!") .. code:: # Let's see what it is p Next we need to create lists of x and y coordinates that we want to plot. .. ipython:: python # Create a list of x-coordinates x_coords = [0,1,2,3,4] # Create a list of y-coordinates 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. .. code:: # Plot the points p.circle(x=x_coords, y=y_coords, size=10, color="red") .. ipython:: python :suppress: # 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. .. code:: python # open the plot html, will open a new tab show(p) .. ipython:: python # Save the plot by passing the plot -object and output path outfp = "points.html" output_file(outfp) 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: .. raw:: html Bokeh Plot
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: .. image:: ../_static/img//Pan.png :align: left :scale: 75 % **Pan button** allows you to switch the dragging possibility on and off (on by default). .. image:: ../_static/img//BoxZoom.png :align: left :scale: 75 % **BoxZoom button** allows you to zoom into an area that you define by left dragging with mouse an area of your interest. .. image:: ../_static/img//Save.png :align: left :scale: 75 % **Save button** allows you to save your interactive plot as a static low resolution ``.png`` file. .. image:: ../_static/img//WheelZoom.png :align: left :scale: 75 % **WheelZoom button** allows you to use mouse wheel to zoom in and out. .. image:: ../_static/img//Reset.png :align: left :scale: 75 % **Reset button** allows you to use reset the plot as it was in the beginning. Creating interactive maps using Bokeh and Geopandas --------------------------------------------------- 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: 1. **Read the Shapefile into GeoDataFrame** 2. **Calculate the x and y coordinates of the geometries into separate columns** 3. **Convert the GeoDataFrame into a Bokeh DataSource** 4. **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. .. ipython:: python import geopandas as gpd # File path points_fp = "source/_static/data/L6/schools_tartu.shp" # Read the data points = gpd.read_file(points_fp) Let's see what we have. .. ipython:: python points.head() 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'``. .. ipython:: python 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 Let's then use our function using ``.apply()``. .. ipython:: python # Calculate x coordinates points['x'] = points.apply(getPointCoords, geom='geometry', coord_type='x', axis=1) # Calculate y coordinates points['y'] = points.apply(getPointCoords, geom='geometry', coord_type='y', axis=1) # Let's see what we have now points.head() 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. .. ipython:: python # Make a copy and drop the geometry column p_df = points.drop('geometry', axis=1).copy() # See head p_df.head(2) Now we can convert that pandas DataFrame into a ColumnDataSource. .. ipython:: python from bokeh.models import ColumnDataSource # Point DataSource psource = ColumnDataSource(p_df) .. code:: # 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. .. ipython:: python # 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) .. code:: python # open the plot html, will open a new tab show(p) And now the last thing is to save our map as html file into our computer. .. ipython:: python # Output filepath outfp = "point_map.html" # Save the map output_file(outfp) Now you can open your point map in the browser. Your map should look like following: .. raw:: html Bokeh Plot
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 . .. ipython:: python from bokeh.models import HoverTool Next, we need to initialize our tool. .. ipython:: python 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: .. ipython:: python my_hover.tooltips = [('Name of the School', '@name')] 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. .. ipython:: python p.add_tools(my_hover) .. code:: python # open the plot html, will open a new tab show(p) Great! Let's save this enhanced version of our map as ``point_map_hover.html`` and see the result. .. ipython:: python outfp = "point_map_hover.html" output_file(outfp) .. raw:: html Bokeh Plot
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: .. code:: python 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. .. ipython:: python import geopandas as gpd # File path roads_fp = "source/_static/data/L6/roads.shp" # Read the data roads = gpd.read_file(roads_fp) Let's see what we have. .. ipython:: python roads.head() 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. .. ipython:: python from shapely.geometry import LineString, MultiLineString def getLineCoords(row, geom, coord_type): 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 <../L1&Geometric-Objects.html#linestring-attributes-and-functions>`_. 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 the Python built/in ``list()`` -function. Let's now apply our function in a similar manner as previously. .. ipython:: python # Calculate x coordinates of the line roads['x'] = roads.apply(getLineCoords, geom='geometry', coord_type='x', axis=1) # Calculate y coordinates of the line roads['y'] = roads.apply(getLineCoords, geom='geometry', coord_type='y', axis=1) # Let's see what we have now roads.head() 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. .. ipython:: python # Make a copy and drop the geometry column m_df = roads.drop('geometry', axis=1).copy() # Point DataSource 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. .. ipython:: python # 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) .. code:: python # open the plot html, will open a new tab show(p) Great! Let's save this and check the result. .. ipython:: python outfp = "roads_map.html" # Save the map output_file(outfp) Now you can open your point map in the browser and it should look like following: .. raw:: html Bokeh Plot
.. 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. .. ipython:: python from bokeh.plotting import figure, show, output_file, output_notebook from bokeh.models import ColumnDataSource, HoverTool, LogColorMapper import geopandas as gpd import geopandas as gpd # Filepaths grid_fp = "source/_static/data/L6/population_square_km.shp" roads_fp = "source/_static/data/L6/roads.shp" schools_fp = "source/_static/data/L6/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. .. ipython:: python # Get the CRS of our grid CRS = grid.crs print(CRS) # 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: .. ipython:: python print(schools['geometry'].head(1)) print(roads['geometry'].head(1)) print(grid['geometry'].head(1)) Indeed, they do. Let's proceed and parse the x and y values of our grid. Let's create own function for that as well. .. ipython:: python def getPolyCoords(row, geom, coord_type): """Returns the coordinates ('x' or 'y') of edges of a Polygon exterior""" # Parse the exterior of the coordinate exterior = row[geom].exterior if coord_type == 'x': # Get the x coordinates of the exterior return list( exterior.coords.xy[0] ) elif coord_type == 'y': # Get the y coordinates of the exterior return list( exterior.coords.xy[1] ) **2nd step**: Let's now apply the functions that we have created and parse the x and y coordinates for all of our datasets. .. ipython:: python # Get the Polygon x and y coordinates grid['x'] = grid.apply(getPolyCoords, geom='geometry', coord_type='x', axis=1) grid['y'] = grid.apply(getPolyCoords, geom='geometry', coord_type='y', axis=1) # Calculate x and y coordinates of the line roads['x'] = roads.apply(getLineCoords, geom='geometry', coord_type='x', axis=1) roads['y'] = roads.apply(getLineCoords, geom='geometry', coord_type='y', axis=1) # Calculate x and y coordinates of the schools schools['x'] = schools.apply(getPointCoords, geom='geometry', coord_type='x', axis=1) schools['y'] = schools.apply(getPointCoords, geom='geometry', coord_type='y', axis=1) Great, now we have x and y coordinates for all of our datasets. Let's see how our grid coordinates look like. .. ipython:: python # Show only head of x and y columns grid[['x', 'y']].head(2) Let's now classify the population data of our grid into 7 classes using a pysal classifier called ``Quantiles``. .. ipython:: python :okwarning: import pysal.viz.mapclassify as mc # Initialize the classifier and apply it classifier = mc.Quantiles.make(k=5) grid['pop_km2'] = grid[['Population']].apply(classifier) # What do we have now? grid.head(2) Okey, so we have many columns but the new one that we just got is the last one, i.e. ``pop_km2`` that contains the classes that we reclassified based on the population per grid square. **3rd step**: Let's now convert our GeoDataFrames into Bokeh ColumnDataSources (without geometry columns) .. ipython:: python # Make a copy, drop the geometry column and create ColumnDataSource m_df = roads.drop('geometry', axis=1).copy() msource = ColumnDataSource(m_df) # Make a copy, drop the geometry column and create ColumnDataSource p_df = schools.drop('geometry', axis=1).copy() psource = ColumnDataSource(p_df) # Make a copy, drop the geometry column and create ColumnDataSource g_df = grid.drop('geometry', axis=1).copy() gsource = ColumnDataSource(g_df) Okey, now we are ready to roll and visualize our layers. **4th step**: For visualizing the Polygons we need to define the `color palette `_ that we are going to use. There are many different ones available but we are now going to use a palette called ``RdYlBu`` and use eleven color-classes for the values (defined as ``RdYlBu11``). Let's prepare our color_mapper. .. ipython:: python # Let's first do some coloring magic that converts the color palet into map numbers (it's okey not to understand) from bokeh.palettes import RdYlBu11 as palette from bokeh.models import LogColorMapper # Create the color mapper color_mapper = LogColorMapper(palette=palette) Now we are ready to visualize our polygons and add the roads line and the schools on top of that. Polygons are visualized using ``patches`` objects in Bokeh. .. ipython:: python # Initialize our figure p = figure(title="Population per km2 compared to Road network in Tartu") # Plot grid p.patches('x', 'y', source=gsource, fill_color={'field': 'pop_km2', 'transform': color_mapper}, fill_alpha=1.0, line_color="black", line_width=0.05) # Add roads on top of the same figure p.multi_line('x', 'y', source=msource, color="red", line_width=2) # Add schools on top (as yellow points) p.circle('x', 'y', size=3, source=psource, color="yellow") # let's also add the hover over info tool tooltip = HoverTool() tooltip.tooltips = [('Name of the school', '@name'), ('Type of road', '@TYYP'), ('Population density', '@Population')] p.add_tools(tooltip) .. code:: python # open the plot html, will open a new tab show(p) Let's save this and check the result. .. ipython:: python outfp = "roads_pop_km2_map.html" # Save the map output_file(outfp) .. raw:: html Bokeh Plot
Cool, now we have an interactive map with three layers! We can do one a few little more things. Let's get a bit more experimental. As we are developing basically an interactive web map, let's add a background base map. We can use a so called `tile source `_. In our example we use the `CartoDB Positron free tile layer `_ as our basemap background. .. code:: python from bokeh.tile_providers import get_provider, Vendors # instatiate the tile source provider tile_provider = get_provider(Vendors.CARTODBPOSITRON) # add the back ground basemap p.add_tile(tile_provider) One important aspect here is, that these web tile layers are often only available in WGS84 (EPSG:4326) or Web Mercator (EPSG:3857). We need to reproject our geometries if we want them to align with the background web map. In addition, we create a `legend scale bar `_ that we can add to the plot. We use a different `ColorMapper `_ to visualise our population density grid. .. code:: python from bokeh.palettes import RdPu9 from bokeh.models import LinearColorMapper, BasicTicker, ColorBar, Legend, LegendItem # instantiate a new colormapper, provide min and max values for the scale color_mapper_v = LinearColorMapper(palette=RdPu9, low=grid_proj['pop_km2'].min(), high=grid_proj['pop_km2'].max()) # build a legend scale for the grid population classes, using the LinearColorMapper from above color_bar = ColorBar(color_mapper=color_mapper_v, ticker=BasicTicker(), label_standoff=12, border_line_color=None, location=(0,0)) # and add this additional legend scale bar to the plot p.add_layout(color_bar, 'right') And in the end we build a `custom legend `_. For that we need create separate `LegendItems `_. The tricky bit is to link the legend item to the actual rendered (plotted) layer in the plot. We can access these separate renderers via the ``p.renderers[]``-list. The order in this list is based on the order in which we place our layers on the map. The tile source background map is also rendered by a separate renderer. But it is our basemap, so we skip the zero'th element. .. code:: python # now let's develop a custom legend for our 3 active layers # the first rendered layer (p.renderers[0]) is the TileRenderer of our background map! # here we link the rendered layer to a legend item li1 = LegendItem(label='Pop Km2 Grid', renderers=[p.renderers[1]]) li2 = LegendItem(label='Roads', renderers=[p.renderers[2]]) li3 = LegendItem(label='Schools', renderers=[p.renderers[3]]) # then we put them together into an actual legend and place it on the plot legend1 = Legend(items=[li1, li2, li3], location='top_right') p.add_layout(legend1) # we can even make this interactive and de-/activate layers p.legend.click_policy="hide" Altogether, that workflow would look like that: .. code:: python from bokeh.tile_providers import get_provider, Vendors from bokeh.palettes import RdPu9 from bokeh.models import LinearColorMapper, BasicTicker, ColorBar, Legend, LegendItem # Convert the geometries grid_proj = grid.to_crs(epsg=3857) schools_proj = schools.to_crs(epsg=3857) roads_proj = roads.to_crs(epsg=3857) # Get the Polygon x and y coordinates grid_proj['x'] = grid_proj.apply(getPolyCoords, geom='geometry', coord_type='x', axis=1) grid_proj['y'] = grid_proj.apply(getPolyCoords, geom='geometry', coord_type='y', axis=1) # Calculate x and y coordinates of the line roads_proj['x'] = roads_proj.apply(getLineCoords, geom='geometry', coord_type='x', axis=1) roads_proj['y'] = roads_proj.apply(getLineCoords, geom='geometry', coord_type='y', axis=1) # Calculate x and y coordinates of the schools schools_proj['x'] = schools_proj.apply(getPointCoords, geom='geometry', coord_type='x', axis=1) schools_proj['y'] = schools_proj.apply(getPointCoords, geom='geometry', coord_type='y', axis=1) # Make a copy, drop the geometry column and create ColumnDataSource grid_proj = grid_proj.drop('geometry', axis=1).copy() grid_proj_source = ColumnDataSource(grid_proj) # Make a copy, drop the geometry column and create ColumnDataSource roads_proj = roads_proj.drop('geometry', axis=1).copy() roads_proj_source = ColumnDataSource(roads_proj) # Make a copy, drop the geometry column and create ColumnDataSource schools_proj = schools_proj.drop('geometry', axis=1).copy() schools_proj_source = ColumnDataSource(schools_proj) # Initialize our figure p = figure(title="Population per km2 compared to Road network in Tartu", x_range=(2899000,3065000), y_range=(7980000,8140000), x_axis_type="mercator", y_axis_type="mercator") # instatiate the tile source provider tile_provider = get_provider(Vendors.CARTODBPOSITRON) # add the back ground basemap p.add_tile(tile_provider) # instantiate a new colormapper color_mapper_v = LinearColorMapper(palette=RdPu9, low=grid_proj['pop_km2'].min(), high=grid_proj['pop_km2'].max()) # Plot grid p.patches('x', 'y', source=grid_proj_source, fill_color={'field': 'pop_km2', 'transform': color_mapper_v}, fill_alpha=0.7, line_color="black", line_width=0.05) # build a legend scale for the grid population classes color_bar = ColorBar(color_mapper=color_mapper_v, ticker=BasicTicker(), label_standoff=12, border_line_color=None, location=(0,0)) # and add this additional legend scale bar to the plot p.add_layout(color_bar, 'right') # Add roads on top of the same figure p.multi_line('x', 'y', source=roads_proj_source, color="red", line_width=2) # Add schools on top (as yellow points) p.circle('x', 'y', size=3, source=schools_proj_source, color="yellow") # now let's develop a custom legend for our 3 active layers # the first rendered layer (p.renderers[0]) is the TileRenderer of our background map! # here we link the rendered layer to a legend item li1 = LegendItem(label='Pop Km2 Grid', renderers=[p.renderers[1]]) li2 = LegendItem(label='Roads', renderers=[p.renderers[2]]) li3 = LegendItem(label='Schools', renderers=[p.renderers[3]]) # then we put them together into an actual legend and place it on the plot legend1 = Legend(items=[li1, li2, li3], location='top_right') p.add_layout(legend1) # we can even make this interactive and de-/activate layers p.legend.click_policy="hide" # let's also add the hover over info tool tooltip = HoverTool() tooltip.tooltips = [('Name of the school', '@name'), ('Type of road', '@TYYP')] p.add_tools(tooltip) show(p) .. raw:: html Bokeh Plot
**Launch in the web/MyBinder:** .. image:: https://mybinder.org/badge_logo.svg :target: https://mybinder.org/v2/gh/allixender/testgeo2020b/master?filepath=L6%2FL6_interactive_maps.ipynb