In this article we take a look at how location information can be used to sort a list of items on a webpage. This might be useful for local search results; for example ‘Restaurants near me’ type searches, or for sorting a predefined list of locations such as a company’s office locations in order of distance from the user.
Contextualising information based on location offers excellent usability benefits, particularly for mobile users where input is often more difficult. Who hasn’t felt the pain of scrolling through a long list of countries in a drop-down list for example? Such usability issues can make or break the mobile user-experience—users may not even realise that things have been reorganised for them based on their location, but they may well get frustrated when they aren’t!
In the example that follows, we’ll assume we have a list of points of interest (POIs) and that each POI has location information in the form of a latitude, longitude (latlon) pair. For this article we’re not concerned with where this list of POIs comes from, whether it’s the result of a local search, or a predefined list of locations.
We’ll also consider how we might handle low-end devices as well as high-end. For high-end devices, we’ll use the HTML5 Geolocation API to determine the user’s location, and to perform the sorting based on this data. For low-end devices that don’t support geolocation it would be easy to simply output an ‘unsupported’ or ‘upgrade your device message’ for functionality like this. However, we’ll go one better and consider how, with a little bit of server side help, we can provide a similar experience for such devices. View a live example right now, or continue reading to see how it works.
Location sorting for high end devices
For this example, we have three international location items in our list, and without any information about the user’s location at this point, we have a default ordering of the list, as shown in the HTML below:
1 2 3 4 5 6 |
<h2>Our office locations</h2> <ul id="locations"> <li id="office-nyc" data-latlon="40.7251397,-73.9918808">NEW YORK - 315 Bowery, Manhattan, New York, USA</li> <li id="office-dub" data-latlon="53.349635, -6.250268">DUBLIN - 2 La Touche House, IFSC, Dublin 1, Ireland</li> <li id="office-ber" data-latlon="52.489405, 13.359632">BERLIN - Hauptstrasse 155, Schoneberg, Berlin, Germany</li> </ul> |
Next we turn to the Geolocation API to get some information about the user’s location. After the page has loaded, we call the Geolocation API, and pass it the name of a callback function, sortResults
, which we use to sort the results list based on the user’s location:
1 2 3 4 |
window.onload = function() { navigator.geolocation.getCurrentPosition(sortResults); } // Error checking ommitted for brevity |
(Note: error checking code has been ommitted here for brevity—see this article for more details about how to handle errors).
So, now we have two tasks:
- to determine the distance from the user to each of the POIs, and
- to sort the list of POIs based on these distances
To figure out the distance between the user and the POIs, we’ll turn to a very useful script provided by Chris Veness of Movable Type. It defines a simple JavaScript API around latitude/longitude spherical geodesy formulae, so that we can easily compute the distance between two latlon points.
To use this script, we need to define our POI latlons as LatLon
objects as defined in Veness’ script. Then we’ll be able to apply the distanceTo
and bearingTo
functions to any pair of LatLon
objects e.g.
1 2 3 4 5 |
// Sample usage var p1 = new LatLon(51.5136, -0.0983); var p2 = new LatLon(51.4778, -0.0015); var dist = p1.distanceTo(p2); var brng = p1.bearingTo(p2); |
So, back to our sortResults
function. First of all we grab the user’s position from the geolocation API, and coerce it into a LatLon
object:
1 |
var latlon = new LatLon(position.coords.latitude, position.coords.longitude); |
Next we want to iterate over the list of POIs, and determine the distance from the user. To do this, we use the JavaScript array sort
method, and pass it a custom comparator function that takes two of our location elements as arguments, and determines which is closer to the user.
We obtain the list of location elements like this:
1 2 |
var locations = document.getElementById('locations'); var locationList = locations.querySelectorAll('li'); |
Since querySelectorAll
returns a NodeList
object, we must first convert this to an array, so that we can sort it:
1 |
var locationArray = Array.prototype.slice.call(locationList, 0); |
Now we can sort our array. We call the array sort method with inline custom comparator function:
1 2 3 |
locationArray.sort(function(a,b) { ... }); |
The comparator function retrieves the latlon from our POIs. We can then determine the distance between each POI and the user, compare these distances, and return the result:
1 2 3 4 5 6 7 8 9 |
// Get latlon values var locA = a.getAttribute('data-latlon').split(','); var locB = b.getAttribute('data-latlon').split(','); // Get distance from user to the two POIs distA = latlon.distanceTo(new LatLon(Number(locA[0]),Number(locA[1]))); distB = latlon.distanceTo(new LatLon(Number(locB[0]),Number(locB[1]))); return distA - distB; |
Finally, we can update the DOM with the distance-sorted list:
1 2 3 4 |
locations.innerHTML = ""; locationArray.forEach(function(el) { locations.appendChild(el); }); |
Don’t block while waiting!
So, why bother to render the list at the beginning if we are going to sort again it anyway? The reason for this is that we don’t want to block while waiting for the location permission to be granted by the user, or while we wait for the user’s device to get a location fix. If we do it this way, we can display something to the user right away, and if and when the user’s location is determined, then the presentation of the information can be improved.
The full code is listed below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
<html> <h2>Our offices</h2> <ul id="locations"> <li id="office-ny" data-latlon="40.7251397,-73.9918808">NEW YORK - 315 Bowery, Manhattan, New York, USA</li> <li id="office-dub" data-latlon="53.349635, -6.250268">DUBLIN - 2 La Touche House, IFSC, Dublin 1, Ireland</li> <li id="office-ber" data-latlon="52.489405,13.359632">BERLIN - Hauptstrasse 155, Schoneberg, Berlin, Germany</li> </ul> <script type="text/javascript" src="../js/geo/latlon.js"></script> <script> window.onload = function() { navigator.geolocation.getCurrentPosition(sortResults); } function sortResults(position) { // Grab current position var latlon = new LatLon(position.coords.latitude, position.coords.longitude); var locations = document.getElementById('locations'); var locationList = locations.querySelectorAll('li'); var locationArray = Array.prototype.slice.call(locationList, 0); locationArray.sort(function(a,b){ var locA = a.getAttribute('data-latlon').split(','); var locB = b.getAttribute('data-latlon').split(','); distA = latlon.distanceTo(new LatLon(Number(locA[0]),Number(locA[1]))); distB = latlon.distanceTo(new LatLon(Number(locB[0]),Number(locB[1]))); return distA - distB; }); //Reorder the list locations.innerHTML = ""; locationArray.forEach(function(el) { locations.appendChild(el); }); }; </script> </html> |
Geosorting for low-end devices
This is all fine when the Geolocation API is supported on capable devices. But what about the low-end devices that support neither JavaScript nor Geolocation sensors? One option would be to omit this functionality altogether, but adopting an easy things should be easy, hard things should be possible approach we can go one better and try to bridge the gap between the high-end and low-end experiences.
One option is the use of GeoIP techniques. GeoIP uses the device IP address to determine the approximate location of the user. It is reliable to country, and often city resolution, and so could be used for applications which don’t require finer granularity than country or city level, such as rearranging a list of countries in a drop-down list, to take an example we mentioned earlier.
However, if the application needs to make use of local information, such as restaurants near me type searches, then the usefulness of GeoIP techniques will be limited.
A simple but effective solution for low-end devices is to allow the user to manually enter their location. Yes, it’s a low-tech solution to the problem, but even a partial address can be geocoded accurately enough to be usable for local search.
To implement this, a form will be presented as a simple unobtrusive textbox, and if the user chooses to ignore it, then the webpage will still work with the default list ordering. When a user enters an address or location however, we’ll determine the latlon for the entered location using Google’s free Geocoding API, and then we’ll sort the list of locations as we did for high-end devices, but this time on the server. So, similar functionality will be available for the low-end user, but the user will have to work just a little bit harder for it.
We show the initial list of locations1 with default ordering to the low-end device on first page request. We also add the form and textbox so that the user can specify a location. So, the HTML for low-end will look something like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<div> <form action=""> Address: <input type="text" name="address" /><input type="submit" value="Update"/> </form> </div> <div> Your location: <?php print !empty($address)?$address:'unknown' ?> </div> <h2>Our offices</h2> <ul id="locations"> <?php foreach($locations as $location) { ?> <li id="<?php print $location['id']?>" data-latlon="<?php print $location['latlon']?>"><?php print $location['address']?></li> <?php } ?> </ul> |
We use PHP as the language for the server code in this is example, but equally any could be used. In the code above, we loop over an array of locations, our POIs, which looks like this:
1 2 3 |
$locations = array(array("id" => "office-us", "latlon" => "40.7251397,-73.9918808", "address" => "NEW YORK - 315 Bowery, Manhattan, New York, USA"), array("id" => "office-irl", "latlon" => "53.349635, -6.250268", "address" => "DUBLIN - 2 La Touche House, IFSC, Dublin 1, Ireland"), array("id" => "office-de", "latlon" => "52.489405, 13.359632", "address" => "BERLIN - Hauptstrasse 155, Schoneberg, Berlin, Germany")); |
We also add some rudimentary form processing to take the address entered by the user, geocode it using the Google API (see below), and then call our sort function to find the closest POIs:
1 2 3 4 5 6 7 8 9 10 |
if(isset($_GET['address'])) $address = $_GET['address']; if(!empty($address)) { sortResults($address, $locations); } function sortResults($address, &$locations) { $latlon = geocodeAddress($address); . . . } |
As with the high-end device version, we need to sort our results based on the distance from the device to the user. We can write a similar custom comparison function as we did earlier, and then call the PHP usort
function with this comparator:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function sortResults($address, &$locations) { $latlon = geocodeAddress($address); //Call usort with anonymous comparator function usort($locations, function($a, $b) use ($latlon) { $locA = array_map('floatval', array_map('trim', explode(',', $a['latlon']))); $locB = array_map('floatval', array_map('trim', explode(',', $b['latlon']))); $distA = geoDistance($latlon['lat'], $latlon['lon'], $locA[0], $locA[1]); $distB = geoDistance($latlon['lat'], $latlon['lon'], $locB[0], $locB[1]); if($distA == $distB) { return 0; } return ($distA < $distB) ? -1 : 1; }); } |
Determine the distance between two latlon points
We use a similar algorithm as before to compute the distance between two latlon points, but this time on the server. Various different distance algorithms are possible, the following code uses the Haversine distance.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function geoDistance($latitudeFrom, $longitudeFrom, $latitudeTo, $longitudeTo, $earthRadius = 6371000) { // convert from degrees to radians $latFrom = deg2rad($latitudeFrom); $lonFrom = deg2rad($longitudeFrom); $latTo = deg2rad($latitudeTo); $lonTo = deg2rad($longitudeTo); $latDelta = $latTo - $latFrom; $lonDelta = $lonTo - $lonFrom; $angle = 2 * asin(sqrt(pow(sin($latDelta / 2), 2) + cos($latFrom) * cos($latTo) * pow(sin($lonDelta / 2), 2))); return $angle * $earthRadius; } ?> |
Geocode an address with the Google API
To determine the latlon based on the location specified by the user, we use the following function. Given a string representing the location by the user, the function makes a request the the Geocoding API, and is returned a JSON object containing, among other things, the latitude and longitude of the location:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function geocodeAddress($address) { // Base URL for the Google geocoding API $API_REQ = "https://maps.googleapis.com/maps/api/geocode/json?sensor=false&address="; $req = $API_REQ . urlencode($address); // Send API request to Google geocoder $response = file_get_contents($req); // Parse json response $response = json_decode($response, true); $lat = $response['results'][0]['geometry']['location']['lat']; $lon = $response['results'][0]['geometry']['location']['lng']; return array('lat'=> $lat, 'lon' => $lon); } |
Adding a Google Map to a location
For completeness, and to make the example a little more appealing, we’ll add a Google map for each of our POIs.
We modify our HTML POI list slightly so that it now contains an extra div
into which we are going to load our map:
1 2 3 4 5 6 7 8 9 10 11 12 |
<h2>Our offices</h2> <ul id="locations"> <li id="office-nyc" data-latlon="40.7251397,-73.9918808">NEW YORK - 315 Bowery, Manhattan, New York, USA <div id="map-canvas-0" ></div> </li> <li id="office-ber" data-latlon="52.489405, 13.359632">BERLIN - Hauptstrasse 155, Schoneberg, Berlin, Germany <div id="map-canvas-2"></div> </li> <li id="office-dub" data-latlon="53.349635, -6.250268">DUBLIN - 2 La Touche House, IFSC, Dublin 1, Ireland <div id="map-canvas-1"></div> </li> </ul> |
Now we iterate over our list of POIs, and this time we will use the Google Maps API to render a map centered of the latlon of each of the POIs into the div
s we just defined. So, we set up our loop as before, but this time, for each POI, we define some mapOptions
which includes the latlon as well as some other map settings (see the Google Maps API documentation for more details):
1 2 3 4 |
var mapOptions = { zoom: 16, center: new google.maps.LatLng(Number(loc[0]),Number(loc[1])) }; |
Now we’re ready to draw the map, making sure we have the right id
for each of the map divs we defined earlier:
1 |
var map = new google.maps.Map(document.getElementById('map-canvas-' + i), mapOptions); |
The full loop is given below:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// Iterate over POIs var locations = document.getElementById('locations'); var locationList = locations.querySelectorAll('li'); for(var i=0;i<locationList.length; i++) { var loc = locationList[i].getAttribute('data-latlon').split(','); var mapOptions = { zoom: 16, center: new google.maps.LatLng(Number(loc[0]),Number(loc[1])) }; var map = new google.maps.Map(document.getElementById('map-canvas-' + i), mapOptions); } |
Dynamic maps for high-end devices, static maps for low-end
One final improvement that we can make to this code is to serve up a static map image tile for low-end devices, rather than the more intensive and likely-to-make-a-low-end-device-implode dynamic JavaScript maps we just outlined. This can be achieved by using server side device detection to determine the capabilities of the device, and then outputting the appropriate map type, dynamic vs static.
Serving up a static map tile with the Google Maps API is almost trivial! We need simply request a static tile around a latlon coordinate as the src
URL of an image tag. We can also specify other sensible options such as zoom level and tile size:
1 2 3 4 5 6 7 |
//Build up the static map tile URL $mapUrl = "http://maps.google.com/maps/api/staticmap?"; //Supply latlon $mapUrl .= 'center=' .$lat . ',' . $lon; //Set zoom, size, maptype $mapUrl .= '&zoom=15&size=512x512&maptype=roadmap&sensor=false'; print '<img src="'.$mapUrl .'" />'; |
So the loop for our low-end device is changed to the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
<h2>Our offices</h2> <ul id="locations"> <?php foreach($locations as $location) { $latlon = explode(',', $location['latlon']); $url = "//maps.google.com/maps/api/staticmap?"; //Supply lat lon $url .= 'center='.$latlon[0].','.$latlon[1]; //Set zoom, size, maptype $url .= '&zoom=15&size=200x200&maptype=roadmap&sensor=false'; ?> <li id="<?php print $location['id']?>" data-latlon="<?php print $location['latlon']?>"> <?php print $location['address']?> <br /> <img src="<?php print $url?>" /> </li> <?php } ?> </ul> </html> |
Conclusion
In this article we showed how location information can be used to change the sort order of data presented to a user. For high-end devices, we used the HTML5 Geolocation API to determine the user’s location. We also considered low-end devices that might not have location capabilities, and in this case, we allow the user to manually enter a location. In both cases, a default sort-order of the data was presented when no location information was available. In the high-end case, this is so that we are not blocking page rendering while waiting for location permission to be granted, or while waiting for a GPS fix for example. In the low-end case, the list order is updated if and when location information is provided by the user. But in either case, there is always some information that can be displayed right away, and this is refined later if possible.
Links and References
- Latitude/longitude spherical geodesy formulae & scripts http://www.movable-type.co.uk/scripts/latlong.html
- Google Geocoding API https://developers.google.com/maps/documentation/geocoding/
- Google Maps API https://developers.google.com/maps/documentation/javascript/tutorial
- Live location based sorting example http://mobiforge.com/page/geolocation-distance-sorting-example
1. The example used in this article was chosen because the audience of mobiForge is an international one. If a local example was chosen, with the POIs all in the same city, then most visitors would not notice any difference in the ordering of the locations. By choosing international locations for the example, more visitors will see a change in the sort order when the user’s location has been acquired. This example could be solved using GeoIP for the low-end version, but the goal of the article was demonstrate a solution that would apply to local searches too. ↩
Leave a Reply