UNPKG

landmark-serve

Version:

Web Application Framework and Admin GUI / Content Management System built on Express.js and Mongoose

544 lines (425 loc) 13 kB
/*! * Module dependencies. */ var _ = require('underscore'), landmark = require('../../'), querystring = require('querystring'), https = require('https'), util = require('util'), utils = require('landmark-utils'), super_ = require('../field'); var RADIUS_KM = 6371, RADIUS_MILES = 3959; /** * Location FieldType Constructor * @extends Field * @api public */ function location(list, path, options) { this._underscoreMethods = ['format', 'googleLookup', 'kmFrom', 'milesFrom']; this.enableMapsAPI = landmark.get('google api key') ? true : false; if (!options.defaults) { options.defaults = {}; } if (options.required) { if (Array.isArray(options.required)) { // required can be specified as an array of paths this.requiredPaths = options.required; } else if ('string' === typeof options.required) { // or it can be specified as a comma-delimited list this.requiredPaths = options.required.replace(/,/g, ' ').split(/\s+/); } // options.required should always be simplified to a boolean options.required = true; } // default this.requiredPaths if (!this.requiredPaths) { this.requiredPaths = ['street1', 'suburb']; } location.super_.call(this, list, path, options); } /*! * Inherit from Field */ util.inherits(location, super_); /** * Registers the field on the List's Mongoose Schema. * * @api public */ location.prototype.addToSchema = function() { var field = this, schema = this.list.schema, options = this.options; var paths = this.paths = { number: this._path.append('.number'), name: this._path.append('.name'), street1: this._path.append('.street1'), street2: this._path.append('.street2'), suburb: this._path.append('.suburb'), state: this._path.append('.state'), postcode: this._path.append('.postcode'), country: this._path.append('.country'), geo: this._path.append('.geo'), geo_lat: this._path.append('.geo_lat'), geo_lng: this._path.append('.geo_lng'), serialised: this._path.append('.serialised'), improve: this._path.append('_improve'), overwrite: this._path.append('_improve_overwrite') }; var getFieldDef = function(type, key) { var def = { type: type }; if (options.defaults[key]) { def.default = options.defaults[key]; } return def; }; schema.nested[this.path] = true; schema.add({ number: getFieldDef(String, 'number'), name: getFieldDef(String, 'name'), street1: getFieldDef(String, 'street1'), street2: getFieldDef(String, 'street2'), street3: getFieldDef(String, 'street3'), suburb: getFieldDef(String, 'suburb'), state: getFieldDef(String, 'state'), postcode: getFieldDef(String, 'postcode'), country: getFieldDef(String, 'country'), geo: { type: [Number], index: '2dsphere' } }, this.path + '.'); schema.virtual(paths.serialised).get(function() { return _.compact([ this.get(paths.number), this.get(paths.name), this.get(paths.street1), this.get(paths.street2), this.get(paths.suburb), this.get(paths.state), this.get(paths.postcode), this.get(paths.country) ]).join(', '); }); // pre-save hook to fix blank geo fields // see http://stackoverflow.com/questions/16388836/does-applying-a-2dsphere-index-on-a-mongoose-schema-force-the-location-field-to schema.pre('save', function(next) { var obj = field._path.get(this); if (Array.isArray(obj.geo) && (obj.geo.length !== 2 || (obj.geo[0] === null && obj.geo[1] === null))) { obj.geo = undefined; } next(); }); this.bindUnderscoreMethods(); }; /** * Formats a list of the values stored by the field. Only paths that * have values will be included. * * Optionally provide a space-separated list of values to include. * * Delimiter defaults to `', '`. * * @api public */ location.prototype.format = function(item, values, delimiter) { if (!values) { return item.get(this.paths.serialised); } var paths = this.paths; values = values.split(' ').map(function(i) { return item.get(paths[i]); }); return _.compact(values).join(delimiter || ', '); }; /** * Detects whether the field has been modified * * @api public */ location.prototype.isModified = function(item) { return item.isModified(this.paths.number) || item.isModified(this.paths.name) || item.isModified(this.paths.street1) || item.isModified(this.paths.street2) || item.isModified(this.paths.suburb) || item.isModified(this.paths.state) || item.isModified(this.paths.postcode) || item.isModified(this.paths.country) || item.isModified(this.paths.geo); }; /** * Validates that a value for this field has been provided in a data object * * options.required specifies an array or space-delimited list of paths that * are required (defaults to street1, suburb) * * @api public */ location.prototype.validateInput = function(data, required, item) { if (!required) { return true; } var paths = this.paths, nested = this._path.get(data), values = nested || data, valid = true; this.requiredPaths.forEach(function(path) { if (nested) { if (!(path in values) && item && item.get(paths[path])) { return; } if (!values[path]) { valid = false; } } else { if (!(paths[path] in values) && item && item.get(paths[path])) { return; } if (!values[paths[path]]) { valid = false; } } }); return valid; }; /** * Updates the value for this field in the item from a data object * * @api public */ location.prototype.updateItem = function(item, data) { var paths = this.paths, fieldKeys = ['number', 'name', 'street1', 'street2', 'suburb', 'state', 'postcode', 'country'], geoKeys = ['geo', 'geo_lat', 'geo_lng'], valueKeys = fieldKeys.concat(geoKeys), valuePaths = valueKeys, values = this._path.get(data); if (!values) { // Handle flattened values valuePaths = valueKeys.map(function(i) { return paths[i]; }); values = _.pick(data, valuePaths); } // convert valuePaths to a map for easier usage valuePaths = _.object(valueKeys, valuePaths); var setValue = function(key) { if (valuePaths[key] in values && values[valuePaths[key]] !== item.get(paths[key])) { item.set(paths[key], values[valuePaths[key]] || null); } }; _.each(fieldKeys, setValue); if (valuePaths.geo in values) { var oldGeo = item.get(paths.geo) || [], newGeo = values[valuePaths.geo]; if (!Array.isArray(newGeo) || newGeo.length !== 2) { newGeo = []; } if (newGeo[0] !== oldGeo[0] || newGeo[1] !== oldGeo[1]) { item.set(paths.geo, newGeo); } } else if (valuePaths.geo_lat in values && valuePaths.geo_lng in values) { var lat = utils.number(values[valuePaths.geo_lat]), lng = utils.number(values[valuePaths.geo_lng]); item.set(paths.geo, (lat && lng) ? [lng, lat] : undefined); } }; /** * Returns a callback that handles a standard form submission for the field * * Handles: * - `field.paths.improve` in `req.body` - improves data via `.googleLookup()` * - `field.paths.overwrite` in `req.body` - in conjunction with `improve`, overwrites existing data * * @api public */ location.prototype.getRequestHandler = function(item, req, paths, callback) { var field = this; if (utils.isFunction(paths)) { callback = paths; paths = field.paths; } else if (!paths) { paths = field.paths; } callback = callback || function() {}; return function() { var update = req.body[paths.overwrite] ? 'overwrite' : true; if (req.body && req.body[paths.improve]) { field.googleLookup(item, false, update, function() { callback(); }); } else { callback(); } }; }; /** * Immediately handles a standard form submission for the field (see `getRequestHandler()`) * * @api public */ location.prototype.handleRequest = function(item, req, paths, callback) { this.getRequestHandler(item, req, paths, callback)(); }; /** * Internal Google geocode request method * * @api private */ function doGoogleGeocodeRequest(address, region, callback) { // https://developers.google.com/maps/documentation/geocoding/ // Use of the Google Geocoding API is subject to a query limit of 2,500 geolocation requests per day, except with an enterprise license. // Note: the Geocoding API may only be used in conjunction with a Google map; geocoding results without displaying them on a map is prohibited. // Please make sure your Landmark app complies with the Google Maps API License. var options = { sensor: false, language: 'en', address: address }; if (arguments.length === 2 && _.isFunction(region)) { callback = region; region = null; } if (region) { options.region = region; } var endpoint = 'https://maps.googleapis.com/maps/api/geocode/json?' + querystring.stringify(options); https.get(endpoint, function(res) { var data = []; res.on('data', function(chunk) { data.push(chunk); }) .on('end', function() { var dataBuff = data.join('').trim(); var result; try { result = JSON.parse(dataBuff); } catch (exp) { result = {'status_code': 500, 'status_text': 'JSON Parse Failed', 'status': 'UNKNOWN_ERROR'}; } callback(null, result); }); }) .on('error', function(err) { callback(err); }); } /** * Autodetect the full address and lat, lng from the stored value. * * Uses Google's Maps API and may only be used in conjunction with a Google map. * Geocoding results without displaying them on a map is prohibited. * Please make sure your Landmark app complies with the Google Maps API License. * * Internal status codes mimic the Google API status codes. * * @api private */ location.prototype.googleLookup = function(item, region, update, callback) { if (_.isFunction(update)) { callback = update; update = false; } var field = this, stored = item.get(this.path), address = item.get(this.paths.serialised); if (address.length === 0) { return callback({'status_code': 500, 'status_text': 'No address to geocode', 'status': 'NO_ADDRESS'}); } doGoogleGeocodeRequest(address, region || landmark.get('default region'), function(err, geocode){ if (err || geocode.status !== 'OK') { return callback(err); } // use the first result // if there were no results in the array, status would be ZERO_RESULTS var result = geocode.results[0]; // parse the address components into a location object var location = {}; _.each(result.address_components, function(val){ if ( _.indexOf(val.types,'street_number') >= 0 ) { location.street1 = location.street1 || []; location.street1.push(val.long_name); } if ( _.indexOf(val.types,'route') >= 0 ) { location.street1 = location.street1 || []; location.street1.push(val.short_name); } // in some cases, you get suburb, city as locality - so only use the first if ( _.indexOf(val.types,'locality') >= 0 && !location.suburb) { location.suburb = val.long_name; } if ( _.indexOf(val.types,'administrative_area_level_1') >= 0 ) { location.state = val.short_name; } if ( _.indexOf(val.types,'country') >= 0 ) { location.country = val.long_name; } if ( _.indexOf(val.types,'postal_code') >= 0 ) { location.postcode = val.short_name; } }); if (Array.isArray(location.street1)) { location.street1 = location.street1.join(' '); } location.geo = [ result.geometry.location.lng, result.geometry.location.lat ]; //console.log('------ Google Geocode Results ------'); //console.log(address); //console.log(result); //console.log(location); if (update === 'overwrite') { item.set(field.path, location); } else if (update) { _.each(location, function(value, key) { if (key === 'geo') { return; } if (!stored[key]) { item.set(field.paths[key], value); } }); if (!Array.isArray(stored.geo) || !stored.geo[0] || !stored.geo[1]) { item.set(field.paths.geo, location.geo); } } callback(null, location, result); }); }; /** * Internal Distance calculation function * * See http://en.wikipedia.org/wiki/Haversine_formula * * @api private */ function calculateDistance(point1, point2) { var dLng = (point2[0] - point1[0]) * Math.PI / 180; var dLat = (point2[1] - point1[1]) * Math.PI / 180; var lat1 = (point1[1]) * Math.PI / 180; var lat2 = (point2[1]) * Math.PI / 180; var a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.sin(dLng/2) * Math.sin(dLng/2) * Math.cos(lat1) * Math.cos(lat2); var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); return c; } /** * Returns the distance from a [lat, lng] point in kilometres * * @api public */ location.prototype.kmFrom = function(item, point) { return calculateDistance(this.get(this.paths.geo), point) * RADIUS_KM; }; /** * Returns the distance from a [lat, lng] point in miles * * @api public */ location.prototype.milesFrom = function(item, point) { return calculateDistance(this.get(this.paths.geo), point) * RADIUS_MILES; }; /*! * Export class */ exports = module.exports = location;