import { GeoDistanceUnit , GEO_DISTANCE_UNITS , GeoPointInput , GeoShape , GeoPoint , GeoDistanceObj , GeoShapeType , ESGeoShapeType , GeoShapePoint , GeoShapePolygon , GeoShapeMultiPolygon , ESGeoShape , CoordinateTuple , GeoShapeRelation , GeoInput } from '@terascope/types' ; import bbox from '@turf/bbox' ; import bboxPolygon from '@turf/bbox-polygon' ; import equal from '@turf/boolean-equal' ; import createCircle from '@turf/circle' ; import pointInPolygon from '@turf/boolean-point-in-polygon' ; import within from '@turf/boolean-within' ; import contains from '@turf/boolean-contains' ; import disjoint from '@turf/boolean-disjoint' ; import intersect from '@turf/boolean-intersects' ; import { lineString , multiPolygon , polygon as tPolygon , point as tPoint , MultiPolygon , Feature , Properties , Polygon , Position , } from '@turf/helpers' ; import lineToPolygon from '@turf/line-to-polygon' ; import { getCoords } from '@turf/invariant' ; import { find as geoToTimezone } from 'geo-tz' ; import { isArrayLike } from './arrays' ; import { isPlainObject , geoHash , getTypeOf } from './deps' ; import { trim , toString } from './strings' ; import { parseNumberList , toNumber , isNumber } from './numbers' ; export const geoJSONTypes = Object . keys ( GeoShapeType ) . map ( ( key ) => key . toLowerCase ( ) ) ; export function isGeoJSON ( input ) { if ( ! isPlainObject ( input ) ) return false ; if ( ! isArrayLike ( ( input as any ) . coordinates ) ) return false ; const type = ( input as any ) . type as unknown ; if ( typeof type !== 'string' ) return false ; return geoJSONTypes . includes ( type . toLowerCase ( ) ) ; } export function isGeoShapePoint ( input ) { return isGeoJSON ( input ) && ( input . type === GeoShapeType . Point || input . type === ESGeoShapeType . Point ) ; } export function isGeoShapePolygon ( input ) { return isGeoJSON ( input ) && ( input . type === GeoShapeType . Polygon || input . type === ESGeoShapeType . Polygon ) ; } export function isGeoShapeMultiPolygon ( input ) { return isGeoJSON ( input ) && ( input . type === GeoShapeType . MultiPolygon || input . type === ESGeoShapeType . MultiPolygon ) ; } export function parseGeoDistance ( str ) { const matches = trim ( str ) . match ( /(\d+)(.*)$/ ) ; if ( ! matches || ! matches . length ) { throw new TypeError ( `Incorrect geo distance parameter provided: ${ str }` ) ; } const distance = Number ( matches [ 1 ] ) ; const unit = parseGeoDistanceUnit ( matches [ 2 ] ) ; return { distance , unit } ; } export function parseGeoDistanceUnit ( input ) { const unit = GEO_DISTANCE_UNITS [ trim ( input ) ] ; if ( ! unit ) { throw new TypeError ( `Incorrect distance unit provided: ${ input }` ) ; } return unit ; } function getLonAndLat ( input , throwInvalid = true ) { if ( ! isPlainObject ( input ) ) { if ( ! throwInvalid ) return null ; throw new TypeError ( `Invalid geo point, expected object, got ${ input } (${ getTypeOf ( input ) })` ) ; } const obj = input as Record < string , unknown > ; let lat ; let lon ; if ( 'lat' in obj && 'lon' in obj ) { if ( isNumber ( obj . lat ) && isNumber ( obj . lon ) ) { return obj as unknown as GeoPoint ; } lat = obj . lat ; lon = obj . lon ; } else if ( 'latitude' in obj && 'longitude' in obj ) { lat = obj . latitude ; lon = obj . longitude ; } else if ( isGeoShapePoint ( obj ) ) { [ lon , lat ] = obj . coordinates ; } if ( throwInvalid && ( lat == null || lon == null ) ) { if ( isGeoShapePolygon ( obj ) || isGeoShapeMultiPolygon ( obj ) ) { throw new TypeError ( [ `Expected a Point geo shape, received a geo ${ obj . type } shape,` , 'you may need to switch to a polygon compatible operation' ] . join ( ' ' ) ) ; } throw new TypeError ( 'Invalid geo point object, it must contain keys lat,lon or latitude/longitude' ) ; } lat = toNumber ( lat ) ; lon = toNumber ( lon ) ; if ( ! isNumber ( lat ) || ! isNumber ( lon ) ) { if ( throwInvalid ) { throw new TypeError ( 'Invalid geo point, lat and lon must be numbers' ) ; } return null ; } return { lat , lon } ; } /**
 * Convert an input into a Geo Point object with lat and lon
*/ export function parseGeoPoint ( point ) ; export function parseGeoPoint ( point , throwInvalid ) ; export function parseGeoPoint ( point , throwInvalid ) ; export function parseGeoPoint ( point , throwInvalid = true ) { let lat ; let lon ; if ( typeof point === 'string' ) { if ( point . includes ( ',' ) ) { [ lat , lon ] = parseNumberList ( point ) ; } else { try { return geoHash . decode ( point ) ; } catch ( err ) { } } } else if ( isPlainObject ( point ) ) { const results = getLonAndLat ( point , throwInvalid ) ; if ( results ) return results ; } else if ( isArrayLike ( point ) ) { [ lon , lat ] = parseNumberList ( point ) ; } if ( throwInvalid && ( lat == null || lon == null ) ) { throw new TypeError ( `Invalid geo point given to parse, got ${ point } (${ getTypeOf ( point ) })` ) ; } if ( lat != null && lon != null ) { return { lat , lon } ; } return null ; } export function isGeoPoint ( input ) { return parseGeoPoint ( input as GeoPointInput , false ) != null ; } export function makeGeoBBox ( point1 , point2 ) { const line = lineString ( [ makeCoordinatesFromGeoPoint ( point1 ) , makeCoordinatesFromGeoPoint ( point2 ) ] ) ; const box = bbox ( line ) ; return bboxPolygon ( box ) ; } export function inGeoBoundingBox ( top_left , bottom_right , point ) { const topLeft = parseGeoPoint ( top_left ) ; const bottomRight = parseGeoPoint ( bottom_right ) ; const polygon = makeGeoBBox ( topLeft , bottomRight ) ; if ( polygon == null ) { throw new Error ( `Invalid bounding box created from topLeft: ${ topLeft }, bottomRight: ${ bottomRight }` ) ; } return geoPolyHasPoint ( polygon ) ( point ) ; } export function inGeoBoundingBoxFP ( top_left , bottom_right ) { const topLeft = parseGeoPoint ( top_left ) ; const bottomRight = parseGeoPoint ( bottom_right ) ; const polygon = makeGeoBBox ( topLeft , bottomRight ) ; if ( polygon == null ) { throw new Error ( `Invalid bounding box created from topLeft: ${ topLeft }, bottomRight: ${ bottomRight }` ) ; } return geoPolyHasPoint ( polygon ) ; } export function makeCoordinatesFromGeoPoint ( point ) { return [ point . lon , point . lat ] ; } export function geoPolyHasPoint < G extends Polygon | MultiPolygon > ( polygon ) { return ( fieldData ) : boolean => { const point = parseGeoPoint ( fieldData as any , false ) ; if ( ! point ) return false ; return pointInPolygon ( makeCoordinatesFromGeoPoint ( point ) , polygon ) ; } ; } export function makeGeoCircle ( point , distance , unitVal ? ) { const units = unitVal === 'inch' ? 'inches' : unitVal ; return createCircle ( makeCoordinatesFromGeoPoint ( point ) , distance , { units } ) ; } export function geoPointWithinRange ( startingPoint , distanceValue , point ) { const sPoint = parseGeoPoint ( startingPoint ) ; const { distance , unit } = parseGeoDistance ( distanceValue ) ; const polygon = makeGeoCircle ( sPoint , distance , unit ) ; if ( polygon == null ) { throw new Error ( `Invalid startingPoint: ${ startingPoint }` ) ; } return geoPolyHasPoint ( polygon ) ( point ) ; } export function geoPointWithinRangeFP ( startingPoint , distanceValue ) { const sPoint = parseGeoPoint ( startingPoint ) ; const { distance , unit } = parseGeoDistance ( distanceValue ) ; const polygon = makeGeoCircle ( sPoint , distance , unit ) ; if ( polygon == null ) { throw new Error ( `Invalid startingPoint: ${ startingPoint }` ) ; } return geoPolyHasPoint ( polygon ) ; } export function geoRelationFP ( geoShape , relation ) { if ( relation === GeoShapeRelation . Within ) { return geoWithinFP ( geoShape ) ; } if ( relation === GeoShapeRelation . Contains ) { return geoContainsFP ( geoShape ) ; } if ( relation === GeoShapeRelation . Intersects ) { return geoIntersectsFP ( geoShape ) ; } if ( relation === GeoShapeRelation . Disjoint ) { return geoDisjointFP ( geoShape ) ; } throw new Error ( `Unsupported relation ${ relation }` ) ; } /** Converts a geoJSON object to its turf geo feature counterpart */ export function makeGeoFeature ( geoShape ) { if ( isGeoShapePoint ( geoShape ) ) { return tPoint ( geoShape . coordinates ) ; } if ( isGeoShapeMultiPolygon ( geoShape ) ) { return multiPolygon ( geoShape . coordinates ) ; } if ( isGeoShapePolygon ( geoShape ) ) { return tPolygon ( geoShape . coordinates ) ; } return ; } /** Converts a geoJSON object to its turf geo feature counterpart, will throw if not valid */ export function makeGeoFeatureOrThrow ( geoShape ) { const results = makeGeoFeature ( geoShape ) ; if ( ! results ) { throw new Error ( `Invalid input: ${ JSON . stringify ( geoShape ) }, is not a valid geo-shape` ) ; } return results ; } /**
 * Returns true if the second geometry is completely contained by the first geometry.
 * The interiors of both geometries must intersect and, the interior and boundary of
 * the secondary geometry must not intersect the exterior of the first geometry.
 */ export function geoContains ( firstGeoEntity , secondGeoEntity ) { return geoContainsFP ( secondGeoEntity ) ( firstGeoEntity ) ; } /**
 * When provided with geoInput that acts as the argument geo-feature, it will return a function
 * that accepts any geoInput and checks to see if the new input contains the argument geo-feature
 */ export function geoContainsFP ( queryGeoEntity ) { const queryGeo = toGeoJSONOrThrow ( queryGeoEntity ) ; const queryFeature = makeGeoFeatureOrThrow ( queryGeo ) ; if ( isGeoShapePoint ( queryGeo ) ) return _pointContains ( queryFeature ) ; const { polygons : queryPolygons , holes : queryHoles } = _featureToPolygonAndHoles ( queryFeature ) ; return function _geoContains ( input ) : boolean { const inputGeoEntity = toGeoJSON ( input ) ; if ( ! inputGeoEntity ) return false ; if ( isGeoPoint ( inputGeoEntity ) ) { return false ; } const inputFeature = makeGeoFeature ( inputGeoEntity ) ; if ( ! inputFeature ) return false ; const { polygons : inputPolygons , holes : inputHoles } = _featureToPolygonAndHoles ( inputFeature ) ; if ( inputHoles . length ) { const withinInputHole = inputHoles . some ( ( iHolePoly ) => queryPolygons . some ( ( qPoly ) => intersect ( qPoly , iHolePoly ) ) ) ; if ( withinInputHole ) { if ( queryHoles . length ) { return queryHoles . some ( ( qHole ) => inputHoles . some ( ( iHole ) => equal ( qHole , iHole ) ) ) ; } return false ; } } return queryPolygons . every ( ( qPoly ) => inputPolygons . some ( ( iPoly ) => contains ( iPoly , qPoly ) ) ) ; } ; } function _pointContains ( queryFeature ) { return ( input ) => { const inputGeoEntity = toGeoJSON ( input ) ; if ( ! inputGeoEntity ) return false ; const inputFeature = makeGeoFeature ( inputGeoEntity ) ; if ( ! inputFeature ) return false ; if ( isGeoPoint ( inputGeoEntity ) ) { return equal ( inputFeature , queryFeature ) ; } const { polygons , holes } = _featureToPolygonAndHoles ( inputFeature ) ; let pointInHole = false ; if ( holes . length ) { pointInHole = holes . some ( ( iPolyHole ) => contains ( iPolyHole , queryFeature ) ) ; } return ! pointInHole && polygons . some ( ( poly ) => contains ( poly , queryFeature ) ) ; } ; } function _featureToPolygonAndHoles ( inputFeature ) { const inputHoles = [ ] ; const inputCoords = getCoords ( inputFeature ) ; let inputPolygons ; if ( inputFeature . geometry . type === 'MultiPolygon' ) { inputPolygons = inputCoords . map ( ( coords ) => { if ( coords . length > 1 ) { const [ polygon , ... holes ] = coords . map ( ( innerCords ) => tPolygon ( [ innerCords ] ) ) ; inputHoles . push ( ... holes ) ; return polygon ; } return tPolygon ( coords ) ; } ) as Feature < any > [ ] ; } else if ( inputFeature . geometry . type === 'Polygon' ) { const [ polyCoords , ... holeCords ] = inputCoords ; inputPolygons = [ tPolygon ( [ polyCoords ] ) ] ; const holePolygons = holeCords . map ( ( coords ) => tPolygon ( [ coords ] ) ) as Feature < any > [ ] ; inputHoles . push ( ... holePolygons ) ; } else { throw new Error ( `Cannot convert ${ toString ( inputFeature ) } to a polygon` ) ; } return { holes : inputHoles , polygons : inputPolygons } ; } function _pointToPointMatch ( queryInput ) { return ( input ) => { const inputGeoEntity = toGeoJSON ( input ) ; if ( ! inputGeoEntity ) return false ; if ( ! isGeoShapePoint ( inputGeoEntity ) ) return false ; const inputFeature = makeGeoFeature ( inputGeoEntity ) ; if ( ! inputFeature ) return false ; return equal ( queryInput , inputFeature ) ; } ; } /**
 * Returns true if the first geometry is completely within the second geometry.
 * The interiors of both geometries must intersect and, the interior and boundary
 * of the first geometry must not intersect the exterior of the second geometry
 */ export function geoWithin ( firstGeoEntity , secondGeoEntity ) { return geoWithinFP ( secondGeoEntity ) ( firstGeoEntity ) ; } /**
 * When provided with geoInput that acts as the parent geo-feature, it will return a function
 * that accepts any geoInput and checks to see if the new input is within the parent geo-feature
 */ export function geoWithinFP ( queryGeoEntity ) { const queryGeo = toGeoJSONOrThrow ( queryGeoEntity ) ; const queryFeature = makeGeoFeatureOrThrow ( queryGeo ) ; if ( isGeoShapePoint ( queryGeo ) ) return _pointToPointMatch ( queryFeature ) ; const { polygons : queryPolygons , holes : queryHoles } = _featureToPolygonAndHoles ( queryFeature ) ; const hasQueryHoles = queryHoles . length > 0 ; return function _geoWithinFP ( input ) { const inputGeoEntity = toGeoJSON ( input ) ; if ( ! inputGeoEntity ) return false ; const inputFeature = makeGeoFeature ( inputGeoEntity ) ; if ( ! inputFeature ) return false ; if ( isGeoShapePoint ( inputGeoEntity ) ) { if ( hasQueryHoles ) { const withinQueryHole = queryHoles . some ( intersect . bind ( intersect , inputFeature ) ) ; if ( withinQueryHole ) return false ; } return queryPolygons . some ( within . bind ( within , inputFeature ) ) ; } const { polygons : inputPolygons , holes : inputHoles } = _featureToPolygonAndHoles ( inputFeature ) ; if ( hasQueryHoles ) { const withinQueryHole = queryHoles . some ( ( qHole ) => { const bool = intersect ( inputFeature , qHole ) ; if ( bool && inputHoles . length ) { const inner = ! inputHoles . some ( equal . bind ( equal , qHole ) ) ; return inner ; } return bool ; } ) ; if ( withinQueryHole ) return false ; } return inputPolygons . every ( ( iPoly ) => queryPolygons . some ( within . bind ( within , iPoly ) ) ) ; } ; } /** Returns true if both geo entities intersect each other, if one of the input geo entity
 * is a point, it will check if the other geo-entity contains the point
 */ export function geoIntersects ( firstGeoEntity , secondGeoEntity ) { return geoIntersectsFP ( firstGeoEntity ) ( secondGeoEntity ) ; } export function geoIntersectsFP ( queryGeoEntity ) { const queryGeo = toGeoJSONOrThrow ( queryGeoEntity ) ; const queryFeature = makeGeoFeatureOrThrow ( queryGeo ) ; return function _geoIntersectsFP ( input ) { const inputGeoEntity = toGeoJSON ( input ) ; if ( ! inputGeoEntity ) return false ; const inputFeature = makeGeoFeature ( inputGeoEntity ) ; if ( ! inputFeature ) return false ; return intersect ( inputFeature , queryFeature ) ; } ; } export function geoDisjointFP ( queryGeoEntity ) { const queryGeo = toGeoJSONOrThrow ( queryGeoEntity ) ; const queryFeature = makeGeoFeatureOrThrow ( queryGeo ) ; return function _geoDisjointFP ( input ) { const inputGeoEntity = toGeoJSON ( input ) ; if ( ! inputGeoEntity ) return false ; const inputFeature = makeGeoFeature ( inputGeoEntity ) ; if ( ! inputFeature ) return false ; return disjoint ( inputFeature , queryFeature ) ; } ; } /** Returns true if both geo entities have no overlap */ export function geoDisjoint ( firstGeoEntity , secondGeoEntity ) { return geoDisjointFP ( firstGeoEntity ) ( secondGeoEntity ) ; } const esTypeMap = { [ ESGeoShapeType . Point ] : GeoShapeType . Point , [ ESGeoShapeType . MultiPolygon ] : GeoShapeType . MultiPolygon , [ ESGeoShapeType . Polygon ] : GeoShapeType . Polygon , } as const ; /** Only able to convert geo-points to either a geo-json point or a simple polygon.
 * There is no current support for creating polygon with holes or multi-polygon
 * as of right now. geoJSON input is made sure to be properly formatted for its type value
 */ export function toGeoJSON ( input ) { if ( isGeoJSON ( input ) ) { const { type : inputType } = input ; const type = esTypeMap [ inputType ] ? esTypeMap [ inputType ] : inputType ; return { ... input , type } ; } if ( isGeoPoint ( input ) ) { const coordinates = makeCoordinatesFromGeoPoint ( parseGeoPoint ( input as GeoPointInput ) ) ; return { type : GeoShapeType . Point , coordinates } ; } if ( Array . isArray ( input ) ) { try { const points = input . map ( ( point ) => makeCoordinatesFromGeoPoint ( parseGeoPoint ( point as GeoPointInput ) ) ) ; const coordinates = validateListCoords ( points ) ; return { type : GeoShapeType . Polygon , coordinates } ; } catch ( _err ) { } } } export function toGeoJSONOrThrow ( input ) { const geoJSON = toGeoJSON ( input ) ; if ( ! geoJSON ) { throw new Error ( `Cannot convert ${ JSON . stringify ( input ) } to valid geoJSON` ) ; } return geoJSON ; } export function validateListCoords ( coords ) { if ( coords . length < 3 ) { throw new Error ( 'Points parameter for a geoPolygon query must have at least three geo-points' ) ; } const line = lineString ( coords ) ; const polygon = lineToPolygon ( line ) ; return getCoords ( polygon ) ; } export function polyHasHoles ( input ) { if ( isGeoShapePolygon ( input ) ) { return input . coordinates . length > 1 ; } if ( isGeoShapeMultiPolygon ( input ) ) { return input . coordinates [ 0 ] . length > 1 ; } return false ; } /** Takes in a geo point like entity and returns the timezone of its location */ export function lookupTimezone ( input ) { const { lat , lon } = parseGeoPoint ( input as GeoPointInput ) ; return geoToTimezone ( lat , lon ) [ 0 ] ; }