Handling Antimeridian-Crossing OSM Geometry Jump to heading
A ferry route from Fiji to Samoa, a country whose territory spans the date line, an exclusive economic zone around a scattered island group: each is ordinary geography and each produces a bounding box that appears to cover the entire planet.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Longitude is defined on a circle but stored as a number in the range from −180 to 180, and that mismatch is the entire problem. A line from longitude 179 to −179 is two degrees long going east; stored naively, it is 358 degrees long going west, and every derived computation follows the wrong interpretation.
Three consequences appear in a pipeline.
Bounding boxes become global. A feature spanning the meridian has a minimum longitude near −180 and a maximum near 180, so its box covers the world. Spatial indexes then return it as a candidate for every query on Earth.
Lengths and areas are wildly wrong. A two-degree route measured as 358 degrees is not slightly wrong; it is two orders of magnitude out and will pass any sanity check that only rejects negatives.
Renderers draw a line across the world. The visual symptom is unmistakable and is usually the first sign anybody notices.
The critical detection subtlety is that a wide box and a crossing box are indistinguishable from the box alone. A feature genuinely spanning most of the Pacific has the same bounding box as one crossing the meridian by a degree. Only the coordinate sequence distinguishes them.
Runnable solution Jump to heading
from __future__ import annotations
import logging
from dataclasses import dataclass
from shapely.geometry import LineString, MultiPolygon, Polygon, box
from shapely.geometry.base import BaseGeometry
from shapely.ops import transform
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.crs.antimeridian")
CROSS_THRESHOLD = 180.0 # a step larger than this must be a wrap
def crosses_antimeridian(geom: BaseGeometry) -> bool:
"""A crossing shows as a longitude STEP of more than 180 degrees.
The bounding box cannot distinguish a crossing feature from one that
genuinely spans the Pacific; only consecutive coordinates can.
"""
coords = _all_coords(geom)
return any(abs(b[0] - a[0]) > CROSS_THRESHOLD
for a, b in zip(coords, coords[1:]))
def _all_coords(geom: BaseGeometry) -> list[tuple[float, float]]:
if geom.geom_type == "Point":
return [(geom.x, geom.y)]
if geom.geom_type in {"LineString", "LinearRing"}:
return list(geom.coords)
if geom.geom_type == "Polygon":
out = list(geom.exterior.coords)
for ring in geom.interiors:
out.extend(ring.coords)
return out
out: list[tuple[float, float]] = []
for part in getattr(geom, "geoms", []):
out.extend(_all_coords(part))
return out
def unwrap(geom: BaseGeometry) -> BaseGeometry:
"""Shift eastern-hemisphere coordinates past 180 so the geometry is contiguous.
The result is NOT valid geographic coordinates — longitudes exceed 180 —
but it is correct for measurement and for projecting to a local CRS.
"""
coords = _all_coords(geom)
if not coords:
return geom
reference = coords[0][0]
def shift(x: float, y: float, z: float | None = None):
# Move each point to the representation nearest the reference.
adjusted = x
while adjusted - reference > 180.0:
adjusted -= 360.0
while reference - adjusted > 180.0:
adjusted += 360.0
return (adjusted, y) if z is None else (adjusted, y, z)
return transform(shift, geom)
def split_at_antimeridian(geom: BaseGeometry) -> BaseGeometry:
"""Cut the geometry into an eastern and a western part.
This is what a renderer, a tile pipeline or a database with a
longitude constraint wants; `unwrap` is what a measurement wants.
"""
if not crosses_antimeridian(geom):
return geom
unwrapped = unwrap(geom)
minx, miny, maxx, maxy = unwrapped.bounds
west = unwrapped.intersection(box(-180.0, miny, 180.0, maxy))
east = unwrapped.intersection(box(180.0, miny, maxx, maxy))
# Bring the eastern part back into valid longitude range.
east = transform(lambda x, y, z=None: (x - 360.0, y), east) \
if not east.is_empty else east
parts = [p for p in (west, east) if not p.is_empty]
if not parts:
return geom
if len(parts) == 1:
return parts[0]
if all(p.geom_type in {"Polygon", "MultiPolygon"} for p in parts):
polys: list[Polygon] = []
for part in parts:
polys.extend(getattr(part, "geoms", [part]))
return MultiPolygon(polys)
logger.info("split geometry into %d part(s)", len(parts))
return parts[0].union(parts[1])
def true_bounds(geom: BaseGeometry) -> tuple[float, float, float, float]:
"""Bounds that describe the feature rather than the whole world."""
return unwrap(geom).bounds if crosses_antimeridian(geom) else geom.bounds
if __name__ == "__main__":
route = LineString([(179.0, -18.0), (-179.0, -17.0)])
logger.info("crosses: %s", crosses_antimeridian(route))
logger.info("naive bounds %s", route.bounds)
logger.info("true bounds %s", true_bounds(route))
Step-by-step walkthrough Jump to heading
- Detect from coordinate steps, never from bounds. A step larger than 180 degrees between consecutive points cannot be a real movement and must be a wrap; a wide bounding box proves nothing.
- Unwrap relative to the first coordinate. Shifting every point to the representation nearest a reference produces a contiguous geometry, which is what measurement and projection need.
- Accept invalid longitudes in the unwrapped form. Coordinates past 180 are deliberately outside the geographic range; the unwrapped geometry is an intermediate, not an output.
- Split for renderers and databases. Anything that will draw the geometry, tile it, or store it under a longitude constraint needs two parts in valid range rather than one contiguous one.
- Shift the eastern part back. After cutting at 180, the far side must be moved by a full turn to return to valid coordinates.
- Compute bounds from the unwrapped form. That single change stops a Pacific feature being returned as a candidate for every spatial query worldwide.
- Choose per consumer. A length calculation wants unwrapped; a tile pipeline wants split. Applying one treatment everywhere breaks whichever consumer wanted the other.
Verification Jump to heading
- A short crossing route measures short. A two-degree route across the meridian must not report hundreds of degrees.
- Bounds are local. The true bounds of a Pacific feature should span degrees, not the world.
- A genuinely wide feature is not split. Something spanning the Pacific without crossing must pass through unchanged.
- Split parts are in valid range. Every coordinate of a split result must lie between −180 and 180.
- Round-tripping is stable. Unwrapping and re-splitting should return the same parts.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Feature returned for every query | Naive bounding box spans the world | Compute bounds from the unwrapped geometry |
| Route length two orders too large | Longitude difference taken the long way | Unwrap before measuring |
| Line drawn across the map | Unsplit geometry handed to a renderer | Split at the meridian for anything that draws |
| Wide Pacific features wrongly split | Crossing detected from the bounding box | Detect from consecutive coordinate steps |
| Database rejects the geometry | Unwrapped coordinates exceed 180 | Split before storing under a range constraint |
| Split parts still invalid | Eastern part not shifted back | Subtract a full turn after cutting at 180 |
| Fix for one consumer breaks another | One treatment applied everywhere | Choose unwrap or split per consumer |
Specification reference Jump to heading
Geographic coordinates place longitude in the range −180 to 180 degrees, with the antimeridian at both extremes, so a feature spanning it has coordinates at both ends of the range. The GeoJSON specification recommends splitting such geometries at the antimeridian for interchange. See RFC 7946 on the antimeridian for the interchange recommendation and Coordinate Reference Systems in OSM for the projection context.
Frequently Asked Questions Jump to heading
Why can I not detect a crossing from the bounding box?
Because a feature genuinely spanning most of the Pacific produces the same box as one crossing the meridian by a single degree — both have a minimum near −180 and a maximum near 180. The distinction lives in the coordinate sequence: a crossing geometry contains a step of more than 180 degrees between consecutive points, which cannot represent real movement. That step test is the only reliable detector.
Should I unwrap or split?
It depends entirely on the consumer, which is why this cannot be solved once in the pipeline. Measurements and projections want the unwrapped form, which is contiguous but has longitudes outside the valid range. Renderers, tile pipelines and databases with a range constraint want the split form, which is two valid parts. Spatial indexes want neither — just bounds computed from the unwrapped geometry.
Is it acceptable to store longitudes beyond 180?
As an in-memory intermediate, yes, and it is the simplest way to make measurement correct. As stored or interchanged data, no: it is outside the coordinate reference system’s defined range, and any consumer that validates will reject it while any that does not will place the feature somewhere wrong. Unwrap for a computation and split before anything leaves the process.
How do I test this without Pacific data?
Construct it. A two-point line from longitude 179 to −179 exercises every part of the detection and both transformations, and takes one line to write. Real Pacific extracts are worth testing against eventually, but the synthetic case catches the logic errors and can live in a test suite where anybody will run it, which is more than can be said for a Fiji extract.
Related Jump to heading
- Coordinate Reference Systems in OSM — the parent topic and the projection context.
- Measuring Area Accurately on OSM Polygons — another measurement that degrees get wrong.
- Converting OSM Coordinates to a Local CRS with pyproj — projecting geometry that must be unwrapped first.
- The Mapbox Vector Tile Spec & Tile Geometry — the tile grid that requires the split form.
- Detecting Self-Intersecting OSM Polygons with Shapely — validity checking after a split.
Up one level: Coordinate Reference Systems in OSM.