/**
* @module geo
* @author Flavio De Stefano <flavio.destefano@caffeinalab.com>
*/
/*
Include methods used in this module dynamically to avoid that Titanium
static analysis doesn't include native-language methods.
*/
Ti.Geolocation;
/**
* @property config
* @property {String} [config.gpsAccuracy="ACCURACY_HIGH"] Accuracy of localization. Must be one of `'ACCURACY_HIGH'` and `'ACCURACY_LOW'`
* @property {Boolean} [config.geocodeUseGoogle=true] Tell to use Google Services instead of Titanium geocoding services.
* @property {Number} [config.clusterPixelRadius=15] The clustering radius expressed in px.
* @property {Boolean} [config.clusterRemoveOutOfBB=true] Tell the clustering to remove pins that are out of the bounding box.
* @property {Number} [config.clusterMaxDelta=0.3] The value before the clustering is off.
* @property {Boolean} [config.clusterRegionBounds=false] Tell the clustering to add the region information of its points.
* @property {String} [config.googleApiKey=null] Optional Google API key for some methods.
*/
exports.config = _.extend({
gpsAccuracy: 'ACCURACY_HIGH',
geocodeUseGoogle: true,
clusterPixelRadius: 30,
clusterRemoveOutOfBB: true,
clusterMaxDelta: 0.3,
clusterRegionBounds: false,
googleApiKey: null
}, Alloy.CFG.T ? Alloy.CFG.T.geo : {});
var MODULE_NAME = 'geo';
var HTTP = require('T/http');
var Util = require('T/util');
var Event = require('T/event');
var Dialog = require('T/dialog');
var Q = require('T/ext/q');
var CACHE_TTL = 2592000;
/**
* Attach events to current module
* @param {String} name Event key
* @param {Function} cb Callback
*/
exports.on = exports.event = function(name, cb) {
Event.on(MODULE_NAME + '.' + name, cb);
};
/**
* Remove events to current module
* @param {String} name Event key
* @param {Function} cb Callback
*/
exports.off = function(name, cb) {
Event.off(MODULE_NAME + '.' + name, cb);
};
/**
* Trigger events from current module
* @param {String} name Event key
* @param {Function} cb The data
*/
exports.trigger = function(name, data) {
Event.trigger(MODULE_NAME + '.' + name, data);
};
/**
* @param {Object} opt
* return Q.promise
*/
exports.authorizeLocationServices = function(opt) {
opt = _.defaults(opt || {}, {
inBackground: false,
success: function() {},
error: function() {}
});
return Q.promise(function(_resolve, _reject) {
var resolve = function(e) {
Ti.API.debug(MODULE_NAME + ': authorization success', e);
exports.trigger('authorization.success', e);
opt.success(e);
_resolve(e);
};
var reject = function(e) {
Ti.API.error(MODULE_NAME + ': authorization error', e);
exports.trigger('authorization.error', e);
opt.error(e);
_reject(e);
};
var authToCheck = null;
if (opt.inBackground) {
authToCheck = Ti.Geolocation.AUTHORIZATION_ALWAYS;
} else {
authToCheck = Ti.Geolocation.AUTHORIZATION_WHEN_IN_USE;
}
// The documentation for Android is lying:
// Ti.Geolocation.locationServicesEnabled will be false even if
// the service is available but the app has no location permissions!
// We have to call hasLocationPermissions() first...
if (Ti.Geolocation.hasLocationPermissions(authToCheck)) {
resolve();
} else {
Ti.Geolocation.requestLocationPermissions(authToCheck, function(e) {
if (e.success) {
resolve();
} else {
reject({
disabledBySystem: (Ti.Geolocation.locationServicesAuthorization),
disabledByApp: (Ti.Geolocation.locationServicesAuthorization == Ti.Geolocation.AUTHORIZATION_DENIED),
error: L('geo_ls_restricted', 'Location services unavailable.'),
status: e.status
});
}
});
}
});
};
/**
* Get the current GPS coordinates of user using `Ti.Geolocation.getCurrentPosition`
* @param {Object} opt
*/
exports.getCurrentPosition = function(opt) {
opt = _.defaults(opt || {}, {
success: function() {},
error: function() {},
inBackground: false,
});
exports.authorizeLocationServices({
inBackground: opt.inBackground,
success: function() {
Ti.Geolocation.getCurrentPosition(function(e) {
if (e.success && e.coords != null) {
opt.success(e.coords);
} else {
opt.error(e);
}
});
},
error: opt.error
});
};
/**
* Open Apple Maps on iOS, Google Maps on Android and route from user location to defined location
* @param {Number} lat Destination latitude
* @param {Number} lng Destination longitude
* @param {String} [mode] GPS mode used (walking,driving)
*/
exports.startNavigator = function(lat, lng, mode) {
var query = {
directionsmode: mode || 'walking',
daddr: lat + ',' + lng
};
if (OS_IOS && Ti.Platform.canOpenURL('comgooglemapsurl://')) {
Dialog.option(L('open_with', 'Open with...'), [{
title: 'Google Maps',
callback: function() {
Ti.Platform.openURL('comgooglemapsurl://' + Util.buildQuery(query));
}
}, {
title: 'Apple Maps',
callback: function() {
Ti.Platform.openURL('http://maps.apple.com/' + Util.buildQuery(query));
}
}, {
title: L('cancel', 'Cancel'),
cancel: true
}]);
} else {
Ti.Platform.openURL((OS_IOS ? 'http://maps.apple.com/' : 'https://maps.google.com/maps/') + Util.buildQuery(query));
}
};
function parseComponents(cps) {
return _.map(cps, function(value, key) {
return key + ':' + value;
}).join('|');
}
/**
* Return the coordinates of an address
* @param {Object} opt
* @param {String} opt.address The address to geocode
* @param {Function} opt.success Success callback
* @param {Function} [opt.error] Error callback
* @param {String} [opt.language] The language in which to return results
* @param {Boolean} [opt.silent=true] Silence HTTP events
* @param {Number} [opt.ttl=2592000] Override the TTL seconds for the cache. The default and maximum value is 30 days. Set to -1 to disable request caching.
* @param {Object} [opt.components] The component filters. Each component filter consists of a component:value pair and will fully restrict the results from the geocoder.
* @see {@link https://developers.google.com/maps/documentation/geocoding/intro}
* @see {@link https://developers.google.com/maps/terms#section_10_5}
*/
exports.geocode = function(opt) {
_.defaults(opt, {
silent: true,
ttl: CACHE_TTL
});
if (opt.ttl > CACHE_TTL) {
Ti.API.error('Geo: cache TTL cannot exceed 30 days. Defaulting to ' + CACHE_TTL + ' seconds');
opt.ttl = CACHE_TTL;
}
if (exports.config.geocodeUseGoogle) {
var data = _.pick(opt, ['address', 'language']);
if (_.isObject(opt.components)) {
_.extend(data, {
components: parseComponents(opt.components)
});
}
_.extend(data, {
sensor: 'false'
});
HTTP.send({
url: 'http://maps.googleapis.com/maps/api/geocode/json',
data: data,
silent: opt.silent,
ttl: opt.ttl,
format: 'json',
success: function(res) {
if (res.status !== 'OK' || _.isEmpty(res.results)) {
if (_.isFunction(opt.error)) opt.error();
return;
}
opt.success({
success: true,
latitude: res.results[0].geometry.location.lat,
longitude: res.results[0].geometry.location.lng,
formatted_address: res.results[0].formatted_address
});
},
error: opt.error
});
} else {
Ti.Geolocation.forwardGeocoder(opt.address, function(res) {
if (!res.success) {
if (_.isFunction(opt.error))
opt.error();
return;
}
opt.success({
success: true,
latitude: res.latitude,
longitude: res.longitude
});
});
}
};
/**
* Return the address with the specified coordinates
* @param {Object} opt
* @param {String} opt.lat The latitude of the address to search
* @param {String} opt.lng The longitude of the address to search
* @param {Function} opt.success Success callback
* @param {Function} [opt.error] Error callback
* @param {String} [opt.language] The language in which to return results
* @param {Boolean} [opt.silent=true] Silence HTTP events
* @param {Number} [opt.ttl=2592000] Override the TTL seconds for the cache. The default and maximum value is 30 days. Set to -1 to disable request caching.
* @see {@link https://developers.google.com/maps/terms#section_10_5}
*/
exports.reverseGeocode = function(opt) {
_.defaults(opt, {
silent: true,
ttl: CACHE_TTL
});
if (opt.ttl > CACHE_TTL) {
Ti.API.error('Geo: cache TTL cannot exceed 30 days. Defaulting to ' + CACHE_TTL + ' seconds');
opt.ttl = CACHE_TTL;
}
if (exports.config.geocodeUseGoogle) {
var data = _.pick(opt, ['language']);
_.extend(data, {
latlng: opt.lat + ',' + opt.lng,
sensor: 'false'
});
HTTP.send({
url: 'http://maps.googleapis.com/maps/api/geocode/json',
data: {
latlng: opt.lat + ',' + opt.lng,
sensor: 'false'
},
silent: opt.silent,
ttl: opt.ttl,
format: 'json',
success: function(res) {
if (res.status !== 'OK' || res.results.length === 0) {
if (_.isFunction(opt.error)) opt.error();
return;
}
opt.success({
success: true,
address: res.results[0].formatted_address,
results: res.results
});
},
error: opt.error
});
} else {
Ti.Geolocation.reverseGeocoder(opt.lat, opt.lng, function(res) {
if (!res.success || _.isEmpty(res.places)) {
if (_.isFunction(opt.error))
opt.error();
return;
}
opt.success({
success: true,
address: res.places[0].address,
results: res.places
});
});
}
};
/**
* Return a list of predicted places based on an input string.
* To use this method, you need to create a browser key for the Google Places API Web Service and place it in the tiapp.xml file as a property named "google.places.api.key".
* @see {@link https://developers.google.com/places/web-service/autocomplete}
* @param {Object} opt
* @param {String} opt.input The text string on which to search.
* @param {String} [opt.offset] The position, in the input term, of the last character that the service uses to match predictions.
* @param {String} [opt.location] The point around which you wish to retrieve place information. Must be specified as latitude,longitude.
* @param {Number} [opt.radius] The distance (in meters) within which to return place results.
* @param {String} [opt.language] The language code, indicating in which language the results should be returned, if possible. See https://developers.google.com/maps/faq#languagesupport
* @param {String} [opt.types] The types of place results to return. See https://developers.google.com/places/web-service/autocomplete#place_types
* @param {Object} [opt.components] A grouping of places to which you would like to restrict your results. Currently, you can use components to filter by country. The country must be passed as a two character, ISO 3166-1 Alpha-2 compatible country code. For example: components: {country: "fr"} would restrict your results to places within France.
* @param {Function} [opt.success] Success callback
* @param {Function} [opt.error] Error callback
* @param {Boolean} [opt.silent=true] Silence HTTP events
* @param {Number} [opt.ttl=2592000] Override the TTL seconds for the cache. The default and maximum value is 30 days. Set to -1 to disable request caching.
* @see {@link https://developers.google.com/maps/terms#section_10_5}
*/
exports.autocomplete = function(opt) {
_.defaults(opt, {
silent: true,
ttl: CACHE_TTL
});
if (opt.ttl > CACHE_TTL) {
Ti.API.error('Geo: cache TTL cannot exceed 30 days. Defaulting to ' + CACHE_TTL + ' seconds');
opt.ttl = CACHE_TTL;
}
var key = exports.config.googleApiKey;
if (!key) {
throw new Error('This method needs a Google Maps API key to work');
}
if (!opt.input) {
Ti.API.error('Geo: Missing required parameter "input"');
if (_.isFunction(opt.error)) opt.error();
return;
}
var data = _.pick(opt, 'input', 'offset', 'location', 'radius', 'language', 'types');
if (_.isObject(opt.components)) {
_.extend(data, {
components: parseComponents(opt.components)
});
}
_.extend(data, {
key: key
});
HTTP.send({
url: 'https://maps.googleapis.com/maps/api/place/autocomplete/json',
data: data,
silent: opt.silent,
ttl: opt.ttl,
format: 'json',
success: function(res) {
if (!res.predictions) {
if (_.isFunction(opt.error)) opt.error();
return;
}
opt.success(res.predictions);
},
error: opt.error
});
};
/**
* Return the details of a place with a specific placeid.
* To use this method, you need to create a browser key for the Google Places API Web Service and place it in the tiapp.xml file as a property named "google.places.api.key".
* @see {@link https://developers.google.com/places/web-service/details}
* @param {Object} opt
* @param {String} opt.placeid A textual identifier that uniquely identifies a place, returned from a Place Search. See https://developers.google.com/places/web-service/search
* @param {Object} [opt.extensions] Indicates if the Place Details response should include additional fields.
* @param {String} [opt.language] The language code, indicating in which language the results should be returned, if possible. See https://developers.google.com/maps/faq#languagesupport
* @param {Function} [opt.success] Success callback
* @param {Function} [opt.error] Error callback
* @param {Boolean} [opt.silent=true] Silence HTTP events
* @param {Number} [opt.ttl=2592000] Override the TTL seconds for the cache. The default and maximum value is 30 days. Set to -1 to disable request caching.
* @see {@link https://developers.google.com/maps/terms#section_10_5}
*/
exports.getPlaceDetails = function(opt) {
_.defaults(opt, {
silent: true,
ttl: CACHE_TTL
});
if (opt.ttl > CACHE_TTL) {
Ti.API.error('Geo: cache TTL cannot exceed 30 days. Defaulting to ' + CACHE_TTL + ' seconds');
opt.ttl = CACHE_TTL;
}
var key = exports.config.googleApiKey;
if (!key) {
throw new Error('This method needs a Google Maps API key to work');
}
if (!opt.placeid) {
Ti.API.error('Geo: Missing required parameter "placeid"');
if (_.isFunction(opt.error)) opt.error();
return;
}
var data = _.pick(opt, 'placeid', 'extensions', 'language');
_.extend(data, {
key: key
});
HTTP.send({
url: 'https://maps.googleapis.com/maps/api/place/details/json',
data: data,
silent: opt.silent,
ttl: opt.ttl,
format: 'json',
success: function(res) {
if (res.status !== 'OK' || _.isEmpty(res.result)) {
if (_.isFunction(opt.error)) opt.error();
return;
}
opt.success(res.result);
},
error: opt.error
});
};
function deg2rad(deg) {
return deg * 0.017453;
}
function dist(a, b) {
return Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2)).toFixed(2);
}
/**
* Return the distance express in km between two points of the earth
*
* @param {Number} lat1 The latitude of first point
* @param {Number} lon1 The longitude of first point
* @param {Number} lat2 The latitude of second point
* @param {Number} lon2 The longitude of second point
* @return {Number} The distance expressed in km
*/
exports.distanceInKm = function(lat1, lon1, lat2, lon2) {
var dLat = deg2rad(lat2 - lat1) / 2;
var dLon = deg2rad(lon2 - lon1) / 2;
var a = Math.sin(dLat) * Math.sin(dLat) + Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.sin(dLon) * Math.sin(dLon);
return 12742 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
/**
* Process a set of markers and cluster them
* @param {Object} event The event raised from `regionchanged`.
* @param {Object} markers The markers **must be** an instance of `Backbone.Collection` or an Object id-indexed
* @param {Object} [keys] The keys of the object to get informations. Default: `{ latitude: 'lat', longitude: 'lng', id: 'id' }`
* @return {Array}
*/
exports.markerCluster = function(event, markers, keys) {
keys = _.defaults(keys || {}, {
latitude: 'lat',
longitude: 'lng',
id: 'id'
});
var pins = {};
var group = {};
var isBackbone = (markers instanceof Backbone.Collection);
// latR, lngR represents the current degrees visible
var latR = (event.source.size.height || Alloy.Globals.SCREEN_HEIGHT) / event.latitudeDelta;
var lngR = (event.source.size.width || Alloy.Globals.SCREEN_WIDTH) / event.longitudeDelta;
var degreeLat = 2 * exports.config.clusterPixelRadius / latR;
var degreeLng = 2 * exports.config.clusterPixelRadius / lngR;
var boundingBox = [event.latitude - event.latitudeDelta / 2 - degreeLat, event.longitude + event.longitudeDelta / 2 + degreeLng, event.latitude + event.latitudeDelta / 2 + degreeLat, event.longitude - event.longitudeDelta / 2 - degreeLng];
function removeOutOfBBFunction(m) {
var tmp_lat = parseFloat(isBackbone ? m.get(keys.latitude) : m[keys.latitude]);
var tmp_lng = parseFloat(isBackbone ? m.get(keys.longitude) : m[keys.longitude]);
if (tmp_lat < boundingBox[2] && tmp_lat > boundingBox[0] && tmp_lng > boundingBox[3] && tmp_lng < boundingBox[1]) {
pins[m[keys.id]] = {
latitude: tmp_lat,
longitude: tmp_lng
};
}
}
function createCObjFunction(m) {
var tmp_lat = parseFloat(isBackbone ? m.get(keys.latitude) : m[keys.latitude]);
var tmp_lng = parseFloat(isBackbone ? m.get(keys.longitude) : m[keys.longitude]);
pins[m[keys.id]] = {
latitude: tmp_lat,
longitude: tmp_lng
};
}
// Start clustering
if (isBackbone) {
markers.each(exports.config.clusterRemoveOutOfBB ? removeOutOfBBFunction : createCObjFunction);
} else {
_.each(markers, exports.config.clusterRemoveOutOfBB ? removeOutOfBBFunction : createCObjFunction);
}
// Cycle over all markers, and group in {g} all nearest markers by {id}
var zoomToCluster = (event.longitudeDelta > exports.config.clusterMaxDelta);
_.each(pins, function(a, id) {
_.each(pins, function(b, jd) {
if (id == jd || zoomToCluster === false) return;
if (a == null || b == null) return;
var d = dist(lngR * Math.abs(+a.latitude - b.latitude), lngR * Math.abs(+a.longitude - b.longitude));
if (d < exports.config.clusterPixelRadius) {
group[id] = group[id] || [id];
group[id].push(jd);
delete pins[id];
delete pins[jd];
}
});
});
// cycle all over pin and calculate the average of group pin
_.each(group, function(g, id) {
var gpin = {
latitude: 0.0,
longitude: 0.0,
count: _.keys(g).length
};
var _markers = [];
_.each(g, function(gid) {
var latitude = parseFloat(isBackbone ? markers.get(gid).get(keys.latitude) : markers[gid][keys.latitude]);
var longitude = parseFloat(isBackbone ? markers.get(gid).get(keys.longitude) : markers[gid][keys.longitude]);
gpin.latitude += latitude;
gpin.longitude += longitude;
if (exports.config.clusterRegionBounds) {
_markers.push({
latitude: latitude,
longitude: longitude
});
}
});
gpin.latitude = gpin.latitude / gpin.count;
gpin.longitude = gpin.longitude / gpin.count;
if (exports.config.clusterRegionBounds) {
gpin.clusterBounds = exports.getRegionBounds(_markers);
}
pins["g" + id] = gpin;
});
group = null;
// Set all annotations
return _.map(pins, function(pin, id) {
if (pin.count > 1) {
return {
latitude: parseFloat(pin.latitude.toFixed(2)),
longitude: parseFloat(pin.longitude.toFixed(2)),
count: pin.count,
clusterBounds: pin.clusterBounds
};
} else {
// Ensure ID is a number
return id << 0;
}
});
};
/**
* Check if the Google Play Services are installed and updated,
* otherwise Maps doesn't work and the app crashes.
*
* It the check fail, an error is displayed that redirect to the Play Store,
* and the app is terminated.
*
* On iOS, simply return `true`
*
* @return {Boolean}
*/
exports.checkForDependencies = function() {
if (OS_IOS) return false;
var TiMap = require('ti.map');
var rc = TiMap.isGooglePlayServicesAvailable();
if (rc === TiMap.SUCCESS) {
return true;
}
var errorMessage = null;
switch (rc) {
case TiMap.SERVICE_MISSING:
errorMessage = L('googleplayservices_missing', 'Google Play services is missing. Please install Google Play services from the Google Play store in order to use the application.');
break;
case TiMap.SERVICE_VERSION_UPDATE_REQUIRED:
errorMessage = L('googleplayservices_outofdate', 'Google Play services is out of date. Please update Google Play services in order to use the application.');
break;
case TiMap.SERVICE_DISABLED:
errorMessage = L('googleplayservices_disabled', 'Google Play services is disabled. Please enable Google Play services in order to use the application.');
break;
case TiMap.SERVICE_INVALID:
errorMessage = L('googleplayservices_invalid', 'Google Play services cannot be authenticated. Reinstall Google Play services in order to use the application.');
break;
default:
errorMessage = L('googleplayservices_error', 'Google Play services generated an unknown error. Reinstall Google Play services in order to use the application.');
break;
}
Util.errorAlert(errorMessage, function() {
Ti.Platform.openURL('https://play.google.com/store/apps/details?id=com.google.android.gms');
Ti.Android.currentActivity.finish();
});
};
/**
* Get the minimum MapRegion to include all annotations in array
* @param {Object} array An array of annotations
* @param {Number} mulGap Gap multiplier
* @return {Map.MapRegionType}
*/
exports.getRegionBounds = function(array, mulGap) {
mulGap = mulGap || 1.4;
var lats = _.pluck(array, 'latitude');
var lngs = _.pluck(array, 'longitude');
var bb = [_.min(lats), _.min(lngs), _.max(lats), _.max(lngs)];
return {
latitude: (bb[0] + bb[2]) / 2,
longitude: (bb[1] + bb[3]) / 2,
latitudeDelta: mulGap * (bb[2] - bb[0]),
longitudeDelta: mulGap * (bb[3] - bb[1])
};
};
/**
* Check if the the app doesn't know if it can use location services. This is the default state.
* @return {Boolean}
*/
exports.isAuthorizationUnknown = function() {
if (OS_IOS) return Ti.Geolocation.locationServicesAuthorization === Ti.Geolocation.AUTHORIZATION_UNKNOWN;
return !Ti.Geolocation.hasLocationPermissions();
};
/**
* Check if the location services are enabled and the app is authorized to use them.
* @return {Boolean}
*/
exports.isAuthorized = function(inBackground) {
if (OS_IOS) {
if (inBackground) {
return Ti.Geolocation.hasLocationPermissions(Ti.Geolocation.AUTHORIZATION_ALWAYS);
} else {
return Ti.Geolocation.hasLocationPermissions(Ti.Geolocation.AUTHORIZATION_WHEN_IN_USE) || Ti.Geolocation.hasLocationPermissions(Ti.Geolocation.AUTHORIZATION_ALWAYS);
}
} else {
return Ti.Geolocation.hasLocationPermissions();
}
};
/**
* On iOS, check if the the app is denied from using the location services and must be notified to the user.
* Returns false for every other platform.
* @return {Boolean}
*/
exports.isDenied = function() {
if (OS_IOS) return Ti.Geolocation.locationServicesAuthorization === Ti.Geolocation.AUTHORIZATION_DENIED;
return false;
};
//////////
// Init //
//////////
Ti.Geolocation.accuracy = Ti.Geolocation[exports.config.gpsAccuracy];