Using Geolocation for More Personalized Results

Geolocation Map

In today's world, users are accustomed to having results personalized for them without having to do any work. If you login to your favorite store, e.g. Amazon, you'll get a list of recommended products. Google will provide you location-based searches. Your video streaming services, banking, big box stores, even your potential online dates all have incorporated location-based way-finding. Sometimes, you aren't even aware that you are being directed by location. 

It's simpler to just make users select their location and filter by that, but the best user experience is to attempt do this step for the user.

Scenario

When showing event-related results, it is quite common to include some form of location-based search, and these results pages are a good candidate to use geolocation on. I recently had a use case where a client wanted to have results filtered by geolocation on page load rather than having to search by location. 

Even though the events had address information associated with them, the event search used a taxonomy hierarchical select list of locations for filtering results. This method is not ideal, since a user located on the boundary of the taxonomy terms would have to do two or more searches in order to find events near them. 

It was agreed that geolocation targeted results would be used on page load, but that a fallback solution with an address exposed filter would be used for additional searches. It is possible that the user was searching for events close to them while on vacation or at work and so the original results would be not ideal for their purposes. 

I guess I should mention that I'm using Drupal as a backend to provide query results, so this post will talk about that integration with the Views module. You will need to also have addressfield, geocoder, geofield, geofield_map, geophp, and better_exposed_filters modules to build a solution like I mention below.

Geolocation Tools

To perform a geolocated event search, you need to first use a tool to gather the user's location coordinates: latitude and longitude. Luckily for us, the HTML5 spec includes an API for this functionality.

if (navigator.geolocation) {
    navigator.geolocation.getCurrentPosition(locationView, error, options);
} else {
    console.log("Geolocation is not supported by your browser");
    // User feedback here...
}

By using the navigator object, you can determine if it is possible to gather location data using the Geolocation API. If so, you can grab the location data and pass it to a function as well as include an error handler and set some options. 

For the options parameter, you can specify a higher level of accuracy, a timeout, and maximum age for a cached value. While having higher accuracy sounds nice, it can take longer and use more power via GPS on the user's device. For my purposes, the only option I used was a timeout of five seconds. Since the "getCurrentPosition" function has a default of "Infinity" for a timeout, I suggest adding a reasonable timeout in case something happens and the position is never returned. 

function error(err) {
    console.log('ERROR(' + err.code + '): ' + err.message);
}

As far as an error function, I mainly used this for debugging while developing, but you could also add some logic to provide feedback to the user. Since I had results rendered on page load, the geolocation functionality would just fail silently and so no need to do too much here. 

Damn You Google!

After understanding how to use the Geolocation API to get the position data I needed, I began trying to implement my events search scenario only to find that Google had put a kink into my plans...grrr.

Google decided that the Chrome browser will only allow use of the Geolocation API over a secure connection. While this seems like a step in the right direction, it can be a pain if you don't have an SSL cert for your project. On the site I was working on, none of the publicly accessible pages were sent over a secure connection. So, for a time, I had to search for another solution. 

Conveniently enough, Google mentions their Geolocation API as an alternative on their notice page. If you can't use a secure connection on your site, you'll have to use a service like this to get the location data. 

httpRequest = new XMLHttpRequest();
httpRequest.onreadystatechange = locationView;
httpRequest.open('POST', 'https://www.googleapis.com/geolocation/v1/geolocate?key=');
httpRequest.setRequestHeader('Content-Type', 'application/json');
httpRequest.send();

You could use jQuery to make a similar AJAX call, but I wanted to exclude any unnecessary dependencies for doing something so simple. 

Replacing Search Result Content

Like I mentioned before, I'm using Drupal to drive the backend for storing events and providing search results. To display the results, I'm using the Views module. 

I could leave the results blank and only request the data once I have the user's position, but what happens if they don't want to share their location or some error occurs. I decided to have an initial set of results loaded based on a default address and only replaced if the geolocation lookup works. The events were for a university website so it made sense to use the main campus as the default address. I did end up making a view template in order to wrap what I wanted to replace in a specific CSS selector.

function locationView(position) {
    // Get view content to replace if geo lookup fails.
    viewDiv = document.querySelector('.view-css-selector');
    
    // Save the view content for now to replace if spinner times out.
    if (viewDiv) {
        oldView = viewDiv.innerHTML;
        viewDiv.innerHTML = [HTML for spinner];
    }

The Views

The next step is to pass the Geolocation data back to Drupal to in order to get new results. You'll have to make two views for this to work in the way I set it up. One view for the exposed filter input and one view for the initial geolocated AJAX request. 

The reason for needing separate views is because the geolocated AJAX request uses one (hidden) exposed filter while the user supplied input request uses two exposed filters. If you want to require the exposed filter to have input but don't want to put in a default, then trying to use that filter with geolocation data you don't initially have is pretty difficult. Rather than try to mess with this, I decided to just have two separate views. 

For view content, I had the usual date, address, time, etc. you'd have for an events display. The only difference between the two views was an added exposed filter for an address field. They both used the Geofield module's proximity field in order to handle the latitude and longitude coordinates. 

I did have to apply a couple of patches to make views and geolocation integration work. The views patch might not be neccessary in your use case, but I needed it due to the fact that there needed to be "events" and "featured events" sections returned on the same page and Views was only targeting one section of content not both. The geofiled patch was dealt with an issue where a query with blank values (converted into NULL) caused an exception that didn't allow the query to finish.

https://www.drupal.org/node/1809958
https://www.drupal.org/node/1626716

Passing Data Back to Views

httpRequest2 = new XMLHttpRequest();
httpRequest2.onreadystatechange = locationReplace;
httpRequest2.open('POST', Drupal.settings.baseUrl + '/events/ajax?long=' + long + '&lat=' + lat);
httpRequest2.setRequestHeader('Content-Type', 'application/json');
httpRequest2.send();

You'll have to make an AJAX call back to a hook_menu() router item, e.g. "/events/ajax", with the latitude and longitude data in order for Views to return appropriate results. One interesting thing about the "Drupal.settings" Javascript object is that it doesn't automatically include the baseUrl, but you can add it in a hook like hook_page_alter().

// Add baseURL to Drupal JS object to retrieve later.
global $base_url;
drupal_add_js(array('baseUrl' => $base_url), 'setting');

You do have to alter the Views query in order to pass the location data in correctly. Since the filter part of the query only uses numbers for keys this part is very brittle and will have to be changed if the view is altered with any additional filter conditions. 

function example_module_views_query_alter(&$view, &$query) {
  // Pass in location data to views filter.
  if ($view->name == 'events_geolocation_view') {
    $query_params = drupal_get_query_parameters();
    if (array_key_exists('lat', $query_params)) {
      $view->query->where[1]['conditions'][3]['field'] = '( 3959 * ACOS( COS( RADIANS(' . $query_params['lat'] . ') ) * COS( RADIANS(field_data_field_adm_event_geofield.field_adm_event_geofield_lat) ) * COS( RADIANS(field_data_field_adm_event_geofield.field_adm_event_geofield_lon) - RADIANS(' . $query_params['long'] . ') ) + SIN( RADIANS(' . $query_params['lat'] . ') ) * SIN( RADIANS(field_data_field_adm_event_geofield.field_adm_event_geofield_lat) ) ) ) <= 50';
    }
  }
}

You don't really have to understand the formula used to find events within a range of coordinates, but you just have to look for the latitude and longitude parts in a sample query. I used the Devel module to inspect a sample query using the handy dpm() function on the $view object, since that object is passed by reference. The end of that formula holds the proximity (in miles) that the client wanted: 50 miles. It would be better to make the proximity into a variable that could be more easily changed through a GUI. 

Printing Results

Once you have the correct query, you need to return the results.

function example_module_events_ajax() {
  $views = array();
  $views['events'] = views_embed_view('events_geolocation_view', 'block');
  print json_encode($views);
}

There are more "best practice" ways to return the the results via AJAX, but if you don't print out the data and just rely on returning the output, Drupal will pretend like it is a full page request and add all the HTML you don't want to parse through. By printing out encoded JSON, you have a tidy Javascript object you can manipulate client-side. The only thing left to do is to replace the view container with the geolocated search results. 

function locationReplace() {
    if (httpRequest2.readyState === XMLHttpRequest.DONE) {
        if (httpRequest2.status === 200) {
            response = JSON.parse(httpRequest2.responseText);
            viewDiv.innerHTML = response.events;
        }
    }
}

But What If The Location Isn't Shared

So...I lied. We aren't done yet. Since we can't rely on the user agreeing to share location data and we want to allow the user to perform additional searches, we have to add an additional search method. 

To start out, we don't want the AJAX call to be made every time the pages is loaded. This would just overwrite the exposed filter input submitted by the user. The best way I found to prevent the AJAX call is to have the exposed filter search include query parameters you can target to exit the Javascript function before the AJAX request is made. 

// Don't do anything if the user has interacted with the search.
query = window.location.search;
if (query.indexOf('field_person_address_value') != -1) {
    return;
}

Since the "field_person_address_value" is always appended to the URL after using the exposed filter to search, we can use that to exit the Javascript function that will continue on to get the user's location. 

I used the address field in combination with the Geofield proximity field to turn any address or even partial address, e.g. "Seattle", into some coordinates that events can be checked against. To make the transition from a string to coordinates, the Geocoder module has a Google API extension that nicely converts an address in the form of a sting to location coordinates. I used the geocoder_google($address) function to get a set of coordinates back without even having to get an API key. Keep in mind that there is a request limit placed on that service, but for my use case, that limit was never really reached. 

If you try to submit the address field exposed filter with the proximity exposed filter, the submit handler will execute the query before you can convert the address to coordinates. In order to make this conversion work with the exposed filters, you need to put a custom submit handler in front of the Views submit handler.

function example_module_form_views_exposed_form_alter(&$form, &$form_state) {
  // Need to limit this to only this view.
  if (request_path() == '/search/events') {
    // Add submit function before regular views submit so coordinates can be passed.
    array_unshift($form['#submit'], 'example_module_address_submit');
  }
}


In that submit handler, you can call the geocoder function and pass the data back to the proper $form_state values for when the Views query is executed. It is important to reset the address field value so that it too is not used to filter results. I used the "does not contain" operator and when set to "NULL" it is impossible for the address value to ever impact the coordinate values in the Views query. 

function example_module_address_submit(&$form, &$form_state) {

  // Get coordinates.
  $geo_data = geocoder_google($form_state['values']['field_person_address_value']);

  // If no result, e.g no object returned, then...return.
  if (!is_object($geo_data)) {
    return;
  }

  // Reset address field to not conflict with geo coordinates.
  $form_state['values']['field_person_address_value'] = NULL;

  // Pass back coordinates into views query.
  $form_state['values']['field_geofield_distance']['origin']['lat'] = $geo_data->coords[1];
  $form_state['values']['field_geofield_distance']['origin']['lon'] = $geo_data->coords[0];
}

You should then get the filtered events results based on whatever address and proximity the user has entered. Be aware that the Google API will return a set of addresses based on the perceived relevance so the first result might not be what you wanted. A query with an address like "Greenville" can be problematic since 48 states or so have a city/town with that name. I trust your users are savvy enough to enter a full address here limiting the chances that they will get inaccurate results.

That about does it for this primer into using Views to provide both an initial location-based search and a fallback search based on user input. If your able and have time to do it, geolocation functionality is a shiny feature that your clients are sure to love.