Part of the Zoom/Pan Constraints & Boundaries guide.
Operative rule: MapLibre maxBounds is [[W, S], [E, N]] — longitude-first corner pairs — while geopandas total_bounds returns (minx, miny, maxx, maxy); reshape carefully or the map locks to the wrong hemisphere.
How It Works
Constraining a dashboard to one country means the camera can never wander into empty ocean or a neighbouring state. MapLibre GL JS enforces this with three settings: maxBounds, a rectangle the viewport is kept within; minZoom, the furthest the user can zoom out; and maxZoom, the closest they can zoom in. Together they turn an infinite pannable globe into a fixed working area. The single source of truth for that rectangle is the country’s geometry, so the reliable pattern is to compute the bounding box in Python from the same polygon your dashboard already uses, then bake the numbers into the client config. This keeps the constraint in lockstep with your data rather than a hand-typed guess, the same generate-config-from-Python discipline behind configuring maxBounds & minZoom in Leaflet via Python.
The one place this goes wrong is coordinate order. geopandas exposes GeoDataFrame.total_bounds, which returns a NumPy array (minx, miny, maxx, maxy) — that is (west, south, east, north). MapLibre’s maxBounds, fitBounds, and every other camera API take LngLatBounds as [[west, south], [east, north]]: longitude first, latitude second, grouped into two corner points. So you are not just passing the four numbers through — you reshape a flat (W, S, E, N) array into [[W, S], [E, N]]. Get the pairing wrong and the box collapses to a sliver or jumps to the wrong hemisphere. The lng/lat ordering is the same WGS84 EPSG:4326 convention that GeoJSON uses, discussed under CRS & Projection Management; MapLibre’s camera math projects those degrees to Web Mercator internally.
Production-Ready Implementation
The Python step reads the country polygon with geopandas, derives total_bounds, optionally pads it so the country is not flush against the frame, and serialises a small JSON config the front end consumes. Generating the config keeps the numbers exact and re-runnable in a build pipeline.
from __future__ import annotations
import json
from pathlib import Path
import geopandas as gpd
def build_maplibre_bounds_config(
country_path: str,
iso_a3: str,
pad_deg: float = 0.5,
min_zoom: float = 4.0,
max_zoom: float = 12.0,
output: Path = Path("dist/bounds_config.json"),
) -> dict:
"""
Read a country polygon and emit a MapLibre GL bounds config.
total_bounds is (minx, miny, maxx, maxy) == (W, S, E, N).
MapLibre wants [[W, S], [E, N]] — longitude first, two corner pairs.
"""
gdf = gpd.read_file(country_path)
# Always work in EPSG:4326 so bounds are lon/lat degrees.
if gdf.crs is None or gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs("EPSG:4326")
country = gdf[gdf["ISO_A3"] == iso_a3]
if country.empty:
raise ValueError(f"No feature with ISO_A3 == {iso_a3!r}")
# (W, S, E, N) as plain floats
w, s, e, n = (float(v) for v in country.total_bounds)
# Pad outward so the coastline is not glued to the frame edge.
w, s, e, n = w - pad_deg, s - pad_deg, e + pad_deg, n + pad_deg
config = {
# Reshape the flat (W, S, E, N) into MapLibre corner pairs.
"maxBounds": [[w, s], [e, n]], # [[SW], [NE]] == [[W,S],[E,N]]
"fitBounds": [[w, s], [e, n]], # same rectangle for initial framing
"minZoom": min_zoom,
"maxZoom": max_zoom,
"fitBoundsOptions": {"padding": 24}, # pixels of breathing room
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(config, indent=2), encoding="utf-8")
return config
if __name__ == "__main__":
cfg = build_maplibre_bounds_config(
country_path="data/ne_10m_admin_0_countries.shp",
iso_a3="PRT", # Portugal
)
print(json.dumps(cfg, indent=2))
The MapLibre GL JS v4 client loads that config and applies the constraints. Note that maxBounds is passed at construction while fitBounds runs on load so the framing adapts to the actual viewport size:
import maplibregl from "maplibre-gl";
const cfg = await fetch("/bounds_config.json").then((r) => r.json());
const map = new maplibregl.Map({
container: "map",
style: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
maxBounds: cfg.maxBounds, // [[W, S], [E, N]] — camera cannot leave this box
minZoom: cfg.minZoom,
maxZoom: cfg.maxZoom,
});
map.on("load", () => {
// Frame the country to the current viewport with pixel padding.
map.fitBounds(cfg.fitBounds, cfg.fitBoundsOptions);
});
Alternative Variants
Derive the bbox from arbitrary feature data, not an admin polygon
When the constraint should hug the data itself — say every sensor in a deployment — take total_bounds of the data layer instead of a country outline. The reshape is identical:
import geopandas as gpd
points = gpd.read_file("data/sensors.geojson").to_crs("EPSG:4326")
w, s, e, n = (float(v) for v in points.total_bounds)
max_bounds = [[w, s], [e, n]] # [[W,S],[E,N]] for MapLibre
Constraint parameter reference
| Setting | MapLibre type | Purpose | Sourced from |
|---|---|---|---|
maxBounds |
[[W,S],[E,N]] |
Hard pan limit | total_bounds reshaped |
minZoom |
number | Furthest zoom-out | Chosen so bbox fills viewport |
maxZoom |
number | Closest zoom-in | Detail level of your data |
fitBounds(bounds, opts) |
method | Initial framing | Same bbox, run on load |
padding |
number / object | Pixel inset for fitBounds |
Taste; 16–48 typical |
Verification Steps
Confirm the lock behaves before shipping:
- Order sanity check — assert
w < eands < non the reshaped bounds. If either fails, the(W, S, E, N)array was mis-paired. - Degree range — assert all four values lie within
(-180, 180)for longitude and(-90, 90)for latitude; out-of-range values mean the polygon was never reprojected toEPSG:4326. - Pan test — drag the map hard in all four directions; the camera should stop at the padded country edge and rubber-band back.
- Zoom-out test — scroll out fully; at
minZoomthe country should roughly fill the viewport with no infinite grey world around it. - Responsive check — resize the window and reload;
fitBoundsshould re-frame the country cleanly at both narrow and wide widths.
Common Errors & Fixes
The map locks to a thin sliver or the wrong part of the world
The flat (W, S, E, N) array was reshaped incorrectly — commonly as [[W, E], [S, N]] or [[S, W], [N, E]]. Fix: MapLibre wants [[W, S], [E, N]] (longitude first in each pair). Map total_bounds explicitly: w, s, e, n = total_bounds then [[w, s], [e, n]].
Users can still pan past the country at low zoom
maxBounds keeps the box within the viewport, but if the box is smaller than the screen the camera must show surrounding area. Fix: raise minZoom until the bbox fills the viewport, or increase the outward pad_deg so the constrained rectangle is larger than what fits on screen.
fitBounds throws or does nothing
fitBounds was called before the style finished loading, or was handed a flat four-number array instead of corner pairs. Fix: call it inside the map.on("load", ...) handler and pass [[W, S], [E, N]], not [W, S, E, N].
The country appears glued to the frame edges
No padding was applied, so the coastline touches the viewport border. Fix: pad the bounds outward in Python (pad_deg) and pass a padding value in fitBoundsOptions for pixel breathing room on the initial frame.
Related
- Zoom/Pan Constraints & Boundaries — parent guide on bounding the camera across renderers
- Configuring maxBounds & minZoom in Leaflet via Python — the Leaflet-side equivalent of this generate-config pattern
- Best Base Map Providers for High-Contrast Geo-Dashboards — picking the basemap the constrained view sits on
- CRS & Projection Management — the lng/lat degree convention behind every bounds value