Location Aware Webpages

I’m working on a site for a client at the moment who has around 35 store locations across Canada. The client was keen to have a ‘your local store’ feature on the home page where the store closest to the visitors location was featured. I thought I’d write briefly about this, the solution I came up with to address this problem, and some of the limitations of it.

To display the local store information I needed to go through a number of distinct steps in the code.

  1. Determine the visitors location from their IP address.
  2. If the visitor is in Canada calculate the distance to their closest store.
  3. If they’re within 100km of a store display information on that store on the homepage.
  4. If the user is not in Canada, is more than 100km away from a store or if there is any kind of error display a generic message about store locations.

Getting the Users Location

Determining a visitors location from their IP address can be done through a number of GeoIp services, some available for free and some paid for. Ideally I wanted to use a PECL PHP extension to do the location lookup but this was not possible as I could not persuade my web host to install this. I fell back to using a free web service provided by ipinfodb for the lookup. I found a class on PHPClasses that used this web service, which I substantially rewrote to serve my purposes. The code that performs the lookup in the class is below:


$strAPIURL = $this->apiUrl . "?ip=" . urlencode ($this->remoteAddress);
 //Make a call to the api and fetch the result.
 $ch        = curl_init ($strAPIURL);
 curl_setopt ($ch, CURLOPT_RETURNTRANSFER, 1);
 $xmlResult = curl_exec ($ch);
 curl_close ($ch);

 if ($xmlResult && strlen ($xmlResult) > 2) {
 //Process the result
 $result = simplexml_load_string ($xmlResult);

 if ((string)$result->Status == 'OK') {
 $this->countryName   = (string)$result->CountryName;
 $this->countryCode   = (string)$result->CountryCode;
 $this->cityName      = (string)$result->City;
 $this->zipPostalCode = (string)$result->ZipPostalCode;
 $this->regionName    = (string)$result->RegionName;
 $this->regionCode    = (string)$result->RegionCode;
 $this->timezone      = (int)$result->Timezone;
 $this->gmtOffset     = (int)$result->Gmtoffset;
 $this->dstOffset     = (int)$result->Dstoffset;
 $this->lat           = (float)$result->Latitude;
 $this->long          = (float)$result->Longitude;
 //Set the ipSniffed flag to true
 $this->ipSniffed     = true;
 }
 }

Once the call to the web service has been completed the information returned is stored in public properties and a flag is set to show that a successful lookup was performed. My class uses filter_var() to make sure a valid IP address that is not in a private range is passed to it, throwing an InvalidArgument Exception if not. Of course, if an exception is thrown I fall back to my default position of displaying a generic message about store locations.

Finding the Closest Store

Once I have the users location from their IP address and I’ve determined that they’re visiting from Canada I proceed to calculate the distance to their closest store. This is done with a database query. Information about all of the stores is stored in a database table along with the latitude and longitude of each store. Using the latitude and longitude returned from the web service I can then calculate the users distance to the nearest store. This is done in the following code:


protected function getClosestStore ($lat, $long) {
 $sql = <<< _SQL_

SELECT StoreId, CONCAT_WS(', ', Address, City, CONCAT(Province, ' ', PostCode)) AS Address, Lat, Lon, ROUND(6371 * 2 * ASIN(SQRT(POWER(SIN((:lat - abs(lat)) * pi()/180 / 2), 2) +
COS(:lat * pi()/180 ) * COS(abs(lat) * pi()/180) *  POWER(SIN((:lon - lon) * pi()/180 / 2), 2) )),2)
AS Distance
FROM Stores
HAVING Distance <= 100
ORDER BY Distance Limit 1
_SQL_;
 try {
 $db = db::getConn();
 $st = $db->prepare($sql);
 $st->execute(array(':lat' => $lat, ':lon' => $long));
 $this->storeInfo = $st->fetch(PDO::FETCH_ASSOC);
 }
 catch (PDOException $e) {
 error_log($e->getMessage());
 }
 }

This PHP in this code simply connects to the database (db::getConn() is a static method that returns a singleton instance of a PDO object), performs the query and stores the result in the storeInfo property. If no result is returned from the database the fetch() method will return false, which then tells me that a user is not within 100 km of a store. The real meat of this code is in the SQL query. It selects some information about the store and then uses some math to calculate the distance to the nearest store. This is done using the Haversine formula, which calculates the distance between two sets of latitude and longitude, taking into account the curvature of the earth. I’m not going to try to explain the math (mostly because I don’t fully understand it myself!) but there is a Wikipedia article on the formula for anyone who is interested. The query then limits the results to stores that are within 100km, orders the results by distance to make sure the closest one is listed first and then returns the first result. If a result is returned I then record the StoreId of the closest store in a cookie, which is then used when the visitor visits the site again. This is to cut down on processing time for subsequent request for the home page and means that I won’t need to hit the ipinfodb web service on every request for the index page of the site.

Limitations of this Solution

There are three major limitations to this solution that I can see: the accuracy of GeoIP services, the database query and saving the local store information in a cookie.

GeoIP services claim about an 80% accuracy when tracking the geographic location of an IP address down to the city level (accuracy is about 95% for finding the country a user is visiting from). For example I am writing this in Guelph, Ontario but the IP address I am connected to the internet with resolves geographically to the nearby city of Kitchener. This is not a major problem for my application as the client only has 35 stores across Canada and I am just trying to find the closest one to a user. Given the small number of stores chances are that I will hit on the closest one, even allowing for only 80% accuracy in GeoIP tracking. For other applications this could be a problem. The W3C has a draft Geolocation API which some browsers (such as Firefox) are starting to implement. Using this (perhaps with some AJAX) could help to get a more accurate fix on some users locations but would open up some privacy concerns. For this application I display links that allow a user to manually select their closest store if the GeoIP tracking is wrong, hopefully alleviating any problems caused by the 80% accuracy.

The SQL query as I’ve written it would have performance issues if a large number of locations were being stored. To calculate the distance the query has to find the distance to every store held in the database, only then narrowing it down to the closest. This is not a problem currently as the client only has 35 locations, but if hundreds or thousands of locations were being stored the query would be extremely innefficient and take a long time to execute. In the course of my research I did read something about limiting the search to a radius (100 km in this case). This would involve several queries and probably a stored procedure to carry them out in. As I was dealing with a small number of locations I decided against this for simplicity. For another solution involving more locations I would probably go with this approach.

Saving the information on the users local store in a cookie makes the application more efficient but it potentially slightly degrades the user experience. In a scenario where a user travels and expects to see information on the closest store to where they currently are this would not work. I made a judgement call here and decided that it was better to go with the more efficient approach for this client, but in other cases this may not be so. Of course, if I could install the PECL GeoIP extension I wouldn’t need to store the cookie. Looking up a users location using this extension would be no more complicated than calling some PHP functions. The GeoIP information would be stored locally as part of the extension. This would be quicker than creating a call to and processing a response from a web service. Unfortunately this was not an option for me on this occasion. This problem can be overcome by the user manually selecting their closest store, overriding the mechanism I programmed.

All in all I am fairly happy with the solution I arrived at for this problem. It enables me to tailor content on the page based on where a user is. My example is a fairly simple one but it would be possible to do far more sophisticated things using GeoIP such as automatically displaying content in different languages depending on which country a visitor is coming from or displaying prices in different currencies. Due to the less than 100% accuracy of GeoIP mechanisms would always need to be provided to allow a user to override the conclusions found by the code though.

Leave a Reply