Tuesday, February 18, 2014

Windows 8 Bing Maps App Using JavaScript

A couple years ago I wrote a tutorial on how to work on Mircrosoft Virtual Earth. As an update I will create a simple Windows 8 food service locator Bing Map app in this post, which includes below features:

  • Search by location or by name
  • Show different pushpins based on service types
  • Dynamically update data when moving around the map
  • Show service detail when clicking on the pushpin
For testing purpose a mock data service is offering fake food service locations. The map view looks like following:

The custom pushpins shown on the map are copied courtesy of the free map icons online.

Environment Setup

First you need a Windows 8 or Windows 8.1 environment, VM or physical machine, and install Visual Studio 2012 or 2013 in the machine. Inside Visual Studio toolbar menu, select TOOLS=>Extension and Updates, then search and install "Bing Maps SDK for JavaScript".

You also need to setup a Bing Maps account from Microsoft. The detail of Bing Maps Account setup is described in MSDN.

Create Visual Studio Project

Ope Visual Studio, create a Blank App from JavaScript|Windows Store template, add a reference for "Bing Maps for JavaScript", and update default.html, default.js and default.css as following:

default.html:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>BingMap Test</title>

    <!-- WinJS references -->
    <link href="//Microsoft.WinJS.1.0/css/ui-dark.css" rel="stylesheet" />
    <script src="//Microsoft.WinJS.1.0/js/base.js"></script>
    <script src="//Microsoft.WinJS.1.0/js/ui.js"></script>
 
    <!-- Bing Maps reference -->
    <script type="text/javascript" src="ms-appx:///Bing.Maps.JavaScript//js/veapicore.js"></script>

    <!-- BingMapExample references -->
    <link href="/css/default.css" rel="stylesheet" />
    <script src="/js/default.js"></script> 
</head>
<body>
    <div class="mapPage">
        <div class="header">
            <!-- Segoe UI current location symbol -->
            <button id="btnCurrent">&#xE1D2;</button>
            <input id="txtSearch" type="text" />
            <progress id="progressDiv"></progress>
            <!-- Segoe UI search symbol -->
            <button id="btnSearch">&#xE000;</button>
        </div>
        <div id="mapDiv"></div>
    </div>
</body>
</html>
The Segoe UI symbols are listed in MSDN.

default.js:

(function () {
    "use strict";

    var map;            // Bing Map ojbect
    document.addEventListener("DOMContentLoaded", initialize, false);

    function initialize() {
        Microsoft.Maps.loadModule('Microsoft.Maps.Map', { callback: initMap });
        Microsoft.Maps.Events.addHandler(map, 'viewchangeend', mapViewChanged);

        btnCurrent.addEventListener("click", currentLocationClick);
        btnSearch.addEventListener("click", searchLocationClick);
    }

    function initMap() { // Initialize Bing map
        try {
            var mapDiv = document.getElementById("mapDiv");
            var mapOptions = {
                credentials: "xxxxxxx",  //Read from your Bing Maps Account
                center: new Microsoft.Maps.Location(43.813, -79.344), //TORONTO
                zoom: 13,
            };
            map = new Microsoft.Maps.Map(mapDiv, mapOptions);
        }
        catch (err)  {
            showError("Init Map error", err);
        }
    }

    // Repopulate map when it's moved
    function mapViewChanged() {
        // TODO
    }

    // Search by current location
    function currentLocationClick() { 
        // TODO
    }

    // Search servcie by name
    function searchLocationClick() { 
        // TODO
    }
})();

default.css:

.mapPage {
    width: 100%;
    height: 100%;
    display: -ms-grid;
    -ms-grid-columns: 120px 1fr 120px;
    -ms-grid-rows: 20px 40px 1fr 20px;
    position: absolute;
}

.mapPage .header {
 -ms-grid-column: 2;
 -ms-grid-row: 2;
    display: -ms-grid;
    -ms-grid-columns: auto 5px 1fr 5px auto;
}

.mapPage .header #btnCurrent {
    -ms-grid-column: 1;
    -ms-grid-row-align: center;
    font-family: "Segoe UI Symbol";
}
    
.mapPage .header #txtSearch {
    -ms-grid-column: 3;
    -ms-grid-row-align: center;
    width: 100%;
    border: none;
}

.mapPage .header #progressDiv {
    -ms-grid-column: 3;
    -ms-grid-column-align: end;
    -ms-grid-row-align: center;
}

.mapPage .header #btnSearch {
    -ms-grid-column: 5;
    -ms-grid-row-align: center;
    font-family: "Segoe UI Symbol";
}

.mapPage #mapDiv {
    -ms-grid-column: 2;
    -ms-grid-row: 3;
    position: relative;
    height: 100%;
    width: 100%;
}
The CSS3 grid-layout and fractional units are used to layout the page so it can auto-stretch to different screen size. One catch is that the map would occupy the whole screen unless you set its container to relative position.

Now the structure of the page is defined and you will see the map showing properly when debugging the app. Next we will work on the data model and mock data service.

Data Model and Mock Data Service

Our service location entity includes attributes of Geo location, service type, name, address, description, and service type. The Geo location pins the map location as a pushpin and the custom pushpin images will differ based on the service type. The name, address and description will be displayed in an infobox popup.

Mock service will return a list of service locations as well as the center location for the map. To generate locations in valid for a map, mock service needs to know the map boundary. Our prototype has two search types, by Geo location or by name query. For the Geo location search scenario the Geo location is also the map center in the mock service return:

(function () {
    "use strict";

    WinJS.Namespace.define("mockLocationService",
    {
        search: search  // Only expose one search method
    });

    /*
     * Proivde mock data for testing
     * Note: the caculation is based on North America and may not work in other location
     */
    function search(parameters) {
        var response = {};
        response.serviceLocations = [];

        if (parameters.latMin && parameters.latMax && parameters.longMin && parameters.longMax) {
            var latMin = parseFloat(parameters.latMin);
            var latMax = parseFloat(parameters.latMax);
            var longMin = parseFloat(parameters.longMin);
            var longMax = parseFloat(parameters.longMax);

            // recenter the map if search by location
            if (parameters.lat && parameters.long) {
                latMin = parameters.lat - (latMax - latMin) / 2;
                latMax = parameters.lat + (latMax - latMin) / 2;
                longMin = parameters.long - (longMax - longMin) / 2;
                longMax = parameters.long + (longMax - longMin) / 2;
            }

            // generate 10-20 mock service loacations 
            var count = 10 + Math.floor(Math.random() * 10);
            var mockData = generateMockData(latMin, latMax, longMin, longMax, count);
            for (var i = 0; i < mockData.length; i++) {
                var serviceLocation = mockData[i];
                response.serviceLocations.push(serviceLocation)
            }

            // also return a center location for mapping
            response.center = { lat: ((latMax + latMin) / 2), long: ((longMin + longMax) / 2) };
        }
        return response;
    }

    function generateMockData(latMin, latMax, longMin, longMax, count)
    {
        var services = [];
        for (var i = 1; i <= count; i++) {
            var lat = Math.random() * (latMax - latMin) + latMin;
            var long = Math.random() * (longMax - longMin) + longMin;
            var name = "Service location " + i;
            var address = i + " Queen street ...";
            var rand = Math.floor(Math.random() * 5) + 1;
            var type, address, description;
            switch (rand) {
                case 1:
                    type = MapService.ServcieType.Restaurant;
                    description = "Come and taste our famous dishes ...";
                    break;
                case 2:
                    type = MapService.ServcieType.Vegetarian;
                    description = "Looking for healthy vete food? ...";
                    break;
                case 3:
                    type = MapService.ServcieType.Buffet;
                    description = "No idea what to eat? ...";
                    break;
                case 4:
                    type = MapService.ServcieType.Bar;
                    description = "Have a few beers and other drinks here ...";
                    break;
                case 5:
                    type = MapService.ServcieType.Foodtruck;
                    description = "Call us and we will bring your our nice food ...";
                    break;
            }
            var servicePoint = { name: name, lat: lat, long: long, type: type, address: address, description: description };
            services.push(servicePoint);
        }
        return services;
    }

})();

Map Service Module

Map service module handles a few things: get current location, invoke location services, parse response data and create object to serve map pushpins and infobox popup:

(function () {
    "use strict";

    // Service types for mock service
    var ServcieType = { Restaurant: 1, Vegetarian: 2, Buffet: 3, Bar: 4, Foodtruck: 5 };

    function invokeLocationService(searchParameter, callBack, errorHandler, mapBounds) {
        if (!mapBounds) { // Get data from web service
            var searchUrl = "http://mylocationserver.com/search";
            WinJS.xhr({ url: searchUrl, data: searchParameter, responseType: "json" }).done(
                function (result) { // Service call completed
                    if (callBack) {
                        var data = JSON.parse(result.responseText);
                        callBack(data);
                    }
                },
                function (err) { // Service call error
                    if (errorHandler) {
                        errorHandler(err);
                    }
                });
        } else { // Get data from mock service
            if (callBack) {
                try {
                    // mapBounds values in north America, maynot work in other location
                    var latMin = mapBounds.getSouth();
                    var latMax = mapBounds.getNorth();
                    var longMin = mapBounds.getWest();
                    var longMax = mapBounds.getEast();
                    var parameters = { latMin: latMin, latMax: latMax, longMin: longMin, longMax: longMax };

                    if (searchParameter.lat && searchParameter.long) { // Search by location
                        parameters.lat = searchParameter.lat;
                        parameters.long = searchParameter.long;
                    } else { // Search by name
                        parameters.query = searchParameter.query;
                    }
                    var serviceLocations = mockLocationService.search(parameters);
                    callBack(serviceLocations);
                } catch (ex) {
                    if (errorHandler) {
                        errorHandler(ex);
                    }
                }   
            }
        }
    }

    // parse result JSON and return an array of service locaiton objects
    function parseServiceLocations(json) { 
        var serviceLocations = [];      
        for (var i = 0; i < json.serviceLocations.length; i++) {
            var service = json.serviceLocations[i];
            var newLocation = {};
            newLocation.location = new Microsoft.Maps.Location(service.lat, service.long);
            newLocation.name = service.name;
            newLocation.summary = service.description + "<br /><br /><b>Address: </b>" + service.address;
            newLocation.type = service.type;
            populateImageUrls(newLocation);
            serviceLocations.push(newLocation);
        }
        return serviceLocations;
    }

    // parse center location from result JSON
    function parseSearchCenterLocation(data) { 
        return new Microsoft.Maps.Location(data.center.lat, data.center.long);
    }

    // Populate pushpin and location image urls
    function populateImageUrls(serviceLocation) { 
        switch (serviceLocation.type) {
            case ServcieType.Restaurant:
                serviceLocation.pushpinUrl = "images/locationIcons/restaurant.png";
                break;
            case ServcieType.Vegetarian:
                serviceLocation.pushpinUrl = "images/locationIcons/vegetarian.png";
                break;
            case ServcieType.Buffet:
                serviceLocation.pushpinUrl = "images/locationIcons/buffet.png";
                break;
            case ServcieType.Bar:
                serviceLocation.pushpinUrl = "images/locationIcons/bar.png";
                break;
            case ServcieType.Foodtruck:
                serviceLocation.pushpinUrl = "images/locationIcons/foodtruck.png";
                break;
        }
    }

    // create a pushpin for a service location object
    function getServicePointPushpin(serviceLocation) {
        var pushpin = new Microsoft.Maps.Pushpin(serviceLocation.location,
                {
                    icon: serviceLocation.pushpinUrl,
                    width: 32,
                    height: 37,
                    anchor: new Microsoft.Maps.Point(16, 18)
                });
        pushpin.name = serviceLocation.name;
        pushpin.summary = serviceLocation.summary;
        return pushpin;
    }

    // create a pushpin for current location
    function getCurrentLocationPushpin (location) {
        var pushpin = new Microsoft.Maps.Pushpin(location,
                {
                    icon: "/images/locationIcons/current.png",
                    width: 32, height: 37,
                    anchor: new Microsoft.Maps.Point(16, 18)
                });
        pushpin.name = "Your current location";
        pushpin.summary = "latitude: " + location.latitude + " longitude:" + location.longitude;
        return pushpin;
    }

    // Location service helper
    var CurrentLocation = WinJS.Class.define(
        // Constructor
        function (disableCache) {
            this.doCache = !disableCache;
        },
        // Instance members
        {
            getCurrentLocationAsync: function () {
                var self = this;
                return new WinJS.Promise(function (c, e, p) {
                    if (CurrentLocation.geoLocator == null) {
                        CurrentLocation.geoLocator = new Windows.Devices.Geolocation.Geolocator();
                    }
                    if (CurrentLocation.geoLocator) {
                        if (self.doCache && CurrentLocation.location) {
                            c(CurrentLocation.location); // complete the call back with cached data
                        } else {
                            return CurrentLocation.geoLocator.getGeopositionAsync().then(
                                function (pos) { // get position data handler
                                    var loc = new Microsoft.Maps.Location(pos.coordinate.latitude, pos.coordinate.longitude);
                                    CurrentLocation.location = loc; // Cache local position
                                    c(loc);
                                }.bind(this),
                                function (ex) { // location service error
                                    e(ex);
                                }.bind(this));
                        }
                    }
                });
            }
        },
        // Static members
        { 
            geoLocator: null,
            location: null
        }
    );

    // Expose MapService
    WinJS.Namespace.define("MapService",
    {
        ServcieType: ServcieType,
        CurrentLocation: CurrentLocation,
        invokeLocationService: invokeLocationService,
        parseServiceLocations: parseServiceLocations,
        parseSearchCenterLocation: parseSearchCenterLocation,
        getServicePointPushpin: getServicePointPushpin,
        getCurrentLocationPushpin: getCurrentLocationPushpin,
    });

})();
A couple of notes about the MapService module:
  • The mapbounds parameter is unnecessary for real service calls, it's only used by mock service so that it has a coordinate to generate valid service points inside the map.
  • Locaiton service uses native Geolocator to retrieve current location, and caches the data for future usage to improve the performace. The cache feature can turn off when instantiating the CurrentLocation object if that's desired.
  • The Bing Maps' default pushpin is a 25x35 size image pining at the middle bottom of the image. We need to use pushpin's anchor property to adjust the pin point if using custom images with different size or different pining location.
  • Infobox popup supports some HTML tags inside the message, e.g. <br /> line break and <b> bold fonts for "Address" are used in our example

UI Interaction

Now we are ready to refine the UI and implement the user interactions. First add mock service and map service javascript references in the default.html. Following is the screen-shot of the final solution inside Visual Studio:

In general there are three user actions: click current location button to search services around; move map to search nearby locations; type some keyword and click search button to do search name. The first two cases are basically the same as they both search by location. The updated default.js is listed below and it's quite self explanatory:

<script type="text/javascript">
(function () {
    "use strict";

    var map;            // Bing Map ojbect
    var currentLocPin;  // Pushpin for current location
    var locationServie; // Location serverice instance 
    var pushpinLayer;   // A layer to contain all pushpins
    var infoboxLayer;   // A layer to contain the infobox
    var resetMapCenter; // Reset the map center location if is true

    document.addEventListener("DOMContentLoaded", initialize, false);

    function initialize() {
        Microsoft.Maps.loadModule('Microsoft.Maps.Map', { callback: initMap });
        Microsoft.Maps.Events.addHandler(map, 'viewchangeend', mapViewChanged);

        btnCurrent.addEventListener("click", currentLocationClick);
        btnSearch.addEventListener("click", searchLocationClick);
        showProgress();
    }

    function initMap() { // Initialize Bing map
        try {
            var mapDiv = document.getElementById("mapDiv");
            var mapOptions = {
                credentials: "xxxxxxx",  //Read from your Bing Maps Account
                center: new Microsoft.Maps.Location(43.813, -79.344), //TORONTO
                zoom: 13,
            };
            map = new Microsoft.Maps.Map(mapDiv, mapOptions);

            // layers to maintain the pushpins and infobox popup
            pushpinLayer = new Microsoft.Maps.EntityCollection();
            infoboxLayer = new Microsoft.Maps.EntityCollection();
            map.entities.push(pushpinLayer);
            map.entities.push(infoboxLayer);
        }
        catch (err)  {
            showError("Init Map error", err);
        }
    }

    // Repopulate map when its moved
    function mapViewChanged() {
        resetMapCenter = false;
        if (map.getZoom() > 10) {          
            var mapCenter = map.getCenter();
            var parameters = { lat: mapCenter.latitude, long: mapCenter.longitude };
            searchLocationService(parameters);
        }
    }

    // Search by current location
    function currentLocationClick() { 
        resetMapCenter = true;
        if (!locationServie) {
            locationServie = new MapService.CurrentLocation();
        }
        locationServie.getCurrentLocationAsync().then(
            function (loc) {
                currentLocPin = MapService.getCurrentLocationPushpin(loc);
                searchLocationService({ lat: loc.latitude, long: loc.longitude });
            },
            function (err) {
                showError("Location service error", err);
            });
    }

    // Search servcie by name
    function searchLocationClick() { 
        resetMapCenter = true;
        var searchInput = txtSearch.value;
        if (searchInput) {
            var parameters = { query: escape(searchInput) };
            searchLocationService(parameters);
        }
    }

    // Invoke map service by MapService module
    function searchLocationService(searchParameter) { 
        showProgress();
        try {
            var mapBounds = map.getBounds(); // Map Bounds are only used by mock service
            MapService.invokeLocationService(searchParameter, handleSearchResult, handleSearchError, mapBounds);
        } catch (err) {
            showError("Error occurs", err);
        }
    }

    // Show error message dialog
    function showError(title, err) { 
        var msg =  (err && err.message) ? err.message : err;
        var msgBox = new Windows.UI.Popups.MessageDialog(msg, title);
        msgBox.showAsync();
    }

    // Handle search error
    function handleSearchError(err) {
        showError("Map Service error", err);
        hideProgress();
    }

    // Search Result handling call back
    function handleSearchResult(data) { 
        var serviceLocations = MapService.parseServiceLocations(data);

        if (resetMapCenter) {
            var searchCenter = MapService.parseSearchCenterLocation(data);
            var locations = [];
            for (var i = 0; i < serviceLocations.length; i++) {
                locations.push(serviceLocations[i].location);
            }
            var origCenter = map.getCenter();
            var origBound = map.getBounds();
            var mapBound = Microsoft.Maps.LocationRect.fromLocations(locations);
            map.setView({ center: searchCenter, bounds: mapBound });
        }

        updatePushpins(serviceLocations);

        hideProgress();
    }
    
    // Shwo progress
    function showProgress() {
        progressDiv.style.display = "block";
    }

    // Hide progress
    function hideProgress() {
        progressDiv.style.display = "none";
    }

    // Update pushpins on the map
    function updatePushpins(serviceLocations) { 
        infoboxLayer.clear();
        pushpinLayer.clear();

        for (var i = 0; i < serviceLocations.length; i++) {
            var loc = serviceLocations[i];
            var pin = MapService.getServicePointPushpin(loc);
            pin.name = loc.name;
            pin.summary = loc.summary;
            pushpinLayer.push(pin);
            Microsoft.Maps.Events.addHandler(pin, 'click', showInfobox);
        }

        if (currentLocPin) {
            pushpinLayer.push(currentLocPin);
        }
    }

    // Display infobox when pushpin is clicked
    function showInfobox(e) { 
        if (e.targetType == "pushpin") {
            infoboxLayer.clear();
            var pin = e.target;
            var pinLocation = pin.getLocation();
            var infoboxOptions = {
                showCloseButton: true,
                //offset: new Microsoft.Maps.Point(0, 5),
                showPointer: true,
                zIndex: 10,
                title: pin.name,
                description: pin.summary,
            };

            var infobox = new Microsoft.Maps.Infobox(pinLocation, infoboxOptions);
            infoboxLayer.push(infobox);
        }
    }
})();
</script>

The whole project can be downloaded from GitHub.