Traversing the Louvre

Priya Jacob
15 min readAug 13, 2021
https://i.pinimg.com/originals/d3/05/e3/d305e38f4135c95ecfd4d6b5e8b78406.jpg

After having modeled the ‘Louvre Online Collection Database’ leveraging the fastest path to Graph with Neo4j, and having explored & examined more or less its copious Paintings & Sculptures, let’s look for ways to traverse through what seems like vast endless acres of Architecture and Artistry.

Here’s an aerial shot of the Louvre (…and now you know why ^^). And no, I’m not planning on a heist.

So the Louvre has three main “Wings”, if you may, as seen from above, viz. the Richelieu, the Denon and the Sully. In Part 1, we happened to model the “Rooms” within these “Wings’ that carry Louvre’s prized Collection of Exhibits.

MATCH (r:Room)
WHERE r.room CONTAINS 'RICHELIEU'
RETURN COUNT(r), COLLECT(r.room)
MATCH (r:Room)
WHERE r.room CONTAINS 'DENON'
RETURN COUNT(r), COLLECT(r.room)
MATCH (r:Room)
WHERE r.room CONTAINS 'SULLY'
RETURN COUNT(r), COLLECT(r.room)

The Louvre Online Collection data is devoid of geo-coordinates and from what I find on WikiData, the “Wings” (and not the “Rooms”) are seen to carry coordinate data. Let’s query WikiData for all listed parts / constituents of the “Louvre Palace”, including the “Louvre Museum”.

SELECT * WHERE {
{SELECT ?part ?partLabel ?point ?lon ?lat WHERE {
?part wdt:P361 wd:Q1075988 .
?part p:P625 ?coordinate .
?coordinate ps:P625 ?point .
?coordinate psv:P625 ?coordinate_node .
?coordinate_node wikibase:geoLongitude ?lon .
?coordinate_node wikibase:geoLatitude ?lat .
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
}}
UNION
{SELECT ?part ?partLabel ?point ?lon ?lat WHERE {
?part wdt:P361 wd:Q19675 .
?part p:P625 ?coordinate .
?coordinate ps:P625 ?point .
?coordinate psv:P625 ?coordinate_node .
?coordinate_node wikibase:geoLongitude ?lon .
?coordinate_node wikibase:geoLatitude ?lat .
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
}}
UNION
{SELECT ?part ?partLabel ?point ?lon ?lat WHERE {
?part wdt:P2789 wd:Q19675 .
?part p:P625 ?coordinate .
?coordinate ps:P625 ?point .
?coordinate psv:P625 ?coordinate_node .
?coordinate_node wikibase:geoLongitude ?lon .
?coordinate_node wikibase:geoLatitude ?lat .
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
}}
}

Neo4j has support for spatial data by which you can assign instances of Point and lists of Point to Node and Relationship Properties. Its apoc library also has support for spatial procedures in addition to the out-of-the-box functions. We’re going to give all of these a go. For advanced spatial operations on data, there’s a Neo4j Spatial Library you could further explore.

Let’s start with apoc. The library has a procedure that takes a geo location as input and provides the listed location address, as well as procedures that take a location address as input and provide what the likely matched geo location(s) might be. The default Geocode Provider here is OSM. You may configure Google as well as custom Geocode Providers as the need may be.

WITH 
"SELECT * WHERE {
{SELECT ?part ?partLabel ?point ?lon ?lat WHERE {
?part wdt:P361 wd:Q1075988 .
?part p:P625 ?coordinate .
?coordinate ps:P625 ?point .
?coordinate psv:P625 ?coordinate_node .
?coordinate_node wikibase:geoLongitude ?lon .
?coordinate_node wikibase:geoLatitude ?lat .
SERVICE wikibase:label { bd:serviceParam wikibase:language \"en\". }
}}
UNION
{SELECT ?part ?partLabel ?point ?lon ?lat WHERE {
?part wdt:P361 wd:Q19675 .
?part p:P625 ?coordinate .
?coordinate ps:P625 ?point .
?coordinate psv:P625 ?coordinate_node .
?coordinate_node wikibase:geoLongitude ?lon .
?coordinate_node wikibase:geoLatitude ?lat .
SERVICE wikibase:label { bd:serviceParam wikibase:language \"en\". }
}}
UNION
{SELECT ?part ?partLabel ?point ?lon ?lat WHERE {
?part wdt:P2789 wd:Q19675 .
?part p:P625 ?coordinate .
?coordinate ps:P625 ?point .
?coordinate psv:P625 ?coordinate_node .
?coordinate_node wikibase:geoLongitude ?lon .
?coordinate_node wikibase:geoLatitude ?lat .
SERVICE wikibase:label { bd:serviceParam wikibase:language \"en\". }
}}
}" AS query
CALL apoc.load.jsonParams("http://query.wikidata.org/sparql?format=json&query=" + apoc.text.urlencode(query), {}, null)
YIELD value
WITH value.results.bindings AS zones
UNWIND zones AS zone
WITH zone.part.value AS zone, zone.partLabel.value AS zoneLabel, zone.point.value AS point, toFloat(zone.lon.value) AS lon, toFloat(zone.lat.value) AS lat
CALL apoc.spatial.reverseGeocode(lat,lon) YIELD location
RETURN zoneLabel, location.description;

The listed OSM addresses above are seen to match their geo coordinates. Let’s do a reverse geocoding now by citing our location address as input and fetching the first, or highest ranking result among many, should the case be.

WITH 
"SELECT * WHERE {
{SELECT ?part ?partLabel ?point ?lon ?lat WHERE {
?part wdt:P361 wd:Q1075988 .
?part p:P625 ?coordinate .
?coordinate ps:P625 ?point .
?coordinate psv:P625 ?coordinate_node .
?coordinate_node wikibase:geoLongitude ?lon .
?coordinate_node wikibase:geoLatitude ?lat .
SERVICE wikibase:label { bd:serviceParam wikibase:language \"en\". }
}}
UNION
{SELECT ?part ?partLabel ?point ?lon ?lat WHERE {
?part wdt:P361 wd:Q19675 .
?part p:P625 ?coordinate .
?coordinate ps:P625 ?point .
?coordinate psv:P625 ?coordinate_node .
?coordinate_node wikibase:geoLongitude ?lon .
?coordinate_node wikibase:geoLatitude ?lat .
SERVICE wikibase:label { bd:serviceParam wikibase:language \"en\". }
}}
UNION
{SELECT ?part ?partLabel ?point ?lon ?lat WHERE {
?part wdt:P2789 wd:Q19675 .
?part p:P625 ?coordinate .
?coordinate ps:P625 ?point .
?coordinate psv:P625 ?coordinate_node .
?coordinate_node wikibase:geoLongitude ?lon .
?coordinate_node wikibase:geoLatitude ?lat .
SERVICE wikibase:label { bd:serviceParam wikibase:language \"en\". }
}}
}" AS query
CALL apoc.load.jsonParams("http://query.wikidata.org/sparql?format=json&query=" + apoc.text.urlencode(query), {}, null)
YIELD value
WITH value.results.bindings AS zones
UNWIND zones AS zone
WITH zone.part.value AS zone, zone.partLabel.value AS zoneLabel, zone.point.value AS point, toFloat(zone.lon.value) AS lon, toFloat(zone.lat.value) AS lat
CALL apoc.spatial.geocodeOnce(zoneLabel)
YIELD location
RETURN zone AS zoneWiki, zoneLabel AS zoneLabelWiki, lon AS lonWiki, lat AS latWiki, location.description AS descOSM, location.longitude AS lonOSM, location.latitude AS latOSM

As one may expect, the results are likely matches and some are way off! A few weren’t found/resolved by the Geocode service. Now that we’ve got a sense of what these can do, let’s persist the Point data in our Graph. We’ll need to create additional Nodes labeled ‘Zone’ (that represent “parts” of the Louvre Palace/Museum queried earlier) and link them to our ‘Room’ Nodes, for where a match is found.

WITH 
"SELECT * WHERE {
{SELECT ?part ?partLabel ?point ?lon ?lat WHERE {
?part wdt:P361 wd:Q1075988 .
?part p:P625 ?coordinate .
?coordinate ps:P625 ?point .
?coordinate psv:P625 ?coordinate_node .
?coordinate_node wikibase:geoLongitude ?lon .
?coordinate_node wikibase:geoLatitude ?lat .
SERVICE wikibase:label { bd:serviceParam wikibase:language \"en\". }
}}
UNION
{SELECT ?part ?partLabel ?point ?lon ?lat WHERE {
?part wdt:P361 wd:Q19675 .
?part p:P625 ?coordinate .
?coordinate ps:P625 ?point .
?coordinate psv:P625 ?coordinate_node .
?coordinate_node wikibase:geoLongitude ?lon .
?coordinate_node wikibase:geoLatitude ?lat .
SERVICE wikibase:label { bd:serviceParam wikibase:language \"en\". }
}}
UNION
{SELECT ?part ?partLabel ?point ?lon ?lat WHERE {
?part wdt:P2789 wd:Q19675 .
?part p:P625 ?coordinate .
?coordinate ps:P625 ?point .
?coordinate psv:P625 ?coordinate_node .
?coordinate_node wikibase:geoLongitude ?lon .
?coordinate_node wikibase:geoLatitude ?lat .
SERVICE wikibase:label { bd:serviceParam wikibase:language \"en\". }
}}
}" AS query
CALL apoc.load.jsonParams("http://query.wikidata.org/sparql?format=json&query=" + apoc.text.urlencode(query), {}, null)
YIELD value
WITH value.results.bindings AS zones
UNWIND zones AS zone
WITH zone.part.value AS zone, zone.partLabel.value AS zoneLabel, zone.point.value AS point, toFloat(zone.lon.value) AS lon, toFloat(zone.lat.value) AS lat
MERGE (z:Zone {uri:zone})
SET z.zone = toUpper(trim(zoneLabel)),
z.point = point({longitude: lon, latitude: lat}),
z.longitude = lon,
z.latitude = lat

We can now choose to assign the actual Zone address using apoc.spatial.reverseGeocode. The Geocoding service is a public service that can throttle or blacklist sites that hit the service badly, so make sure you’re not allowing for that.

//update zone address
MATCH (z:Zone)
CALL apoc.spatial.reverseGeocode(z.latitude, z.longitude) YIELD location
SET z.address = location.description
//delete louvre lens & conservation center zones since they've already been tagged as locations in Part 1
MATCH (z:Zone)
WHERE z.zone IN ['LOUVRE CONSERVATION CENTER','LOUVRE-LENS']
DELETE z

Here’s what a ‘Zone’ Node looks like; We can now map the Louvre ‘Rooms’ to their respective ‘Zones’.

MATCH (z:Zone {zone:'SULLY WING'})
MATCH (r:Room)
WHERE r.room CONTAINS 'SULLY'
MERGE (r)-[:ZONE]->(z)
MATCH (z:Zone {zone:'DENON WING'})
MATCH (r:Room)
WHERE r.room CONTAINS 'DENON'
MERGE (r)-[:ZONE]->(z)
MATCH (z:Zone {zone:'RICHELIEU WING'})
MATCH (r:Room)
WHERE r.room CONTAINS 'RICHELIEU'
MERGE (r)-[:ZONE]->(z)

So now that we have the geolocations of the Zones, we can do things such as chart the shortest path from one Zone to another. But to make that possible, we’ll need to create paths between Zones themselves. We’ll assume there to be no obstacles dividing the Zones (walls, barricades & such) and join each Zone to the nearest say seven in the vicinity thereby creating paths between them. Note the use of the default Cypher function ‘distance’ here to compute distance in mtrs. between two Points.

MATCH (z1:Zone), (z2:Zone) 
WHERE ID(z1) < ID(z2)
WITH z1, z2, distance(z1.point, z2.point) AS distance
ORDER BY distance ASC
RETURN z1.zone, COLLECT({zone:z2.zone, distance:distance})[..7] AS zones
MATCH (z1:Zone), (z2:Zone) 
WHERE ID(z1) < ID(z2)
WITH z1, z2, distance(z1.point, z2.point) AS distance
ORDER BY distance ASC
WITH z1 AS zone, COLLECT(z2)[..7] AS zones
UNWIND zones AS nearby
WITH zone, nearby
MERGE (zone)-[:NEARBY]-(nearby)

Now let’s leverage a procedure from the apoc spatial library that calculates the shortest path (in kms) based on distance between two Nodes from their latitude/longitude properties, also a reason we persisted both Point and coordinates on the ‘Zone’ Nodes to be able to demonstrate the same. Here we’re looking at entering from ‘PORTE DES LIONS’ entrance to explore the ‘SULLY WING’. A thing to note here is if we are to leave out the directionality whilst fetching all possible paths between the two Nodes in question, the query may stall from an OOM situation. So we’re not doing that here.

MATCH (z1:Zone {zone:'PORTE DES LIONS'}), (z2:Zone {zone:'SULLY WING'})
MATCH p=(z1)-[:NEARBY*]->(z2)
WITH COLLECT(p) AS paths
CALL apoc.spatial.sortByDistance(paths) YIELD path, distance
RETURN path, distance LIMIT 3

So that was the apoc spatial library offering. In order to compare with the Pathfinding algorithms from the Neo4j Graph Data Science library, we would have to have the distance stored as a ‘weight’ property on the ‘NEARBY’ Relationships. Let’s do a few other things before that such as map the ‘Zones’ to the Louvre Museum ‘Location’ to indicate what lies within the Louvre, to be able to differentiate from its annexes, geocode other Exhibit ‘Locations’, followed by mapping nearby ‘Locations’ say to their 10 nearest ‘Locations’.

//link the entrance zones alone to louvre museum location
MATCH (l:Location)
WHERE l.location = 'MUSÉE DU LOUVRE'
MATCH (z:Zone)
WHERE z.zone IN ["PORTE DES LIONS","LOUVRE PYRAMID","CARROUSEL DU LOUVRE","RICHELIEU WING"]
MERGE (z)-[r:ZONE_LOCATION]->(l)
SET r.distance = 0.0

A preliminary analysis of the ‘Location’ geocoding for ‘Painting’ Exhibits checks out, so let’s go ahead and update them. We’ll then do the same for ‘Sculpture’ Exhibits. Since the Geocoding service run takes fairly longer owing to the enforced throttle delay i.e. apoc.spatial.geocode.osm.throttle=5000 (in ms to induce delay between calls so as not to overload OSM servers), we’ll only have the Painting & Sculpture ‘Locations’ geocoded for now.

CALL apoc.periodic.iterate(
'MATCH (l:Location) WHERE NOT l.location = "NON EXPOSÉ" AND size((:Painting)-->(l)) > 0 RETURN l',
'WITH l
CALL apoc.spatial.geocodeOnce(l.location) YIELD location
SET l.description = location.description,
l.point = point({longitude: location.longitude, latitude: location.latitude})',
{batchSize:50})
CALL apoc.periodic.iterate(
'MATCH (l:Location) WHERE NOT l.location = "NON EXPOSÉ" AND size((:Sculpture)-->(l)) > 0 AND l.point IS NULL RETURN l',
'WITH l
CALL apoc.spatial.geocodeOnce(l.location) YIELD location
SET l.description = location.description,
l.point = point({longitude: location.longitude, latitude: location.latitude})',
{batchSize:50})

I must say OSM does a fairly decent job. Next is to map nearby ‘Locations’ now that we have their coordinates, and to compute/persist distances on both nearby ‘Zones’ and ‘Locations’.

//compute distance on nearby zones
MATCH (z1:Zone)-[r:NEARBY]-(z2:Zone)
WHERE ID(z1) < ID(z2)
SET r.distance = distance(z1.point, z2.point)
//fetch nearby locations for louvre and its annexes
MATCH (l1:Location), (l2:Location)
WHERE ID(l1) < ID(l2) AND l1 <> l2 AND l1.point IS NOT NULL AND l2.point IS NOT NULL
AND l1.location CONTAINS 'LOUVRE' OR l1.location CONTAINS 'DELACROIX'
WITH l1, l2, distance(l1.point, l2.point) AS distance
ORDER BY distance ASC
RETURN l1.location AS location, l1.description AS description, COLLECT({location:l2.location, description:l2.description, distance:distance})[..10] AS nearbyLocations
ORDER BY location
//map nearby locations with their distances
MATCH (l1:Location), (l2:Location)
WHERE ID(l1) < ID(l2) AND l1.point IS NOT NULL AND l2.point IS NOT NULL
WITH l1, l2, distance(l1.point, l2.point) AS distance
ORDER BY distance ASC
WITH l1 AS location, COLLECT({n:l2, distance:distance})[..10] AS locations
UNWIND locations AS nearby
WITH location, nearby.n AS n, nearby.distance AS distance
MERGE (location)-[r:NEARBY]-(n)
SET r.distance = distance

And just so we remind ourselves of how the Exhibits are organized; the Room-Zone-Location taxonomy is only available for Exhibits within the Louvre, for everything else, the Location alone is maintained, as was modeled in Part 1.

We can now start to employ the Pathfinding algos to find shortest routes that map our agenda! We start by projecting an in-memory Graph of ‘Zones’ & ‘Locations’.

CALL gds.graph.create(
'theLouvre&Beyond',
['Zone', 'Location'],
{
ZONE_LOCATION: {
type: 'ZONE_LOCATION',
orientation: 'UNDIRECTED',
properties: {
distance: {
property: 'distance'
}
}
},
NEARBY: {
type: 'NEARBY',
orientation: 'UNDIRECTED',
properties: {
distance: {
property: 'distance'
}
}
}
}
)
YIELD graphName, nodeCount, relationshipCount;

We can now find the shortest path between two ‘Zones’ at the Louvre or say the shortest path to get from an annex to a Wing inside the Louvre.

//compute shortest path between two zones in the louvre
MATCH (source:Zone {zone: 'PORTE DES LIONS'}), (target:Zone {zone: 'SULLY WING'})
CALL gds.shortestPath.dijkstra.stream('theLouvre&Beyond', {
nodeLabels: ['Zone'],
sourceNode: source,
targetNode: target,
relationshipWeightProperty: 'distance'
})
YIELD index, sourceNode, targetNode, totalCost, nodeIds, costs, path
RETURN path
//compute shortest path between an annex & a louvre zone
MATCH (source:Location {location: 'MUSÉE NATIONAL EUGÈNE DELACROIX'}), (target:Zone {zone: 'SULLY WING'})
CALL gds.shortestPath.dijkstra.stream('theLouvre&Beyond', {
sourceNode: source,
targetNode: target,
relationshipWeightProperty: 'distance'
})
YIELD index, sourceNode, targetNode, totalCost, nodeIds, costs, path
RETURN path

So what this tells me is that if I want to get from the Eugene Delacroix Museum to the Louvre Sully Wing, I got to take the Pyramid entrance, if I got to go to the Denon Wing, the Carrousel and if it’s the Richelieu, then that’s an entrance by itself! For a ‘find-me-places-near-me’ feature, we can employ one of the Single Source Shortest Path algorithms as below; (I’m thinking the Wiki coordinates could be a little off with some of the distances assuming a stone’s throw away from what they ought to be)

MATCH (source:Zone {zone: 'LOUVRE PYRAMID'})
CALL gds.allShortestPaths.dijkstra.stream('theLouvre&Beyond', {
sourceNode: source,
relationshipWeightProperty: 'distance',
nodeLabels: ['Zone']
})
YIELD index, sourceNode, targetNode, totalCost, nodeIds, costs, path
RETURN
index,
gds.util.asNode(sourceNode).zone AS sourceNodeName,
gds.util.asNode(targetNode).zone AS targetNodeName,
totalCost,
[nodeId IN nodeIds | gds.util.asNode(nodeId).zone] AS nodeNames,
costs
ORDER BY index

To find more than one shortest path from a source to a target Node you could use Yen’s K-shortest paths, there’s also the Random Walk algo (that could be a bad idea or possible serendipity at the Louvre) and some others that we could leverage for optimal route mapping / cost compute. But what I really want to do on a good day is to wander aimlessly through a maze of galleries and stumble upon art. Yes, but that’s when I’ll have all the time in the world to, but for now it’s to put down a clear agenda and have an optimal route planner chart it for me. So let’s assume I’d like my tour to cover the following areas of interest;

  • A few masterpieces from “Leonardo da Vinci” — I see they’re in the Denon Wing
  • Pay homage to “French History” in-pictures at the Denon Wing
  • And while I’m there, catch the “Psyche Revived by Cupid’s Kiss” at the Denon
  • I just have to see the “Winged Victory of Samothrace” at the Denon, too
  • Travel to Nile — the “Egyptian Wing” through the “Pavillon de L’Horloge” (that apparently was once the watery moat!)
  • Walk through the “Tuileries Garden” to Place de la Concorde should time permit
  • Must see a “Rembrandt” at the Richelieu Wing that also houses Vermeer’s “The Astronomer” (1668) and “The Lacemaker” (1669–70)
  • I have got to see the “Chardin” Still-Lives, Sully Wing (that were in Part 3)
  • I’m told the “Apollo Gallery” at the Denon Wing sports high arches and frescoed walls that are jaw-droppingly ornate, opulent and garishly gold, painted by “Charles Le Brun” & “Eugène Delacroix” — a must see then?
  • Catch the former chambers of “Napoleon III” that span several rooms of the Richelieu Wing (with a dining room that would seat nearly a 100!)
  • I’ll be hungry at which point and will need to visit a café in the central pavilion under the “Louvre Pyramid”
  • And then that I’ve rejuvenated, a trip to the nearby annex “Eugene Delacroix Museum” to catch the maestro’s masterpieces (because the “Louvre Lens” annexe is far north of France and would warrant another day trip)
MATCH (a:Artist)<--(p:Painting)-->(r:Room)
WHERE a.name = "LÉONARD DE VINCI (LEONARDO DI SER PIERO DA VINCI, DIT LEONARDO DA VINCI)"
RETURN p.arkID, p.wikiURI, p.title, r.room
MATCH (a:Artist)<--(s:Sculpture)-->(r:Room)
WHERE a.name = "CANOVA, ANTONIO"
RETURN s.arkID, s.wikiURI, s.title, r.room
MATCH (s:Sculpture)-->(r:Room)
WHERE toUpper(s.wikiDescription) CONTAINS 'SAMOTHRACE'
RETURN s.arkID, s.wikiURI, s.title, r.room
MATCH (c:Collection)<--(:Exhibit)-->(r:Room)-->(z:Zone)
WHERE c.collection = "DÉPARTEMENT DES ANTIQUITÉS ÉGYPTIENNES"
RETURN c.collection, COLLECT(DISTINCT r.room), COLLECT(DISTINCT z.zone)
MATCH (l:Location)
WHERE toLower(l.location) CONTAINS 'tuilerie'
RETURN l
MATCH (a:Artist)<--(p:Painting)-->(r:Room)
WHERE a.name = "REMBRANDT, HARMENSZ. VAN RIJN"
RETURN p.arkID, p.wikiURI, p.title, r.room
MATCH (a:Artist)<--(p:Painting)-->(r:Room)
WHERE a.name CONTAINS 'VERMEER'
RETURN p.arkID, p.wikiURI, p.title, r.room
MATCH (a:Artist)<--(p:Painting)-->(r:Room)
WHERE a.name = "CHARDIN, JEAN BAPTISTE SIMÉON"
RETURN p.arkID, p.wikiURI, p.title, r.room

So, here are the Zones and Locations I’ll need to traverse through; “DENON WING”, “SULLY WING”, “RICHELIEU WING”, “PAVILLON DE L’HORLOGE”,
“LOUVRE PYRAMID”, “PARIS (FRANCE), PALAIS DES TUILERIES”, “[SCULPT] JARDIN DES TUILERIES”, “MUSÉE NATIONAL EUGÈNE DELACROIX”. Here’s an awesome post (also one I instantly fell in love with and been wanting to apply since long) that explains what’s popularly called the Traveling Salesman problem at hand, and how easily we could solve for it, using Neo4j+GDS. So we start with computing the shortest path between each of the areas and the rest of the set and writing them to the Graph. Once that’s done, we then compute the optimal route from the computed paths.

//define the areas of interest
:param areasOfInterest => ["DENON WING","SULLY WING","RICHELIEU WING","PAVILLON DE L’HORLOGE","LOUVRE PYRAMID","PARIS (FRANCE), PALAIS DES TUILERIES","[SCULPT] JARDIN DES TUILERIES","MUSÉE NATIONAL EUGÈNE DELACROIX"]
//stream shortest paths for each area with the rest of the set
WITH $areasOfInterest AS areasOfInterest
CALL {
WITH areasOfInterest
MATCH (a:Zone)
WHERE a.zone IN areasOfInterest
RETURN a
UNION
WITH areasOfInterest
MATCH (a:Location)
WHERE a.location IN areasOfInterest
RETURN a }
WITH COLLECT(a) AS areasOfInterest
UNWIND areasOfInterest AS area
WITH area, areasOfInterest, [x IN areasOfInterest WHERE NOT x = area|x] AS othAreas
UNWIND othAreas AS othArea
CALL gds.shortestPath.dijkstra.stream('theLouvre&Beyond',{sourceNode:area, targetNode:othArea, relationshipWeightProperty:'distance'})
YIELD index, sourceNode, targetNode, totalCost, nodeIds, costs, path
RETURN
index,
sourceNode,
targetNode,
totalCost,
nodeIds AS shortestPathNodeIds,
CASE WHEN LABELS(gds.util.asNode(sourceNode)) = ['Zone'] THEN gds.util.asNode(sourceNode).zone ELSE gds.util.asNode(sourceNode).location END AS sourceArea,
CASE WHEN LABELS(gds.util.asNode(targetNode)) = ['Zone'] THEN gds.util.asNode(targetNode).zone ELSE gds.util.asNode(targetNode).location END AS targetArea,
[x IN nodeIds|CASE WHEN LABELS(gds.util.asNode(x)) = ['Zone'] THEN gds.util.asNode(x).zone ELSE gds.util.asNode(x).location END] AS shortestPathAreas
ORDER BY sourceNode, targetNode
//write the shortest paths to the Graph
WITH $areasOfInterest AS areasOfInterest
CALL {
WITH areasOfInterest
MATCH (a:Zone)
WHERE a.zone IN areasOfInterest
RETURN a
UNION
WITH areasOfInterest
MATCH (a:Location)
WHERE a.location IN areasOfInterest
RETURN a }
WITH COLLECT(a) AS areasOfInterest
UNWIND areasOfInterest AS area
WITH area, areasOfInterest, [x IN areasOfInterest WHERE NOT x = area|x] AS othAreas
UNWIND othAreas AS othArea
CALL gds.shortestPath.dijkstra.stream('theLouvre&Beyond',{sourceNode:area, targetNode:othArea, relationshipWeightProperty:'distance'})
YIELD index, sourceNode, targetNode, totalCost, nodeIds, costs, path
WITH gds.util.asNode(sourceNode) AS sourceNode, gds.util.asNode(targetNode) AS targetNode, totalCost, nodeIds
MERGE (sourceNode)-[r:SHORTEST_PATH]-(targetNode)
SET r.distance = totalCost,
r.shortestPathNodeIDs = nodeIds
//read the optimal route computed
WITH $areasOfInterest AS areasOfInterest
CALL {
WITH areasOfInterest
MATCH (a:Zone)
WHERE a.zone IN areasOfInterest
RETURN a
UNION
WITH areasOfInterest
MATCH (a:Location)
WHERE a.location IN areasOfInterest
RETURN a }
WITH COLLECT(a) AS areasOfInterest
UNWIND areasOfInterest AS area
WITH area, areasOfInterest, [x IN areasOfInterest WHERE NOT x = area|x] AS othAreas, (size(areasOfInterest) - 1) AS level
UNWIND othAreas AS othArea
CALL apoc.path.expandConfig(area, {
relationshipFilter: 'SHORTEST_PATH',
minLevel: level,
maxLevel: level,
whitelistNodes: areasOfInterest,
terminatorNodes: [othArea],
uniqueness: 'NODE_PATH'})
YIELD path
WITH path,
reduce(cost = 0, x IN relationships(path) | cost + x.distance) AS totalCost
ORDER BY totalCost LIMIT 1
WITH path,
totalCost,
apoc.coll.flatten([r IN relationships(path) | r.shortestPathNodeIDs]) AS intermediate_stops,
[n IN nodes(path) | ID(n)] AS node_ids
RETURN
[n IN nodes(path) | CASE WHEN n:Zone THEN n.zone ELSE n.location END] AS path,
round(totalCost) AS total_distance,
apoc.coll.toSet([y IN intermediate_stops WHERE NOT y IN node_ids | CASE WHEN LABELS(gds.util.asNode(y)) = ['Zone'] THEN gds.util.asNode(y).zone ELSE gds.util.asNode(y).location END]) AS intermediate_stops

Right so what that tells me is that I can start with the Tuilieries Garden, enter the Louvre through the Pyramid entrance, find my way to the Pavillon de L’Horloge, visit the Sully, Denon and Richelieu Wings and exit from the Carrousel onward to the Eugene Delacroix Museum covering roughly 2Kms, (all in a day’s work).

So that’s that and thus we’ve explored spatial data with Neo4j and the Graph Data Science library. As always, the accuracy is as good as the raw data on hand. And as always, I’ll be looking to do more. So much for the gorgeous Louvre!

Photo by Julian Hochgesang on Unsplash

--

--