Chapter 15: Visualizing Spatial Data¶
Part II — Geographic Data Science
Learning Objectives¶
By the end of this chapter you will be able to:
- Create static maps with matplotlib and contextily
- Build interactive maps with folium and leafmap
- Apply cartographic best practices
# Standard imports — add chapter-specific imports below
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import geopandas as gpd
Every map in this book so far has been a quick .plot() — a sanity check, not something meant to be read by anyone else. This chapter treats the map as the deliverable: styled well enough to publish, interactive when that adds real value, and honest about what it's showing. Maranhão's municipalities, still the running dataset since Chapter 6, are the canvas throughout.
url = (
"https://geoftp.ibge.gov.br/organizacao_do_territorio/"
"malhas_territoriais/malhas_municipais/municipio_2022/"
"UFs/MA/MA_Municipios_2022.zip"
)
maranhao = gpd.read_file(url)
Static Maps with matplotlib¶
A bare gdf.plot() is a start, not a finished map. Four small additions turn it into something publishable: a meaningful column to color by, a legend, a title, and removing the latitude/longitude tick marks that rarely add anything to a thematic map:
fig, ax = plt.subplots(figsize=(8, 8))
maranhao.plot(
column="AREA_KM2",
cmap="YlOrRd",
edgecolor="black",
linewidth=0.3,
legend=True,
legend_kwds={"label": "Area (km²)", "shrink": 0.6},
ax=ax,
)
ax.set_title("Maranhão municipalities by area")
ax.set_axis_off()
plt.show()
ax.set_axis_off() is a small habit worth keeping for every thematic map from here on: the raw lat/lon tick values almost never help a reader, and removing them focuses attention on the shapes and colors that do.
Adding Basemaps with contextily¶
A map of municipality boundaries floating on a blank white background tells a reader nothing about where they are relative to roads, cities, or the coastline. contextily solves this by fetching map tiles from an online provider and drawing them underneath your own layer — but it has one hard requirement: tile providers speak Web Mercator (EPSG:3857), so anything plotted on top needs the same reprojection Chapter 6 and Chapter 7 already made a habit of checking first:
import contextily as cx
maranhao_web = maranhao.to_crs(epsg=3857)
fig, ax = plt.subplots(figsize=(8, 8))
maranhao_web.plot(ax=ax, facecolor="none", edgecolor="steelblue", linewidth=0.6)
cx.add_basemap(ax, source=cx.providers.Esri.WorldGrayCanvas)
ax.set_axis_off()
ax.set_title("Maranhão municipalities over a basemap")
plt.show()
cx.providers lists dozens of tile sources beyond Esri.WorldGrayCanvas — satellite imagery, terrain shading, OpenStreetMap's own detailed style, and CARTO's Positron/DarkMatter among them, each with its own visual weight and its own access requirements. WorldGrayCanvas above was chosen deliberately: it's a light, low-contrast basemap that stays out of the way of whatever thematic layer sits on top of it, and Esri's free tier serves it without an API key or any special request headers. The two more obvious choices both have a catch worth knowing about, not worth getting stuck on: CARTO's Positron/DarkMatter now require a free API key (CARTO began enforcing this in late 2026 — sign up at carto.com/basemaps/apikey and pass it via cx.providers.CartoDB.Positron(api_key=...)), and OpenStreetMap.Mapnik — the "official" OSM style — now enforces its own tile usage policy strictly enough that a plain, unconfigured request from contextily can come back blocked, since OSM's volunteer-run servers require a proper Referer header identifying the requesting application. Neither is a dead end, just extra setup a first example shouldn't need.
Interactive Maps with folium¶
A static map is the right tool for a printed page or a fixed figure. When the reader needs to pan, zoom, or hover a specific municipality to read its name, an interactive map earns its extra weight. folium wraps the JavaScript mapping library Leaflet, and reading a GeoDataFrame into it is close to direct:
import folium
center = maranhao.geometry.centroid.union_all().centroid
m = folium.Map(location=[center.y, center.x], zoom_start=6, tiles="OpenStreetMap")
folium.GeoJson(
maranhao,
tooltip=folium.GeoJsonTooltip(fields=["NM_MUN", "AREA_KM2"]),
style_function=lambda feature: {"fillColor": "steelblue", "fillOpacity": 0.2, "color": "black", "weight": 0.5},
).add_to(m)
m
/tmp/ipykernel_268850/1449308392.py:3: UserWarning: Geometry is in a geographic CRS. Results from 'centroid' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation. center = maranhao.geometry.centroid.union_all().centroid