Friday, 4 October 2013

Quick Search By Map in a Portlet

Following my previous article about Quick Search By Location in a Portlet, I am now extending the filter by Location to be more visual, introducing the possibility to search the catalog for offerings by selecting a location on a map. Specifically, I am using Google Maps. A useful tutorial on how to use Google Maps can be found at http://www.w3schools.com/googleAPI/

More in general, I will look into integrating a map experience in Saba for:

  • Retrieving geographic coordinates (Latitude and Longitude) for a location; this information is necessary for displaying the Location on the map at the correct point.
  • Displaying a map with the available locations identified by markers; users will then be able to select a location and search for offerings.


Integrating Google Maps

Google Maps exposes a JavaScript API that allows third-party application to display a map to their users and provide services like directions, distance, geolocation, etc.
The first level of integration that I want to present in this article is about retrieving latitude and longitude for a location. This process is called geocoding.  Geocoding is the process of converting addresses (like "1600 Amphitheater Parkway, Mountain View, CA") into geographic coordinates (like latitude 37.423021 and longitude -122.083739), which you can use to place markers or position the map.
By retrieving the coordinates of a location, I'll be able to display location markers on a map later in my search portlet.
But where do we store this information? Best place is to use two custom attributes of the Location component and add Latitude and Longitude as type Real.

Latitude and Longitude as custom attributes of the Location component.

Then we need to introduce a customization in the Location page for allowing to geocode the coordinates from the location name. It is time to use the API of Google Maps then! The API is entirely JavaScript based, so all the interactions between Saba and Google Maps happen client-side, on the browser.
First of all, we need to reference the API in the Saba page by adding this <script> tag:

<script type="text/javascript" src="http://maps.googleapis.com/maps/api/js?v=3.exp&amp;key=<your-API-key>&amp;sensor=true"></script>

You need an API key to specify in the key parameter instead of the <your-API-key> placeholder; details on how to obtain a key are described on the Getting Started web site for Google Maps.
Before being able to use the Google Maps API we need to initialize it. Initialization should happen after the document is loaded. In the Saba view page we need a JavaScript instruction that executes on page load and then creates an instance of the google.maps.Geocoder object.

var geocoder; 
document.addEventListener("DOMContentLoaded",
   function() {
      google.maps.event.addDomListener(window, "load",
         function() {
            geocoder = new google.maps.Geocoder();
         });
   }, false);

To the page document, I'm adding a listener for the DOMContentLoaded event. This event is triggered when the content of the page is fully loaded. The function associated with this listener, in turn, adds another listener to the window's load event in the google.maps.event collection. We need to be sure that the Google Maps API is fully loaded before being able to use it, and due to the disconnected nature of the web, using delegate JavaScript functions is the only way to control asynchronous event handling. Once the API is fully loaded, we can create an instance of the Geocoder object.

The Google Maps API is now ready and we have a geocoder object that we can use for obtaining latitude and longitude coordinates of a location, from its name. Actually, a bit more than just the location name, for avoiding confusion and limit the number of potential matches. We will use city, state and country for identifying a location precisely.

Customized Location page for retrieving Latitude and Longitude.

The assumption here is that locations entered in Saba have the City, State and Country field specified.
By clicking on the Find Coords button, the findCoords() JavaScript function is called, which obtains a list of matches for the indicated location.

function findCoords(city, state, country)
{
   geocoder.geocode(
      {"address": city + ", " + state + ", " + country},
      function (results, status) {
         if (status == google.maps.GeocoderStatus.OK) {
            var location = results[0].geometry.location;
            setField("custom1", location.lat());
            setField("custom2", location.lng());
         }
      });
}

City, State and Country are concatenated in a single address line. The geocode method of the geocoder object accepts two parameters:

  1. address: The full detailed location to search for.
  2. function: JavaScript function called asynchronously at completion of the geocoding; this function has two parameters:
    • results: Collection of matches for the search string.
    • status: Status of the geocoding operation; it can assume one of the values in the GeocodeStatus object.

On successful completion of the geocoding, I take the first match contained in the results collection (index = 0) and specifically the geometry object, which is an instance of GeocoderGeometry. This object contains a location property of type LatLng, from which I can obtain the values of Latitude and Longitude to assign to the relative custom attributes of my location in Saba.

Repeat the geocoding for a few more locations and you will have a set of locations and their geographical coordinates ready to use in the portlet.

Australian locations in Saba with geographical coordinates.

A map in a portlet

Once the back-end information is ready, i.e. the list of locations to display on the map in the portlet, it is time to implement the portlet itself. This portlet is similar to the one presented in my post about a Quick Search by Location, with the obvious difference that the selection of the location happens with a map rather than with a drop-down list. Much better, isn't it?
To accommodate this enhancement, we need to extend our model too, to take into consideration the additional information needed for displaying locations on a map. Specifically, we need the Latitude and Longitude attributes of each location. We would also add the State and Country information, to be used for displaying additional information to the user on the map.

This is the final layout of the portlet that we want, with a map embedded within it, available locations displayed on the map and identified by markers, and the possibility for the user to select a location and  search for offerings available there.

Location "Melbourne" selected on the map in the portlet.

To meet this requirement, we need to extend the XML model document as follows:

<Locations>
   <Location>
      <Id>locat000000000001023</Id>
      <Name>Adelaide</Name>
      <State>SA</State>
      <Country>Australia</Country>
      <Lat>-34.867905</Lat>
      <Lng>138.611316</Lng>
   </Location>
   <Location>
      <Id>locat000000000001026</Id>
      <Name>Brisbane</Name>
      <State>QLD</State>
      <Country>Australia</Country>
      <Lat>-27.469973</Lat>
      <Lng>153.023579</Lng>
   </Location>
   ...
</Locations>

With this data structure, which we obtain from a portlet command that accesses the database, retrieves this data and returns it to the Saba model page in XML format, I can then build the map and display the locations as markers.
As seen before for the Geocoder, we need to invoke the Google Maps API via JavaScript, initialize it and, this time, create an instance of the google.maps.Map object. Then, we need to display the map on the screen, inside the portlet.

Let's start from the Saba view page: we need a place where to display the map inside the portlet. Simply, a <div> would fit.

<div id="googleMap"></div>

When initializing the map, a reference to this <div> is obtained by Id, and passed to the constructor of the google.maps.Map object.

var map = new google.maps.Map(
   document.getElementById("googleMap"),
   {
      center: new google.maps.LatLng(centerX, centerY),
      zoom: 3,
      mapTypeId: google.maps.MapTypeId.ROADMAP
   }
);

The Map object accepts also a second parameter of type MapOptions. This is a collection of attributes used to initialize the layout of the map on screen. Very important to set are:

  • center: The initial center point of the map; I'm passing this information in the centerX and centerY coordinates and point to the center of Australia, in my case.
  • zoom: The initial map zoom level; a value of 3 identifies a country level, approximately.
  • mapTypeId: The initial type of map to display (street map, satellite, terrain or hybrid); values are defined in the MapTypeId collection.

Next, we need to add the locations to the map. We do this in two steps:

  1. Define a JavaScript array of location objects, as defined in the XML model, with all the available information.
  2. Add a marker to the map for each location.
Let's see this in order. The JavaScript object is built dynamically for each <Location> node defined in the XML model. 


var locations = [
   <xsl:for-each select="Locations/Location">
   {
      id: '<xsl:value-of select="Id"/>',
      name: '<xsl:value-of select="Name"/>',
      state: '<xsl:value-of select="State"/>',
      country: '<xsl:value-of select="Country"/>',
      lat: <xsl:value-of select="Lat"/>,
      lng: <xsl:value-of select="Lng"/>
   },
   </xsl:for-each>
   {}];



The trick is in blending the use of server-side XSLT instructions, which are processed by the Saba rendering engine, and JavaScript syntax for building an array of objects. For each location object in the array, I add a marker to the map at the indicated coordinates, by calling the addLocation() function with the index number of each location in the array.

function addLocation(i)
{
   var marker = new google.maps.Marker(
   {
      position: new google.maps.LatLng(
         locations[i].lat, locations[i].lng),
      map: map,
      title: locations[i].name
   });
 
   var infoWindow = new google.maps.InfoWindow(
   {
      content:
         "<div class='info'>" + locations[i].name + ", " +
         locations[i].state + "<br/>" +
         "<a href=\"javascript:selectLocation('"
         locations[i].name + "');\">Select</a></div>"
   });
   
   google.maps.event.addListener(marker, 'click',
      function () {
         infoWindow.open(map, marker);
      });
}

The google.maps.Marker object represents a marker (or pin) on the map. A marker is identified by these attributes:

  • position: Latitude and Longitude of the location.
  • map: The JavaScript object that represents the map, as created during the initialization of the API.
  • title: The tooltip to display when hovering with the mouse on the marker.
The tooltip, however, cannot contain formatted value and is limited in size. The google.maps.InfoWindow object, instead, allows to define larger pop-up windows that can be displayed when clicking on the marker, as represented in the last picture above. An InfoWindow has one attribute:
  • content: The HTML of the content of the pop-up window.
In our case, I have defined a <div> containing two lines: first line is the name of the location and its state separate by comma. In the second line I display a Select link that calls the JavaScript function selectLocation() passing the location name.
To associate the InfoWindow with the marker, I just added a new event listener on the marker object for the click event. The listener calls the open() method of the infoWindow object for the pertinent map and marker.

The last thing to do now is to submit the location information back to Saba and perform the search for offerings in that location. We have a name, we need the locationId.


function selectLocation(locName)
{
   wdkSHF("locationId", getLocationId(locName));
   wdkFrameworkSubmit("/platform/presentation/portal/portalDriver.rdf");
}

function getLocationId(locName)
{
   for (var i in locations) {
      if (locations[i].name == locName) {
         return locations[i].id;
      }
   }
   return "";
}


The selectLocation() function is called when clicking on the Select link in the InfoWindow displayed for each marker on the map. This function obtains the locationId given the location name, and assigns it to a hidden field in the form by using the wdkSHF() function. The wdkSHF() function is part of the Saba JavaScript API. If you are wondering what SHF is for, my best guess is that it stands for Set Hidden Field.
Using a hidden field is a way to pass parameters to a Saba page for values that are not displayed on screen. In our portlet we are displaying locations on a map, but Saba needs their internal ids to work out the search. Ids are in the locations array, and this is what the getLocationId() function does, it loops the locations array searching for the first entry matching the location name, and then returns the value of its id attribute.

The hidden field has been previously defined in the Saba model page using the <wdk:hiddenField> tag.

<wdk:hiddenField name="locationId"></wdk:hiddenField>

What is the closest location to where I am

The last piece of integration of Google Maps in a portlet that I want to show you today is a way to find the closest location, among those available, to where you are.
Let's start from this last point, actually... where am I? Geolocation is the answer, or better the technology to use for identifying your current location in a browser that supports it. This has nothing to do with Google Maps, actually. It is a functionality of modern internet browsers that expose the navigator.geolocation object.


if (navigator.geolocation) {
   navigator.geolocation.getCurrentPosition(
   function (position) {
      var from = new google.maps.LatLng(
         position.coords.latitude, position.coords.longitude);
 
      ...
   },
   function() {
      alert("Current location not found");
   });
}

First of all, let's check whether the geolocation object exists, as not all older browsers support it. The getCurrentPosition() method retrieves your current position, not surprisingly, asynchronously. There are two function parameters to this method. The first function is executed when the position is found successfully, and the second one in case of error. If found, the coordinates of your current position are in the position.coords object as latitude and longitude. I'll convert these into a LatLng object for Google Maps, to be used later a starting point for calculating the distance of the other locations from my current position.

var service = new google.maps.DistanceMatrixService();
service.getDistanceMatrix(
{
   origins: [from],
   destinations: [to],
   travelMode: google.maps.TravelMode.DRIVING,
   unitSystem: google.maps.UnitSystem.METRIC,
   avoidHighways: false,
   avoidTolls: false
}, 
function (response, status) {
   if (status == google.maps.DistanceMatrixStatus.OK) {
      var results = response.rows[0].elements;
      setClosestLocation(index, results[0].distance);
   }
else {
   // Distance not found
}});

There is something to explain here. Distances between points are not exact and may vary according to different conditions. I have made some assumptions here: I'm using driving travel mode among the options available in the google.maps.TravelMode collection (at the time of writing, other options are Bicycling, Transit and Walking). I also want to use the Metric unit system, over the Imperial one. That is kilometers instead of miles, as defined in the google.map.UnitSystem collection. I also do not want to avoid highways and toll roads, get me there as quickly as possible! As you figured it out, the distance service works by calculating the direction to get from point A to B. Actually, the directions, more than one. Hence, results can be more than a possible value only, but for the sake of keeping this simple, I will take the first entry only.

The distance service is called for each location in the locations array, the distance saved in a global variable and updated every time a shortest location is found. At the end, the location with the shortest distance wins, representing the location closest to my current position.
The map is then centered and zoomed on the closest location, and the distance displayed in the portlet in a <div> with id = message.

The closest location found from my current position.

The JavaScript code is as follows.

var closestDistance;
var closestLocation = -1;

function setClosestLocation(index, distance)
{
   if (closestLocation == -1 || 
      distance.value < closestDistance.value) {
      closestLocation = index;
      closestDistance = distance;
   }
}

function displayClosestLocation()
{
   if (closestLocation != -1) {
      var messageDiv = document.getElementById("message");
      messageDiv.innerText = "Your closest location is "
         locations[closestLocation].name + " ("
         closestDistance.text + ")";
      map.setCenter(new google.maps.LatLng(
         locations[closestLocation].lat, 
         locations[closestLocation].lng));
      map.setZoom(10);
   }
}

All done! If you have any suggestion on how to improve the use of maps in this portlet on in general in Saba, please let me know and I'll be happy to explore your suggestion and write about it!

1 comment:

  1. This comment has been removed by a blog administrator.

    ReplyDelete