Search code examples
apisearchpartialstreet-address

Partial Address Search / Typeahead


We have an address field we want to provide typeahead for. It sits behind a login, although if we needed to we could get crafty and make that one page public for licensing compliance.

The Google Maps API is getting locked down. We used to use the "reverse geocode" portion of it to perform partial address search / typeahead for addresses - so for example if the user typed:

1600 Penn

I could hit the service and get back several suggestions, like:

1600 Pennsylvania Avenue, Washington, DC

There are several other partial address searches out there I've come across but they each have problems.

Google: $10000/yr minimum for just 7500 requests/day - ridiculous

Yahoo: Shutdown this year

Bing: Requires the page to be public / not behind login. This isn't a hard stop for us, but it would be a challenging redesign of how the page works.

Mapquest OpenStreetMap API: Searches for the exact string rather than a leading string - so it returns Penn Station instead of 1600 Pennsylvania Ave.

Mapquest OpenStreetMap data: We could download all of this and implement our own, but the CPU and data requirements would likely be too much to bite off for the time being.

We're fine with paying for usage, we'd just seek a solution closer to Amazon's $0.01/10000 requests than Google's.


Solution

  • Google has released Google Places Autocomplete which resolves exactly this problem.

    At the bottom of a page throw this in there:

    <script defer async src="//maps.googleapis.com/maps/api/js?libraries=places&key=(key)&sensor=false&callback=googPlacesInit"></script>
    

    Where (key) is your API key.

    We've set our code up so you mark some fields to handle typeahead and get filled in by that typeahead, like:

    <input name=Address placeholder=Address />
    <input name=Zip placeholder=Zip />
    

    etc

    Then you initialize it (before the Google Places API has loaded typically, since that's going to land async) with:

    GoogleAddress.init('#billing input', '#shipping input');
    

    Or whatever. In this case it's tying the address typeahead to whatever input has name=Address in the #billing tag and #shipping tag, and it will fill in the related fields inside those tags for City, State, Zip etc when an address is chosen.

    Setup the class:

    var GoogleAddress = {
        AddressFields: [],
        //ZipFields: [],    // Not in use and the support code is commented out, for now
        OnSelect: [],
    
        /**
         * @param {String} field Pass as many arguments as you like, each a selector to a set of inputs that should use Google
         * Address Typeahead via the Google Places API.
         * 
         * Mark the inputs with name=Address, name=City, name=State, name=Zip, name=Country
         * All fields are optional; you can for example leave Country out and everything else will still work.
         * 
         * The Address field will be used as the typeahead field. When an address is picked, the 5 fields will be filled in.
         */
        init: function (field) {
            var args = $.makeArray(arguments);
            GoogleAddress.AddressFields = $.map(args, function (selector) {
                return $(selector);
            });
        }
    };
    

    The script snippet above is going to async call into a function named googPlacesInit, so everything else is wrapped in a function by that name:

    function googPlacesInit() {
        var fields = GoogleAddress.AddressFields;
    
        if (
            // If Google Places fails to load, we need to skip running these or the whole script file will fail
            typeof (google) == 'undefined' ||
            // If there's no input there's no typeahead so don't bother initializing
            fields.length == 0 || fields[0].length == 0
        )
            return;
    

    Setup the autocomplete event, and deal with the fact that we always use multiple address fields, but Google wants to dump the entire address into a single input. There's surely a way to prevent this properly, but I'm yet to find it.

    $.each(fields, function (i, inputs) {
        var jqInput = inputs.filter('[name=Address]');
    
        var addressLookup = new google.maps.places.Autocomplete(jqInput[0], {
            types: ['address']
        });
        google.maps.event.addListener(addressLookup, 'place_changed', function () {
            var place = addressLookup.getPlace();
    
            // Sometimes getPlace() freaks out and fails - if so do nothing but blank out everything after comma here.
            if (!place || !place.address_components) {
                setTimeout(function () {
                    jqInput.val(/^([^,]+),/.exec(jqInput.val())[1]);
                }, 1);
                return;
            }
    
            var a = parsePlacesResult(place);
    
            // HACK! Not sure how to tell Google Places not to set the typeahead field's value, so, we just wait it out
            // then overwrite it
            setTimeout(function () {
                jqInput.val(a.address);
            }, 1);
    
            // For the rest, assign by lookup
            inputs.each(function (i, input) {
                var val = getAddressPart(input, a);
                if (val)
                    input.value = val;
            });
    
            onGoogPlacesSelected();
        });
    
        // Deal with Places API blur replacing value we set with theirs
        var removeGoogBlur = function () {
            var googBlur = jqInput.data('googBlur');
            if (googBlur) {
                jqInput.off('blur', googBlur).removeData('googBlur');
            }
        };
    
        removeGoogBlur();
        var googBlur = jqInput.blur(function () {
            removeGoogBlur();
            var val = this.value;
            var _this = this;
            setTimeout(function () {
                _this.value = val;
            }, 1);
        });
        jqInput.data('googBlur', googBlur);
    });
    
    // Global goog address selected event handling
    function onGoogPlacesSelected() {
        $.each(GoogleAddress.OnSelect, function (i, fn) {
            fn();
        });
    }
    

    Parsing a result into the canonical street1, street2, city, state/province, zip/postal code is not trivial. Google differentiates these localities with varying tags depending on where in the world you are, and as a warning, if you're used to US addresses, there are places for example in Africa that meet none of your expectations of what an address looks like. You can break addresses in the world into 3 categories:

    • US-identical - the entire US and several countries that use a similar addressing system

    • Formal addresses - UK, Australia, China, basically developed countries - but the way their address parts are broken up/locally written has a fair amount of variance

    • No formal addresses - in undeveloped areas there are no street names let alone street numbers, sometimes not even a town/city name, and certainly no zip. In these locations what you really want is a GPS location, which is not handled by this code.

    This code only attempts to deal with the first 2 cases.

    function parsePlacesResult(place) {
        var a = place.address_components;
    
        var p = {};
        var d = {};
    
        for (var i = 0; i < a.length; i++) {
            var ai = a[i];
            switch (ai.types[0]) {
                case 'street_number':
                    p.num = ai.long_name;
                    break;
                case 'route':
                    p.rd = ai.long_name;
                    break;
                case 'locality':
                case 'sublocality_level_1':
                case 'sublocality':
                    d.city = ai.long_name;
                    break;
                case 'administrative_area_level_1':
                    d.state = ai.short_name;
                    break;
                case 'country':
                    d.country = ai.short_name;
                    break;
                case 'postal_code':
                    d.zip = ai.long_name;
            }
        }
    
        var addr = [];
        if (p.num)
            addr.push(p.num);
        if (p.rd)
            addr.push(p.rd);
    
        d.address = addr.join(' ');
    
        return d;
    }
    
    /**
     * @param input  An Input tag, the DOM element not a jQuery object
     * @paran a     A Google Places Address object, with props like .city, .state, .country...
     */
    var getAddressPart = function(input, a) {
        switch(input.name) {
            case 'City': return a.city;
            case 'State': return a.state;
            case 'Zip': return a.zip;
            case 'Country': return a.country;
        }
        return null;
    }
    

    Old Answer

    ArcGis/ESRI has a limited typeahead solution that's functional but returns limited results only after quite a bit of input. There's a demo here:

    http://www.esri.com/services/disaster-response/wildlandfire/latest-news-map.html

    For example you might type 1600 Pennsylvania Ave hoping to get the white house by the time you type "1600 Penn", but have to get as far as "1600 pennsylvania ave, washington dc" before it responds with that address. Still, it could have a small benefit to users in time savings.