A Couple of Hours with Azure Maps

I’m having a random ‘pick a Channel 9 video and blog from there’ session; the subject of the day is Azure Maps and the inspiration came in the form of this video.

The plan is to see what I can achieve in an hour or two, so here’s my quick rundown to wet your whistle. Firstly, a quick resource list to get you going which gives an idea of the product itself as well as details on pricing and the core API:

  1. Azure Maps
  2. Pricing
  3. Quick Starts

I’ll be (partly) following this quick start guide, but I may break away a bit and engage ‘rebel’ mode as that’s my style. šŸ˜›

Within the Azure Portal start by creating a new resource, searching using the keyword ‘Maps’; nice and simple for starters. Click ‘create’ as shown below:

Creating a Maps Resource.
Creating a Maps Resource.

For our next course of yumminess, simply fill in the mandatory fields specifying a Name, selecting a Subscription, an existing Resource Group (or creating a new one, which I did here for some clean separation) and finally selecting a Resource Group location that makes sense for you. I’ve opted to pin this resource to my dashboard for easy access later.

Create a Maps Account.
Create a Maps Account.

Once created, like many resources, we then just need to obtain the access key by going to ‘YOUR_MAP_RESOURCE’ in the Azure Portal > Settings > Keys. The sample application referenced on the demo resources page is doing a wonderful 404 trick at the time of writing, so I’ll see what I can put together as a basic sample myself, as I have the key in tow.

At this point I engaged ‘full nosiness mode’ and poking around further lead me to some step-by-step samples; this looks like a good starting template. Using this template to generate my own code example (and throwing in some ES6 concepts for good measure) I came up with this lightweight, ‘one-shot’ HTML page in VS Code (I really need to use VS Code more as it’s going great guns now and is getting excellent traction in the development community from what I can gather):

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, user-scalable=no" />
    <title>Azure Map Test</title>
    <link rel="stylesheet" href="https://atlas.microsoft.com/sdk/css/atlas.min.css?api-version=1.0" type="text/css" />
    <script src="https://atlas.microsoft.com/sdk/js/atlas.min.js?api-version=1.0"></script>
    <style>
        html,
        body {
            width: 100%;
            height: 100%;
            padding: 0;
            margin: 0;
        }

        #mapContainer {
            width: 100%;
            height: 100%;
        }
    </style>
</head>
<body>    
    <div id="mapContainer"></div>
    <script>
        // Encapsulation class that is a holding container for search parameters
        class SearchOptions {
            constructor(subscriptionKey, searchTerm, startLatitude, startLongitude, searchRadius ) {
                this.subscriptionKey = subscriptionKey;
                this.searchTerm = searchTerm;
                this.startLatitude = startLatitude;
                this.startLongitude = startLongitude;
                this.searchRadius = searchRadius;
            }
            // Utility function for generating a search url based on the class properties
            generateSearchUrl() {
                return `https://atlas.microsoft.com/search/fuzzy/json?api-version=1.0&query=${ this.searchTerm }&subscription-key=${ this.subscriptionKey }&lat=${ this.startLatitude }&lon=${ this.startLongitude }&radius=${ this.searchRadius }`;
            }
        }

        // Function for generating a map (using the mapContainer element reference provided and subscription key)
        function getMap(mapContainer, subscriptionKey) {
            return new atlas.Map(mapContainer, {
                "subscription-key": subscriptionKey
            });
        }

        // Function for preparing the pin layer on the targeted map using the provided layer name
        function prepareMapPins(map, searchLayerName, pinType) {
            map.addPins([], {
                name: searchLayerName,
                cluster: false,
                icon: pinType
            });
        }

        // Function that processes the data from 'fetch' and adds pins (POIs) the the map using the returned json data
        function processMapData(data, searchLayerName, map, cameraPadding) {
            if (data != null && data.results != null && data.results.length > 0) {
                // Initialise a searchPins array and limit the returned json data to those that are marked as POIs
                let searchPins = [],
                    poiResults = data.results.filter((result) => { return result.type === "POI" }) || [];

                // Extract features from the returned data and add it to the searchPins array (this contains location-based information)
                searchPins = poiResults.map((poiResult) => {
                    let poiPosition = [poiResult.position.lon, poiResult.position.lat];

                    return new atlas.data.Feature(new atlas.data.Point(poiPosition), {
                        name: poiResult.poi.name,
                        address: poiResult.address.freeformAddress,
                        position: poiResult.position.lat + ", " + poiResult.position.lon
                    });
                });

                // Add POIs discovered to the appropriate search layer
                map.addPins(searchPins, {
                    name: searchLayerName
                });

                // Set the map camera to be fixed on the 'searchPins'
                let lons = searchPins.map((pin) => pin.geometry.coordinates[0] ),
                    lats = searchPins.map((pin) => pin.geometry.coordinates[1] ),
                    swLon = Math.min.apply(null, lons),
                    swLat = Math.min.apply(null, lats),
                    neLon = Math.max.apply(null, lons),
                    neLat = Math.max.apply(null, lats);

                map.setCameraBounds({
                    bounds: [swLon, swLat, neLon, neLat],
                    padding: cameraPadding
                });             
            }
        }

        // Function that is triggered on 'mouseover' of a pin element to display extra information
        function createMouseOverPopUps(e, popup, map) {
            let popupContentElement = document.createElement("div");
            popupContentElement.style.padding = "5px";

            let popupNameElement = document.createElement("div");
            popupNameElement.innerText = e.features[0].properties.name;
            popupContentElement.appendChild(popupNameElement);

            let popupAddressElement = document.createElement("div");
            popupAddressElement.innerText = e.features[0].properties.address;
            popupContentElement.appendChild(popupAddressElement);

            let popupPositionElement = document.createElement("div");
            popupPositionElement.innerText = e.features[0].properties.name;
            popupContentElement.appendChild(popupPositionElement);

            popup.setPopupOptions({
                position: e.features[0].geometry.coordinates,
                content: popupContentElement
            });

            popup.open(map);
        }

        // Function to actually create the map
        function createMap() {
            // Alter the query parameters here for testing, add a subscription key, search term (e.g. 'hairdressers' or 'pubs'), 
            // the latitude/longitude to begin the search from and the radius to search (in metres)
            const subscriptionKey = "INSERT_SUBSCRIPTION_KEY_HERE",
                  searchTerm = 'pubs',
                  startLatitude = '52.630181',
                  startLongitude = '1.297415',
                  searchRadius = 1000,
                  // The 'search layer' that will contain the discovered 'pins' and will tie to mouse over pop-ups
                  searchLayerName = "search-results",
                  // Use this to switch out the pin type on render (https://docs.microsoft.com/en-us/javascript/api/azure-maps-javascript/pinproperties?view=azure-iot-typescript-latest)
                  pinType = "pin-red",
                  // Had issues when searching a small radius and having this value too high (overlapping pins???) - but adjust as necessary
                  cameraPadding = 1;

            // Encapsulate the search constants into a utility class which containts a function for calculating a 
            // search url. Also, generate a map/popup object pre-search to get us started
            let options = new SearchOptions(subscriptionKey, searchTerm, startLatitude, startLongitude, searchRadius),
                popup = new atlas.Popup();
                map = getMap('mapContainer', subscriptionKey);

            // Initialise the pin layer for the targeted map
            prepareMapPins(map, searchLayerName, pinType);

            // Use fetch to call the generated search URL and process the response to add data points (POIs in this case) to the map
            fetch(options.generateSearchUrl())
                .then(response => response.json())
                .then(data => processMapData(data, searchLayerName, map, cameraPadding));

            // Add a popup to the map which will display some basic information about a search result on hover over a pin
            map.addEventListener("mouseover", searchLayerName, (e) => {
                createMouseOverPopUps(e, popup, map);
            });
        }

        // Create the sample map!
        createMap();
    </script>
</body>
</html>

I’ve added inline comments to try and explain the core workings of the objects on show. In essence, you just need to:

  1. Ensure the atlas.min.css style sheet is in scope.
  2. Ensure the atlas.min.js script is in scope.
  3. Create a div with a selector (using an id in this instance) so it can be targeted.
  4. Call atlas.Map specifying the container (div you previous created) you want to render the map within, along with a valid subscription key.

In this example, I create a SearchOptions class that acts a way of encapsulating configurable parts of a search and provides a way of generating a dynamic search URL using a template string (template literal). The createMap function is called first and creates a SearchOptions instance up front, this function is where you can modify search parameters as you see fit. When using this sample code be sure to switch out ‘INSERT_SUBSCRIPTION_KEY_HERE’ for a valid subscription key. You can specify a latitude/longitude as a linchpin for the search, a search radius in metres and a search term to target specific points of interest (POIs).

Along with a SearchOptions object, a ‘popup’ utility object (to handle how popups are rendered when a map pin is ‘moused over’) and the physical map is created, using the getMap function. This is where atlas.Map is called, for reference.

To render pins on the map for POIs a named ‘layer’ must be created against the map object in scope. This is handled via a call to prepareMapPins. There is some ability to customise how a rendered pin looks so see the URL listed against the pinType constant, in the sample code, for more details.

I use ‘fetch’ to call the API with a generated URL/embedded query and then process the returned JSON data using the processMapData function. This is where the physical pins for POIs are added. Each POI discovered has a latitude/longitude, which is extracted in the form of an atlas.data.Feature (for each POI discovered). These are added to the map via a call to the addPins function, specifying the search layer to attach the pin to (so I’ve inferred here that you can indeed have multiple layers rendering different information which is awesome).

Some calculations are then performed to generate values to set the ‘camera’ location so that it focuses in on the area of the discovered POIs. All in all, it is actually pretty simple and is easy to get fully up and running within the first hour or so.

Lastly, a small mouseover event listener is added to provide a popup (using the previously created popup utility object) for each pin. The createMouseOverPopUps function takes care of this little monkey for us.

The only difficulty I had was that large padding values on the camera didn’t seem to play ball when using a small search radius, it took me a while to figure out that results were a) inaccurate when doing this and b) pins were overlapping and looked as if they were missing, so this is something to watch out for! Not sure why this knocked on to the pin locations, as it appears to be a camera setting. I’ve left this as 1, but a value of around 5 appeared to work fine.

So….what does it look like I hear you ask. Here’s the first results set for pubs which, for anyone who knows me, is most likely not a surprise! šŸ˜‰

Brewdog Location.
Brewdog Location.

The accuracy here, being from Norwich is….a little mixed. The location of Brewdog is near enough and another pin for Gonzos is on the mark, although the returned metadata lists this as ‘Havanas’, which is out of date. Some of the other listed POIs are flat out wrong (or omitted, perhaps as they are listed as ‘bars’ or ‘restaurants’, for example, even when I know they are in range based on radius). I did a follow-up search for hairdressers which seemed to be much more on the mark:

Anglian Hair Academy Map.
Anglian Hair Academy Map.
Anglian Hair Academy Street View.
Anglian Hair Academy Street View.

I had no idea that the Anglian Hair Academy even existed and thankfully my wife was there to set me straight, it’s in the right place apparently. From what I know, the other pins are pretty accurate (in this instance Google Maps looked a little out of date this time around). I tested this one last time on supermarkets in the area and it was reasonably accurate in the majority of cases.

This is an interesting little API to experiment with and please feel free to take this code and play around with it as you see fit. Also, please get in touch if the inaccuracies I saw here are due to some kind of error on my part, I’d love to hear how you all get on.

Thanks all and keep coding!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.