diff --git a/src/address.ts b/src/address.ts index e1518a465db..cfac28067ef 100644 --- a/src/address.ts +++ b/src/address.ts @@ -1,74 +1,5 @@ import type { Faker } from '.'; -/** - * Converts degrees to radians. - * - * @param degrees Degrees. - */ -function degreesToRadians(degrees: number): number { - return degrees * (Math.PI / 180.0); -} - -/** - * Converts radians to degrees. - * - * @param radians Radians. - */ -function radiansToDegrees(radians: number): number { - return radians * (180.0 / Math.PI); -} - -/** - * Converts kilometers to miles. - * - * @param miles Miles. - */ -function kilometersToMiles(miles: number): number { - return miles * 0.621371; -} - -/** - * Calculates coordinates with offset. - * - * @param coordinate Coordinate. - * @param bearing Bearing. - * @param distance Distance. - * @param isMetric Metric: true, Miles: false. - */ -function coordinateWithOffset( - coordinate: [latitude: number, longitude: number], - bearing: number, - distance: number, - isMetric: boolean -): [latitude: number, longitude: number] { - const R = 6378.137; // Radius of the Earth (http://nssdc.gsfc.nasa.gov/planetary/factsheet/earthfact.html) - const d = isMetric ? distance : kilometersToMiles(distance); // Distance in km - - const lat1 = degreesToRadians(coordinate[0]); //Current lat point converted to radians - const lon1 = degreesToRadians(coordinate[1]); //Current long point converted to radians - - const lat2 = Math.asin( - Math.sin(lat1) * Math.cos(d / R) + - Math.cos(lat1) * Math.sin(d / R) * Math.cos(bearing) - ); - - let lon2 = - lon1 + - Math.atan2( - Math.sin(bearing) * Math.sin(d / R) * Math.cos(lat1), - Math.cos(d / R) - Math.sin(lat1) * Math.sin(lat2) - ); - - // Keep longitude in range [-180, 180] - if (lon2 > degreesToRadians(180)) { - lon2 = lon2 - degreesToRadians(360); - } else if (lon2 < degreesToRadians(-180)) { - lon2 = lon2 + degreesToRadians(360); - } - - return [radiansToDegrees(lat2), radiansToDegrees(lon2)]; -} - /** * Module to generate addresses and locations. */ @@ -493,34 +424,52 @@ export class Address { // TODO ST-DDT 2022-02-10: Allow coordinate parameter to be [string, string]. nearbyGPSCoordinate( coordinate?: [latitude: number, longitude: number], - radius?: number, - isMetric?: boolean + radius: number = 10, + isMetric: boolean = false ): [latitude: string, longitude: string] { // If there is no coordinate, the best we can do is return a random GPS coordinate. if (coordinate === undefined) { return [this.latitude(), this.longitude()]; } - radius = radius || 10.0; - isMetric = isMetric || false; - - // TODO: implement either a gaussian/uniform distribution of points in circular region. - // Possibly include param to function that allows user to choose between distributions. - - // This approach will likely result in a higher density of points near the center. - const randomCoord = coordinateWithOffset( - coordinate, - degreesToRadians( - this.faker.datatype.number({ - min: 0, - max: 360, - precision: 1e-4, - }) - ), - radius, - isMetric - ); - return [randomCoord[0].toFixed(4), randomCoord[1].toFixed(4)]; + const angleRadians = this.faker.datatype.float({ + min: 0, + max: 2 * Math.PI, + precision: 0.00001, + }); // in ° radians + + const radiusMetric = isMetric ? radius : radius * 1.60934; // in km + const errorCorrection = 0.995; // avoid float issues + const distanceInKm = + this.faker.datatype.float({ + min: 0, + max: radiusMetric, + precision: 0.001, + }) * errorCorrection; // in km + + /** + * The distance in km per degree for earth. + */ + // TODO @Shinigami92 2022-04-26: Provide an option property to provide custom circumferences. + const kmPerDegree = 40_000 / 360; // in km/° + + const distanceInDegree = distanceInKm / kmPerDegree; // in ° + + const newCoordinate: [latitude: number, longitude: number] = [ + coordinate[0] + Math.sin(angleRadians) * distanceInDegree, + coordinate[1] + Math.cos(angleRadians) * distanceInDegree, + ]; + + // Box latitude [-90°, 90°] + newCoordinate[0] = newCoordinate[0] % 180; + if (newCoordinate[0] < -90 || newCoordinate[0] > 90) { + newCoordinate[0] = Math.sign(newCoordinate[0]) * 180 - newCoordinate[0]; + newCoordinate[1] += 180; + } + // Box longitude [-180°, 180°] + newCoordinate[1] = (((newCoordinate[1] % 360) + 540) % 360) - 180; + + return [newCoordinate[0].toFixed(4), newCoordinate[1].toFixed(4)]; } /** diff --git a/test/address.spec.ts b/test/address.spec.ts index 0d7417c27bc..a0fa2775319 100644 --- a/test/address.spec.ts +++ b/test/address.spec.ts @@ -1,5 +1,38 @@ import { afterEach, describe, expect, it } from 'vitest'; import { faker } from '../src'; +import { times } from './support/times'; + +function degreesToRadians(degrees: number) { + return degrees * (Math.PI / 180.0); +} + +function kilometersToMiles(miles: number) { + return miles * 0.621371; +} + +// http://nssdc.gsfc.nasa.gov/planetary/factsheet/earthfact.html +const EQUATORIAL_EARTH_RADIUS = 6378.137; + +function haversine( + latitude1: number, + longitude1: number, + latitude2: number, + longitude2: number, + isMetric: boolean +) { + const distanceLatitude = degreesToRadians(latitude2 - latitude1); + const distanceLongitude = degreesToRadians(longitude2 - longitude1); + const a = + Math.sin(distanceLatitude / 2) * Math.sin(distanceLatitude / 2) + + Math.cos(degreesToRadians(latitude1)) * + Math.cos(degreesToRadians(latitude2)) * + Math.sin(distanceLongitude / 2) * + Math.sin(distanceLongitude / 2); + const distance = + EQUATORIAL_EARTH_RADIUS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return isMetric ? distance : kilometersToMiles(distance); +} const seededRuns = [ { @@ -29,7 +62,7 @@ const seededRuns = [ cardinalDirection: 'East', cardinalDirectionAbbr: 'E', timeZone: 'Europe/Amsterdam', - nearbyGpsCoordinates: ['-0.0394', '0.0396'], + nearbyGpsCoordinates: ['0.0814', '-0.0809'], }, }, { @@ -59,7 +92,7 @@ const seededRuns = [ cardinalDirection: 'East', cardinalDirectionAbbr: 'E', timeZone: 'Africa/Casablanca', - nearbyGpsCoordinates: ['-0.0042', '0.0557'], + nearbyGpsCoordinates: ['0.0806', '-0.0061'], }, }, { @@ -89,7 +122,7 @@ const seededRuns = [ cardinalDirection: 'West', cardinalDirectionAbbr: 'W', timeZone: 'Asia/Magadan', - nearbyGpsCoordinates: ['0.0503', '-0.0242'], + nearbyGpsCoordinates: ['-0.0287', '0.0596'], }, }, ]; @@ -555,119 +588,46 @@ describe('address', () => { }); describe('nearbyGPSCoordinate()', () => { - it('should return random gps coordinate within a distance of another one', () => { - function haversine(lat1, lon1, lat2, lon2, isMetric) { - function degreesToRadians(degrees) { - return degrees * (Math.PI / 180.0); - } - function kilometersToMiles(miles) { - return miles * 0.621371; - } - const R = 6378.137; - const dLat = degreesToRadians(lat2 - lat1); - const dLon = degreesToRadians(lon2 - lon1); - const a = - Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(degreesToRadians(lat1)) * - Math.cos(degreesToRadians(lat2)) * - Math.sin(dLon / 2) * - Math.sin(dLon / 2); - const distance = R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - - return isMetric ? distance : kilometersToMiles(distance); - } - - let latFloat1: number; - let lonFloat1: number; - let isMetric: boolean; - - for (let i = 0; i < 10000; i++) { - latFloat1 = parseFloat(faker.address.latitude()); - lonFloat1 = parseFloat(faker.address.longitude()); - const radius = Math.random() * 99 + 1; // range of [1, 100) - isMetric = Math.round(Math.random()) === 1; - - const coordinate = faker.address.nearbyGPSCoordinate( - [latFloat1, lonFloat1], - radius, - isMetric + for (const isMetric of [true, false]) { + for (const radius of times(100)) { + it.each(times(5))( + `should return random gps coordinate within a distance of another one (${JSON.stringify( + { isMetric, radius } + )}) (iter: %s)`, + () => { + const latitude1 = +faker.address.latitude(); + const longitude1 = +faker.address.longitude(); + + const coordinate = faker.address.nearbyGPSCoordinate( + [latitude1, longitude1], + radius, + isMetric + ); + + expect(coordinate.length).toBe(2); + expect(coordinate[0]).toBeTypeOf('string'); + expect(coordinate[1]).toBeTypeOf('string'); + + const latitude2 = +coordinate[0]; + expect(latitude2).toBeGreaterThanOrEqual(-90.0); + expect(latitude2).toBeLessThanOrEqual(90.0); + + const longitude2 = +coordinate[1]; + expect(longitude2).toBeGreaterThanOrEqual(-180.0); + expect(longitude2).toBeLessThanOrEqual(180.0); + + const actualDistance = haversine( + latitude1, + longitude1, + latitude2, + longitude2, + isMetric + ); + expect(actualDistance).toBeLessThanOrEqual(radius); + } ); - - expect(coordinate.length).toBe(2); - expect(coordinate[0]).toBeTypeOf('string'); - expect(coordinate[1]).toBeTypeOf('string'); - - const latFloat2 = parseFloat(coordinate[0]); - expect(latFloat2).toBeGreaterThanOrEqual(-90.0); - expect(latFloat2).toBeLessThanOrEqual(90.0); - - const lonFloat2 = parseFloat(coordinate[1]); - expect(lonFloat2).toBeGreaterThanOrEqual(-180.0); - expect(lonFloat2).toBeLessThanOrEqual(180.0); - - // Due to floating point math, and constants that are not extremely precise, - // returned points will not be strictly within the given radius of the input - // coordinate. Using a error of 1.0 to compensate. - const error = 1.0; - const actualDistance = haversine( - latFloat1, - lonFloat1, - latFloat2, - lonFloat2, - isMetric - ); - expect(actualDistance).toBeLessThanOrEqual(radius + error); } - }); - - it('should return near metric coordinates when radius is undefined', () => { - const latitude = parseFloat(faker.address.latitude()); - const longitude = parseFloat(faker.address.longitude()); - const isMetric = true; - - const coordinate = faker.address.nearbyGPSCoordinate( - [latitude, longitude], - undefined, - isMetric - ); - - expect(coordinate.length).toBe(2); - expect(coordinate[0]).toBeTypeOf('string'); - expect(coordinate[1]).toBeTypeOf('string'); - - const distanceToTarget = - Math.pow(+coordinate[0] - latitude, 2) + - Math.pow(+coordinate[1] - longitude, 2); - - expect(distanceToTarget).toBeLessThanOrEqual( - 100 * 0.002 // 100 km ~= 0.9 degrees, we take 2 degrees - ); - }); - - it('should return near non metric coordinates when radius is undefined', () => { - const latitude = parseFloat(faker.address.latitude()); - const longitude = parseFloat(faker.address.longitude()); - const isMetric = false; - - const coordinate = faker.address.nearbyGPSCoordinate( - [latitude, longitude], - undefined, - isMetric - ); - - expect(coordinate.length).toBe(2); - expect(coordinate[0]).toBeTypeOf('string'); - expect(coordinate[1]).toBeTypeOf('string'); - - // const distanceToTarget = - // Math.pow(coordinate[0] - latitude, 2) + - // Math.pow(coordinate[1] - longitude, 2); - - // TODO @Shinigami92 2022-01-27: Investigate why this test sometimes fails - // expect(distanceToTarget).toBeLessThanOrEqual( - // 100 * 0.002 * 1.6093444978925633 // 100 miles to km ~= 0.9 degrees, we take 2 degrees - // ); - }); + } }); } });