Docs
View as markdown

Geospatial queries

Store locations as points, measure distances in meters, find places within a radius or a map box, and speed both up with a point index.

cyphercli

Before you start: Your first graph in the CLI

This guide shows you how to store locations as point values, measure the distance between them, find the places within a radius or inside a map box, and add a point index An index over a property holding point values that narrows bounding-box and radius predicates to nearby nodes through an R-tree. Read more in the glossary that narrows those searches. The cells run in page order on one in-memory store A relate database: a directory, a single .rl file, or :memory:, opened with --database. Read more in the glossary, in your browser and in the docs build; the index steps also run in a terminal, where the plan and its counters are visible.

Prerequisites

Load some places

This cell writes eight places: six near a riverside office in Portland, Oregon, and two in Seattle, Washington, to the north. Each place has a loc property A named value stored on a node or relationship, such as name: 'Ada'; values are scalars, temporal and spatial values, vectors, or lists of them. Read more in the glossary built with point() from a latitude and a longitude.

Cypher
CREATE (:Place {name: 'Riverside Office', kind: 'office', loc: point({latitude: 45.5231, longitude: -122.6765})}),
       (:Place {name: 'Bean There', kind: 'cafe', loc: point({latitude: 45.5262, longitude: -122.6789})}),
       (:Place {name: 'Ferry Cafe', kind: 'cafe', loc: point({latitude: 45.5122, longitude: -122.6587})}),
       (:Place {name: 'Hilltop Roasters', kind: 'cafe', loc: point({latitude: 45.5428, longitude: -122.7011})}),
       (:Place {name: 'Harbor Library', kind: 'library', loc: point({latitude: 45.5190, longitude: -122.6730})}),
       (:Place {name: 'Eastside Cafe', kind: 'cafe', loc: point({latitude: 45.5155, longitude: -122.6205})}),
       (:Place {name: 'Pier Cafe', kind: 'cafe', loc: point({latitude: 47.6097, longitude: -122.3331})}),
       (:Place {name: 'North Library', kind: 'library', loc: point({latitude: 47.6205, longitude: -122.3493})})
Recorded result
(no rows) · nodes created: 8, properties set: 24, labels added: 8

List them with their coordinates to check the load:

Cypher
MATCH (p:Place)
RETURN p.name AS name, p.kind AS kind, p.loc.latitude AS latitude, p.loc.longitude AS longitude
ORDER BY name
Recorded result
namekindlatitudelongitude
"Bean There""cafe"45.5262-122.6789
"Eastside Cafe""cafe"45.5155-122.6205
"Ferry Cafe""cafe"45.5122-122.6587
"Harbor Library""library"45.519-122.673
"Hilltop Roasters""cafe"45.5428-122.7011
"North Library""library"47.6205-122.3493
"Pier Cafe""cafe"47.6097-122.3331
"Riverside Office""office"45.5231-122.6765

8 rows

Search an area on a plane

The explorer draws the places on a plane, longitude across and latitude up, and searches them with two cells on this page: the radius search from Find places within a radius and the box search from Find places inside a map box. Drag the circle or the box, or focus it and press the arrow keys to move it and + or - to resize it. Each move runs the search again on this page's store with the shape's position as its parameter A named value a statement refers to as $name and the caller binds separately, such as with --param name=value on the CLI. Read more in the glossary, and the places it returns light up with their names. The statement and its parameters show under the plane as they ran, with PROFILE in front so the plan reports what the scan read.

The point index button runs the statement from Add a point index, or drops the index, and searches again. With eight places the times are too small to show the index at work; the scan's database hits show it: the scan reads every place without the index and only the places the index returns with it. The two Seattle places lie beyond the plane; without the index the scan still reads them.

The radius cell's recorded result: 4 places within 2000 m of latitude 45.5231, longitude -122.6765.

namekindmeters
Riverside Officeoffice0.0
Bean Therecafe393.0
Harbor Librarylibrary532.0
Ferry Cafecafe1844.0

Not drawn, beyond the plane: to the north-east, North Library and Pier Cafe.

The places and the radius search, as recorded6 places on a plane, longitude across and latitude up, with the radius search's circle of 2000 m around latitude 45.5231, longitude -122.6765. Lit, with their names: Riverside Office, Bean There, Harbor Library and Ferry Cafe. Outside the circle: Eastside Cafe and Hilltop Roasters. Beyond the plane: to the north-east, North Library and Pier Cafe.Bean ThereEastside CafeFerry CafeHarbor LibraryHilltop RoastersRiverside Office2 more beyond the plane: to the north-east, North Library and Pier Cafe1 km
The places and the radius search, as recorded6 places on a plane, longitude across and latitude up, with the radius search's circle of 2000 m around latitude 45.5231, longitude -122.6765. Lit, with their names: Riverside Office, Bean There, Harbor Library and Ferry Cafe. Outside the circle: Eastside Cafe and Hilltop Roasters. Beyond the plane: to the north-east, North Library and Pier Cafe.Bean ThereEastside CafeFerry CafeHarbor LibraryHilltop RoastersRiverside Office2 more beyond the plane: to the north-east, North Library and Pier Cafe1 km
The places, and the 2000 m radius search as recorded

Live: the circle drags across the plane (arrow keys move it, plus and minus resize it), and each move runs the radius search again on the page's database in your browser with the circle as its parameters, lighting up the places it returns and showing the parse, plan and execute times and the scan's database hits; where the page has a box search, a box drags and searches the same way, and where it names its point index, a button creates or drops the index and searches again.

Read a point

A point built from latitude and longitude is a geographic point on the WGS-84 coordinate system, spatial reference id (SRID) 4326. Its parts are properties of the value:

Cypher
MATCH (p:Place {name: 'Riverside Office'})
RETURN p.loc.srid AS srid, p.loc.crs AS crs, p.loc.latitude AS latitude, p.loc.longitude AS longitude
Recorded result
sridcrslatitudelongitude
4326"wgs-84"45.5231-122.6765

1 row

point() mapCoordinate systemSRIDDistance unit
{latitude, longitude}wgs-844326meters, along the Earth's surface
{latitude, longitude, height}wgs-84-3d4979meters
{x, y}cartesian7203the coordinates' own unit, as a straight line
{x, y, z}cartesian-3d9157the coordinates' own unit

On a geographic point, x is the longitude and y the latitude. A latitude outside -90 to 90 fails with InvalidArgumentValue: point() latitude must be in the range [-90, 90], after type error: in the cells and semantic error: on the CLI. The check also catches a latitude and longitude passed the wrong way round wherever the longitude is beyond 90 degrees either way, as in Portland. Points are the whole geospatial surface: there are no polygons or lines, so store a shape as an ordinary property and do the geometry in your program.

Measure a distance

point.distance(a, b) between two geographic points returns meters, by the haversine formula with an Earth radius of 6,378,140 m:

Cypher
MATCH (a:Place {name: 'Riverside Office'}), (b:Place {name: 'Pier Cafe'})
RETURN round(point.distance(a.loc, b.loc) / 1000, 1) AS km
Recorded result
km
233.8

1 row

Between two cartesian points it returns the straight-line distance: point.distance(point({x: 0, y: 0}), point({x: 3, y: 4})) is 5.0. Between points of two different coordinate systems it returns null, with no error.

Find places within a radius

Compare the distance from a center with the radius in meters. The center and the radius are parameters, so the same statement serves any position:

Cypher
MATCH (p:Place)
WHERE point.distance(p.loc, point({latitude: $lat, longitude: $lon})) < $radius
RETURN p.name AS name, p.kind AS kind,
       round(point.distance(p.loc, point({latitude: $lat, longitude: $lon}))) AS meters
ORDER BY meters
Parameters
Recorded result
namekindmeters
"Riverside Office""office"0.0
"Bean There""cafe"393.0
"Harbor Library""library"532.0
"Ferry Cafe""cafe"1844.0

4 rows

Keep point.distance(<property>, <center>) on the left of the comparison, inside the WHERE, as the cell does. That is the shape a point index serves; a distance computed in a WITH and filtered later is checked on every place instead.

Find the nearest places

Order by distance and take the first few. Bounding the search with a radius first lets a point index narrow it, and keeps the result near:

Cypher
MATCH (p:Place {kind: 'cafe'})
WHERE point.distance(p.loc, point({latitude: $lat, longitude: $lon})) < 5000
RETURN p.name AS cafe, round(point.distance(p.loc, point({latitude: $lat, longitude: $lon})) / 1000, 1) AS km
ORDER BY km
LIMIT 3
Parameters
Recorded result
cafekm
"Bean There"0.4
"Ferry Cafe"1.8
"Hilltop Roasters"2.9

3 rows

Find places inside a map box

point.withinBBox(<point>, <lower-left>, <upper-right>) is true when the point lies inside the box the two corners span. For geographic points, the corners are latitude and longitude pairs, so the box is a latitude and longitude rectangle. Its south, west, north and east limits are parameters:

Cypher
MATCH (p:Place)
WHERE point.withinBBox(p.loc, point({latitude: $south, longitude: $west}), point({latitude: $north, longitude: $east}))
RETURN p.name AS name
ORDER BY name
Parameters
Recorded result
name
"Bean There"
"Ferry Cafe"
"Harbor Library"
"Riverside Office"

4 rows

A box whose lower-left longitude is greater than its upper-right longitude crosses the antimeridian, at longitude 180. Mixing coordinate systems among the three points returns null.

Add a point index

A point index keeps the points of one label and property in an R-tree, so a radius or box predicate reads only the nearby nodes:

Cypher
CREATE POINT INDEX place_loc IF NOT EXISTS FOR (p:Place) ON (p.loc)
Recorded result
(no rows) · indexes added: 1

The plan does not name the index: EXPLAIN shows the same NodeByLabelScan with a WHERE filter before and after it, because the index works inside that filter. PROFILE shows the difference in the scan's database hits. To see it in a terminal, write the same places to a new store, places.rl. This command writes:

relate --database ./places.rl "CREATE (:Place {name: 'Riverside Office', kind: 'office', loc: point({latitude: 45.5231, longitude: -122.6765})}),
       (:Place {name: 'Bean There', kind: 'cafe', loc: point({latitude: 45.5262, longitude: -122.6789})}),
       (:Place {name: 'Ferry Cafe', kind: 'cafe', loc: point({latitude: 45.5122, longitude: -122.6587})}),
       (:Place {name: 'Hilltop Roasters', kind: 'cafe', loc: point({latitude: 45.5428, longitude: -122.7011})}),
       (:Place {name: 'Harbor Library', kind: 'library', loc: point({latitude: 45.5190, longitude: -122.6730})}),
       (:Place {name: 'Eastside Cafe', kind: 'cafe', loc: point({latitude: 45.5155, longitude: -122.6205})}),
       (:Place {name: 'Pier Cafe', kind: 'cafe', loc: point({latitude: 47.6097, longitude: -122.3331})}),
       (:Place {name: 'North Library', kind: 'library', loc: point({latitude: 47.6205, longitude: -122.3493})})"

Profile the radius search, read-only, and print only the scan's DbHits counter from the plan in the envelope One compact JSON object per result set, printed by --mode envelope, that keeps the column list even when there are no rows. Read more in the glossary. Without an index, the scan reads all eight places:

relate --database ./places.rl --read-only --mode envelope "PROFILE MATCH (p:Place) WHERE point.distance(p.loc, point({latitude: 45.5231, longitude: -122.6765})) < 2000 RETURN p.name AS name" | grep -o '"DbHits":[0-9]*' | tail -n 1
"DbHits":8

Create the index. This command writes it to places.rl:

relate --database ./places.rl "CREATE POINT INDEX place_loc IF NOT EXISTS FOR (p:Place) ON (p.loc)"

The same search now reads only the four places the index returns:

relate --database ./places.rl --read-only --mode envelope "PROFILE MATCH (p:Place) WHERE point.distance(p.loc, point({latitude: 45.5231, longitude: -122.6765})) < 2000 RETURN p.name AS name" | grep -o '"DbHits":[0-9]*' | tail -n 1
"DbHits":4

The index serves point.withinBBox(<property>, …) and point.distance(<property>, <center>) < <bound> (or <=) in a WHERE, with the center a literal, a parameter or a variable bound before the MATCH. Ordering by distance with LIMIT and no radius reads every node of the label. Tune a query covers reading a profile.

Verify

SHOW INDEXES lists the point index and its state:

Cypher
SHOW INDEXES YIELD name, type, labelsOrTypes, properties, state
WHERE type = 'POINT'
RETURN name, type, labelsOrTypes, properties, state
Recorded result
nametypelabelsOrTypespropertiesstate
"place_loc""POINT"["Place"]["loc"]"ONLINE"

1 row

An ONLINE index and four places within 2 km of the office mean the index is in place and the radius search finds what the map shows.

If it goes wrong

Message or symptomCauseRemedy
semantic error: InvalidArgumentValue: point() latitude must be in the range [-90, 90]The latitude is out of range, often because latitude and longitude are swapped.Pass {latitude: <lat>, longitude: <lon>} by name.
syntax error: InvalidPoint: the map is not a valid POINT. Use either Cartesian ('x' and 'y') or geographic ('latitude' and 'longitude') coordinatesThe map lacks one of the two keys of a kind, as in {x: 1, latitude: 2}.Give x and y, or latitude and longitude.
A distance or withinBBox returns nullThe points use different coordinate systems, or one is null.Compare p.loc.srid on both sides, and build every point the same way.
syntax error: UnknownFunction: unknown function distance()The older Neo4j name.Use point.distance().
PROFILE reads every node after the index existsThe distance is computed in a WITH and filtered later, or the search has no radius.Put point.distance(<property>, <center>) < <bound> in the WHERE of the MATCH.