"""Interactive web maps with Folium - TechnicalGIS tutorial. Builds two Leaflet maps from Python: 1. a one-line choropleth with GeoDataFrame.explore() 2. a hand-crafted Folium map with a choropleth, clustered city markers, multiple basemaps, plugins and a layer switcher. Run with: python folium_maps.py (then open output/*.html in a browser) """ import urllib.request from pathlib import Path import branca.colormap as cm import folium import geopandas as gpd from folium import plugins HERE = Path(__file__).parent DATA = HERE / "data" OUT = HERE / "output" DATA.mkdir(exist_ok=True) OUT.mkdir(exist_ok=True) NE = "https://naciscdn.org/naturalearth/50m/cultural/" # Keyless basemaps. (CARTO's free tiles now require an API key for most uses.) ESRI_GRAY = "https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}" ESRI_GRAY_ATTR = "Tiles © Esri — Esri, DeLorme, NAVTEQ" def fetch(name: str) -> Path: dest = DATA / name if not dest.exists(): req = urllib.request.Request(NE + name, headers={"User-Agent": "technicalgis-tutorial"}) with urllib.request.urlopen(req) as r: dest.write_bytes(r.read()) return dest def section(title: str) -> None: print(f"\n=== {title} ===") # --- Step 1: prepare the data --------------------------------------------------- countries = gpd.read_file(fetch("ne_50m_admin_0_countries.zip")) countries = countries[["NAME", "CONTINENT", "POP_EST", "GDP_MD", "geometry"]].rename( columns={"NAME": "name", "CONTINENT": "continent", "POP_EST": "population", "GDP_MD": "gdp_musd"}) europe = countries[countries["continent"] == "Europe"].copy() europe["area_km2"] = europe.to_crs("EPSG:3035").area / 1e6 # ETRS89 LAEA Europe, equal area europe["density"] = (europe["population"] / europe["area_km2"]).round(1) europe["gdp_per_capita"] = (europe["gdp_musd"] * 1e6 / europe["population"]).round(0) europe = europe[europe["population"] > 0] places = gpd.read_file(fetch("ne_50m_populated_places.zip"))[["NAME", "ADM0NAME", "POP_MAX", "geometry"]] places = places.rename(columns={"NAME": "city", "ADM0NAME": "country", "POP_MAX": "population"}) eu_places = gpd.sjoin(places, europe[["geometry"]], predicate="within").drop(columns="index_right") section("data") print(len(europe), "European countries;", len(eu_places), "populated places") print(europe.sort_values("density", ascending=False)[["name", "population", "area_km2", "density"]] .head(5).round(0).to_string(index=False)) # Simplify geometries (~1 km) to keep the HTML small - the browser draws every vertex. # Clip to a European window first: Russia crosses the antimeridian, which would otherwise # draw a band across the whole map. europe = europe.clip((-25, 34, 50, 72)) europe["geometry"] = europe.to_crs("EPSG:3035").simplify(1000).to_crs("EPSG:4326") # --- Step 2: the one-liner ------------------------------------------------------- m1 = europe.explore(column="density", scheme="Quantiles", k=5, cmap="YlGnBu", tooltip=["name", "population", "density"], legend_kwds={"caption": "People per km²", "colorbar": False, "fmt": "{:.0f}"}, tiles=ESRI_GRAY, attr=ESRI_GRAY_ATTR, location=[54, 15], zoom_start=4) m1.save(OUT / "explore_density.html") # --- Step 3: a hand-crafted Folium map -------------------------------------------- m = folium.Map(location=[54, 12], zoom_start=4, tiles=None, control_scale=True) folium.TileLayer("OpenStreetMap", name="OpenStreetMap").add_to(m) folium.TileLayer(ESRI_GRAY, attr=ESRI_GRAY_ATTR, name="Light grey (Esri)").add_to(m) folium.TileLayer( tiles="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", attr="Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community", name="Esri World Imagery").add_to(m) colormap = cm.LinearColormap(["#f7fcf0", "#7bccc4", "#0868ac"], vmin=float(europe.gdp_per_capita.quantile(0.05)), vmax=float(europe.gdp_per_capita.quantile(0.95)), caption="GDP per capita (USD, Natural Earth estimates)") folium.GeoJson( europe[["name", "population", "density", "gdp_per_capita", "geometry"]], name="GDP per capita", style_function=lambda f: {"fillColor": colormap(f["properties"]["gdp_per_capita"]), "color": "#475569", "weight": 0.6, "fillOpacity": 0.7}, highlight_function=lambda f: {"weight": 2.5, "color": "#e8590c", "fillOpacity": 0.85}, tooltip=folium.GeoJsonTooltip(fields=["name", "gdp_per_capita", "density"], aliases=["Country", "GDP per capita (USD)", "People per km²"], localize=True, sticky=True), ).add_to(m) colormap.add_to(m) cluster = plugins.MarkerCluster(name="Cities (clustered)").add_to(m) for _, r in eu_places.iterrows(): folium.Marker( location=[r.geometry.y, r.geometry.x], tooltip=r["city"], popup=folium.Popup(f"{r['city']}
{r['country']}
Population: {r['population']:,}", max_width=220), icon=folium.Icon(color="darkblue", icon="info-sign"), ).add_to(cluster) big = eu_places[eu_places["population"] >= 2_000_000] fg = folium.FeatureGroup(name="Cities ≥ 2 million", show=False).add_to(m) for _, r in big.iterrows(): folium.CircleMarker([r.geometry.y, r.geometry.x], radius=4 + r["population"] / 1.5e6, color="#e8590c", fill=True, fill_opacity=0.6, weight=1, tooltip=f"{r['city']}: {r['population']:,}").add_to(fg) plugins.Fullscreen().add_to(m) plugins.MiniMap(toggle_display=True).add_to(m) plugins.MeasureControl(primary_length_unit="kilometers").add_to(m) folium.LayerControl(collapsed=False).add_to(m) m.save(OUT / "europe_folium.html") section("saved") for f in sorted(OUT.glob("*.html")): print(f"{f.name:24s} {f.stat().st_size / 1024:,.0f} KB")