Freigeben über


How to add custom auto complete functionality to your map app

More and more developers want to add auto complete to their map applications, and for good reason. It’s convenient for users and can help make your map application a user-friendly delight. In this blog post I will show you how to use Bing Spatial Data Services to create custom auto complete functionality in your next app.

Some developers have turned to using the Bing Maps geocoding services to do this, but this often ends up generating a large number of transactions. If you are using Bing Maps under the free terms of use this can result in your application quickly exceeding the free usage limits. If you have an enterprise license for Bing Maps, these additional transactions could potentially increase the cost of your license significantly. Also, the majority of suggestions that will come out of the geocoder will likely be irrelevant to your users, making this added feature a bit of a waste. The reason for this is that the geocoder has the whole Bing Maps data set behind it, every address, every city is a potential suggestion that can be returned, but for majority of applications there is only a limited number of areas that are relevant.

A few of months ago I wrote a blog post on how to create an auto complete functionality that was based on past searches by your users. This creates a tailored auto complete experience to your application. The only suggestions that are ever shown are locations your users have searched for in the past. In addition to this, it orders the suggestions based on the popularity of those searches in your app. To create this service, I used the Microsoft Azure mobile web service to save time developing a custom web service that’s connected to a database. All of the suggestions get stored in this service. The more your users use the search functionality on your app, the more this auto suggestion functionality is used and fewer calls are made to the Bing Maps geocoding service. This results in much lower licensing costs or it allows you to use the free terms of use longer.

I recently had a discussion with a customer who was interested in this kind of custom auto complete functionality. However, they wanted the suggestions to only be cities in which they have locations. Their data is stored in the Bing Spatial Data Services and wanted to know if it would be possible to create an auto complete functionality that was powered by their data in the Bing Spatial Data Services. It is, and that is exactly what I’ll be showing you how to do.

Full source code and the data sources I created for this blog post can be found on the MSDN Code Samples gallery here.

Creating our data sources

A lot of data starts out as a two-dimensional table inside of Microsoft Excel. With this in mind, we will use this as the starting point for our application. In the code samples I’ve included an Excel file that contains a set of mock coffee shops in Seattle, San Antonio, San Diego and San Francisco. It seems our coffee shops business strategy is to only open up locations in cities that start with “S”. Here is a screenshot of what the data looks like:

Screenshot: Data

Screenshot: Data

Looking at this table, we can make the following observations:

  • The ID column contains a unique value for each location. We will be able to use this as our primary key in our data source.
  • Location information is stored using the AddressLine, Locality, AdminDistrict, PostalCode, and CountryRegion columns. These are the standard address related column names the Bing Spatial Data Services looks for when geocoding locations.
  • There is a Latitude and Longitude column that contains the coordinates of each coffee shop. If you are using your own data, you likely don’t have this information yet. That’s ok because the Bing Spatial Data Services will fill this column in for us when we upload the data source.
  • There are some additional columns that contain some metadata related to the coffee shops. You can create your own columns that contain metadata that’s more relevant to your location data.

Now that we have a set of data to work with, we need to format it so that it aligns with the required data source schema for the Bing Spatial Data Services. The main changes required to align our data to this schema are as follows:

1. Add a new row that contains the versioning information of the Bing Spatial Data Services and the entity type name. The entity type name should be something that describes a single location in your data source. In our case it makes sense to call this “CoffeeShop”. We will set the first cell in the first row of the table to “Bing Spatial Data Services, 1.0, CoffeeShop”.

2. Specify the data type as an OData type in brackets beside each column header name. Here is a list of the different OData type values that can be used:

Data Type OData Type
String Edm.String
Long, Integer Edm.Int64
Double, Float Edm.Double
Boolean Edm.Boolean
DateTime Edm.DateTime
Well Known Text shape Edm.Geography

3. Finally we need to identify which column contains the primary key for uniquely identifying each location.

Here is a screenshot of how these changes look when completed.

Screenshot: Data with changes applied

Screenshot: Data with changes applied

From here we need to export the data into a file format that can be uploaded. The Bing Spatial Data Services supports XML, CSV, Tab and Pipe delimited formatted files. When working with Excel, I prefer to use Tab or Pipe delimited files, as it makes the upload process a bit easier. To export this data as a Tab delimited file in Excel press the Save As button and in in the Save as type drop down select Text (Tab delimited)(*.txt) . We will call this file CoffeeShops_tab.txt.

Screenshot: Selecting tab delimited file type

Screenshot: Selecting tab delimited file type

Now that we have a Tab delimited file we need to open it in a text editor and make a couple of minor edits. The first one is to remove all the trailing Tab characters that are in the first row of the file. The second edit is to remove all the quote characters from the file as these are not needed and can cause some issues when uploading. If using notepad you can easily do this by pressing Ctrl+H to open the find replace functionality. In the screenshot below you can see where the trailing Tab characters are highlighted in blue:

Screenshot: Text file in Notepad

Screenshot: Text file in Notepad

At this point we have our main data source for all our coffee shop locations. Before we upload these we will create a second data source that powers our auto complete functionality. If we tried to do an auto complete against the Locality (city/town) column in the main data source, we will end up with a lot of identical suggestions. We need to create a data set that contains a list of each unique city only once. To do this open a new Excel file and add the following columns: ID, Locality, AdminDistrict, CountryRegion, Latitude, and Longitude. Next copy the Locality, AdminDistrict and CountryRegion data from the main data source into this table, leaving the ID, Latitude, and Longitude columns empty. Next select all the data in the table and then go to the Data tab and press the Remove Duplicates button. This will give us a set of unique cities.

Next create a new column called FormattedAddress. We will populate this column with a nicely formatted address string that we can display to the user. To populate this column we can do a simple string concatenation in Excel using a formula like this: =CONCATENATE(B3,”, “,C3,” “,D3)

The string comparison functionality in the Bing Spatial Data Services is case sensitive. Since our users are unlikely to use the proper text casing in the search box, we will need to create an additional column that contains the formatted address information in lower case. We can later ensure that the data we send to the service to do the auto complete is in lowercase as well. To do this create a new column called FormattedAddress_Lower and use of the Lower function in Excel to convert the formatted address data to lowercase.

Next we need to populate the ID column with a unique value. An easy way to do this is to set the first ID to 1 and then use a formula that increments the ID. For example, in cell A4 use the formula =A3+1 and then copy this cell to all the other ID cells.

The final step is to ensure your data has the correct data schema information. Set the entity type value to CoffeeShopCities. Export this as a tab delimited file called CoffeeShopCities_tab.txt. Here is a screenshot of the final data source:

Screenshot: Final data source

Screenshot: Final data source

Uploading our data sources to Bing

Now that we have our data sources we need to upload them to the Bing Spatial Data Services. To do this log into the Bing Maps portal using your Microsoft Account ID. If you don’t have a Bing Maps account you can sign up to create a new one.

Once signed in, on the left side panel you will see a Data Sources section, select the Upload data to a data source link. You will be presented with a form to upload your data source. Set the data source name of the main data set to CoffeeShops. Next select a Bing Maps key to be your master key. A master key allows you to programmatically query, edit, update and delete your data source. You can optionally provide a second Bing Maps key to be a query key. A Bing Maps key that is specified as a query key will only be able to search against the data sources and not modify them. If test dropdowns are empty you need to create a Bing Maps key. Next set the data type to TAB and then press the Browser button to select your data source file to upload. Once this is done press the Upload button.

Screenshot: Data Source section of Bing Maps Portal

Screenshot: Data Source section of Bing Maps Portal

Repeat these steps for the second data source and give it a data source name of CoffeeShopCities.

From here click on the Manage my data sources link in the left side panel. You will see a panel listing the data sources that have been geocoded. Press the publish button to have these data sources exposed as a spatial REST service through the Bing Spatial Data Services.

Screenshot: Manage data sources in Bing Maps Portal

Screenshot: Manage data sources in Bing Maps Portal

The publishing step may take a few minutes. You can monitor this on the Published Data Sources tab. Press the Refresh button from time to time update the status.

Screenshot: Published data sources tab in Bing Maps Portal

Screenshot: Published data sources tab in Bing Maps Portal

Once complete we can stop here if we want. To make things a bit easier for people who download the code, I’ve pressed the Make Public button next to both of these data sources. By doing this any Bing Maps key can be used to query these data sources. This is a great way to share data sources with other people.

Screenshot: Make data sources public in Bing Maps Portal

Screenshot: Make data sources public in Bing Maps Portal

Now that the data sources are uploaded we will need to get the URL information to query the data sources. On the left side panel select the View Data Source Information link. You should see all your published data sources listed along with the URL, Master key and Query key to access them. We will need the URLs later in our code.

Screenshot: View data source information in Bing Maps Portal

Screenshot: View data source information in Bing Maps Portal

Creating the auto complete client application

Before we create an app to use the Bing Spatial Data Services and our data sources to create an auto complete functionality, lets first take a closer look at the logic behind how the auto complete will work. The Bing Spatial Data Services expose the data source data as a spatial REST service, as such our query against the services must have some sort of spatial context. Since we want to be able to search against all our data, we can make the spatial part of the query a bounding box search against the whole globe. From here all we need to do is add a StartsWith filter to the query that checks to see if the FormattedAddress_Lower column starts with the keys that have been typed. Putting this together we get a query URL for the service that looks like this:

[DataSourceURL]?spatialFilter=bbox(-90,-180,90,180)&$filter=StartsWith(FormattedAddress_Lower,’[USER_INPUT]‘)&key=[YOUR_BING_MAPS_KEY]

In our application we will of course update the values that are in the square brackets. We will also specify the format of the response to be JSON, and add an option to limit the number of results.

Now that we understand how to use the REST services that expose our location data, we can easily use it to power an auto complete function in a client application. Our client application can use any of the Bing Maps APIs. To keep things simple we will use the Bing Maps AJAX Control, Version 7.0 to create a web page that has a map and a search box. To do this open Visual Studio and create a new ASP.NET Web Application project called BingSDSAutoComplete.

Screenshot: New project

Screenshot: New project

When prompted select the Empty ASP.NET template and press OK to continue.

Screenshot: ASP.NET template

Screenshot: ASP.NET template

Once the project is loaded add a new HTML page called index.html by right clicking on the project and selecting Add → New Item. Create a folder called js. In the js folder create a JavaScript file called BingSDSAutoComplete.js. At this point your project should look like this:

Screenshot: JavaScript file

Screenshot: JavaScript file

Open the index.html file. In here we will load in script references for Bing Maps, jQuery, jQuery UI and our BingSDSAutoComplete.js file. We will also create a textbox for our search query and add a div for the map. To help keep things simple, we will use the auto complete widget in jQuery UI. Update the HTML in this file with the following:

 <!DOCTYPE html>
<html xmlns="https://www.w3.org/1999/xhtml">
<head>
    <title>Bing SDS Auto Complete</title>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />

    <!-- Bing Maps reference -->
    <script type="text/javascript" src="https://ecn.dev.virtualearth.net/mapcontrol/mapcontrol.ashx?v=7.0"></script>
   
    <!-- jquery and jquery UI references -->
    <script type="text/javascript" src='https://code.jquery.com/jquery-1.11.0.min.js'></script>
   
    <script src="https://code.jquery.com/ui/1.10.4/jquery-ui.min.js" type="text/javascript"></script>
    <link href="https://code.jquery.com/ui/1.10.4/themes/smoothness/jquery-ui.css" rel="stylesheet" type="text/css" />
   
    <!-- Our JavaScript & CSS references -->
    <script type="text/javascript" src='js/BingSDSAutoComplete.js'></script>

    <style type="text/css">
    .container {
        width: 800px;
        margin: auto;
    }

    #myMap {
        position: absolute;
        width: 800px;
        height: 600px;
    }

    .searchBar {
        margin: 10px 0;
    }

    #searchBtn {
        height: 30px;
        margin-top: -5px;
    }

    #searchBox, #searchResult {
        width: 300px;
    }
    </style>
</head>
<body>
    <div class="container">
        <div class="searchBar">
            <div class="ui-widget">
                <label for="searchBox">Search:</label>
                <input id="searchBox" />
                <button id="searchBtn"></button>
            </div>
            <div id="searchResult" class="ui-widget"></div>
        </div>

        <div id="myMap"></div>

        <div id="resultsDialog">
            <ul id="resultsList"></ul>
        </div>
    </div>
</body>
</html>

Next open the BingSDSAutoComplete.js file. In here we will add a few options for configuring how the application works. We will then load the map and add two layers, one for data and the other for an infobox. By using to two layers we can ensure that the infobox will always appear above our data. We will then load the Bing Maps Search module for doing geocoding when a user specifies a query that doesn’t match any of the locations in the auto complete data source. Finally we will generate a session key from the map to use with the Bing Spatial Data Services REST services. If you use a the master or query key from your data source to load the map, the generated session key from the map will also be able to access your data source using the REST services. The benefit of using a session key rather than just your Bing Maps key is that all the calls to the services are tracked as part of the user session and are marked as non-billable transactions in the Bing Maps transaction reports for your account. Add the following JavaScript to the BingSDSAutoComplete.js file.

 $(document).ready(function () {
    var dataSourceUrl = 'https://spatial.virtualearth.net/REST/v1/data/515d38d4d4e348d9a61c615f59704174/CoffeeShops/CoffeeShop';
    var autoCompleteDataSourceUrl = 'https://spatial.virtualearth.net/REST/v1/data/a4834fe8a10f47a1af839675eabe0951/CoffeeShopCities/CoffeeShopCities';
    
    var maxSuggestions = 5;
    var maxResults = 10;
    var dataLayer, infobox;

    // Load the map
    var map = new Microsoft.Maps.Map($("#myMap")[0], {
        credentials: 'YOUR_BING_MAPS_KEY'
    });

    // Create a layer to load results to.
    var dataLayer = new Microsoft.Maps.EntityCollection();
    map.entities.push(dataLayer);

    // Create to load infobox in so that it appears above all results.
    var infoboxLayer = new Microsoft.Maps.EntityCollection();
    map.entities.push(infoboxLayer);

    //Create an infobox that we can reuse.
    var infobox = new Microsoft.Maps.Infobox(new Microsoft.Maps.Location(0, 0), {
        visible: false,
        offset: new Microsoft.Maps.Point(0, 20),
        height: 180
    });
    infoboxLayer.push(infobox);

    var sessionKey;
    map.getCredentials(function (c) {
        sessionKey = c;
    });

    // Load the Bing Maps Search Manager
    var searchManager = null;

    Microsoft.Maps.loadModule('Microsoft.Maps.Search', {
        callback: function () {
            searchManager = new Microsoft.Maps.Search.SearchManager(map);
        }
    });
});

If you run the application a web page will open that looks like this:

Example: Web page when running application

Example: Web page when running application

If you try typing in the search box or pressing the search button nothing will happen, as we haven’t set that functionality up yet. Before we do that we will create a couple of helper functions. The first function is called GetSuggestions and it will take in a string and compare it against the FormattedAddress_Lower column in the CoffeeShopCities data source using the StartsWith filter. If it finds any suggestions it then populates and displays the auto complete box under the search box. The second helper function is called FindNearbyLocations and it will take in a latitude and longitude value and will search for nearby coffee shops that are in the main CoffeeShops data source. The third helper function is called ShowInfobox and will be called whenever a user clicks on a pushpin on the map. It will then take all the data we have about the coffee shop and populate an infobox (popup) on the map. Add the following JavaScript to the BingSDSAutoComplete.js file.

 function GetSuggestions(query, callback) {
    var queryUrl = autoCompleteDataSourceUrl + "?spatialFilter=bbox(-90,-180,90,180)&$format=json" +
        "&$filter=StartsWith(FormattedAddress_Lower,'" + encodeURI(query.toLowerCase()) + "')%20eq%20true" +
        "&$top=" + maxSuggestions + "&key=" + sessionKey;

    $.ajax({
        url: queryUrl,
        dataType: "jsonp",
        jsonp: "jsonp",
        success: function (data) {
            callback(data.d.results);
        },
        error: function (e) {
            alert(e.statusText);
        }
    });
}

function FindNearbyLocations(lat, lon) {
    dataLayer.clear();

    var queryUrl = dataSourceUrl + "?spatialFilter=nearby(" + lat + "," + lon + ",20)&$format=json" +
        "&$top=" + maxResults + "&key=" + sessionKey;

    $.ajax({
        url: queryUrl,
        dataType: "jsonp",
        jsonp: "jsonp",
        success: function (data) {
            var results = data.d.results;                

            if (results.length > 0) {
                var locs = [];

                //Loop through results and add to map
                for (var i = 0; i < results.length; i++) {
                    var loc = new Microsoft.Maps.Location(results[i].Latitude, results[i].Longitude);
                    var pin = new Microsoft.Maps.Pushpin(loc);
                    pin.Metadata = results[i];

                    Microsoft.Maps.Events.addHandler(pin, 'click', ShowInfobox);
                    dataLayer.push(pin);
                    locs.push(loc);
                }

                //Use the array of locations from the results to set the map view to show all locations.
                if (locs.length > 1) {
                    map.setView({ bounds: Microsoft.Maps.LocationRect.fromLocations(locs), padding: 80 });
                } else {
                    map.setView({ center: locs[0], zoom: 15 });
                }
            }
        },
        error: function (e) {
            alert(e.statusText);
        }
    });
}

function ShowInfobox(e) {
    if (e.target.Metadata) {
        var data = e.target.Metadata;
        var desc = [data.StoreType, '<br/><br/>', data.AddressLine, '<br/>', data.Locality, ' ', data.AdminDistrict, ' ',
            data.PostalCode, '<br/>', data.CountryRegion, '<br/><br/>Hours: ', data.Open, ' - ', data.Close];

        if(data.IsWiFiHotSpot){
            desc.push('<br/>Wifi Available');
        }

        infobox.setLocation(e.target.getLocation());
        infobox.setOptions({ visible: true, title: data.Name, description: desc.join('') });
    }
}

At this point all we need to do is wire up the auto complete widget from jQuery UI. When the user types more than one character in the search box, the auto complete widget will call the GetSuggestions function. If a user selects any of the suggestions, the map will be centered and zoomed into that city and the FindLocations function will be called. To do this add the following code to the BingSDSAutoComplete.js file.

 //Wire up auto complete functionality
$("#searchBox").autocomplete({
    source: function (request, response) {
        GetSuggestions(request.term, function (results) {
            if (results) {
                response($.map(results, function (item) {
                    return {
                        data: item,
                        label: item.FormattedAdress,
                        value: item.FormattedAdress
                    }
                }));
            }
        });
    },
    select: function (event, ui) {  //Suggestion selected
        var item = ui.item.data;

        //Center map over selected location
        map.setView({ center: new Microsoft.Maps.Location(item.Latitude, item.Longitude), zoom: 11 });

        //Find nearby locations
        FindNearbyLocations(item.Latitude, item.Longitude);
    },
    minLength: 1    //Minimium number of characters before auto suggest is triggered
});

At this point the auto complete functionality is complete and we can test it out. If you run the application and type in “sa” you will see a list of suggestions appear under the search box. If you click on any of them the map will zoom into that location and load in all the coffee shop locations we have in our data source that are near that city. If we click on a pushpin we will then see additional data about that coffee shop. Here is a screenshot that shows all this:

Example: Auto complete functionality

Example: Auto complete functionality

Now we could stop here, but what if the user enters in a query that there are no suggestions for? We should then fall back to using geocoding. Add the following code to the BingSDSAutoComplete.js file. If the user presses enter in the search box or presses the search button it will geocode the users query and then do a nearby search around the resulting locations.

 function GeocodeQuery() {
    var query = $("#searchBox").val();

    if (query != '') {
        searchManager.geocode({
            where: query,
            callback: function (r) {
                if (r && r.results && r.results.length > 0) {
                    if (r.results.length == 1) {
                        //Set the map view over the geocoded location
                        map.setView({ bounds: r.results[0].bestView });

                        //Find nearby shops
                        FindNearbyLocations(r.results[0].location.latitude, r.results[0].location.longitude);
                    } else {
                        var geocodeResults = [];
                        var result;

                        //Loop through geocodie results and create a disambiguous box.
                        for (var i = 0; i < r.results.length; i++) {
                            result = r.results[i];

                            geocodeResults.push({
                                name: result.name,
                                latitude: result.location.latitude,
                                longitude: result.location.longitude,
                                bestView: result.bestView
                            });

                            $('#resultsList').append('<li rel="' + i + '">' + result.name + '</li>');
                        }

                        $('#resultsList li').click(function () {
                            var idx = $(this).attr('rel');
                            var r = geocodeResults[idx];

                            $("#searchBox").val(r.name);

                            //Set the map view over the selected location
                            map.setView({ bounds: r.bestView });

                            //Find nearby shops
                            FindNearbyLocations(r.latitude, r.longitude);

                            $('#resultsList').html('');
                            $('#resultsDialog').dialog('close');
                        });

                        $('#resultsDialog').dialog('open');
                    }
                } else {
                    $('#resultsList').html('No results found.');
                    $('#resultsDialog').dialog('open');
                }
            },
            error: function (e) {
                alert(e.statusText);
            }
        });
    }
}

//Handle users enter key press
$("#searchBox").keypress(function (event) {
    if (event.which == 13) {
        event.preventDefault();
        GeocodeQuery();
    }else if(event.which == 39){
        return false;
    }
});

//Create the search button
$('#searchBtn').button({
    icons: {
        primary: "ui-icon-search"
    },
    text: false
}).click(GeocodeQuery);

//Functionality for geocoding users query against Bing Maps
var geocodeResults = [];

$('#resultsDialog').dialog({
    modal: true,
    autoOpen: false,
    title: 'Geocode Results'
});

The application is now complete. You can download the full source code for the web app in this blog from the MSDN Code Samples here.

Comments