Bounding-Box Search Across the Antimeridian

A radius query that works flawlessly over Europe returns an empty set the instant an operator runs it near Fiji, the Aleutians, or Kiribati — and no error is thrown. The cause is arithmetic, not data. A bounding box is built as min_lon = lon - Δλ and max_lon = lon + Δλ, and near ±180° one of those crosses the seam: a box centred at 178.4° with a half-width of 2° wants to span from 176.4° to 180.4°, but longitude does not go to 180.4° — it wraps to −179.6°. The naive result is min_lon = 176.4, max_lon = -179.6, so the predicate longitude >= 176.4 AND longitude <= -179.6 is unsatisfiable and the index seek returns zero rows. The fix is to detect the wrap in client code and split the search into two index-seekable boxes — one running east to +180°, one from −180° back to the far edge. This page builds that detection and split, plus the pole case where the longitude band overflows entirely. It is a focused edge case of the broader distance filter query patterns.

A bounding box centred near plus 180 degrees longitude split into two seekable boxes across the antimeridian seam A horizontal longitude strip runs from minus 180 on the left to plus 180 on the right, with the antimeridian seam marked at both edges. The top row shows the desired search box centred at 178.4 degrees that overshoots past plus 180, so its naive range where longitude is at least 176.4 and at most minus 179.6 is unsatisfiable and returns nothing. The bottom row shows the fix: the box is split at the seam into box A, from 176.4 up to plus 180, and box B, from minus 180 up to minus 179.6, the two disjoint ranges that together cover the true search area with no overlap. Naive box — wraps the seam, seeks nothing −180° +180° 176.4°→ lon ≥ 176.4 AND lon ≤ −179.6 ✗ empty detect wrap → split Split boxes — two disjoint index seeks −180° +180° A 176.4° → +180° B −180° → −179.6°

Prerequisites & Versions

The wrap detection is pure client-side Python; the query is the standard two-stage box-then-distance filter on Neo4j’s native point. No APOC or GDS is needed.

Requirement Minimum version Note
Python 3.10+ dict/list handling and match syntax used below
Neo4j 5.13+ Native point, CREATE POINT INDEX, index-backed range predicates
neo4j (driver) 5.x AsyncGraphDatabase, async sessions
pip install "neo4j>=5.18"

Coordinates must be stored as native point values per sound node and edge spatial mapping, with the point index in place:

CREATE POINT INDEX station_location IF NOT EXISTS
FOR (s:Station) ON (s.location);
// (:Station {station_id, location: point({srid:4326, latitude, longitude})})

Implementation

seam_aware_box computes the latitude band, then the longitude band, and returns a list of (min_lon, max_lon) ranges: one range in the normal case, two when the band crosses ±180°, and the full [-180, 180] when a pole falls inside the radius. The query OR-joins the ranges so each stays an index seek, and always keeps the exact point.distance() clip so the answer is a true radius, not two rectangles.

import asyncio
import math
from neo4j import AsyncGraphDatabase

EARTH_RADIUS_M = 6_371_000.0


def seam_aware_box(lat: float, lon: float, radius_m: float) -> dict:
    """Bounding box that survives the antimeridian and the poles.

    Returns min/max latitude plus a list of (min_lon, max_lon) longitude
    ranges — two ranges when the band wraps ±180°, one range otherwise.
    """
    d_lat = math.degrees(radius_m / EARTH_RADIUS_M)
    min_lat = max(lat - d_lat, -90.0)
    max_lat = min(lat + d_lat, 90.0)

    # Clamp the latitude used for the cos() term so a near-pole query
    # does not blow up as cos(phi) -> 0.
    cos_phi = math.cos(math.radians(min(abs(lat), 89.9)))
    d_lon = math.degrees(radius_m / (EARTH_RADIUS_M * cos_phi))

    if d_lon >= 180.0:
        # Radius spans a full parallel (pole inside the circle): all longitudes.
        lon_ranges = [(-180.0, 180.0)]
    else:
        lo, hi = lon - d_lon, lon + d_lon
        if lo < -180.0:
            lon_ranges = [(-180.0, hi), (lo + 360.0, 180.0)]
        elif hi > 180.0:
            lon_ranges = [(lo, 180.0), (-180.0, hi - 360.0)]
        else:
            lon_ranges = [(lo, hi)]

    return {"min_lat": min_lat, "max_lat": max_lat, "lon_ranges": lon_ranges}


QUERY = """
WITH point({srid:4326, latitude:$lat, longitude:$lon}) AS target
MATCH (s:Station)
WHERE s.location.latitude >= $min_lat AND s.location.latitude <= $max_lat
  AND any(rng IN $lon_ranges
          WHERE s.location.longitude >= rng[0] AND s.location.longitude <= rng[1])
WITH s, point.distance(s.location, target) AS metres
WHERE metres <= $radius
RETURN s.station_id AS station_id, metres
ORDER BY metres ASC
LIMIT 200
"""


async def search_radius(driver, lat: float, lon: float, radius_m: float) -> list[dict]:
    box = seam_aware_box(lat, lon, radius_m)
    async with driver.session(database="neo4j") as session:
        result = await session.run(
            QUERY, lat=lat, lon=lon, radius=radius_m,
            min_lat=box["min_lat"], max_lat=box["max_lat"],
            lon_ranges=[list(r) for r in box["lon_ranges"]],
        )
        return [rec.data() async for rec in result]


async def main() -> None:
    driver = AsyncGraphDatabase.driver(
        "neo4j+s://your-cluster.databases.neo4j.io",
        auth=("neo4j", "secure-password"),
        max_connection_pool_size=40,
        connection_acquisition_timeout=5.0,
    )
    try:
        # Suva, Fiji — a 200 km radius straddles the +180° seam.
        rows = await search_radius(driver, -18.1416, 178.4419, 200_000)
        print(f"resolved {len(rows)} stations across the seam")
    finally:
        await driver.close()


if __name__ == "__main__":
    asyncio.run(main())

How It Works

The whole correctness argument rests on keeping longitude a set of ordered ranges rather than a single min/max pair.

  • Wrap detection is a sign test on the raw bounds. Before clamping, lon - d_lon and lon + d_lon are computed on the real number line, where they can fall below −180 or above +180. That overflow is exactly the wrap signal. Rather than modulo the bounds silently (which produces the unsatisfiable min > max), the function branches: an eastern overshoot (hi > 180) yields (lo, 180) plus (-180, hi - 360); a western overshoot mirrors it. The two ranges are disjoint — one ends at +180, the other starts at −180 — so a station is matched by at most one, which is what keeps the seam from being double-counted.
  • any() over the ranges stays index-seekable. The Cypher any(rng IN $lon_ranges WHERE …) expands to an OR of range predicates on location.longitude. The planner resolves an OR of ranges on the same indexed property as multiple PointIndexSeekByRange operations, so both boxes are seeks, not scans — the mechanism explored in graph query planner optimization. Passing the ranges as a parameter keeps one cacheable plan whether there are one or two boxes.
  • The distance clip is unchanged. point.distance() computes the true great-circle metre distance and treats +179.9° and −179.9° as 0.2° apart, not 359.8° — the seam is invisible to the ellipsoidal metric. So the box split only fixes the seek; the exact radius semantics still come from the final WHERE metres <= $radius, exactly as in the endpoint filter described in filtering graph paths by Haversine distance in Cypher.

Common Failure Patterns

1. Silent empty result (the wrap swallowed). The default symptom — a query that returns nothing near the date line and no exception. Because longitude >= 176.4 AND longitude <= -179.6 is simply false for every row, the seek is valid and fast; it just matches nobody. Guard against it in tests with a fixture on the seam and assert non-empty:

# WRONG — a single min/max pair goes unsatisfiable across ±180°:
#   longitude >= 176.5 AND longitude <= -179.7  →  matches no row, no error
# RIGHT — a list of ranges; the wrap case carries two, tested explicitly
box = seam_aware_box(-18.14, 178.44, 200_000)
assert len(box["lon_ranges"]) == 2         # wrap split into two disjoint ranges
assert box["lon_ranges"][0][1] == 180.0    # first range closes at +180
assert box["lon_ranges"][1][0] == -180.0   # second range opens at −180

2. Double-counting the seam. A tempting shortcut is to run two overlapping queries and UNION ALL the results — but overlap at the seam returns a station twice, inflating counts and corrupting a nearest-K LIMIT. Keep the ranges disjoint (one closes at +180, the other opens at −180) and OR them inside a single MATCH so each node is evaluated once. If you must issue two separate queries, dedupe on station_id or use UNION (which removes duplicates) rather than UNION ALL.

3. High-latitude band overflow. Near the poles the longitude band balloons because meridians converge, and a modest radius can demand a Δλ larger than 180° — mathematically the circle wraps all the way around. The d_lon >= 180 branch catches this and returns the full [-180, 180] range so the latitude band alone bounds the seek; without it, lon + d_lon overshoots into a nonsense split. The cos_phi clamp at 89.9° prevents a division-by-near-zero that would otherwise produce infinities at the pole itself.

Performance Notes

The longitude half-width driving the whole edge case is

$$\Delta\lambda = \frac{r}{R\cos\phi}\cdot\frac{180}{\pi}$$

for radius $r$ at latitude $\phi$. The $\cos\phi$ in the denominator is why the band widens with latitude: at the equator $\cos\phi = 1$ and $\Delta\lambda$ is small, but as $\phi \to 90°$, $\cos\phi \to 0$ and $\Delta\lambda \to \infty$. Two thresholds fall out of that. First, $\Delta\lambda$ crossing the distance from the origin to ±180° is when the split fires — purely a function of how close the origin sits to the seam. Second, $\Delta\lambda \ge 180°$ is when the band covers a full parallel and the split collapses to an all-longitudes scan bounded only by latitude.

The split costs essentially nothing at the seam: two index seeks over disjoint ranges together touch the same node count a single seek would if the world did not wrap, so latency tracks the box hit count $M$ just as in the non-wrapping case. The genuine cost centre is the pole branch — bounding by latitude alone over a full parallel can return a large $M$, so cap the radius near the poles or add a secondary attribute filter. Keep the query text fixed and the ranges parameterised so the plan cache stays warm across seam and non-seam calls alike; the PROFILE loop for confirming both boxes seek is the one in cypher performance tuning.

This guide is part of Distance Filter Query Patterns, within the Cypher Spatial Queries & Pathfinding Patterns pillar.

For authoritative reference, consult the Neo4j Cypher spatial functions documentation and the OGC Simple Features specification.