Show:

File: platform/plugins/Places/web/js/Places.js

/**
 * Places plugin's front end code
 *
 * @module Places
 * @class Places
 */

(function(Q, $, w) {

var Places = Q.Places = Q.plugins.Places = {
	
	metric: true, // whether to display things using the metric system units
	
	options: {
		platform: 'google'
	},
	
	Google: {},

	/**
	 * @method loadGoogleMaps
	 * @static
	 * Use this to load Google Maps before using them in the callback
	 * @param {Function} callback Once the callback is called, google.maps is accessible
	 */
	loadGoogleMaps: function (callback) {
		if (w.google && w.google.maps) {
			callback();
		} else {
			Places.loadGoogleMaps.waitingCallbacks.push(callback);
			Q.addScript(Places.loadGoogleMaps.src);
		}
	},
	
	/**
	 * @method loadCountries
	 * @static
	 * Use this to load country data into Q.Places.countries and Q.Places.countriesByCode
	 * @param {Function} callback Once the callback is called, 
	 *   Q.Places.countries and Q.Places.countries is accessible
	 */
	loadCountries: function (callback) {
		Q.addScript('Q/plugins/Places/js/lib/countries.js', function () {
			var pc = Places.countries;
			var cbc = Places.countriesByCode = {};
			for (var i=0, l = Places.countries.length; i < l; ++i) {
				var pci = pc[i];
				cbc[ pci[1] ] = pci;
			}
			callback();
		});
	},
	
	/**
	 * @method distance
	 * @static
	 * Use this to calculate the haversine distance between two sets of lat/long coordinates on the Earth
	 * @param {Number} lat1 latitude in degrees
	 * @param {Number} long1 longitude in degrees
	 * @param {Number} lat2 latitude in degrees
	 * @param {Number} long2 longitude in degrees
	 * @return {Number} The result, in meters, of applying the haversine formula
	 */
	distance: function(lat1, long1, lat2, long2) {
		var earthRadius = 6378137; // equatorial radius in meters

		var sin_lat   = Math.sin(_deg2rad(lat2  - lat1)  / 2.0);
		var sin2_lat  = sin_lat * sin_lat;

		var sin_long  = Math.sin(_deg2rad(long2 - long1) / 2.0);
		var sin2_long = sin_long * sin_long;

		var cos_lat1 = Math.cos(_deg2rad(lat1));
		var cos_lat2 = Math.cos(_deg2rad(lat2));

		var sqrt      = Math.sqrt(sin2_lat + (cos_lat1 * cos_lat2 * sin2_long));
		var distance  = 2.0 * earthRadius * Math.asin(sqrt);

		return distance;
	},

	/**
	 * Use this method to generate a label for a radius based on a distance in meters
	 * @method distanceLabel
	 * @static
	 * @param {Number} meters
	 * @param {String} [units] optionally specify 'km', 'kilometers' or 'miles'
	 * @return {String} Returns a label that looks like "x.y km", "x miles" or "x meters"
	 */
	distanceLabel: function(meters, units) {
		if (!units) {
			var milesr = Math.abs(meters/1609.34 - Math.round(meters/1609.34));
			var kmr = Math.abs(meters/1000 - Math.round(meters/1000));
			units = milesr < kmr ? 'miles' : 'km';
		}
		switch (units) {
		case 'miles':
			return Math.round(meters/1609.34*10)/10+" miles";
		case 'km':
		case 'kilometers':
		default:
			return meters % 100 == 0 ? (meters/1000)+' '+units : Math.ceil(meters)+" meters";
		}
	},

	/**
	 * Use this method to calculate the heading from pairs of coordinates
	 * @method heading
	 * @static
	 * @param {Number} lat1 latitude in degrees
	 * @param {Number} long1 longitude in degrees
	 * @param {Number} lat2 latitude in degrees
	 * @param {Number} long2 longitude in degrees
	 * @return {Number} The heading, in degrees
	 */
	heading: function(lat1, long1, lat2, long2) {
		lat1 = lat1 * Math.PI / 180;
		lat2 = lat2 * Math.PI / 180;
		var dLong = (long2 - long1) * Math.PI / 180;
		var y = Math.sin(dLong) * Math.cos(lat2);
		var x = Math.cos(lat1) * Math.sin(lat2) -
		        Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLong);
		var brng = Math.atan2(y, x);
		return (((brng * 180 / Math.PI) + 360) % 360);
	},
	
	/**
	 * Gets the heading of a car along a route given its current coordinates.
	 * The coordinates should already have "latitude" and "longitude" properties.
	 * This is good as a fallback for rotating the car icon on the map,
	 * in case the "heading" isn't already specified.
	 * @param {Object} route The route along which the car is traveling
	 * @param {Object} coordinates]
	 * @param {Object} coordinates.latitude
	 * @param {Object} coordinates.longitude
	 * @param {Object} [options]
	 * @param {String} [options.platform=Places.options.platform]
	 * @return {Number} the heading
	 */
	headingAlongRoute: function (route, coordinates, options) {
		if (!route.legs || !route.legs.length) {
			// TODO: try to get heading from Places.heading() and previous coordinates
			return 0;
		}
		var point = {
			x: coordinates.latitude,
			y: coordinates.longitude
		};
		var polyline = Places.polyline(route, options);
		var closest = Places.closest(point, polyline);
		var from = polyline[closest.index-1];
		var to = polyline[closest.index];
		return Places.heading(from.x, from.y, to.x, to.y);
	},
	
	/**
	 * Use this method to calculate the closest point on a polyline
	 * @method closest
	 * @static
	 * @param {Object} point
	 * @param {Number} point.x
	 * @param {Number} point.y 
	 * @param {Array} polyline an array of objects that contain "x" and "y" properties
	 * @return {Object} contains properties "index", "x", "y", "distance", "fraction"
	 */
	closest: function(point, polyline) {
		var x = point.x;
		var y = point.y;
		var a, b, c, d, e, f, i, l, n, n1, n2, frac, dist;
		var distance = null;
		var closest = null;

		for (i = 1, l = polyline.length; i < l; i++) {
			a = polyline[i-1].x;
			b = polyline[i-1].y;
			c = polyline[i].x;
			d = polyline[i].y;
			n = (c-a)*(c-a) + (d-b)*(d-b);
			frac = n ? ((x-a)*(c-a) + (y-b)*(d-b)) / n : 0;
			frac = Math.max(0, Math.min(1, frac));
			e = a + (c-a)*frac;
			f = b + (d-b)*frac;
			dist = Math.sqrt((x-e)*(x-e) + (y-f)*(y-f));
			if (distance === null || distance > dist) {
				distance = dist;
				closest = {
					index: i,
					x: e,
					y: f,
					distance: dist,
					fraction: frac
				};
			}
		}
	    return closest;
	},
	
	/**
	 * Calculate a route.
	 * @param {String|Object} from an address, a string with properties "latitude", "longitude", or placeId 
	 * @param {String|Object} to an address, a string with properties "latitude", "longitude", or placeId
	 * @param {Array} waypoints array of objects with properties "location" and "stopover", wher "location" is an object that can be passed to Places.Coordinates constructor
	 * @param {Function} callback
	 * @param {Object} options Can include the following:
	 *   @param {Number} [options.startTime] Time to start trip. Standard Unix time (seconds from 1970). If specified, do not also set endTime.
	 *   @param {Number} [options.endTime] Time to end trip. Standard Unix time (seconds from 1970). If specified, do not also set startTime.
	 *   @param {Number} [options.travelMode='driving'] Can be "driving", "bicycling", "transit", "walking"
	 *   @param {String} [options.platform=Places.options.platform]
	 */
	route: function (from, to, waypoints, optimize, callback, options) {
		options = options || {};
		var platform = options.platform || Places.options.platform;
		var params = {
			origin: from,
			destination: to,
			waypoints: waypoints,
			optimizeWaypoints: optimize
		};
		var googleTravelModes = {
			driving: google.maps.TravelMode.DRIVING,
			bicycling: google.maps.TravelMode.BICYCLING,
			transit: google.maps.TravelMode.TRANSIT,
			walking: google.maps.TravelMode.WALKING
		};
		params.travelMode = googleTravelModes[options.travelMode || 'driving'];
		if (options.startTime) {
			params.transitOptions = {
				arrivalTime: new Date(options.endTime*1000)
			};
		} else if (options.startTime) {
			params.transitOptions = {
				departureTime: new Date(options.startTime*1000)
			};
		}
		var d = Places.Google.directionsService;
		if (!d) {
			d = Places.Google.directionsService = new google.maps.DirectionsService();
		}
		d.route(params, function (directions, status) {
			if (status !== google.maps.DirectionsStatus.OK) {
				return Q.handle(Places.route.onError, Places, [directions, status, d, params]);
			}
			Q.handle(Places.route.onResult, Places, [directions, status, d, params]);
			Q.handle(callback, Places, [directions, status, d, params]);
		});
	},
	/**
	 * Obtain a polyline from a route
	 * @param {Object} route the route
	 * @param {Object} options
	 * @param {String} [options.platform=Places.options.platform]
	 */
	polyline: function(route, options) {
		options = options || {};
		var platform = options.platform || Places.options.platform;
		var polyline = [];
		var str = route.overview_polyline.points;
		var index = 0,
			lat = 0,
			lng = 0,
			shift = 0,
			result = 0,
			byte = null,
			latitude_change,
			longitude_change,
			precision = 5,
			factor = Math.pow(10, precision);

		// Coordinates have variable length when encoded, so just keep
		// track of whether we've hit the end of the string. In each
		// loop iteration, a single coordinate is decoded.
		while (index < str.length) {
			// Reset shift, result, and byte
			byte = null;
			shift = 0;
			result = 0;

			do {
				byte = str.charCodeAt(index++) - 63;
				result |= (byte & 0x1f) << shift;
				shift += 5;
			} while (byte >= 0x20);

			latitude_change = ((result & 1) ? ~(result >> 1) : (result >> 1));

			shift = result = 0;

			do {
				byte = str.charCodeAt(index++) - 63;
				result |= (byte & 0x1f) << shift;
				shift += 5;
			} while (byte >= 0x20);

			longitude_change = ((result & 1) ? ~(result >> 1) : (result >> 1));

			lat += latitude_change;
			lng += longitude_change;

			polyline.push({
				x: lat / factor,
				y: lng / factor
			});
		}

		return polyline;
	}
};

Places.route.onResult = new Q.Event();
Places.route.onError = new Q.Event(function (directions, status) {
	console.warn('Places.route: request failed due to ' + status);
});

/**
 * Represents geospacial coordinates with latitude, longitude, heading.
 * Similar to HTML5 Coordinates object.
 * It has an onReady event that occurs when the coordinates have been geocoded.
 * It also has an onUpdated event which you handle and trigger.
 * Use Places.Coordinates.from() to create it
 * @class Places.Coordinates
 * @constructor
 */
Places.Coordinates = function (_internal) {
	if (_internal !== true) {
		throw new Q.Error("You should use Places.Coordinates.from(data, callback)");
	}
};

/**
 * Create a Places.Coordinates object from some data
 * @class Places.Coordinates
 * @method from
 * @static
 * @param {Object|Streams.Stream\Places.Coordinates} data
 *  Can be a stream with attributes,
 *  or object with properties, which can include either
 *  ("latitude" and "longitude") together,
 *  or ("address") or ("userId" with optional "streamName").
 *  It can additionally specify "heading" and "speed".
 * @param {String} data.updatedKey If data is a stream, then set this to a string or Q.Tool
 *  to subscribe to the "Places/location/updated" message for location updates.
 * @param {Number} data.latitude Used together with latitude
 * @param {Number} data.longitude Used together with longitude
 * @param {Number} data.heading Used to set heading, in clockwise degrees from true north
 * @param {Number} data.speed Horizontal component of the velocity in meters-per-second
 * @param {String} [data.platform=Places.options.platform]
 * @param {String} [data.address] This would be geocoded by the platform
 * @param {String} [data.placeId] A google placeId, if the platform is 'google'
 * @param {String} [data.userId] Can be used to indicate a specific user
 * @param {String} [data.streamName="Places/user/location"] Name of a stream published by the user
 * @param {Function} [callback] Called after latitude and longitude are available
 *  (may do geocoding with the platform to obtain them if they're not available yet).
 *  The first parameter is an error string, if any.
 *  Next is an array of platform-specific Result objects. 
 *  The "this" object is the Coordinates object itself,
 *  containing the latitude and longitude from the main result.
 * @return {Places.Coordinates}
 */
Places.Coordinates.from = function (data, callback) {
	var c = (data instanceof Places.Coordinates)
		? Q.copy(data)
		: new Places.Coordinates(true);
	c.onUpdated = new Q.Event();
	c.onReady = new Q.Event();
	if (!data) {
		throw new Q.Error("Places.Coordinates.from: data is required");
	}
	if (Q.typeOf (data) === 'Q.Streams.Stream') {
		_stream.call(data);
	} else if (data.userId) {
		var streamName = data.streamName || 'Places/user/location';
		Q.Streams.get(data.userId, streamName, _stream);
	} else {
		for (var k in data) {
			c[k] = data[k];
		}
		_geocode(callback);
	}
	return c;
	function _stream() {
		c.stream = this;
		Q.extend(c, this.getAllAttributes());
		if (data.updatedKey) {
			this.onMessage('Places/location/updated')
			.set(function (stream, message) {
				var instructions = message.getAllInstructions();
				Q.extend(c, instructions);
				Q.handle(c.onUpdated, c, arguments);
			}, data.updatedKey);
		}
		_geocode(callback);
	}
	function _geocode(callback) {
		if (!callback) {
			return;
		}
		c.geocode(callback, Q.extend({
			basic: true
		}, data));
	}
};

var Cp = Places.Coordinates.prototype;
	
/**
 * Obtain geocoding definition from a geocoding service
 * @method geocode
 * @static
 * @param {Function} callback
 *  The first parameter is an error string, if any.
 *  Next is an array of platform-specific Result objects. 
 *  The "this" object is the Coordinates object itself,
 *  containing the latitude and longitude from the main result.
 * @param {Object} [options]
 * @param {String} [options.platform=Places.options.platform]
 * @param {Boolean} [options.basic=false] If true, skips requests to platform if latitude & longitude are available
 */
Cp.geocode = function (callback, options) {
	var o = Q.extend({}, Cp.geocode.options, options);
	if (o.platform !== 'google') {
		throw new Q.Error("Places.Coordinates.prototype.geocode: only works with platform=google for now");
	}	
	var c = this;
	if (options && options.basic
	&& c.latitude && c.longitude) {
		return callback && callback.call(c, null, []);
	}

	if (Q.typeOf(c.lat) === "function" && Q.typeOf(c.lng) === "function") {
		c.latitude = c.latitude || c.lat();
		c.longitude = c.longitude || c.lng();
	}

	Places.loadGoogleMaps(function () {
		var param = {};
		var p = "Places.Location.geocode: ";
		if (c.placeId) {
			param.placeId = c.placeId;
		} else if (c.latitude || c.longitude) {
			if (!c.latitude) {
				callback && callback.call(c, p + "missing latitude");
			}
			if (!c.longitude) {
				callback && callback.call(c, p + "missing longitude");
			}
			param.location = {
				lat: parseFloat(c.latitude),
				lng: parseFloat(c.longitude)
			};
		} else if (c.lat && c.lng) {
			if (!c.lat) {
				callback && callback.call(c, p + "missing latitude");
			}
			if (!c.lng) {
				callback && callback.call(c, p + "missing longitude");
			}
			param.location = {
				lat: parseFloat(c.lat()),
				lng: parseFloat(c.lng())
			};
		} else if (c.address) {
			param.address = c.address;
		} else {
			return callback && callback.call(c, p + "wrong location format");
		}
		var key, cached;
		if (cached = _geocodeCache.get(param)) {
			return Q.handle(callback, cached.subject, cached.params);
		}
		if (param) {
			var geocoder = new google.maps.Geocoder;
			geocoder.geocode(param, function (results, status) {
				var json, err, d;
				if (status !== 'OK') {
					d = Q.copy(c);
					delete d.onReady;
					delete d.onUpdated;
					json = JSON.stringify(d);
					err = p + "can't geocode (" + status + ") " + json;
				} else if (!results[0]) {
					d = Q.copy(c);
					delete d.onReady;
					delete d.onUpdated;
					json = JSON.stringify(c);
					err = p + "no place matched " + json;
				} else {
					var result = results[0];
					if (result.geometry && result.geometry.location) {
						var loc = result.geometry.location;
						c.latitude = loc.lat();
						c.longitude = loc.lng();
					}
					_geocodeCache.set(param, 0, c, [err, results]);
				}
				Q.handle(callback, c, [err, results]);
			});
		}
	});
};

Cp.geocode.options = {
	platform: Places.options.platform,
	basic: false
};

var _geocodeCache = new Q.Cache({max: 100});

function _deg2rad(angle) {
	return angle * 0.017453292519943295; // (angle / 180) * Math.PI;
}

Q.beforeInit.set(function () {
	var plk = Places.loadGoogleMaps.key;
	Places.loadGoogleMaps.src = 'https://maps.googleapis.com/maps/api/js?v=3.exp'
		+ '&libraries=geometry,places'
		+ (plk ? '&key='+encodeURIComponent(plk) : '')
		+ '&callback=Q.Places.loadGoogleMaps.loaded';
}, 'Places');

Places.loadGoogleMaps.waitingCallbacks = [];
Places.loadGoogleMaps.loaded = function _PLaces_loadGoogleMaps_loaded () {
	Q.handle(Places.loadGoogleMaps.waitingCallbacks);
};

Q.Streams.Message.shouldRefreshStream("Places/location/updated", true);

Q.text.Places = {


};

Q.Tool.define({
	"Places/address": "Q/plugins/Places/js/tools/address.js",
	"Places/globe": "Q/plugins/Places/js/tools/globe.js",
	"Places/countries": "Q/plugins/Places/js/tools/countries.js",
	"Places/user/location": "Q/plugins/Places/js/tools/user/location.js",
	"Places/location": "Q/plugins/Places/js/tools/location.js",
	"Places/areas": "Q/plugins/Places/js/tools/areas.js"
});

})(Q, jQuery, window);