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.
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_lonandlon + d_lonare 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 unsatisfiablemin > 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 Cypherany(rng IN $lon_ranges WHERE …)expands to an OR of range predicates onlocation.longitude. The planner resolves an OR of ranges on the same indexed property as multiplePointIndexSeekByRangeoperations, 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 finalWHERE 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.
Related
- Filtering Graph Paths by Haversine Distance in Cypher — the exact great-circle clip that treats the seam as invisible.
- Distance Filter Query Patterns — the two-stage box-then-distance primitive this edge case extends.
- K-Nearest-Neighbor Routing — where a seam-crossing candidate set feeds a graph projection and re-rank.
- Spatial Indexing Strategies — the point index that makes both split boxes seekable.
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.