Using folium to create interactive maps offline

Pareekshith Katti
5 min readOct 20, 2022

--

Introduction

Geo-spatial data science applications are increasing as time goes on and more data are becoming location specific. Geo-spatial data ranges from weather to cab routing data to world economic indicators. There are many libraries to visualize spatial data and general plotting libraries like plotly also provide spatial plotting methods. One of the most useful interactive plotting or mapping library in python is folium, which is a wrapper around leaflet.js. The only issue is that folium is designed to be used with tiles provided by openstreetmaps or its alternatives, so you need an active internet connection and the map needs to load new tiles as you zoom or move to a different location making it slow. In this tutorial, we will see how we can use folium offline.

Importing Libraries

Let’s import the necessary libraries. We need folium for producing interactive map, geopandas for loading the shapefiles we’ll use to produce the map, pandas to load population data and matplotlib for styling static map in the notebook. We are using numpy to take log of the population which we’ll explore later.

import folium
import geopandas as gpd
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd

Setting up the data and shapefiles

Let’s start by importing the data

df = pd.read_csv("world_population.csv")

We’ll now import the required shapefiles, we’ll only need countries shapefile and rest of them are optional. We’ll just be using it to add features to the map to make it look better. The shapefiles are available from naturalearthdata.com/

countries = gpd.read_file("/natural_earth/50m/ne_50m_admin_0_countries.shp")ocean = gpd.read_file("/natural_earth/50m/ne_50m_ocean.shp")gridlines = gpd.read_file("/natural_earth/50m/ne_50m_geographic_lines.shp")rivers = gpd.read_file("/natural_earth/50m/ne_50m_rivers_lake_centerlines.shp")lakes = gpd.read_file("/natural_earth/50m/ne_50m_lakes.shp")

We just need the ISO code and geometry from the countries shapefile so we’ll just filter out the rest.

countries = countries[["ISO_A3_EH", "geometry"]]

Let us plot our shapefiles to see how it looks

ax = countries.plot(figsize=(40, 40), color="gray")ocean.plot(ax=ax, figsize=(40, 40), color="#7ae2ff")gridlines.plot(ax=ax, figsize=(40, 40), color="black")rivers.plot(ax=ax, figsize=(40, 40), color="#7ae2ff")lakes.plot(ax=ax, figsize=(40, 40), color="#7ae2ff")plt.show()

Since population is in millions or billions, we’ll express it in terms of millions. We’ll also take the log of population since the data is very skewed towards India and China and folium doesn’t like skewed data for its legend.

df["pop_est_mill"] = df["2022 Population"] / 1000000df["pop_log"] = np.log10(df["pop_est_mill"])

We’ll rename the ISO3 column from our population data to match the countries shapefile, we can also use left_on and right_on for merging however, i prefer renaming. We’ll then subset required columns.

df = df.rename({"CCA3": "ISO_A3_EH"}, axis=1)df = df[["ISO_A3_EH", "Country", "pop_est_mill", "pop_log"]]

We’ll now merge the data with shapefile and drop duplicates in ISO3 codes. Since we’ll need to use ISO3 as index to the GeoJSON file for folium map, we need it to be unique.

countries = countries.merge(df, on="ISO_A3_EH")countries = countries.drop_duplicates(subset=["ISO_A3_EH"])

We’ll now convert the countries data to GeoJSON and also set the ISO3 code as index and store it in ‘geo’. We need our other shapefiles to be a folium GeoJson object in order to add it to the map. We’ll create those objects and store them in newly created variables. The data to folium GeoJson() should be in GeoJSON format, so we’ll convert each of the shapefiles (stored as GeoDataFrames) to GeoJSON using to_json() method. We also can pass a style function in order to customize the looks. I’ve passed a lambda function which doesn’t do any processing. It just sets the color and weight (line width) of each object.

geo = countries.set_index("ISO_A3_EH")[
["geometry", "pop_est_mill", "Country"]
].to_json()
ocean_json = folium.GeoJson(
data=ocean.to_json(), style_function=lambda x: {"color": "#a9eafc", "weight": 0.5}
)
lines_json = folium.GeoJson(
data=gridlines.to_json(),
style_function=lambda x: {"color": "#000000", "weight": 0.5, "dashArray": "5, 5"},
)
rivers_json = folium.GeoJson(
data=rivers.to_json(), style_function=lambda x: {"color": "#a9eafc", "weight": 0.5}
)
lakes_json = folium.GeoJson(
data=lakes.to_json(), style_function=lambda x: {"color": "#a9eafc", "weight": 0.5}
)

Creating the map

Let us now create a choropleth map. First we’ll create a basemap. We need to pass tiles=None in order to not load any tile set (the default source is from openstreetmaps) which requires an internet connection. Since we want our maps to be used offline, we don’t want that. The location parameter allows us to express where the map should be centered, we’ll just use (0,0) as our coordinates. The zoom level can be set according to the need. Higher zoom level means higher details. If we want to display most of the world, we’ll need to set zoom level low. max_zoom will set the maximum zoom level.

We’ll now create a choropleth map and add it to our map object. We’ll pass our countries GeoJson object to geo_data parameter. We’ll add a legend name. We need to pass the population data which is now stored in countries dataframe to data parameter. We need to specify the index and column to plot in columns parameter as the data must be bound to the index for pandas (or geopandas) dataframe. We’ll be plotting log of population and our index (of the GeoJson object) is ISO_A3_EH, which is the ISO3 code. key_on parameter is used to express the attribute in the GeoJSON data to bind our plotting data to. We’ll just use the index. We will use a sequential color palette, I’ve used Orange to Red palette (OrRd) but we can use any sequential palette of our choice. We’ll also fill geographies with missing data using white. You can use any color but make sure it doesn’t conflict with our actual color scheme. We’ll also make the borders disappear from the shapefile by making line_opacity as 0 but this is completely optional.

m = folium.Map(location=[0, 0], tiles=None, zoom_start=2, max_zoom=5)choropleth = folium.Choropleth(
geo_data=geo,
legend_name="World Population in Millions (Logorithmic Scale)",
data=countries,
columns=["ISO_A3_EH", "pop_log"],
key_on="feature.id",
fill_color="OrRd",
nan_fill_color="White",
line_opacity=0,
)

We’ll now create a GeoJsonTooltip to display the country name and population (in millions) when we hover over the country. We are using the actual population here because log values are not intuitive.

folium.GeoJsonTooltip(["Country", "pop_est_mill"]).add_to(choropleth.geojson)

We’ll now add all our data to the map.

  1. We’ll add the choropleth to the map
  2. We’ll add our detailing objects (Oceans, Rivers, Lakes and important latitudes and longitudes)
choropleth.add_to(m)ocean_json.add_to(m)lines_json.add_to(m)rivers_json.add_to(m)lakes_json.add_to(m)

This step is optional but a nice feature. We can add an image overlay to our map. We are using a low resolution (in terms of mapping standards) 5250 x 5250 world map with Web Mercator projection. This was generated using QGIS using openmapservices plugin by specifying the bounds (in Web Mercator projection coordinates) and then saving the map with appropriate DPI. We’ll reduce the opacity to 0.5 and add it to the map. We’ll also add Layer control which will allow us to disable layers we’ve added

img = folium.raster_layers.ImageOverlay(
name="Physical Map",
image="mercader.png",
bounds=[[-90, -180], [90, 180]],
opacity=0.5,
interactive=False,
cross_origin=False,
zindex=1,
).add_to(m)
folium.LayerControl().add_to(m)

Finally, let us display the map.

m

It doesn’t look bad right? Apart from removing the dependency for an active internet connection, you don’t have to load the tile set as you zoom in or out which means we’ll have less memory consumption as well as smoother interactivity.

Of-course, some applications does require an active internet connection but if you just want to explore your data and build interactive maps, this is a great alternative.

We can also save our map to a HTML file to share it with others.

m.save("map.html")

--

--