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.
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.
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)(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).
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| places | cities | visits |
|---|---|---|
| 80 | 2 | 18 |
1 row · nodes created: 84, relationships created: 98, properties set: 280, labels added: 84
| places | cities | visits | |--------|--------|--------| | 80 | 2 | 18 | 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.
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 typeParameters
| name | city | lat | lon | srid | type |
|---|---|---|---|---|---|
| "Blue Star Donuts" | "Portland" | 45.5185 | -122.6795 | 4326 | "POINT NOT NULL" |
1 row
| name | city | lat | lon | srid | type | |--------------------|------------|---------|-----------|------|------------------| | "Blue Star Donuts" | "Portland" | 45.5185 | -122.6795 | 4326 | "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.
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, nameParameters
| name | metres |
|---|---|
| "Blue Star Donuts" | 447 |
| "Stumptown" | 507 |
| "Coava" | 1560 |
3 rows
| name | metres | |--------------------|--------| | "Blue Star Donuts" | 447 | | "Stumptown" | 507 | | "Coava" | 1560 | 3 rows
The same radius, counted per city. Seattle is 230 km away and contributes nothing:
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 cityParameters
| city | places | within |
|---|---|---|
| "Portland" | 55 | 13 |
| "Seattle" | 25 | 0 |
2 rows
| city | places | within | |------------|--------|--------| | "Portland" | 55 | 13 | | "Seattle" | 25 | 0 | 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.
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| name | kind | latitude | longitude |
|---|---|---|---|
| "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
| name | kind | latitude | longitude | |----------------------|-------------|----------|------------| | "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
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, nameParameters
| name | metres |
|---|---|
| "Powell's" | 121.0 |
| "Marshall Bookstore" | 926.0 |
| "Salmon Bookstore" | 1929.0 |
3 rows
| name | metres | |----------------------|--------| | "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 |
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.
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 metresParameters
| name | metres |
|---|---|
| "Blue Star Donuts" | 447 |
| "Stumptown" | 507 |
2 rows
| name | metres | |--------------------|--------| | "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.
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 namesParameters
| in_box | names |
|---|---|
| 2 | ["Blue Star Donuts", "Stumptown"] |
1 row
| in_box | names | |--------|-----------------------------------| | 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.
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 $nParameters
| name | seq | ts |
|---|---|---|
| "Blue Star Donuts" | 10 | 1788330030000 |
| "Stumptown" | 6 | 1788288080000 |
2 rows
| name | seq | ts | |--------------------|-----|---------------| | "Blue Star Donuts" | 10 | 1788330030000 | | "Stumptown" | 6 | 1788288080000 | 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.
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 $nParameters
| agent | name | seq | ts |
|---|---|---|---|
| "a2" | "Pike Place" | 6 | 1788363159000 |
| "a2" | "Pike Cafe" | 5 | 1788352372000 |
| "a2" | "Blanchard Cafe" | 4 | 1788350079000 |
3 rows
| agent | name | seq | ts | |-------|------------------|-----|---------------| | "a2" | "Pike Place" | 6 | 1788363159000 | | "a2" | "Pike Cafe" | 5 | 1788352372000 | | "a2" | "Blanchard Cafe" | 4 | 1788350079000 | 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 agenta1's newest visits instead of the newest overall: it mis-orders a relationship property underORDER BY … LIMITwhen the pattern starts from more than one node.
To see the shape, return the visits themselves. Open the graph view:
MATCH path = (a:Agent)-[v:VISITED]->(p:Place)-[:IN]->(:City)
WHERE point.distance(p.loc, point($here)) < $radius
RETURN pathParameters
// 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}:
RETURN point($here).srid AS srid,
round(point.distance(point({latitude: 45.5225, longitude: -122.68}),
point($here)), 2) AS metresParameters
| srid | metres |
|---|---|
| 7203 | null |
1 row
| srid | metres | |------|--------| | 7203 | null | 1 row
Note: the distance is
null, not a number: the two points carry different CRSs (WGS-84 vs cartesian), and Neo4j returnsnullfor a mixed-CRSpoint.distanceinstead 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:
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, nameParameters
| 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.