Skip to content

About

Spatial algorithm functions for Neo4j Cypher: point-in-polygon, intersection, buffer, bounding box, and point-to-polygon distance. Built on JTS 1.20.0.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

neo4j-spatial-functions

Five spatial algorithm functions for Neo4j Cypher — point-in-polygon containment, polygon intersection, buffer zone generation, bounding box extraction, and point-to-polygon distance.

Built on JTS (Java Topology Suite) 1.20.0. Targets Neo4j 2026.01.x Community and Enterprise. Designed as an extension to the neo4j-contrib/spatial-algorithms ecosystem.


Why This Exists

Neo4j has excellent built-in spatial support for points (point()), point indexing, and rectangular bounding box queries (point.withinBBox()). What it lacks are polygon-based spatial primitives — the ability to test whether a point falls inside an arbitrary polygon, whether two polygons overlap, or how far a point is from a polygon boundary.

Without these, any application that needs polygon-based spatial reasoning must maintain a parallel PostGIS/spatial database alongside Neo4j and coordinate queries across both systems. This is operationally expensive and architecturally complex.

These five functions close that gap entirely for the most common spatial query patterns, letting you express polygon-based queries in plain Cypher.


Functions

Function Signature Returns
spatial.algo.withinPolygon (point, polygonWkt) Boolean
spatial.algo.intersectsPolygon (wktA, wktB) Boolean
spatial.algo.buffer (wkt, distanceMetres) String (WKT)
spatial.algo.envelope (wkt) Map
spatial.algo.pointToPolygonDistance (point, polygonWkt) Double (metres)

All functions return null if any input is null, following Neo4j's standard null propagation convention.


Coordinate Order

Important: This library handles coordinate order conversion internally.

WKT stores coordinates as (longitude, latitude) — i.e. (x, y) order. Neo4j's point() type uses named parameters {latitude, longitude}.

You use Neo4j point syntax. The library handles the rest.

-- Correct: just use point() normally
RETURN spatial.algo.withinPolygon(
    point({latitude: 51.0447, longitude: -114.0719}),  -- Calgary
    'POLYGON((-120 49, -110 49, -110 60, -120 60, -120 49))')  -- Alberta bbox
-- → true

-- The WKT 'POLYGON((-120 49, ...))' means (lon=-120, lat=49) at each vertex.
-- The function converts the Neo4j point to (lon, lat) before the JTS comparison.

API Reference

spatial.algo.withinPolygon

spatial.algo.withinPolygon(point :: POINT, polygonWkt :: STRING) :: BOOLEAN

Returns true if point is contained within the geometry described by polygonWkt.

Supports POLYGON and MULTIPOLYGON WKT. A point exactly on the polygon boundary returns true (JTS contains semantics).

Example — basic containment:

RETURN spatial.algo.withinPolygon(
    point({latitude: 53.5461, longitude: -113.4938}),  -- Edmonton
    'POLYGON((-120 49, -110 49, -110 60, -120 60, -120 49))')  -- Alberta
-- → true

Example — find which administrative area contains a coordinate:

MATCH (a:AdministrativeArea)
WHERE a.wkt IS NOT NULL
  AND spatial.algo.withinPolygon(
      point({latitude: $lat, longitude: $lon}),
      a.wkt)
RETURN a.name, a.level

Performance pattern — bbox pre-filter then precise check:

For large datasets, always pre-filter with indexed bounding box properties before calling withinPolygon. The bbox indexes reduce the candidate set from thousands of polygons to a handful before the (more expensive) polygon containment check runs.

-- Assumes AdministrativeArea nodes have bboxMinLat/bboxMaxLat/bboxMinLon/bboxMaxLon
-- properties with RANGE indexes (see Installation section)
MATCH (a:AdministrativeArea)
WHERE a.bboxMinLat <= $lat AND a.bboxMaxLat >= $lat
  AND a.bboxMinLon <= $lon AND a.bboxMaxLon >= $lon
  AND a.wkt IS NOT NULL
  AND spatial.algo.withinPolygon(
      point({latitude: $lat, longitude: $lon}),
      a.wkt)
RETURN a.name, a.level
ORDER BY a.level

spatial.algo.intersectsPolygon

spatial.algo.intersectsPolygon(wktA :: STRING, wktB :: STRING) :: BOOLEAN

Returns true if the two WKT geometries share any space (intersect).

Supports any combination of POLYGON, MULTIPOLYGON, LINESTRING, POINT, etc.

Example — find overlapping zones:

MATCH (a:ConsultationZone), (b:ProtectedArea)
WHERE spatial.algo.intersectsPolygon(a.wkt, b.wkt)
RETURN a.name AS zone, b.name AS protectedArea

Example — find all protected areas that overlap a given parcel:

MATCH (parcel:LandParcel {parcelId: $parcelId})
MATCH (pa:ProtectedArea)
WHERE spatial.algo.intersectsPolygon(parcel.wkt, pa.wkt)
RETURN pa.name, pa.designation

spatial.algo.buffer

spatial.algo.buffer(wkt :: STRING, distanceMetres :: FLOAT) :: STRING

Returns a WKT polygon string representing the input geometry expanded outward by distanceMetres. The output WKT can be used directly with withinPolygon or intersectsPolygon.

Accuracy: Uses a planar approximation to convert metres to degrees (1° latitude ≈ 111,320 m). Accurate to approximately 2% for buffer distances under 50 km at latitudes between 45°N and 60°N (covers most of Canada, US, Europe). Not suitable for survey-grade or legally binding setback determinations — use PostGIS ST_Buffer with geography type for those cases.

Example — facilities within 500m of any protected area:

MATCH (pa:ProtectedArea)
WHERE pa.wkt IS NOT NULL
WITH pa, spatial.algo.buffer(pa.wkt, 500) AS setbackZone
MATCH (f:Facility)
WHERE f.location IS NOT NULL
  AND spatial.algo.withinPolygon(f.location, setbackZone)
  AND f.status = 'ACTIVE'
MATCH (o:Organization)-[:OPERATES]->(f)
RETURN o.name AS operator, f.facilityId, pa.name AS nearProtectedArea
ORDER BY o.name

Example — consultation zone around a pipeline corridor:

MATCH (pl:PipelineLicence {licenceNumber: $licence})
WITH pl, spatial.algo.buffer(pl.routeWkt, 1000) AS consultZone
MATCH (lp:LandParcel)
WHERE spatial.algo.intersectsPolygon(lp.wkt, consultZone)
RETURN lp.parcelId, lp.owner

spatial.algo.envelope

spatial.algo.envelope(wkt :: STRING) :: MAP

Returns the axis-aligned bounding box of a WKT geometry as a map with keys minLon, minLat, maxLon, maxLat (all FLOAT).

Useful for pre-computing bbox properties from WKT, and for quick spatial reasoning without full polygon evaluation.

Example — compute and store bbox from WKT:

MATCH (a:GeographicArea)
WHERE a.wkt IS NOT NULL AND a.bboxMinLat IS NULL
WITH a, spatial.algo.envelope(a.wkt) AS env
SET a.bboxMinLat = env.minLat,
    a.bboxMaxLat = env.maxLat,
    a.bboxMinLon = env.minLon,
    a.bboxMaxLon = env.maxLon

Example — quick bbox overlap check before expensive intersection:

WITH spatial.algo.envelope($wkt) AS queryBox
MATCH (a:AdministrativeArea)
WHERE a.bboxMinLon <= queryBox.maxLon AND a.bboxMaxLon >= queryBox.minLon
  AND a.bboxMinLat <= queryBox.maxLat AND a.bboxMaxLat >= queryBox.minLat
RETURN a.name

spatial.algo.pointToPolygonDistance

spatial.algo.pointToPolygonDistance(point :: POINT, polygonWkt :: STRING) :: FLOAT

Returns the distance in metres from point to the nearest edge of polygonWkt. Returns 0.0 if the point is inside the polygon (JTS distance() semantics).

Neo4j's built-in point.distance() function only operates point-to-point. This function enables proximity queries against polygon boundaries — essential for setback compliance checks, proximity reporting, and spatial ranking.

Accuracy: Same planar approximation as buffer(). ~2% error for distances under 50 km at mid-latitudes.

Example — how far is a well from the nearest protected area?

MATCH (w:Well {wellId: $wellId})
MATCH (pa:ProtectedArea)
WHERE pa.wkt IS NOT NULL
WITH w, pa,
     spatial.algo.pointToPolygonDistance(w.location, pa.wkt) AS distMetres
WHERE distMetres < 5000  -- within 5km
RETURN pa.name, pa.designation, round(distMetres) AS distanceMetres
ORDER BY distMetres

Example — find the closest administrative boundary and its distance:

MATCH (a:AdministrativeArea)
WHERE a.wkt IS NOT NULL AND a.level = 'province'
WITH a,
     spatial.algo.pointToPolygonDistance(
         point({latitude: $lat, longitude: $lon}),
         a.wkt) AS distMetres
RETURN a.name, round(distMetres / 1000, 1) AS distanceKm
ORDER BY distMetres
LIMIT 3

Installation

Step 1 — Copy the JAR to your plugins directory

cp neo4j-spatial-functions-1.0.0.jar $NEO4J_HOME/plugins/

The JAR is self-contained (JTS is shaded in with a relocated package prefix to avoid classpath conflicts).

Step 2 — Restart Neo4j

# Linux/Mac service
sudo systemctl restart neo4j

# Or Neo4j Desktop: stop/start from the UI

Step 3 — Verify installation

RETURN spatial.algo.withinPolygon(
    point({latitude: 51.0, longitude: -114.0}),
    'POLYGON((-120 49, -110 49, -110 60, -120 60, -120 49))')
-- → true

Step 4 — (Recommended) Create bbox indexes for performance

If your nodes have WKT polygon properties, pre-compute bbox properties and index them for fast spatial pre-filtering:

-- Populate bbox properties from WKT (one-time backfill)
MATCH (a:AdministrativeArea) WHERE a.wkt IS NOT NULL AND a.bboxMinLat IS NULL
WITH a, spatial.algo.envelope(a.wkt) AS env
SET a.bboxMinLat = env.minLat, a.bboxMaxLat = env.maxLat,
    a.bboxMinLon = env.minLon, a.bboxMaxLon = env.maxLon;

-- Create RANGE indexes on each bbox dimension
CREATE INDEX idx_area_bbox_minlat IF NOT EXISTS FOR (a:AdministrativeArea) ON (a.bboxMinLat);
CREATE INDEX idx_area_bbox_maxlat IF NOT EXISTS FOR (a:AdministrativeArea) ON (a.bboxMaxLat);
CREATE INDEX idx_area_bbox_minlon IF NOT EXISTS FOR (a:AdministrativeArea) ON (a.bboxMinLon);
CREATE INDEX idx_area_bbox_maxlon IF NOT EXISTS FOR (a:AdministrativeArea) ON (a.bboxMaxLon);

This pattern filters from the full node set to a handful of bbox candidates before running the precise polygon containment check — typically reducing query time from seconds to milliseconds on large datasets.


Building from Source

Requirements: JDK 21+, Maven 3.9+

git clone https://github.com/DIGITAL-FABRIC-AI/neo4j-spatial-functions.git
cd neo4j-spatial-functions
mvn clean package
# → target/neo4j-spatial-functions-1.0.0.jar

Without a local JDK (Docker-based build):

./build.sh
# → target/neo4j-spatial-functions-1.0.0.jar

Testing

mvn clean test

Tests use the Neo4j test harness to run an embedded Neo4j instance. All tests use real Canadian coordinates (Edmonton, Calgary, Vancouver, Regina) against simplified provincial boundary polygons:

Test Purpose
Edmonton / Calgary inside Alberta Basic containment
Vancouver / Regina outside Alberta Outside-polygon rejection
Coordinate order not swapped Guards against lat/lon reversal bugs
Null inputs → null Null propagation
Buffer expands to contain origin Buffer correctness
Buffer returns valid WKT Output format
Envelope returns correct corners Bounding box correctness
Distance = 0 inside polygon Interior point
Distance > 0 outside polygon Exterior point
MultiPolygon containment Island / multi-region support
Intersection true/false Polygon overlap

Design Notes

Why JTS?

JTS (Java Topology Suite) is the reference implementation of the OGC Simple Features geometry model and is used by PostGIS, GeoTools, GEOS, and most serious spatial software. Using JTS means:

  • Well-tested computational geometry (polygon buffer, distance, containment)
  • Correct handling of edge cases (degenerate polygons, self-intersections, holes)
  • Full MULTIPOLYGON and geometry collection support
  • Maintained, actively developed upstream

The neo4j-contrib/spatial-algorithms repository uses a clean-room approach (no external geometry libraries). That has merit for simple algorithms, but polygon buffer generation and robust containment testing are non-trivial to implement correctly from scratch. Using JTS avoids reimplementing well-solved problems and lets this library focus on the Neo4j integration.

JTS is shaded into the plugin JAR with a relocated package prefix (org.neo4j.spatial.shaded.jts), so it cannot conflict with any JTS version your application or Neo4j itself might use.

Buffer Accuracy

JTS buffer() operates in the coordinate space of the geometry. For WGS84 geometries, that means the buffer is computed in degrees, not metres. The conversion uses a constant: 1° latitude ≈ 111,320 m.

This is a planar approximation. The actual metres-per-degree varies:

  • Latitude degrees are nearly constant (~111,320 m everywhere)
  • Longitude degrees shrink toward the poles: at 60°N, 1° longitude ≈ 55,660 m

The buffer implementation uses the latitude-degree constant uniformly, which means buffer zones are slightly squashed in the east-west direction at high latitudes. The error is less than 2% for distances under 50 km anywhere between 45°N and 60°N — sufficient for screening, analysis, and visualization queries.

For legally binding setback determinations, use PostGIS ST_Buffer with geography type.

Null Handling

All five functions follow Neo4j's null propagation convention: if any argument is null, the function returns null. This lets queries use these functions safely on nodes that may or may not have WKT properties without needing explicit null guards:

-- Safe even if a.wkt is null for some nodes
MATCH (a:AdministrativeArea)
RETURN a.name,
       spatial.algo.withinPolygon(point({latitude: $lat, longitude: $lon}), a.wkt)
       AS contains

Thread Safety

WKTReader is not thread-safe, so a new instance is created per function call. GeometryFactory is thread-safe and shared as a static constant. This is the standard JTS usage pattern.


Compatibility

Component Version
Neo4j 2026.01.x (Community and Enterprise)
Java 21+
JTS 1.20.0 (shaded — no classpath conflicts)

Note on Neo4j version: This plugin targets Neo4j 2026.01.x. The Neo4j procedure API has been stable since 5.x. If you need compatibility with Neo4j 5.x (non-2026), the code should compile and run without modification — the procedure registration API and point coordinate access are identical. Please open an issue if you encounter version-specific problems.


Contributing

Issues and pull requests welcome. Please:

  • Include a test case for any new function or bug fix
  • Run mvn clean test before submitting
  • Follow the existing code style (Javadoc on all public methods, Apache 2.0 header)

Related Projects


License

Apache License 2.0 — see LICENSE

Author

Ashley Dunfield github.com/DIGITAL-FABRIC-AI

Built while developing geospatial capabilities for oil & gas regulatory compliance on a production Neo4j graph with 5,400+ administrative boundary polygons and 3.1M entity-jurisdiction relationships. Validated on Neo4j 2026.01.4 Community Edition.

About

Spatial algorithm functions for Neo4j Cypher: point-in-polygon, intersection, buffer, bounding box, and point-to-polygon distance. Built on JTS 1.20.0.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages