UNPKG

9.52 kBJavaScriptView Raw
1// Copyright IBM Corp. 2013,2019. All Rights Reserved.
2// Node module: loopback-datasource-juggler
3// This file is licensed under the MIT License.
4// License text available at https://opensource.org/licenses/MIT
5
6'use strict';
7
8const assert = require('assert');
9
10/*!
11 * Get a near filter from a given where object. For connector use only.
12 */
13
14exports.nearFilter = function nearFilter(where) {
15 const nearResults = [];
16 nearSearch(where);
17 return (!nearResults.length ? false : nearResults);
18
19 function nearSearch(clause, parentKeys) {
20 if (typeof clause !== 'object') {
21 return false;
22 }
23 parentKeys = parentKeys || [];
24
25 Object.keys(clause).forEach(function(clauseKey) {
26 if (typeof clause[clauseKey] !== 'object' || !clause[clauseKey]) return;
27 if (Array.isArray(clause[clauseKey])) {
28 clause[clauseKey].forEach(function(el, index) {
29 const ret = nearSearch(el, parentKeys.concat(clauseKey).concat(index));
30 if (ret) return ret;
31 });
32 } else {
33 if (clause[clauseKey].hasOwnProperty('near')) {
34 const result = clause[clauseKey];
35 nearResults.push({
36 near: result.near,
37 maxDistance: result.maxDistance,
38 minDistance: result.minDistance,
39 unit: result.unit,
40 // If key is at root, define a single string, otherwise append it to the full path array
41 mongoKey: parentKeys.length ? parentKeys.concat(clauseKey) : clauseKey,
42 key: clauseKey,
43 });
44 }
45 }
46 });
47 }
48};
49
50/*!
51 * Filter a set of results using the given filters returned by `nearFilter()`.
52 * Can support multiple locations, but will include results from all of them.
53 *
54 * WARNING: "or" operator with GeoPoint does not work as expected, eg:
55 * {where: {or: [{location: {near: (29,-90)}},{name:'Sean'}]}}
56 * Will actually work as if you had used "and". This is because geo filtering
57 * takes place outside of the SQL query, so the result set of "name = Sean" is
58 * returned by the database, and then the location filtering happens in the app
59 * logic. So the "near" operator is always an "and" of the SQL filters, and "or"
60 * of other GeoPoint filters.
61 *
62 * Additionally, since this step occurs after the SQL result set is returned,
63 * if using GeoPoints with pagination the result set may be smaller than the
64 * page size. The page size is enforced at the DB level, and then we may
65 * remove results at the Geo-app level. If we "limit: 25", but 4 of those results
66 * do not have a matching geopoint field, the request will only return 21 results.
67 * This may make it erroneously look like a given page is the end of the result set.
68 */
69
70exports.filter = function(rawResults, filters) {
71 const distances = {};
72 const results = [];
73
74 filters.forEach(function(filter) {
75 const origin = filter.near;
76 const max = filter.maxDistance > 0 ? filter.maxDistance : false;
77 const min = filter.minDistance > 0 ? filter.minDistance : false;
78 const unit = filter.unit;
79 const key = filter.key;
80
81 // create distance index
82 rawResults.forEach(function(result) {
83 let loc = result[key];
84
85 // filter out results without locations
86 if (!loc) return;
87
88 if (!(loc instanceof GeoPoint)) loc = GeoPoint(loc);
89
90 if (typeof loc.lat !== 'number') return;
91 if (typeof loc.lng !== 'number') return;
92
93 const d = GeoPoint.distanceBetween(origin, loc, {type: unit});
94
95 // filter result if distance is either < minDistance or > maxDistance
96 if ((min && d < min) || (max && d > max)) return;
97
98 distances[result.id] = d;
99 results.push(result);
100 });
101
102 results.sort(function(resA, resB) {
103 const a = resA[key];
104 const b = resB[key];
105
106 if (a && b) {
107 const da = distances[resA.id];
108 const db = distances[resB.id];
109
110 if (db === da) return 0;
111 return da > db ? 1 : -1;
112 } else {
113 return 0;
114 }
115 });
116 });
117
118 return results;
119};
120
121exports.GeoPoint = GeoPoint;
122
123/**
124 * The GeoPoint object represents a physical location.
125 *
126 * For example:
127 *
128 * ```js
129 * var loopback = require(‘loopback’);
130 * var here = new loopback.GeoPoint({lat: 10.32424, lng: 5.84978});
131 * ```
132 *
133 * Embed a latitude / longitude point in a model.
134 *
135 * ```js
136 * var CoffeeShop = loopback.createModel('coffee-shop', {
137 * location: 'GeoPoint'
138 * });
139 * ```
140 *
141 * You can query LoopBack models with a GeoPoint property and an attached data source using geo-spatial filters and
142 * sorting. For example, the following code finds the three nearest coffee shops.
143 *
144 * ```js
145 * CoffeeShop.attachTo(oracle);
146 * var here = new GeoPoint({lat: 10.32424, lng: 5.84978});
147 * CoffeeShop.find( {where: {location: {near: here}}, limit:3}, function(err, nearbyShops) {
148 * console.info(nearbyShops); // [CoffeeShop, ...]
149 * });
150 * ```
151 * @class GeoPoint
152 * @property {Number} lat The latitude in degrees.
153 * @property {Number} lng The longitude in degrees.
154 *
155 * @options {Object} Options Object with two Number properties: lat and long.
156 * @property {Number} lat The latitude point in degrees. Range: -90 to 90.
157 * @property {Number} lng The longitude point in degrees. Range: -180 to 180.
158 *
159 * @options {Array} Options Array with two Number entries: [lat,long].
160 * @property {Number} lat The latitude point in degrees. Range: -90 to 90.
161 * @property {Number} lng The longitude point in degrees. Range: -180 to 180.
162 */
163
164function GeoPoint(data) {
165 if (!(this instanceof GeoPoint)) {
166 return new GeoPoint(data);
167 }
168
169 if (arguments.length === 2) {
170 data = {
171 lat: arguments[0],
172 lng: arguments[1],
173 };
174 }
175
176 assert(Array.isArray(data) || typeof data === 'object' || typeof data === 'string',
177 'must provide valid geo-coordinates array [lat, lng] or object or a "lat, lng" string');
178
179 if (typeof data === 'string') {
180 try {
181 data = JSON.parse(data);
182 } catch (err) {
183 data = data.split(/,\s*/);
184 assert(data.length === 2, 'must provide a string "lat,lng" creating a GeoPoint with a string');
185 }
186 }
187 if (Array.isArray(data)) {
188 data = {
189 lat: Number(data[0]),
190 lng: Number(data[1]),
191 };
192 } else {
193 data.lng = Number(data.lng);
194 data.lat = Number(data.lat);
195 }
196
197 assert(typeof data === 'object', 'must provide a lat and lng object when creating a GeoPoint');
198 assert(typeof data.lat === 'number' && !isNaN(data.lat), 'lat must be a number when creating a GeoPoint');
199 assert(typeof data.lng === 'number' && !isNaN(data.lng), 'lng must be a number when creating a GeoPoint');
200 assert(data.lng <= 180, 'lng must be <= 180');
201 assert(data.lng >= -180, 'lng must be >= -180');
202 assert(data.lat <= 90, 'lat must be <= 90');
203 assert(data.lat >= -90, 'lat must be >= -90');
204
205 this.lat = data.lat;
206 this.lng = data.lng;
207}
208
209/**
210 * Determine the spherical distance between two GeoPoints.
211 *
212 * @param {GeoPoint} pointA Point A
213 * @param {GeoPoint} pointB Point B
214 * @options {Object} options Options object with one key, 'type'. See below.
215 * @property {String} type Unit of measurement, one of:
216 *
217 * - `miles` (default)
218 * - `radians`
219 * - `kilometers`
220 * - `meters`
221 * - `miles`
222 * - `feet`
223 * - `degrees`
224 */
225
226GeoPoint.distanceBetween = function distanceBetween(a, b, options) {
227 if (!(a instanceof GeoPoint)) {
228 a = GeoPoint(a);
229 }
230 if (!(b instanceof GeoPoint)) {
231 b = GeoPoint(b);
232 }
233
234 const x1 = a.lat;
235 const y1 = a.lng;
236
237 const x2 = b.lat;
238 const y2 = b.lng;
239
240 return geoDistance(x1, y1, x2, y2, options);
241};
242
243/**
244 * Determine the spherical distance to the given point.
245 * Example:
246 * ```js
247 * var loopback = require(‘loopback’);
248 *
249 * var here = new loopback.GeoPoint({lat: 10, lng: 10});
250 * var there = new loopback.GeoPoint({lat: 5, lng: 5});
251 *
252 * loopback.GeoPoint.distanceBetween(here, there, {type: 'miles'}) // 438
253 * ```
254 * @param {Object} point GeoPoint object to which to measure distance.
255 * @options {Object} options Options object with one key, 'type'. See below.
256 * @property {String} type Unit of measurement, one of:
257 *
258 * - `miles` (default)
259 * - `radians`
260 * - `kilometers`
261 * - `meters`
262 * - `miles`
263 * - `feet`
264 * - `degrees`
265 */
266
267GeoPoint.prototype.distanceTo = function(point, options) {
268 return GeoPoint.distanceBetween(this, point, options);
269};
270
271/**
272 * Simple serialization.
273 */
274
275GeoPoint.prototype.toString = function() {
276 return this.lat + ',' + this.lng;
277};
278
279/**
280 * @property {Number} DEG2RAD - Factor to convert degrees to radians.
281 * @property {Number} RAD2DEG - Factor to convert radians to degrees.
282 * @property {Object} EARTH_RADIUS - Radius of the earth.
283*/
284
285// factor to convert degrees to radians
286const DEG2RAD = 0.01745329252;
287
288// factor to convert radians degrees to degrees
289const RAD2DEG = 57.29577951308;
290
291// radius of the earth
292const EARTH_RADIUS = {
293 kilometers: 6370.99056,
294 meters: 6370990.56,
295 miles: 3958.75,
296 feet: 20902200,
297 radians: 1,
298 degrees: RAD2DEG,
299};
300
301function geoDistance(x1, y1, x2, y2, options) {
302 const type = (options && options.type) || 'miles';
303
304 // Convert to radians
305 x1 = x1 * DEG2RAD;
306 y1 = y1 * DEG2RAD;
307 x2 = x2 * DEG2RAD;
308 y2 = y2 * DEG2RAD;
309
310 // use the haversine formula to calculate distance for any 2 points on a sphere.
311 // ref http://en.wikipedia.org/wiki/Haversine_formula
312 const haversine = function(a) {
313 return Math.pow(Math.sin(a / 2.0), 2);
314 };
315
316 const f = Math.sqrt(haversine(x2 - x1) + Math.cos(x2) * Math.cos(x1) * haversine(y2 - y1));
317
318 return 2 * Math.asin(f) * EARTH_RADIUS[type];
319}