Geocoding (Teil 1) : PLZ Umkreissuche mit Elasticsearch, Groovy, JQuery und der Google Maps API

von Peter Soth

Mit diesem Blogpost möchte ich aufzeigen, wie einfach es ist, mit Elasticsearch eine Postleitzahlenumkreissuche zu realisieren. Viele SQL Datenbanken unterstützen die Verarbeitung von Geodaten. Manche nicht, hier muss man demzufolge selber die mathematischen Algorithmen entwickeln, oder Beispiele aus dem Internet benutzen. Wird Elasticsearch bereits als Suchmaschine eingesetzt, liegt es nahe, diese auch für eine PLZ-Umkreissuche wiederzuverwenden. Als Beispiel habe ich mir eine Umkreissuche überlegt, die die nächsten IKEA Häuser im Umkreis einer Postleitzahl findet. Den gesamten Quellcode und alle Datenfiles kann man von unserem Web-Server laden [1].

Wie kommt man an die Geo-Daten der einzelnen Postleitzahlen?

Zuerst dachte ich, ich benutze OpenStreetMap [2]. Diese Idee verwarf ich sehr schnell, als ich sah, dass die Daten mehrere Gigabyte groß sind. Meine fortgeführte Suche brachte mich schließlich auf die OpenGeoDB [3][4][5]. Anmerkung: Das Thema mit OpenStreetMap ließ mich nicht los. In dem zweiten Teil meines Blog-Post fand ich schließlich doch eine Lösung :-)

Aus dieser Datei habe ich mit folgendem Groovy-Script (PlzCoords.groovy) die Mapping-Datei (plzmapping.json) und die Datendatei (plz.json) für Elasticsearch generiert.

class Plz {
    def String plz
    def location = [:]
    def String ort
}

def linenumber = 0;
def plzList = []
final String INDEX_NAME = 'plzsuche'
final String DOC_TYPE = 'plz'


new File("PLZ.csv").splitEachLine("\t") {fields ->
    //skip 1st line
    if (linenumber > 0) {
        // skip 1st column
        plzList.add(new Plz(plz: fields[1], location: [lon: fields[2] as Double, lat: fields[3] as Double], ort: fields[4]))
    }
    linenumber ++;
}

def json = new groovy.json.JsonBuilder()

new File("plz.json").withWriter {out ->
    plzList.eachWithIndex{row, i ->
        out.println ("{\"index\":{\"_index\":\"${INDEX_NAME}\",\"_type\":\"${DOC_TYPE}\",\"_id\":\"${i+1}\"}}")
        json(plz: row.plz, location: row.location, ort: row.ort)
        out.println (json.toString())
    }
}

new File("plzmapping.json").withWriter {out ->
        json.mappings{
            "${DOC_TYPE}" {
                properties {
                    plz {
                        type 'string'
                    }
                    location {
                        type 'geo_point'
                    }
                    ort {
                        type 'string'
                    }
                }
            }
        }
        out.println (json.toPrettyString())
}

Die Mapping Datei für Elasticsearch hat folgenden Aufbau:

{
    "mappings": {
        "plz": {
            "properties": {
                "plz": {
                    "type": "string"
                },
                "location": {
                    "type": "geo_point"
                },
                "ort": {
                    "type": "string"
                }
            }
        }
    }
}

Die Datei mit den Geo-Koordinaten zu jeder PLZ hat folgenden Aufbau (gekürzte Darstellung):

{"index":{"_index":"plzsuche","_type":"plz","_id":"1"}}
{"plz":"01067","location":{"lon":13.7210676148814,"lat":51.0600336463379},"ort":"Dresden"}
{"index":{"_index":"plzsuche","_type":"plz","_id":"2"}}
{"plz":"01069","location":{"lon":13.7389066401609,"lat":51.039558876083},"ort":"Dresden"}
{"index":{"_index":"plzsuche","_type":"plz","_id":"3"}}
{"plz":"01097","location":{"lon":13.7439674110642,"lat":51.0667452412037},"ort":"Dresden"}
{"index":{"_index":"plzsuche","_type":"plz","_id":"4"}}
{"plz":"01099","location":{"lon":13.8289798683304,"lat":51.0926193047084},"ort":"Dresden"}
{"index":{"_index":"plzsuche","_type":"plz","_id":"5"}}
{"plz":"01108","location":{"lon":13.8289798683304,"lat":51.0926193047084},"ort":"Dresden"}
...

Nun müssen wir die Mapping-Datei mit folgendem cURL Statement in Elasticsearch laden: 

$ curl -XPUT localhost:9200/plzsuche -d @plzmapping.json
{"ok":true,"acknowledged":true}

Und die PLZ-Daten werden per Bulk-Request in Elasticsearch geladen:

$ curl -XPOST localhost:9200/_bulk --data-binary @plz.json

Nun können wir bereits mit den Postleitzahlen experimentieren. Mit folgender Query erhalten wir die Geo-Koordinaten der PLZ 76199:

$ curl -XGET 'localhost:9200/plzsuche/plz/_search?pretty=true' -d '{"query": { "term" : { "plz" : "76199" } } }'
{
  "took" : 76,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 1,
    "max_score" : 7.719618,
    "hits" : [ {
      "_index" : "plzsuche",
      "_type" : "plz",
      "_id" : "5575",
      "_score" : 7.719618, "_source" : {"plz":"76199","location":{"lon":8.41412416273753,"lat":48.9790711139834},"ort":"Karlsruhe"}
    } ]
  }
}

Folgende Query gibt die ersten 5 PLZs, die sich im Umkreis von 15 km zu der PLZ 76199 (es werden lon und lat aus dem obigen Ergebnis benutzt) liegen, zurück. Für die Berechnung der Distanz habe ich arcDistanceInKm benutzt, da diese Methode die Erdkrümmung beachtet und somit genauer ist.

curl -XGET 'localhost:9200/plzsuche/plz/_search?pretty=true&size=5' -d '
{
        "filter" : {
            "geo_distance" : {
                "distance" : "15km",
                "plz.location" : {
                    "lat" : 48.9790711139834,
                    "lon" : 8.41412416273753
                }
            }
        },
  "fields" : [ "_source" ],
     "script_fields" : {
     "distance" : {
     "params" : {
     "lat" : 48.9790711139834,
     "lon" : 8.41412416273753
     },
     "script" : "doc[\u0027plz.location\u0027].arcDistanceInKm(lat,lon)"
     }
     }
}'

{
  "took" : 360,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 31,
    "max_score" : 1.0,
    "hits" : [ {
      "_index" : "plzsuche",
      "_type" : "plz",
      "_id" : "5569",
      "_score" : 1.0, "_source" : {"plz":"76137","location":{"lon":8.41715198135546,"lat":49.0007141097442},"ort":"Karlsruhe"},
      "fields" : {
        "distance" : 2.4194178286751953
      }
    }, {
      "_index" : "plzsuche",
      "_type" : "plz",
      "_id" : "5571",
      "_score" : 1.0, "_source" : {"plz":"76149","location":{"lon":8.40363333991651,"lat":49.0538323666989},"ort":"Karlsruhe"},
      "fields" : {
        "distance" : 8.357554036939588
      }
    }, {
      "_index" : "plzsuche",
      "_type" : "plz",
      "_id" : "5576",
      "_score" : 1.0, "_source" : {"plz":"76227","location":{"lon":8.47889141753469,"lat":48.9934183718416},"ort":"Karlsruhe"},
      "fields" : {
        "distance" : 4.993691262385394
      }
    }, {
      "_index" : "plzsuche",
      "_type" : "plz",
      "_id" : "5583",
      "_score" : 1.0, "_source" : {"plz":"76316","location":{"lon":8.3500529771148,"lat":48.8838048110464},"ort":"Malsch"},
      "fields" : {
        "distance" : 11.594035414344411
      }
    }, {
      "_index" : "plzsuche",
      "_type" : "plz",
      "_id" : "5590",
      "_score" : 1.0, "_source" : {"plz":"76359","location":{"lon":8.4519716163756,"lat":48.8542980799165},"ort":"Marxzell"},
      "fields" : {
        "distance" : 14.162932085262243
      }
    } ]
  }
}

Wie komme ich an die Geo-Daten der IKEA Warenhäuser?

Hierzu habe ich mir alle Adressen von der IKEA Homepage heruntergeladen und diese mit einem gewöhnlichen Editor bearbeitet. Die Geo-Daten der Adressen erhalte ich dann über die Google API, die ich in folgendem Skript (IkeaCoords.groovy) benutzte, um mir wieder eine Elasticsearch Mapping-Datei und Daten-Datei zu generieren. Bitte die Google Map API Lizenz [6] beachten, da die Geo-Daten, die unter Benutzung der Google-API ermittelt werden nur in Google Maps benutzt werden dürfen. Hier ist noch anzumerken, dass ich zwischen jedem Aufruf der Google-API eine Sekunde warte, da wenn man die Abfragen zu schnell sendet teilweise leere Daten zurückkommen.

@Grab(group='org.codehaus.groovy.modules.http-builder', module='http-builder', version='0.6')
import groovyx.net.http.*
import static groovyx.net.http.ContentType.*
import static groovyx.net.http.Method.*


def ikea_addresses = [
        'Otto-Hahn-Str. 99,86368 Gersthofen,Germany,DE',
        'Gewerbehof 10,13597 Berlin,Germany,DE',
        ...
]

final String INDEX_NAME = 'ikeasuche'
final String DOC_TYPE = 'shop'
def jsonBuilder = new groovy.json.JsonBuilder()


def http = new HTTPBuilder( 'https://maps.googleapis.com' )


def th = Thread.start {
    new File("ikea.json").withWriter {out ->
        ikea_addresses.eachWithIndex {address, i ->
            http.request( GET, JSON ) {
                uri.path = '/maps/api/geocode/json'
                uri.query = [address:"${address}", sensor: false ]

                headers.'User-Agent' = 'Mozilla/5.0 Ubuntu/8.10 Firefox/3.0.4'

                response.success = { resp, json ->
                    println resp.statusLine
                    def lonVal = json.results.geometry.location.lng[0] as Double
                    def latVal = json.results.geometry.location.lat[0] as Double
                    println 'address : ' + address + ' lon : ' + lonVal + ' lat : ' + latVal
                    out.println ("{\"index\":{\"_index\":\"${INDEX_NAME}\",\"_type\":\"${DOC_TYPE}\",\"_id\":\"${i+1}\"}}")
                    jsonBuilder(address: address, location: [lon: lonVal, lat: latVal])
                    out.println (jsonBuilder.toString())
                }
                response.failure = { resp ->
                    println "Unexpected error: ${resp.statusLine.statusCode} : ${resp.statusLine.reasonPhrase}"
                }
            }
            sleep(1000)
        }
    }
}

new File("ikeamapping.json").withWriter {out ->
    jsonBuilder.mappings{
        "${DOC_TYPE}" {
            properties {
                address {
                    type 'string'
                }
                location {
                    type 'geo_point'
                }
            }
        }
    }
    out.println (jsonBuilder.toPrettyString())
}

Mit folgenden cURL Statements lade ich dann beide JSON-Dateien in Elasticsearch:

$ curl -XPUT localhost:9200/ikeasuche -d @ikeamapping.json
$ curl -XPOST localhost:9200/_bulk --data-binary @ikea.json

Darstellung mit Google Maps

Für die Darstellung in Google Maps habe ich das GMap JQuery Plugin benutzt [7]. Folgender HTML-Code erzeugt das Ergebnis, das der Screen-Shot ganz oben links in diesem Blog-Post zeigt.

<title></title>
    <script src="http://maps.google.com/maps/api/js?sensor=false&language=de" type="text/javascript"></script>
    <script src="http://code.jquery.com/jquery-1.9.1.js"></script>
    <script src="gmap3.js" type="text/javascript"></script>




<form action="/" id="searchForm">
    <input name="plz" placeholder="Bitte PLZ eingeben: " type="text">
    <input type="submit" value="Search">
</form>
<div id="result">
</div>
<div id="map_canvas">
</div>
<script>
    var plzsuche = {
        query: {
            term: {
                plz: 'X'
            }
        },
        fields: ['location','ort']
    };

    var ikeasuche = {
        query: {
            matchAll : {}
        },
        fields: ['address', 'location'],
        sort : [{
            _geo_distance : {
                unit : 'km',
                location : {
                    lat: 'X',
                    lon: 'Y'
                }
            }
        }]
    };

    // Attach a submit handler to the form
    $( "#searchForm" ).submit(function( event ) {
        event.preventDefault();

        var plzLocation = [];
        var markerList = [];
        var plz = $(this).find( "input[name='plz']" ).val();
        plzsuche.query.term.plz = plz;

        $.post( 'http://localhost:9200/plzsuche/plz/_search?pretty=true', JSON.stringify(plzsuche), function( response ) {
            var jsonResult = response.hits.hits[0].fields;
            ikeasuche.sort[0]._geo_distance.location.lat = jsonResult.location.lat;
            ikeasuche.sort[0]._geo_distance.location.lon = jsonResult.location.lon;
            plzLocation = [jsonResult.location.lat, jsonResult.location.lon];
            markerList.push({
                latLng: plzLocation,
                data: jsonResult.ort,
                options: {icon: 'http://maps.google.com/mapfiles/ms/icons/blue-dot.png'}
            });
            $.post( 'http://localhost:9200/ikeasuche/shop/_search?pretty=true', JSON.stringify(ikeasuche) , function( response ) {
                var addressList = "<ul>";
                $.each(response.hits.hits, function( index, value ) {
                    markerList.push({
                        latLng: [value.fields.location.lat, value.fields.location.lon],
                        data: value.fields.address,
                        options: {icon: 'http://maps.google.com/mapfiles/ms/icons/red-dot.png'}
                    });
                    addressList += "
<li>" + value.fields.address + "</li>
";
                });
                addressList += "</ul>
";
                $("#result").html(addressList);
    $("#map_canvas").gmap3("clear", "markers");
                $("#map_canvas").width("700px").height("500px").gmap3({
                    map: {
                        options: {
                            center: plzLocation,
                            zoom: 7
                        },
                        callback: function(){
                            $(this).css('border', '5px solid black');
                        }
                    },
                    marker:{
                        values: markerList,
                        events:{
                            mouseover: function(marker, event, context){
                                var map = $(this).gmap3("get"),
                                        infowindow = $(this).gmap3({get:{name:"infowindow"}});
                                if (infowindow){
                                    infowindow.open(map, marker);
                                    infowindow.setContent(context.data);
                                } else {
                                    $(this).gmap3({
                                        infowindow:{
                                            anchor:marker,
                                            options:{content: context.data}
                                        }
                                    });
                                }
                            },
                            mouseout: function(){
                                var infowindow = $(this).gmap3({get:{name:"infowindow"}});
                                if (infowindow){
                                    infowindow.close();
                                }
                            }
                        }
                    }
                });
            });
        });
    });

</script>

Fazit

Elasticsearch ermöglicht es, sehr einfach mit Geo-Koordinaten zu arbeiten. Wie dieser Blog-Post zeigt sind wirklich nur sehr wenig Schritte nötig, um ein beachtliches Ergebnis zu erzielen. Viel Spaß bei weiteren Experimentieren mit Elasticsearch. Man könnte bspw. das Ganze noch um eine Facettierung erweitern, die alle IKEA Häuser im Umkreis von 20 km, 50 km, 100 km und 200 km zurückgibt.

Kategorien: ElasticsearchGroovy

Zurück