geolocation-utils
Version:
A utility library for calculations with geolocations
611 lines (525 loc) • 16.8 kB
JavaScript
import pointInPolygon from 'point-in-polygon'
import {
degToRad, radToDeg
} from './convert'
export const EARTH_RADIUS = 6378137 // Earth's radius in meters
/**
* Test whether two locations are equal or approximately equal
* @param {Location} location1 A location in any of the supported location formats
* @param {Location} location2 A location in any of the supported location formats
* @param {number} [epsilon=0] The maximum absolute difference between the
* two latitudes and between the two longitudes.
* Use for example 1e-12 to get rid of round-off errors.
* The epsilon value itself is included.
* Optional, default value is 0.
*/
export function isEqual (location1, location2, epsilon = 0) {
return (location1 && location2)
? (Math.abs(getLatitude(location1) - getLatitude(location2)) <= epsilon) &&
(Math.abs(getLongitude(location1) - getLongitude(location2)) <= epsilon)
: false
}
/**
* Test whether an object is an object containing numeric properties `lat` and `lon`
* @param {*} object Anything
* @param {boolean} Returns true when object is of type LatLon
*/
export function isLatLon (object) {
return (!!object &&
typeof object.lat === 'number' &&
typeof object.lon === 'number')
}
/**
* Test whether an object is an object containing numeric properties `lat` and `lng`
* @param {*} object Anything
* @param {boolean} Returns true when object is of type LatLng
*/
export function isLatLng (object) {
return (!!object &&
typeof object.lat === 'number' &&
typeof object.lng === 'number')
}
/**
* Test whether an object is an object containing numeric properties `latitude` and `longitude`
* @param {*} object Anything
* @param {boolean} Returns true when object is of type LatitudeLongitude
*/
export function isLatitudeLongitude (object) {
return (!!object &&
typeof object.latitude === 'number' &&
typeof object.longitude === 'number')
}
/**
* Test whether an object is an array containing two numbers (longitude and latitude)
*
* IMPORTANT: this function cannot see the difference between an array with lat/lon
* or an array with lon/lat numbers. It assumes an order lon/lat.
*
* @param {*} object Anything
* @param {boolean} Returns true when object is of type LonLatTuple
*/
export function isLonLatTuple (object) {
return (Array.isArray (object) &&
typeof object[0] === 'number' &&
typeof object[1] === 'number')
}
/**
* Get the type of a location object
* @param {Location} location
* @return {string} Returns the type of the location object
* Recognized types: 'LonLatTuple', 'LatLon', 'LatLng', 'LatitudeLongitude'
*/
export function getLocationType (location) {
if (isLonLatTuple(location)) {
return 'LonLatTuple'
}
if (isLatLon(location)) {
return 'LatLon'
}
if (isLatLng(location)) {
return 'LatLng'
}
if (isLatitudeLongitude(location)) {
return 'LatitudeLongitude'
}
throw new Error('Unknown location format ' + JSON.stringify(location))
}
/**
* Create a location object of a specific type
* @param {number} latitude
* @param {number} longitude
* @param {string} type Available types: 'LonLatTuple', 'LatLon', 'LatLng', 'LatitudeLongitude'
*/
export function createLocation (latitude, longitude, type) {
if (type === 'LonLatTuple') {
return [longitude, latitude]
}
if (type === 'LatLon') {
return { lat: latitude, lon: longitude }
}
if (type === 'LatLng') {
return { lat: latitude, lng: longitude }
}
if (type === 'LatitudeLongitude') {
return { latitude, longitude }
}
throw new Error('Unknown location format ' + JSON.stringify(location))
}
/**
* Convert a location into an object with properties `lat` and `lon`
* @param {Location} location
* @returns {LatLon}
*/
export function toLatLon (location) {
if (isLonLatTuple(location)) {
return {
lat: location[1],
lon: location[0]
}
}
if (isLatLon(location)) {
return {
lat: location.lat,
lon: location.lon
}
}
if (isLatLng(location)) {
return {
lat: location.lat,
lon: location.lng
}
}
if (isLatitudeLongitude(location)) {
return {
lat: location.latitude,
lon: location.longitude
}
}
throw new Error('Unknown location format ' + JSON.stringify(location))
}
/**
* Convert a location into an object with properties `lat` and `lng`
* @param {Location} location
* @returns {LatLng}
*/
export function toLatLng (location) {
if (isLonLatTuple(location)) {
return {
lat: location[1],
lng: location[0]
}
}
if (isLatLon(location)) {
return {
lat: location.lat,
lng: location.lon
}
}
if (isLatLng(location)) {
return {
lat: location.lat,
lng: location.lng
}
}
if (isLatitudeLongitude(location)) {
return {
lat: location.latitude,
lng: location.longitude
}
}
throw new Error('Unknown location format ' + JSON.stringify(location))
}
/**
* Convert a location into an object with properties `latitude` and `longitude`
* @param {Location} location
* @returns {LatitudeLongitude}
*/
export function toLatitudeLongitude (location) {
if (isLonLatTuple(location)) {
return {
latitude: location[1],
longitude: location[0]
}
}
if (isLatLon(location)) {
return {
latitude: location.lat,
longitude: location.lon
}
}
if (isLatLng(location)) {
return {
latitude: location.lat,
longitude: location.lng
}
}
if (isLatitudeLongitude(location)) {
return {
latitude: location.latitude,
longitude: location.longitude
}
}
throw new Error('Unknown location format ' + JSON.stringify(location))
}
/**
* Convert a location into a tuple `[longitude, latitude]`, as used in the geojson standard
*
* Note that for example Leaflet uses a tuple `[latitude, longitude]` instead, be careful!
*
* @param {Location} location
* @returns {LonLatTuple}
*/
export function toLonLatTuple (location) {
if (isLonLatTuple(location)) {
return [location[0], location[1]]
}
if (isLatLon(location)) {
return [location.lon, location.lat]
}
if (isLatLng(location)) {
return [location.lng, location.lat]
}
if (isLatitudeLongitude(location)) {
return [location.longitude, location.latitude]
}
throw new Error('Unknown location format ' + JSON.stringify(location))
}
/**
* Get the longitude of a location
* @param {Location} location
* @return {number} Returns the longitude
*/
export function getLongitude (location) {
if (isLonLatTuple(location)) {
return location[0]
}
if (isLatLon(location)) {
return location.lon
}
if (isLatLng(location)) {
return location.lng
}
if (isLatitudeLongitude(location)) {
return location.longitude
}
throw new Error('Unknown location format ' + JSON.stringify(location))
}
/**
* Get the latitude of a location object or array
* @param {Location} location
* @return {number} Returns the latitude
*/
export function getLatitude (location) {
if (isLonLatTuple(location)) {
return location[1]
}
if (isLatLon(location)) {
return location.lat
}
if (isLatLng(location)) {
return location.lat
}
if (isLatitudeLongitude(location)) {
return location.latitude
}
throw new Error('Unknown location format ' + JSON.stringify(location))
}
/**
* Move to a new location from a start location, heading, and distance
*
* This is a rough estimation.
*
* Source:
*
* http://gis.stackexchange.com/questions/2951/algorithm-for-offsetting-a-latitude-longitude-by-some-amount-of-meters
*
* @param {Location} from Start location
* @param {HeadingDistance} headingDistance An object with property `heading` in degrees and `distance` in meters
* @return {Location} Returns the moved location
*/
export function moveTo (from, headingDistance) {
// TODO: improve precision of this function moveTo
const lat = getLatitude(from)
const lon = getLongitude(from)
const { heading, distance } = headingDistance
const dLat = distance * Math.cos(degToRad(heading)) / EARTH_RADIUS
const dLon = distance * Math.sin(degToRad(heading)) / (EARTH_RADIUS * Math.cos(degToRad(lat)))
return createLocation(lat + radToDeg(dLat), lon + radToDeg(dLon), getLocationType(from))
}
/**
* Calculate the heading and distance between two locations
*
* Sources:
*
* http://www.movable-type.co.uk/scripts/latlong.html
* http://mathforum.org/library/drmath/view/55417.html
*
* @param {Location} from Start location
* @param {Location} to End location
* @return {HeadingDistance} Returns an object with `heading` in degrees and `distance` in meters
*/
export function headingDistanceTo (from, to) {
const fromLat = getLatitude(from)
const fromLon = getLongitude(from)
const toLat = getLatitude(to)
const toLon = getLongitude(to)
const lat1 = degToRad(fromLat)
const lat2 = degToRad(toLat)
const dlat = degToRad(toLat - fromLat)
const dlon = degToRad(toLon - fromLon)
const a = Math.sin(dlat/2) * Math.sin(dlat/2) +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dlon/2) * Math.sin(dlon/2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))
const distance = EARTH_RADIUS * c
const y = Math.sin(dlon) * Math.cos(lat2)
const x = Math.cos(lat1) * Math.sin(lat2) -
Math.sin(lat1) * Math.cos(lat2) * Math.cos(dlon)
const heading = radToDeg(Math.atan2(y, x))
return { distance, heading }
}
/**
* Calculate the heading from one location to another location
* @param {Location} center
* @param {Location} location
* @return {number} Returns an heading in degrees
*/
export function headingTo (center, location) {
return headingDistanceTo(center, location).heading
}
/**
* Calculate the distance between two locations
* @param {Location} center
* @param {Location} location
* @return {number} Returns the distance in meters
*/
export function distanceTo (center, location) {
return headingDistanceTo(center, location).distance
}
/**
* Test whether a location lies inside a given bounding box.
* @param {Location} location
* @param {BoundingBox} boundingBox
* A bounding box containing a top left and bottom right location.
* The order doesn't matter.
* @return {boolean} Returns true when the location is inside the bounding box
* or on the edge.
*/
export function insideBoundingBox (location, boundingBox) {
const lat = getLatitude(location)
const lon = getLongitude(location)
const topLeftLon = getLongitude(boundingBox.topLeft)
const topLeftLat = getLatitude(boundingBox.topLeft)
const bottomRightLon = getLongitude(boundingBox.bottomRight)
const bottomRightLat = getLatitude(boundingBox.bottomRight)
const minLat = Math.min(topLeftLat, bottomRightLat)
const maxLat = Math.max(topLeftLat, bottomRightLat)
const minLon = Math.min(topLeftLon, bottomRightLon)
const maxLon = Math.max(topLeftLon, bottomRightLon)
return lon >= minLon && lon <= maxLon && lat >= minLat && lat <= maxLat
}
/**
* Test whether a location lies inside a given polygon
* @param {Location} location
* @param {Location[]} polygon
* @return {boolean} Returns true when the location is inside the bounding box
* or on the edge.
*/
export function insidePolygon (location, polygon) {
if (!polygon || !Array.isArray(polygon)) {
throw new TypeError('Invalid polygon. Array with locations expected')
}
if (polygon.length === 0) {
throw new TypeError('Invalid polygon. Non-empty Array expected')
}
return pointInPolygon(toLonLatTuple(location), polygon.map(toLonLatTuple))
}
/**
* Test whether a location lies inside a circle with certain radius
* @param {Location} location
* @param {Location} center
* @param {number} radius A radius in meters
* @return {boolean} Returns true when the location lies inside or
* on the edge of the circle
*/
export function insideCircle (location, center, radius) {
return distanceTo(center, location) <= radius
}
/**
* Normalize an heading into the range [0, 360)
* @param {number} heading An heading in degrees
* @return {number} Returns the normalized heading (degrees)
*/
export function normalizeHeading(heading) {
let normalized = heading % 360
if (normalized < 0) {
normalized += 360
}
if (normalized >= 360) {
normalized -= 360
}
return normalized
}
/**
* Normalize a latitude into the range [-90, 90] (upper and lower bound included)
*
* See https://stackoverflow.com/questions/13368525/modulus-to-limit-latitude-and-longitude-values
*
* @param {number} latitude
* @return {number} Returns the normalized latitude
*/
export function normalizeLatitude (latitude) {
return Math.asin(Math.sin((latitude / 180) * Math.PI)) * (180 / Math.PI);
}
/**
* Normalize a longitude into the range (-180, 180] (lower bound excluded, upper bound included)
*
* @param {number} longitude
* @return {number} Returns the normalized longitude
*/
export function normalizeLongitude (longitude) {
let normalized = longitude % 360
if (normalized > 180) {
normalized -= 360
}
if (normalized <= -180) {
normalized += 360
}
return normalized
}
/**
* Normalize the longitude and latitude of a location.
* Latitude will be in the range [-90, 90] (upper and lower bound included)
* Lontitude will be in the range (-180, 180] (lower bound excluded, upper bound included)
* @param {Location} location
* @return {Location} Returns the normalized location
*/
export function normalizeLocation (location) {
if (isLonLatTuple(location)) {
return [normalizeLongitude(location[0]), normalizeLatitude(location[1])]
}
if (isLatLon(location)) {
return {
lat: normalizeLatitude(location.lat),
lon: normalizeLongitude(location.lon)
}
}
if (isLatLng(location)) {
return {
lat: normalizeLatitude(location.lat),
lng: normalizeLongitude(location.lng)
}
}
if (isLatitudeLongitude(location)) {
return {
latitude: normalizeLatitude(location.latitude),
longitude: normalizeLongitude(location.longitude)
}
}
throw new Error('Unknown location format ' + JSON.stringify(location))
}
/**
* Calculate the average of a list with locations
* @param {Location[]} locations
* @return {Location} Returns the average location or null when the list is empty
* Location has the same structure as the first location from
* the input array.
*/
export function average (locations) {
if (!Array.isArray(locations) || locations.length === 0) {
return null
}
const first = locations[0]
const latitude = avg(locations.map(getLatitude))
const longitude = avg(locations.map(getLongitude))
return createLocation(latitude, longitude, getLocationType(first))
}
/**
* Get the bounding box of a list with locations
* @param {Locations[]} locations
* @param {number} [margin] Optional margin in meters. Zero by default.
* @return {BoundingBox} Returns a bounding box described by it's top left
* and bottom right location
*/
export function getBoundingBox (locations, margin = 0) {
if (!Array.isArray(locations) || locations.length === 0) {
return {
topLeft: null,
bottomRight: null
}
}
const type = getLocationType(locations[0])
const topLeftLat = Math.max(...locations.map(getLatitude))
const topLeftLon = Math.min(...locations.map(getLongitude))
const bottomRightLat = Math.min(...locations.map(getLatitude))
const bottomRightLon = Math.max(...locations.map(getLongitude))
const topLeft = createLocation(topLeftLat, topLeftLon, type)
const bottomRight = createLocation(bottomRightLat, bottomRightLon, type)
if (margin === null || margin === 0) {
// no margin
return { topLeft, bottomRight }
}
else {
// add a margin in meters
const distance = Math.SQRT2 * margin
return {
topLeft: moveTo(topLeft, {heading: 315, distance}),
bottomRight: moveTo(bottomRight, {heading: 135, distance})
}
}
}
/**
* Calculate the average of a list with numbers
* @param {number[]} values
* @return {number}
*/
function avg (values) {
return sum(values) / values.length
}
/**
* calculate the sum of a list with numbers
* @param {number[]} values
* @return {number} Returns the sum
*/
function sum (values) {
return values.reduce((a, b) => a + b, 0)
}