@windingtree/wt-search-api
Version:
NodeJS app that enables quick search over data from Winding Tree platform
169 lines (158 loc) • 4.82 kB
JavaScript
const { db } = require('../../../config');
const Location = require('../../../db/indexed/models/location');
function _toRadians (degrees) {
return degrees * Math.PI / 180;
}
const LATITUDE_DEGREE_LENGTH = 111; // Approximately, in kilometers.
const LONGITUDE_DEGREE_LENGTH_EQUATOR = 111.321;
/**
* Approximately compute how many degrees in each direction does the
* given distance go.
*
* @param {Number} lat in degrees
* @param {Number} lng in degrees
* @param {Number} distance in kilometers
* @return {Object}
*
*/
function _convertKilometersToDegrees (lat, lng, distance) {
// We are invariant wrt. hemispheres.
lat = Math.abs(lat);
lng = Math.abs(lng);
// The distance between longitude degrees decreases with the distance from equator.
const scale = Math.cos(_toRadians(lat)),
longitudeDegreeLength = scale * LONGITUDE_DEGREE_LENGTH_EQUATOR;
return {
lat: distance / LATITUDE_DEGREE_LENGTH,
lng: distance / longitudeDegreeLength,
};
}
/**
* Get a filtering function that can be further used by the knex
* query builder.
*
* Note: for simplicity and portability, the distance is only
* approximate at the moment:
*
* - we assume a locally flat surface
* - we approximate the circle radius with its square bounding box
*
* @param {Number} lat in degrees
* @param {Number} lng in degrees
* @param {Number} distance in kilometers
* @return {Function}
*
*/
function _getFilter (lat, lng, distance) {
const distances = _convertKilometersToDegrees(lat, lng, distance);
return {
table: Location.TABLE,
condition: function () {
this.where(function () {
const max = lat + distances.lat,
min = lat - distances.lat;
this.where(`${Location.TABLE}.lat`, '<=', max).andWhere(`${Location.TABLE}.lat`, '>=', min);
}).andWhere(function () {
const max = lng + distances.lng,
min = lng - distances.lng;
this.where(`${Location.TABLE}.lng`, '<=', max).andWhere(`${Location.TABLE}.lng`, '>=', min);
});
},
};
};
/**
* Return the expression for ordering by (approximate) distance
* from the given point.
*
* NOTE: In order to retain portability across SQL backends, we
* do not rely on any native geospatial functions of the
* database. As a result, however, the ordering does not use
* indices.
*
* @param {Number} lat in degrees
* @param {Number} lng in degrees
* @return {Object}
*
*/
function _getSorting (lat, lng) {
const scale = Math.cos(_toRadians(Math.abs(lat))),
scaleSquared = Math.pow(scale, 2);
return {
name: 'distance',
table: Location.TABLE,
columnName: 'location_distance',
// Order by the euclidean distance between two points (we
// approximate by a locally flat surface).
//
// The `lng` delta is scaled due to longitude degrees
// having unequal spacing depending on the distance from
// the equator.
select: db.raw(`${LATITUDE_DEGREE_LENGTH} * ${LATITUDE_DEGREE_LENGTH} * ` +
`(${Location.TABLE}.lat - ${lat}) * (${Location.TABLE}.lat - ${lat}) + ` +
`${LONGITUDE_DEGREE_LENGTH_EQUATOR} * ${LONGITUDE_DEGREE_LENGTH_EQUATOR} * ` +
`${scaleSquared} * (${Location.TABLE}.lng - ${lng}) * (${Location.TABLE}.lng - ${lng}) ` +
'as location_distance'),
// Convert sorting criterium (location_distance) to
// something comprehensible, in this case to distance in
// kilometers.
computeScore: (sortingCriterium) => {
return Math.sqrt(sortingCriterium);
},
};
};
/**
* Extract filtering conditions from query.
*
* @param {Object} query
* @return {Array|undefined}
*
*/
function getFiltering (query) {
if (!query.filters) {
return undefined;
}
const filters = query.filters.filter((f) => f.type === 'location');
if (!filters.length) {
return undefined;
}
return filters.map((filter) => {
const cond = filter.condition;
return _getFilter(cond.lat, cond.lng, cond.distance);
});
}
/**
* Extract sorting condition from query.
*
* @param {Object} query
* @return {Object|undefined}
*
*/
function getSorting (query) {
if (!query.sorting || query.sorting.type !== 'distance') {
return undefined;
}
return _getSorting(query.sorting.data.lat, query.sorting.data.lng);
}
/**
* Index / deindex a single hotel (based on its current data).
*
* @param {Object} hotel as returned from Hotel.getHotelData.
* @return {Promise<void>}
*
*/
async function indexHotel (hotel) {
const coords = hotel.data.description && hotel.data.description.location;
if (coords) {
await Location.upsert(hotel.address, coords.latitude, coords.longitude);
} else {
await Location.delete(hotel.address);
}
}
module.exports = {
_convertKilometersToDegrees,
_getFilter,
_getSorting,
getFiltering,
getSorting,
indexHotel,
};