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);
});

A raw country bounding box is almost never the right envelope. Padding it turns a box that touches the data into one that lets the reader breathe, and the amount of padding needs to be a fraction of the extent, not a fixed number of degrees.

Padding a bounding box: fixed degrees versus a proportional margin The raw extent leaves features touching the viewport edge. A fixed pad of half a degree adds far more ground distance in longitude near the equator than near the poles, so the margin looks uneven. A proportional pad of five per cent of each axis keeps the visual margin constant at any latitude and any extent size. Three ways to size the maxBounds envelope raw total_bounds data touches the edge border features get clipped by the frame fixed pad: ±0.5° uneven margin 0.5° is 55 km east–west at 0°, 28 km at 60° proportional pad: 5 % even margin, any latitude scales with the dataset, not with the map pad_x = (maxLon − minLon) × 0.05 · pad_y = (maxLat − minLat) × 0.05 — computed once, in the same Python pass as the bounds. Clamp the padded latitudes to ±85.05 before handing them to MapLibre, or the Mercator projection rejects the envelope.

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

Three APIs that all take a bounding box

MapLibre exposes several methods that accept the same envelope and do quite different things with it. Confusing them produces maps that are constrained but start in the wrong place, or framed correctly but pannable to the far side of the world.

setMaxBounds, fitBounds and cameraForBounds compared setMaxBounds constrains where the camera may travel and applies for the lifetime of the map but does not move it. fitBounds moves the camera once to frame the envelope and does not constrain anything afterwards. cameraForBounds computes the centre and zoom that would frame the envelope and returns them without touching the map, which is what you need to derive a minimum zoom. Same envelope, three very different jobs setMaxBounds constrains where the camera may go applies for the map's lifetime does not move the camera at all use it for the hard boundary pair it with a matching minZoom fitBounds moves the camera once, now honours padding and maxZoom constrains nothing afterwards use it for the opening view animates unless you pass duration 0 cameraForBounds computes centre and zoom returns them — touches nothing pure function of the viewport size use it to derive minZoom recompute it on container resize A correct setup calls all three: cameraForBounds to derive the floor, setMaxBounds to enforce it, fitBounds to open on it.

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.