Skip to content

Commit

Permalink
Issue 234 - Support reverse geocoding in apoc.spatial (#876)
Browse files Browse the repository at this point in the history
* add inverseGeocoding

* fixes #234 - add inverseGeocoding

* fixes #234 - add inverseGeocoding
  • Loading branch information
albertodelazzari authored and jexp committed Aug 8, 2018
1 parent 7070052 commit bce884a
Show file tree
Hide file tree
Showing 7 changed files with 320 additions and 37 deletions.
3 changes: 2 additions & 1 deletion docs/overview.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,8 @@ TODO:

[cols="1m,5"]
|===
| CALL apoc.spatial.geocode('address') YIELD location, latitude, longitude, description, osmData | look up geographic location of location from openstreetmap geocoding service
| CALL apoc.spatial.geocode('address') YIELD location, latitude, longitude, description, osmData | look up geographic location of location from a geocoding service (the default one is OpenStreetMap)
| CALL apoc.spatial.reverseGeocode(latitude,longitude) YIELD location, latitude, longitude, description | look up address from latitude and longitude from a geocoding service (the default one is OpenStreetMap)
| CALL apoc.spatial.sortPathsByDistance(Collection<Path>) YIELD path, distance | sort a given collection of paths by geographic distance based on lat/long properties on the path nodes
|===

Expand Down
31 changes: 25 additions & 6 deletions docs/spatial.adoc
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The spatial procedures are intended to enable geographic capabilities on your data.

== geocode
== Geocode

The first procedure _geocode_ which will convert a textual address
into a location containing _latitude_, _longitude_ and _description_. Despite being
Expand All @@ -17,15 +17,25 @@ CALL apoc.spatial.geocodeOnce('21 rue Paul Bellamy 44000 NANTES FRANCE') YIELD l
RETURN location.latitude, location.longitude // will return 47.2221667, -1.5566624
----

There are two forms of the procedure:
There are three forms of the procedure:

* geocodeOnce(address) returns zero or one result
* geocode(address,maxResults) returns zero, one or more up to maxResults
* geocodeOnce(address) returns zero or one result.
* geocode(address,maxResults) returns zero, one or more up to maxResults.
* reverseGeocode(latitude,longitude) returns zero or one result.

This is because the backing geocoding service (OSM, Google, OpenCage or other) might return multiple
results for the same query. GeocodeOnce() is designed to return the first, or highest
ranking result.

The third procedure _reverseGeocode_ will convert a location containing _latitude_ and _longitude_
into a textual address.
----
CALL apoc.spatial.reverseGeocode(47.2221667,-1.5566625) YIELD location
RETURN location.description; // will return 21, Rue Paul Bellamy, Talensac - Pont Morand,
//Hauts-Pavés - Saint-Félix, Nantes, Loire-Atlantique, Pays de la Loire,
//France métropolitaine, 44000, France
----

== Configuring Geocode

There are a few options that can be set in the neo4j.conf file to control the service:
Expand All @@ -43,11 +53,18 @@ https://developers.google.com/maps/documentation/geocoding/get-api-key#key

== Configuring Custom Geocode Provider

*Geocode*

For any provider that is not 'osm' or 'google' you get a configurable supplier that requires two
additional settings, 'url' and 'key'. The 'url' must contain the two words 'PLACE' and 'KEY'.
The 'KEY' will be replaced with the key you get from the provider when you register for the service.
The 'PLACE' will be replaced with the address to geocode when the procedure is called.

*Reverse Geocode*

The 'url' must contain the three words 'LAT', 'LNG' and 'KEY'.
The 'LAT' will be replaced with the latitude and 'LNG' will be replaced with the the longitude to reverse geocode when the procedure is called.

For example, to get the service working with OpenCage, perform the following steps:

* Register your own application key at https://geocoder.opencagedata.com/
Expand All @@ -57,12 +74,15 @@ For example, to get the service working with OpenCage, perform the following ste
apoc.spatial.geocode.provider=opencage
apoc.spatial.geocode.opencage.key=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
apoc.spatial.geocode.opencage.url=http://api.opencagedata.com/geocode/v1/json?q=PLACE&key=KEY
apoc.spatial.geocode.opencage.reverse.url=http://api.opencagedata.com/geocode/v1/json?q=LAT+LNG&key=KEY
----

* make sure that the 'XXXXXXX' part above is replaced with your actual key
* Restart the Neo4j server and then test the geocode procedures to see that they work
* If you are unsure if the provider is correctly configured try verify with:



[source,cypher]
----
CALL apoc.spatial.showConfig()
Expand Down Expand Up @@ -186,5 +206,4 @@ WHERE distance < 5000 AND days_before_due < 14 AND apoc.date.parse(e.datetime,'h
RETURN e.name AS event, e.datetime AS date,
location.description AS description, distance
ORDER BY distance
----

----
116 changes: 106 additions & 10 deletions src/main/java/apoc/spatial/Geocode.java
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package apoc.spatial;

import apoc.ApocConfiguration;
import org.neo4j.procedure.*;
import apoc.util.JsonUtil;
import apoc.util.Util;
import org.neo4j.graphdb.GraphDatabaseService;
import org.neo4j.logging.Log;
import org.neo4j.procedure.*;

import java.io.UnsupportedEncodingException;
import java.util.List;
Expand Down Expand Up @@ -33,7 +33,8 @@ public class Geocode {
public Log log;

interface GeocodeSupplier {
Stream<GeoCodeResult> geocode(String encodedAddress, long maxResults);
Stream<GeoCodeResult> geocode(String params, long maxResults);
Stream<GeoCodeResult> reverseGeocode(Double latitude, Double longitude);
}

private static class Throttler {
Expand Down Expand Up @@ -75,27 +76,42 @@ private static class SupplierWithKey implements GeocodeSupplier {
private Throttler throttler;
private String configBase;
private String urlTemplate;
private String urlTemplateReverse;

public SupplierWithKey(Map<String, Object> config, TerminationGuard terminationGuard, String provider) {
this.configBase = provider;

if (!config.containsKey(configKey("url"))) {
throw new IllegalArgumentException("Missing 'url' for geocode provider: " + provider);
}
if (!config.containsKey(configKey("reverse.url"))) {
throw new IllegalArgumentException("Missing 'reverse.url' for reverse-geocode provider: " + provider);
}
urlTemplate = config.get(configKey("url")).toString();
if (!urlTemplate.contains("PLACE")) throw new IllegalArgumentException("Missing 'PLACE' in url template: " + urlTemplate);

urlTemplateReverse = config.get(configKey("reverse.url")).toString();
if (!urlTemplateReverse.contains("LAT") || !urlTemplateReverse.contains("LNG")) throw new IllegalArgumentException("Missing 'LAT' or 'LNG' in url template: " + urlTemplateReverse);

if (urlTemplate.contains("KEY") && !config.containsKey(configKey("key"))) {
throw new IllegalArgumentException("Missing 'key' for geocode provider: " + provider);
}

if (urlTemplateReverse.contains("KEY") && !config.containsKey(configKey("key"))) {
throw new IllegalArgumentException("Missing 'key' for reverse-geocode provider: " + provider);
}
String key = config.get(configKey("key")).toString();
urlTemplate = urlTemplate.replace("KEY", key);
urlTemplateReverse = urlTemplateReverse.replace("KEY", key);

this.throttler = new Throttler(terminationGuard, toLong(ApocConfiguration.get(configKey("throttle"), Throttler.DEFAULT_THROTTLE)));
}

@SuppressWarnings("unchecked")
public Stream<GeoCodeResult> geocode(String address, long maxResults) {
if (address.isEmpty()) {
return Stream.empty();
}
throttler.waitForThrottle();
String url = urlTemplate.replace("PLACE", Util.encodeUrlComponent(address));
Object value = JsonUtil.loadJson(url).findFirst().orElse(null);
Expand All @@ -110,6 +126,25 @@ public Stream<GeoCodeResult> geocode(String address, long maxResults) {
throw new RuntimeException("Can't parse geocoding results " + value);
}

@Override
public Stream<GeoCodeResult> reverseGeocode(Double latitude, Double longitude) {
if (latitude == null || longitude == null) {
return Stream.empty();
}
throttler.waitForThrottle();
String url = urlTemplateReverse.replace("LAT", latitude.toString()).replace("LNG", longitude.toString());
Object value = JsonUtil.loadJson(url).findFirst().orElse(null);
if (value instanceof List) {
return findResults((List<Map<String, Object>>) value, 1);
} else if (value instanceof Map) {
Object results = ((Map) value).get("results");
if (results instanceof List) {
return findResults((List<Map<String, Object>>) results, 1);
}
}
throw new RuntimeException("Can't parse reverse-geocoding results " + value);
}

@SuppressWarnings("unchecked")
private Stream<GeoCodeResult> findResults(List<Map<String, Object>> results, long maxResults) {
return results.stream().limit(maxResults).map(data -> {
Expand Down Expand Up @@ -140,7 +175,11 @@ private String configKey(String name) {
}

private static class OSMSupplier implements GeocodeSupplier {
public static final String OSM_URL = "http://nominatim.openstreetmap.org/search.php?format=json&q=";
public static final String OSM_URL = "https://nominatim.openstreetmap.org";

private static final String OSM_URL_REVERSE_GEOCODE = OSM_URL + "/reverse?format=jsonv2&";
private static final String OSM_URL_GEOCODE = OSM_URL + "/search.php?format=json&q=";

private Throttler throttler;

public OSMSupplier(Map<String, Object> config, TerminationGuard terminationGuard) {
Expand All @@ -149,23 +188,47 @@ public OSMSupplier(Map<String, Object> config, TerminationGuard terminationGuard

@SuppressWarnings("unchecked")
public Stream<GeoCodeResult> geocode(String address, long maxResults) {
if (address.isEmpty()) {
return Stream.empty();
}
throttler.waitForThrottle();
Object value = JsonUtil.loadJson(OSM_URL + Util.encodeUrlComponent(address)).findFirst().orElse(null);
Object value = JsonUtil.loadJson(OSM_URL_GEOCODE + Util.encodeUrlComponent(address)).findFirst().orElse(null);
if (value instanceof List) {
return ((List<Map<String, Object>>) value).stream().limit(maxResults).map(data ->
new GeoCodeResult(toDouble(data.get("lat")), toDouble(data.get("lon")), valueOf(data.get("display_name")), data));
}
throw new RuntimeException("Can't parse geocoding results " + value);
}

@Override
public Stream<GeoCodeResult> reverseGeocode(Double latitude, Double longitude) {
if (latitude == null || longitude == null) {
return Stream.empty();
}
throttler.waitForThrottle();

Object value = JsonUtil.loadJson(OSM_URL_REVERSE_GEOCODE + String.format("lat=%s&lon=%s", latitude, longitude)).findFirst().orElse(null);
if (value instanceof Map) {
Map<String, Object> data = (Map<String, Object>) value;
return Stream.of(new GeoCodeResult(toDouble(data.get("lat")), toDouble(data.get("lon")), valueOf(data.get("display_name")), (Map<String,Object>)data.get("address")));
}
throw new RuntimeException("Can't parse reverse-geocoding results " + value);
}
}

class GoogleSupplier implements GeocodeSupplier {
private final Throttler throttler;
private String baseUrl;
private Map<String, Object> configMap;

private static final String BASE_GOOGLE_API_URL = "https://maps.googleapis.com/maps/api/geocode/json";

private static final String REVERSE_GEOCODE_URL = BASE_GOOGLE_API_URL + "?%s&latlng=";
private static final String GEOCODE_URL = BASE_GOOGLE_API_URL + "?%s&address=";


public GoogleSupplier(Map<String, Object> config, TerminationGuard terminationGuard) {
this.throttler = new Throttler(terminationGuard, toLong(config.getOrDefault("google.throttle", Throttler.DEFAULT_THROTTLE)));
this.baseUrl = String.format("https://maps.googleapis.com/maps/api/geocode/json?%s&address=", credentials(config));
this.configMap = config;
}

private String credentials(Map<String, Object> config) {
Expand All @@ -180,11 +243,11 @@ private String credentials(Map<String, Object> config) {

@SuppressWarnings("unchecked")
public Stream<GeoCodeResult> geocode(String address, long maxResults) {
if (address.length() < 1) {
if (address.isEmpty()) {
return Stream.empty();
}
throttler.waitForThrottle();
Object value = JsonUtil.loadJson(baseUrl + Util.encodeUrlComponent(address)).findFirst().orElse(null);
Object value = JsonUtil.loadJson(String.format(GEOCODE_URL, credentials(this.configMap)) + Util.encodeUrlComponent(address)).findFirst().orElse(null);
if (value instanceof Map) {
Map map = (Map) value;
if (map.get("status").equals("OVER_QUERY_LIMIT")) throw new IllegalStateException("QUOTA_EXCEEDED from geocode API: "+map.get("status")+" message: "+map.get("error_message"));
Expand All @@ -198,6 +261,27 @@ public Stream<GeoCodeResult> geocode(String address, long maxResults) {
}
throw new RuntimeException("Can't parse geocoding results " + value);
}

@Override
public Stream<GeoCodeResult> reverseGeocode(Double latitude, Double longitude) {
if (latitude == null || longitude == null) {
return Stream.empty();
}
throttler.waitForThrottle();
Object value = JsonUtil.loadJson(String.format(REVERSE_GEOCODE_URL, credentials(this.configMap)) + Util.encodeUrlComponent(latitude+","+longitude)).findFirst().orElse(null);
if (value instanceof Map) {
Map map = (Map) value;
if (map.get("status").equals("OVER_QUERY_LIMIT")) throw new IllegalStateException("QUOTA_EXCEEDED from geocode API: "+map.get("status")+" message: "+map.get("error_message"));
Object results = map.get("results");
if (results instanceof List) {
return ((List<Map<String, Object>>) results).stream().limit(1).map(data -> {
Map location = (Map) ((Map) data.get("geometry")).get("location");
return new GeoCodeResult(toDouble(location.get("lat")), toDouble(location.get("lng")), valueOf(data.get("formatted_address")), data);
});
}
}
throw new RuntimeException("Can't parse reverse-geocoding results " + value);
}
}

private GeocodeSupplier getSupplier() {
Expand All @@ -214,13 +298,13 @@ private GeocodeSupplier getSupplier() {
}

@Procedure
@Description("apoc.spatial.geocodeOnce('address') YIELD location, latitude, longitude, description, osmData - look up geographic location of address from openstreetmap geocoding service")
@Description("apoc.spatial.geocodeOnce('address') YIELD location, latitude, longitude, description, osmData - look up geographic location of address from a geocoding service (the default one is OpenStreetMap)")
public Stream<GeoCodeResult> geocodeOnce(@Name("location") String address) throws UnsupportedEncodingException {
return geocode(address, 1L,false);
}

@Procedure
@Description("apoc.spatial.geocode('address') YIELD location, latitude, longitude, description, osmData - look up geographic location of address from openstreetmap geocoding service")
@Description("apoc.spatial.geocode('address') YIELD location, latitude, longitude, description, osmData - look up geographic location of address from a geocoding service (the default one is OpenStreetMap)")
public Stream<GeoCodeResult> geocode(@Name("location") String address, @Name(value = "maxResults",defaultValue = "100") long maxResults, @Name(value = "quotaException",defaultValue = "false") boolean quotaException) {
try {
return getSupplier().geocode(address, maxResults == 0 ? MAX_RESULTS : Math.min(Math.max(maxResults, 1), MAX_RESULTS));
Expand All @@ -230,6 +314,17 @@ public Stream<GeoCodeResult> geocode(@Name("location") String address, @Name(val
}
}

@Procedure
@Description("apoc.spatial.reverseGeocode(latitude,longitude) YIELD location, latitude, longitude, description - look up address from latitude and longitude from a geocoding service (the default one is OpenStreetMap)")
public Stream<GeoCodeResult> reverseGeocode(@Name("latitude") double latitude, @Name("longitude") double longitude, @Name(value = "quotaException",defaultValue = "false") boolean quotaException) {
try {
return getSupplier().reverseGeocode(latitude, longitude);
} catch(IllegalStateException re) {
if (!quotaException && re.getMessage().startsWith("QUOTA_EXCEEDED")) return Stream.empty();
throw re;
}
}

public static class GeoCodeResult {
public final Map<String, Object> location;
public final Map<String, Object> data;
Expand All @@ -246,3 +341,4 @@ public GeoCodeResult(Double latitude, Double longitude, String description, Map<
}
}
}

Loading

0 comments on commit bce884a

Please sign in to comment.