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.

Three symptoms of an unhandled antimeridian crossing Three panels. A global bounding box appears because the minimum longitude is near minus one hundred and eighty and the maximum near one hundred and eighty, making spatial indexes return the feature for every query on Earth. A wrong length or area appears because the coordinate difference is measured the long way round, inflating a short route by two orders of magnitude. A line drawn across the map appears because renderers interpolate between the stored coordinates directly, producing a streak through every intervening longitude. Three symptoms, one cause Global bounding box Min near -180, max near 180 Index returns it everywhere Query performance collapses Looks like a data error Wrong measurements Two degrees read as 358 Two orders of magnitude out Passes a positivity check Fails nothing automatically A line across the map Renderer interpolates directly Streak through every longitude The symptom people notice Usually reported as a bug Only the third symptom is obvious, which is why the first two survive in pipelines that have no Pacific data to notice them with.
All three come from treating a circular coordinate as if it were a line segment on the real number line.

Runnable solution Jump to heading

python
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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Shift the eastern part back. After cutting at 180, the far side must be moved by a full turn to return to valid coordinates.
  6. Compute bounds from the unwrapped form. That single change stops a Pacific feature being returned as a candidate for every spatial query worldwide.
  7. Choose per consumer. A length calculation wants unwrapped; a tile pipeline wants split. Applying one treatment everywhere breaks whichever consumer wanted the other.
Whether to unwrap or to split, decided by the consumer A decision node about what will consume the geometry, with three outcomes. A measurement such as a length, an area or a projection into a local coordinate system wants the unwrapped form, which is contiguous even though its longitudes exceed the valid range. A renderer, a tile pipeline or a database enforcing a longitude constraint wants the split form, which is two valid parts. A spatial index wants neither the naive bounds nor a transformed geometry, but bounds computed from the unwrapped form so the feature is not returned for every query. Who is consuming this geometry? Measure, draw or index? Three consumers, three forms Applying one everywhere breaks two Unwrap it Lengths, areas, projection to a local CRS Split it Rendering, tiling, a longitude-constrained column Unwrapped bounds only Spatial indexing: fix the box, keep the geometry The third branch is the cheapest fix and the one that resolves the query-performance symptom without touching any geometry.
Treating this as one problem with one answer is why a fix for the renderer usually breaks the length calculation.
One short route across the meridian, in four representations A single two-degree route shown in four forms. As stored, its coordinates run from longitude one hundred and seventy nine to minus one hundred and seventy nine, which reads as a 358 degree span. Its naive bounding box therefore covers the whole world. Unwrapped, the second coordinate becomes one hundred and eighty one, the geometry is contiguous and the span reads as two degrees. Split, the route becomes two short segments meeting at the meridian, both within valid longitude range. The same two-degree route, four ways as stored 179 to -179 reads as 358 degrees both ends of the range correct data naive bounds -180 to 180 covers the world every query matches the index symptom unwrapped 179 to 181 contiguous, 2 degrees outside valid range for measurement only split two parts both in valid range meet at the meridian for rendering and storage The first column is correct data; everything wrong in the second follows from reading a circular coordinate as a line segment.
Two of these four forms are outputs and two are diagnoses, which is why naming them separately is worth doing.

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.

Up one level: Coordinate Reference Systems in OSM.