Locking Map Panning to a Country Bounding Box in MapLibre

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.

From total_bounds to maxBounds Left: geopandas total_bounds returns a flat array west, south, east, north. Middle: it is reshaped into two corner pairs, southwest and northeast, longitude first. Right: the resulting box constrains the MapLibre viewport, with minZoom keeping the box filling the screen. geopandas total_bounds (W, S, E, N) reshape MapLibre maxBounds [[W, S], [E, N]] SW corner, NE corner — lng first Viewport country bbox NE SW minZoom is set so the bbox fills the viewport — otherwise the camera can reveal area outside the box at low zoom

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 < e and s < n on 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 to EPSG: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 minZoom the country should roughly fill the viewport with no infinite grey world around it.
  • Responsive check — resize the window and reload; fitBounds should 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.