Cookbook
All recipes
Nearby places for an agentGeospatial

Agent memory

Search and retrieval

Data quality

Graph analytics

Geospatial

Cookbook / Geospatial · Beginner

Nearby places for an agent

A travel assistant remembers the places its users mention and the places its agents visit. Five small queries turn that memory into tools: what is within 2 km, what is nearest, what is in this map box, and where an agent went recently near here.

The graph is (:Place {name, kind, loc})-[:IN]->(:City) for places, and (:Agent {id})-[:VISITED {ts, seq}]->(:Place) for visits. loc is a WGS-84 point, so distances come back in metres. "Here" is Hotel Lucia in downtown Portland.

Data model: agents visit places, and places are in citiesAn Agent VISITED a Place, and each visit records a timestamp and a sequence number. A Place has a name, a kind and a location, and is IN a City.AgentidPlacename · kindlocCitynameVISITEDts · seqIN
Data model: agents visit places, and places are in citiesAn Agent VISITED a Place, and each visit records a timestamp and a sequence number. A Place has a name, a kind and a location, and is IN a City.AgentidPlacename · kindlocCitynameVISITEDts · seqIN
Data model: agents visit places, and places are in cities

Load the places and visits

Constraints keep names unique, so MERGE never duplicates a place, city, or agent. Visit timestamps are epoch milliseconds, and two relationship property constraints reject a visit without an integer ts.

Step 1
Show the setup step
CREATE CONSTRAINT place_name IF NOT EXISTS FOR (p:Place) REQUIRE p.name IS UNIQUE;
CREATE CONSTRAINT city_name IF NOT EXISTS FOR (c:City) REQUIRE c.name IS UNIQUE;
CREATE CONSTRAINT agent_id IF NOT EXISTS FOR (a:Agent) REQUIRE a.id IS UNIQUE;
CREATE CONSTRAINT visit_ts IF NOT EXISTS
  FOR ()-[r:VISITED]-() REQUIRE r.ts IS :: INTEGER;
CREATE CONSTRAINT visit_ts_nn IF NOT EXISTS
  FOR ()-[r:VISITED]-() REQUIRE r.ts IS NOT NULL;
CREATE POINT INDEX place_loc IF NOT EXISTS FOR (p:Place) ON (p.loc)
Recorded result
(no rows) · indexes added: 1, constraints added: 5

The fixture is one JSON file with a places list and a visits list. point({latitude, longitude}) builds a WGS-84 point (SRID 4326).

Step 2
Show the setup step
UNWIND $places AS r
MERGE (c:City {name: r.city})
CREATE (:Place {name: r.name, kind: r.kind,
                loc: point({latitude: r.lat, longitude: r.lon})})-[:IN]->(c);

UNWIND $visits AS v
WITH DISTINCT v.agent AS id ORDER BY id
MERGE (:Agent {id: id});

UNWIND $visits AS v
MATCH (a:Agent {id: v.agent}), (p:Place {name: v.place})
CREATE (a)-[:VISITED {ts: v.ts, seq: v.seq}]->(p);

MATCH (p:Place) WITH count(p) AS places
MATCH (c:City) WITH places, count(c) AS cities
MATCH (:Agent)-[v:VISITED]->()
RETURN places, cities, count(v) AS visits
Loads places.json (10 KB)
Recorded result
placescitiesvisits
80218

1 row · nodes created: 84, relationships created: 98, properties set: 280, labels added: 84

Where is a place?

A point exposes its coordinates and reference system as properties. Return scalars when a caller needs numbers; return the point itself when it will be passed back into another query.

Step 3
MATCH (p:Place {name: $name})-[:IN]->(c:City)
RETURN p.name AS name, c.name AS city,
       p.loc.latitude AS lat, p.loc.longitude AS lon, p.loc.srid AS srid,
       valueType(p.loc) AS type
Parameters
Recorded result
namecitylatlonsridtype
"Blue Star Donuts""Portland"45.5185-122.67954326"POINT NOT NULL"

1 row

Cafes within 2 km

$here is a plain map, so point($here) builds the caller's position. A radius is a distance comparison rather than a function of its own: point.distance returns the gap in metres, and < $radius keeps the places inside it. Every radius query below is written the same way.

Step 4
MATCH (p:Place)
WHERE p.kind = $kind AND point.distance(p.loc, point($here)) < $radius
RETURN p.name AS name, toInteger(round(point.distance(p.loc, point($here)))) AS metres
ORDER BY metres, name
Parameters
Recorded result
namemetres
"Blue Star Donuts"447
"Stumptown"507
"Coava"1560

3 rows

The same radius, counted per city. Seattle is 230 km away and contributes nothing:

Step 5
MATCH (p:Place)-[:IN]->(c:City)
RETURN c.name AS city, count(p) AS places,
       sum(CASE WHEN point.distance(p.loc, point($here)) < $radius
                THEN 1 ELSE 0 END) AS within
ORDER BY city
Parameters
Recorded result
cityplaceswithin
"Portland"5513
"Seattle"250

2 rows

The radius on a plane

The explorer below draws the Portland bookstores on a plane around Hotel Lucia and searches them with the radius tool. It takes the search in two steps: one returns each bookstore's coordinates as numbers, and one is the radius query with the position as $lat and $lon instead of the $here map.

Step 6
MATCH (p:Place {kind: 'bookstore'})-[:IN]->(:City {name: 'Portland'})
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
"Belmont Bookstore""bookstore"45.52876-122.73347
"Division Bookstore""bookstore"45.48139-122.67622
"Marshall Bookstore""bookstore"45.51802-122.66999
"Overton Bookstore""bookstore"45.55439-122.72204
"Powell's""bookstore"45.5231-122.6813
"Salmon Bookstore""bookstore"45.53677-122.66596
"Yamhill Bookstore""bookstore"45.47985-122.73765

7 rows

Step 7
MATCH (p:Place {kind: 'bookstore'})
WITH p, point.distance(p.loc, point({latitude: $lat, longitude: $lon})) AS d
WHERE d < $radius
RETURN p.name AS name, round(d) AS metres
ORDER BY metres, name
Parameters
Recorded result
namemetres
"Powell's"121.0
"Marshall Bookstore"926.0
"Salmon Bookstore"1929.0

3 rows

Dragging the circle runs the near step again with the circle's centre and radius as its parameters; the arrow keys move the circle, and plus and minus resize it. Each search runs under PROFILE, so the explorer also shows its parse, plan and execute times and the database hits of its scan.

The near cell's recorded result: 3 places within 2000 m of latitude 45.5225, longitude -122.68.

name metres
Powell's 121.0
Marshall Bookstore 926.0
Salmon Bookstore 1929.0
The places and the radius search, as recorded7 places on a plane, longitude across and latitude up, with the radius search's circle of 2000 m around latitude 45.5225, longitude -122.68. Lit, with their names: Powell's, Marshall Bookstore and Salmon Bookstore. Outside the circle: Belmont Bookstore, Division Bookstore, Overton Bookstore and Yamhill Bookstore.Belmont BookstoreDivision BookstoreMarshall BookstoreOverton BookstorePowell'sSalmon BookstoreYamhill Bookstore2 km
The places and the radius search, as recorded7 places on a plane, longitude across and latitude up, with the radius search's circle of 2000 m around latitude 45.5225, longitude -122.68. Lit, with their names: Powell's, Marshall Bookstore and Salmon Bookstore. Outside the circle: Belmont Bookstore, Division Bookstore, Overton Bookstore and Yamhill Bookstore.Belmont BookstoreDivision BookstoreMarshall BookstoreOverton BookstorePowell'sSalmon BookstoreYamhill Bookstore2 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.

The nearest k cafes

With no radius, order by distance and cut. $k is a parameter, so the tool can ask for as many as it needs.

Step 8
MATCH (p:Place)
WHERE p.kind = $kind
WITH p, point.distance(p.loc, point($here)) AS d
ORDER BY d, p.name
LIMIT $k
RETURN p.name AS name, toInteger(round(d)) AS metres
Parameters
Recorded result
namemetres
"Blue Star Donuts"447
"Stumptown"507

2 rows

What is in this map box?

point.withinBBox(point, lowerLeft, upperRight) answers "what is on screen". The box below has Blue Star Donuts exactly on its lower-left corner and Stumptown exactly on its upper-right corner. Both count: the boundary is inclusive.

Step 9
MATCH (p:Place)
WHERE point.withinBBox(p.loc, point($ll), point($ur))
WITH p ORDER BY p.name
RETURN count(p) AS in_box, collect(p.name) AS names
Parameters
Recorded result
in_boxnames
2["Blue Star Donuts", "Stumptown"]

1 row

Where did this agent go recently, near here?

One agent's last visits within the radius. The anchor, Agent {id: $agent}, is unique, and ts is an integer, so ordering by v.ts and cutting is exact.

Step 10
MATCH (a:Agent {id: $agent})-[v:VISITED]->(p:Place)
WHERE point.distance(p.loc, point($here)) < $radius
RETURN p.name AS name, v.seq AS seq, v.ts AS ts
ORDER BY v.ts DESC
LIMIT $n
Parameters
Recorded result
nameseqts
"Blue Star Donuts"101788330030000
"Stumptown"61788288080000

2 rows

The latest visits across all agents

A supervisor wants the newest visits from every agent. The same read now starts from two Agent nodes.

Step 11
MATCH (a:Agent)-[v:VISITED]->(p:Place)
RETURN a.id AS agent, p.name AS name, v.seq AS seq, v.ts AS ts
ORDER BY v.ts DESC, v.seq ASC
LIMIT $n
Parameters
Recorded result
agentnameseqts
"a2""Pike Place"61788363159000
"a2""Pike Cafe"51788352372000
"a2""Blanchard Cafe"41788350079000

3 rows

Note Keep the second sort key (v.seq ASC) when the anchor pattern matches more than one node. Without it, Relate currently returns agent a1's newest visits instead of the newest overall: it mis-orders a relationship property under ORDER BY … LIMIT when the pattern starts from more than one node.

To see the shape, return the visits themselves. Open the graph view:

Step 12
MATCH path = (a:Agent)-[v:VISITED]->(p:Place)-[:IN]->(:City)
WHERE point.distance(p.loc, point($here)) < $radius
RETURN path
Parameters
Recorded result
// 4 rows
(n82:Agent {id: "a1"})-[r89:VISITED {seq: 10, ts: 1788330030000}]->(n3:Place {kind: "cafe", loc: {crs: "wgs-84", latitude: 45.5185, longitude: -122.6795, srid: 4326}, name: "Blue Star Donuts"})-[r1:`IN`]->(n0:City {name: "Portland"})
(n82)-[r85:VISITED {seq: 6, ts: 1788288080000}]->(n4:Place {kind: "cafe", loc: {crs: "wgs-84", latitude: 45.5225, longitude: -122.6735, srid: 4326}, name: "Stumptown"})-[r2:`IN`]->(n0)
(n83:Agent {id: "a2"})-[r93:VISITED {seq: 2, ts: 1788281285000}]->(n5:Place {kind: "cafe", loc: {crs: "wgs-84", latitude: 45.5225, longitude: -122.66, srid: 4326}, name: "Coava"})-[r3:`IN`]->(n0)
(n83)-[r92:VISITED {seq: 1, ts: 1788254379000}]->(n6:Place {kind: "bookstore", loc: {crs: "wgs-84", latitude: 45.5231, longitude: -122.6813, srid: 4326}, name: "Powell's"})-[r4:`IN`]->(n0)

Guard the caller's position

Nothing stops a caller from sending a cartesian point. Relate does not reject a distance between points in different reference systems; it applies the first argument's metric to both. Here the caller sent {"x": -122.68, "y": 45.5225}:

Step 13
RETURN point($here).srid AS srid,
       round(point.distance(point({latitude: 45.5225, longitude: -122.68}),
                            point($here)), 2) AS metres
Parameters
Recorded result
sridmetres
7203null

1 row

Note: the distance is null, not a number: the two points carry different CRSs (WGS-84 vs cartesian), and Neo4j returns null for a mixed-CRS point.distance instead of computing across reference systems.

No error, and 0.0 metres: the cartesian x/y were read as longitude/latitude. Had the caller swapped them, the answer would be a plausible-looking wrong number instead. Check the SRID and the coordinate ranges before the search runs. With the cartesian $here from the guard parameters, the guarded tool returns no rows. Edit here to {"latitude": 45.5225, "longitude": -122.68} and run it again:

Step 14
WITH point($here) AS here
WHERE here.srid = 4326
  AND abs(here.latitude) <= 90 AND abs(here.longitude) <= 180
MATCH (p:Place)
WHERE p.kind = $kind AND point.distance(p.loc, here) < $radius
RETURN p.name AS name, toInteger(round(point.distance(p.loc, here))) AS metres
ORDER BY metres, name
Parameters
Recorded result
| name | metres |
|------|--------|
0 rows

About the data

Dataset
80 places in Portland and Seattle and 18 visits by two agents (10 KB)
Source
Synthetic, generated with a fixed seed by the examples project's 19-nearby-places/generate.py
License
Apache-2.0

Every data block is published beside this page as data/<name>.json, and the whole recipe as markdown.

All recipes