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 | ;
|
7 |
|
8 | const assert = require('assert');
|
9 |
|
10 | /*!
|
11 | * Get a near filter from a given where object. For connector use only.
|
12 | */
|
13 |
|
14 | exports.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 |
|
70 | exports.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 |
|
121 | exports.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 |
|
164 | function 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 |
|
226 | GeoPoint.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 |
|
267 | GeoPoint.prototype.distanceTo = function(point, options) {
|
268 | return GeoPoint.distanceBetween(this, point, options);
|
269 | };
|
270 |
|
271 | /**
|
272 | * Simple serialization.
|
273 | */
|
274 |
|
275 | GeoPoint.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
|
286 | const DEG2RAD = 0.01745329252;
|
287 |
|
288 | // factor to convert radians degrees to degrees
|
289 | const RAD2DEG = 57.29577951308;
|
290 |
|
291 | // radius of the earth
|
292 | const 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 |
|
301 | function 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 | }
|