const _ = require('lodash'); const logger = require('pelias-logger').get('api'); const Document = require('pelias-model').Document; const geolib = require('geolib'); const Debug = require('../helper/debug'); const debugLog = new Debug('controller:placeholder'); // composition of toNumber and isFinite, useful for single call to convert a value // to a number, then checking to see if it's finite function isFiniteNumber(value) { return !_.isEmpty(_.trim(value)) && _.isFinite(_.toNumber(value)); } // returns true if value is parseable as finite non-negative number function isNonNegativeFiniteNumber(value) { return isFiniteNumber(value) && _.gte(value, 0); } function hasLatLon(result) { return _.isFinite(_.get(result.geom, 'lat')) && _.isFinite(_.get(result.geom, 'lon')); } function getLatLon(result) { return { latitude: result.geom.lat, longitude: result.geom.lon }; } // if geom.lat/lon are parseable as finite numbers, convert to a finite number // otherwise remove the field function numberifyGeomLatLon(result) { ['lat', 'lon'].forEach((f) => { if (isFiniteNumber(_.get(result.geom, f))) { result.geom[f] = _.toFinite(result.geom[f]); } else { // result.geom may not exist, so use unset instead of delete _.unset(result.geom, f); } }); return result; } // returns true if all 4 ,-delimited (max) substrings are parseable as finite numbers // '12.12,21.21,13.13,31.31' returns true // '12.12,21.21,13.13,31.31,14.14' returns false // '12.12,21.21,13.13,blah' returns false // '12.12,21.21,13.13,31.31,blah' returns false // '12.12,NaN,13.13,31.31' returns false // '12.12,Infinity,13.13,31.31' returns false function is4CommaDelimitedNumbers(bbox) { return _.defaultTo(bbox, ''). split(','). filter(isFiniteNumber).length === 4; } function hasName(result) { return !_.isEmpty(_.trim(result.name)); } // filter that passes only results that match on requested layers function getLayersFilter(clean) { // passes everything if: // - req.clean.layers is empty // - req.clean.parsed_text.street is available if (_.isEmpty(_.get(clean, 'layers', [])) || _.has(clean, ['parsed_text', 'street'])) { return () => true; } // otherwise return a function that checks for set inclusion of a result placetype return (result) => _.includes(clean.layers, result.placetype); } // return true if the hierarchy does not have a country.abbr // OR hierarchy country.abbr matches boundary.country function matchesBoundaryCountry(boundaryCountry, hierarchy) { return !boundaryCountry || _.get(hierarchy, 'country.abbr') === boundaryCountry; } // return true if the result does not have a lineage // OR at least one lineage matches the requested boundary.country function atLeastOneLineageMatchesBoundaryCountry(boundaryCountry, result) { return !result.lineage || result.lineage.some(_.partial(matchesBoundaryCountry, boundaryCountry)); } // return a function that detects if a result has at least one lineage in boundary.country function getBoundaryCountryFilter(clean, do_geometric_filters_apply) { if ( do_geometric_filters_apply && _.has(clean, 'boundary.country') ) { return _.partial(atLeastOneLineageMatchesBoundaryCountry, clean['boundary.country']); } // there's no boundary.country filter, so return a function that always returns true return () => true; } // return a function that detects if a result is inside a bbox if a bbox is available function getBoundaryRectangleFilter(clean, do_geometric_filters_apply) { // check to see if boundary.rect.min_lat/min_lon/max_lat/max_lon are all available if (do_geometric_filters_apply && ['min_lat', 'min_lon', 'max_lat', 'max_lon'].every((f) => { return _.has(clean, `boundary.rect.${f}`); })) { const polygon = [ { latitude: clean['boundary.rect.min_lat'], longitude: clean['boundary.rect.min_lon'] }, { latitude: clean['boundary.rect.max_lat'], longitude: clean['boundary.rect.min_lon'] }, { latitude: clean['boundary.rect.max_lat'], longitude: clean['boundary.rect.max_lon'] }, { latitude: clean['boundary.rect.min_lat'], longitude: clean['boundary.rect.max_lon'] } ]; // isPointInside takes polygon last, so create a function that has it pre-populated const isPointInsidePolygon = _.partialRight(geolib.isPointInside, polygon); return _.partial(isInsideGeometry, isPointInsidePolygon); } // there's no bbox filter, so return a function that always returns true return () => true; } // return a function that detects if a result is inside a circle if a circle is available function getBoundaryCircleFilter(clean, do_geometric_filters_apply) { // check to see if boundary.circle.lat/lon/radius are all available if (do_geometric_filters_apply && ['lat', 'lon', 'radius'].every((f) => { return _.has(clean, `boundary.circle.${f}`); })) { const center = { latitude: clean['boundary.circle.lat'], longitude: clean['boundary.circle.lon'] }; const radiusInMeters = clean['boundary.circle.radius'] * 1000; // isPointInCircle takes circle/radius last, so create a function that has them pre-populated const isPointInCircle = _.partialRight(geolib.isPointInCircle, center, radiusInMeters); return _.partial(isInsideGeometry, isPointInCircle); } // there's no circle filter, so return a function that always returns true return () => true; } // helper that calls an "is inside some geometry" function function isInsideGeometry(f, result) { return hasLatLon(result) ? f(getLatLon(result)) : false; } // returns true if hierarchyElement has both name and id function placetypeHasNameAndId(hierarchyElement) { return !_.isEmpty(_.trim(hierarchyElement.name)) && !_.isEmpty(_.trim(hierarchyElement.id)); } // synthesize an ES doc from a placeholder result function synthesizeDocs(boundaryCountry, result) { const doc = new Document('whosonfirst', result.placetype, result.id.toString()); doc.setName('default', result.name); // only assign centroid if both lat and lon are finite numbers if (hasLatLon(result)) { doc.setCentroid( { lat: result.geom.lat, lon: result.geom.lon } ); } else { logger.error(`could not parse centroid for id ${result.id}`); } // _.conformsTo verifies that an object property has a certain format if (_.conformsTo(result.geom, { 'bbox': is4CommaDelimitedNumbers } )) { const parsedBoundingBox = result.geom.bbox.split(',').map(_.toFinite); doc.setBoundingBox({ upperLeft: { lat: parsedBoundingBox[3], lon: parsedBoundingBox[0] }, lowerRight: { lat: parsedBoundingBox[1], lon: parsedBoundingBox[2] } }); } else { logger.error(`could not parse bbox for id ${result.id}: ${_.get(result, 'geom.bbox')}`); } // set population and popularity if parseable as finite number if (isNonNegativeFiniteNumber(result.population)) { doc.setPopulation(_.toFinite(result.population)); } if (isNonNegativeFiniteNumber(result.popularity)) { doc.setPopularity(_.toFinite(result.popularity)); } _.defaultTo(result.lineage, []) // remove all lineages that don't match an explicit boundary.country .filter(_.partial(matchesBoundaryCountry, boundaryCountry)) // add all the lineages to the doc .map((hierarchy) => { Object.keys(hierarchy) .filter(doc.isSupportedParent) .filter((placetype) => { return placetypeHasNameAndId(hierarchy[placetype]); }) .forEach((placetype) => { doc.addParent( placetype, hierarchy[placetype].name, hierarchy[placetype].id.toString(), hierarchy[placetype].abbr); }); }); return buildESDoc(doc); } function buildESDoc(doc) { const esDoc = doc.toESDocument(); return _.extend(esDoc.data, { _id: esDoc._id, _type: esDoc._type }); } function setup(placeholderService, do_geometric_filters_apply, should_execute) { function controller( req, res, next ){ // bail early if req/res don't pass conditions for execution if (!should_execute(req, res)) { return next(); } const initialTime = debugLog.beginTimer(req); const start = Date.now(); placeholderService(req, (err, results) => { logger.info('placeholder', { response_time: Date.now() - start, params: req.clean, result_count: _.defaultTo(res.data, []).length, text_length: _.get(req, 'clean.text.length', 0), controller: 'placeholder', }); if (err) { // push err.message or err onto req.errors req.errors.push( _.get(err, 'message', err)); } else { const boundaryCountry = do_geometric_filters_apply ? _.get(req, ['clean', 'boundary.country']) : undefined; // convert results to ES docs // boundary.country filter must happen after synthesis since multiple // lineages may produce different country docs res.meta = { query_type: 'search_fallback' }; res.data = results // filter out results that don't have a name .filter(hasName) // filter out results that don't match on requested layer(s) .filter(getLayersFilter(req.clean)) // filter out results that don't match on any lineage country .filter(getBoundaryCountryFilter(req.clean, do_geometric_filters_apply)) // clean up geom.lat/lon for boundary rect/circle checks .map(numberifyGeomLatLon) // filter out results that aren't in the boundary.rect .filter(getBoundaryRectangleFilter(req.clean, do_geometric_filters_apply)) // filter out results that aren't in the boundary.circle .filter(getBoundaryCircleFilter(req.clean, do_geometric_filters_apply)) // convert results to ES docs .map(_.partial(synthesizeDocs, boundaryCountry)); const messageParts = [ '[controller:placeholder]', `[result_count:${_.defaultTo(res.data, []).length}]` ]; logger.debug(messageParts.join(' ')); debugLog.push(req, messageParts[1].slice(1,-1)); debugLog.push(req, res.data); } debugLog.stopTimer(req, initialTime); return next(); }); } return controller; } module.exports = setup;