Fragmented Thought

A generic, lazy loading, self dependency resolving geolocation javascript service object

By

Published

Lance Gliser

Heads up! This content is more than six months old. Take some time to verify everything still works as expected.

I've been working on upgrading a client's form autofill options. Right now, they have server side ip sniffing, and forms I'm unable to cache because of the defaulted information. So, blank forms with client side geolocation through the the javascript navigator object is required. I've done this kind of thing before, but it's always been on the page, assuming everything would work. This time around, I'm doing things a little differently, anticipating using this over and over for their many forms. So this time, I'm playing by a few new rules...

  • Ask the user their location as rarely as possible, once being ideal.
  • Do not assume dependencies will be met. Load things like google map apis on the fly, but only when required.
  • Function as a service. Any number of things might need the geolocation information, as soon as its available, handle them all.

To that end, a short explanation of what's completed, then the object for your copying pleasure:

The geolocation.js file will expose three methods you should expect to use: geolocation.getCoordinates(), geolocation.getAddress(), geolocation.reverseGeocode(). More often than not, you'll use coordinates and address, that last one was in there because I needed it for the first two. Usage for all of these involves registering your function to callback to 'whenever the information becomes available.' This whole thing depends on multiple levels of delay and callback. An initial pause is to be expected as the user is asked for their coordinates. Another delay is required if an address is required, while google maps api is loaded, then again as the address is reverse geocoded. If returned, the coordinates and address information are stored in the browser's localStorage and returned immediately to prevent dependency loads in future uses. Please note that I have not concerned myself with backwards compatibility yet. If you care, you may need to add support in case JSON objects are missing in the browser, and a backup localStorage method just as a cookie. I do not encourage the storage of geolocation data in cookies. Your users will not thank you.

geolocation = { error: null, location: { coordinates: { timestamp: null, latitude: null, longitude: null, }, address: { timestamp: null, street_number: null, route: null, locality: null, administrative_area_level_3: null, administrative_area_level_2: null, administrative_area_level_1: null, country: null, postal_code: null, }, }, coordinatesQueue: { items: [], add: function (callbacks) { if ( callbacks != null && !geolocation.coordinatesQueue.contains(callbacks) ) { geolocation.coordinatesQueue.items.push(callbacks); } }, get: function () { return geolocation.coordinatesQueue.items; }, contains: function (callbacks) { var i = geolocation.coordinatesQueue.items.length; while (i--) { if (geolocation.coordinatesQueue.items[i] === callbacks) { return true; } } return false; }, }, getCoordinates: function (success, error) { // Return from local browser cache if set try { var coordinates = sessionStorage.getItem("geolocation.coordinates"); if (coordinates != null) { geolocation.location.coordinates = JSON.parse(coordinates); success(geolocation.location.coordinates); return null; } } catch (err) {} // If we've hit an error, return it if (geolocation.error != null) { if (typeof error == "function") { error(geolocation.error); } return false; } // Return from window object if already defined if (geolocation.location.coordinates.timestamp != null) { success(geolocation.location.coordinates); return null; } // Add this as a package to the queue to notify when available var callbacks = { success: success, error: error, }; geolocation.coordinatesQueue.add(callbacks); // Ask for it, store it, and send it along to the submitted callback navigator.geolocation.getCurrentPosition( geolocation.setCoordinates, geolocation.setError ); return null; }, setCoordinates: function (position) { // Prevent race firing this twice if (geolocation.location.coordinates.timestamp != null) { return; } geolocation.location.coordinates.latitude = position.coords.latitude; geolocation.location.coordinates.longitude = position.coords.longitude; geolocation.location.coordinates.timestamp = new Date().getTime(); try { sessionStorage.setItem( "geolocation.coordinates", JSON.stringify(geolocation.location.coordinates) ); } catch (err) {} var queue = geolocation.coordinatesQueue.get(); if (queue.length > 0) { for (var i = 0; i < queue.length; i++) { queue[i].success(geolocation.location.coordinates); } } geolocation.coordinatesQueue = null; }, addressQueue: { items: [], add: function (callbacks) { if (callbacks != null && !geolocation.addressQueue.contains(callbacks)) { geolocation.addressQueue.items.push(callbacks); } }, get: function () { return geolocation.addressQueue.items; }, contains: function (callbacks) { var i = geolocation.addressQueue.items.length; while (i--) { if (geolocation.addressQueue.items[i] === callbacks) { return true; } } return false; }, }, getAddress: function (success, error) { // Return from local browser cache if set try { var address = sessionStorage.getItem("geolocation.address"); if (address != null) { geolocation.location.address = JSON.parse(address); success(geolocation.location.address); return null; } } catch (err) {} // If we've hit an error, return it if (geolocation.error != null) { if (typeof error == "function") { error(geolocation.error); } return false; } // Return from window object if already defined if (geolocation.location.address.timestamp != null) { success(geolocation.location.address); return null; } // Add this to the queue to notify when available // Add this as a package to the queue to notify when available var callbacks = { success: success, error: error, }; geolocation.addressQueue.add(callbacks); // Start the event chain geolocation.getCoordinates( geolocation.getCoordinatesAddress, geolocation.setError ); }, getCoordinatesAddress: function (coordinates) { geolocation.reverseGeocode(geolocation.setAddress, coordinates); }, setAddress: function (address) { // Prevent race firing this twice if (geolocation.location.address.timestamp != null) { return; } geolocation.location.address = address; try { sessionStorage.setItem("geolocation.address", JSON.stringify(address)); } catch (err) {} var queue = geolocation.addressQueue.get(); if (queue.length > 0) { for (var i = 0; i < queue.length; i++) { queue[i].success(address); } } geolocation.addressQueue = null; }, reset: function () { sessionStorage.removeItem("geolocation.coordinates"); sessionStorage.removeItem("geolocation.address"); }, setError: function (error) { // Clear queues, notify all registered error functions geolocation.error = error; // Coordinates var queue = geolocation.coordinatesQueue.get(); if (queue.length > 0) { for (var i = 0; i < queue.length; i++) { if (typeof queue[i].error == "function") { queue[i].error(error); } } } geolocation.coordinatesQueue = null; // Addresses queue = geolocation.addressQueue.get(); if (queue.length > 0) { for (var i = 0; i < queue.length; i++) { if (typeof queue[i].error == "function") { queue[i].error(error); } } } geolocation.addressQueue = null; }, geocoder: null, geocodeQueue: { items: [], add: function (callback) { if (callback != null && !geolocation.geocodeQueue.contains(callback)) { geolocation.geocodeQueue.items.push(callback); } }, get: function () { return geolocation.geocodeQueue.items; }, contains: function (callback) { var i = geolocation.geocodeQueue.items.length; while (i--) { if (geolocation.geocodeQueue.items[i] === callback) { return true; } } return false; }, }, geocode: function (callback, address) { if (geolocation.geocoder == null) { geolocation.geocodeQueue.add({ callback: callback, address: address, }); geolocation._geocoderLoad(); return null; } geolocation.geocoder.geocode({ address: address }, function ( results, status ) { if (status == google.maps.GeocoderStatus.OK) { coordinates = {}; coordinates.latitude = results[0].geometry.location.lat(); coordinates.longitude = results[0].geometry.location.lng(); coordinates.timestamp = new Date().getTime(); callback(coordinates); } else { callback(null); } }); }, reverseGeocodeQueue: { items: [], add: function (callback) { if ( callback != null && !geolocation.reverseGeocodeQueue.contains(callback) ) { geolocation.reverseGeocodeQueue.items.push(callback); } }, get: function () { return geolocation.reverseGeocodeQueue.items; }, contains: function (callback) { var i = geolocation.reverseGeocodeQueue.items.length; while (i--) { if (geolocation.reverseGeocodeQueue.items[i] === callback) { return true; } } return false; }, }, reverseGeocode: function (callback, coordinates) { if (geolocation.geocoder == null) { geolocation.reverseGeocodeQueue.add({ callback: callback, coordinates: coordinates, }); geolocation._geocoderLoad(); return null; } var latlng = new google.maps.LatLng( coordinates.latitude, coordinates.longitude ); geolocation.geocoder.geocode({ latLng: latlng }, function ( results, status ) { if (status == google.maps.GeocoderStatus.OK) { var address = { timestamp: null, street_number: null, route: null, locality: null, administrative_area_level_3: null, administrative_area_level_2: null, administrative_area_level_1: null, country: null, postal_code: null, }; for (var i = 0; i < results[0].address_components.length; i++) { var _element = results[0].address_components[i]; switch (_element.types[0]) { case "street_number": address.street_number = _element; break; case "route": address.route = _element; break; case "locality": address.locality = _element; break; case "administrative_area_level_3": address.administrative_area_level_3 = _element; break; case "administrative_area_level_2": address.administrative_area_level_2 = _element; break; case "administrative_area_level_1": address.administrative_area_level_1 = _element; break; case "country": address.country = _element; break; case "postal_code": address.postal_code = _element; break; } } address.timestamp = new Date().getTime(); callback(address); } else { callback(null); } }); }, /* internal functions */ _geocoderLoad: function () { var loaded = false; try { var geocoder = new google.maps.Geocoder(); loaded = true; } catch (err) {} if (!loaded) { var gm = document.createElement("script"); gm.type = "text/javascript"; gm.async = true; gm.src = "https://maps.googleapis.com/maps/api/js?v=3.exp&sensor=false&callback=geolocation._geocoderLoaded"; var s = document.getElementsByTagName("script")[0]; s.parentNode.insertBefore(gm, s); } else { geolocation._geocoderLoaded(); } return null; }, _geocoderLoaded: function () { geolocation.geocoder = new google.maps.Geocoder(); var queue = geolocation.reverseGeocodeQueue.get(); if (queue.length > 0) { for (var i = 0; i < queue.length; i++) { geolocation.reverseGeocode(queue[i].callback, queue[i].coordinates); } } geolocation.reverseGeocodeQueue = null; queue = geolocation.geocodeQueue.get(); if (queue.length > 0) { for (var i = 0; i < queue.length; i++) { geolocation.geocode(queue[i].callback, queue[i].address); } } geolocation.geocodeQueue = null; }, };

Usage example:

var feedback = { init: function () { geolocation.getAddress(feedback.addressAvailable); }, addressAvailable: function (address) { var $element; jQuery("#country").val(address.country.short_name.toLowerCase()); $element = jQuery("#state"); if ($element.val() == "") { $element.val(address.administrative_area_level_1.long_name); } $element = jQuery("#postal_code"); if ($element.val() == "") { $element.val(address.postal_code.long_name); } $element = jQuery("#city"); if ($element.val() == "") { $element.val(address.locality.long_name); } $element = jQuery("#street"); if ($element.val() == "") { $element.val( address.street_number.long_name + " " + address.route.long_name ); } }, }; jQuery(document).ready(feedback.init);