Skip to content

Commit

Permalink
fix: address.nearbyGPSCoordinate (#876)
Browse files Browse the repository at this point in the history
Co-authored-by: ST-DDT <ST-DDT@gmx.de>
  • Loading branch information
Shinigami92 and ST-DDT committed Apr 27, 2022
1 parent a64cbde commit 3e23fc4
Show file tree
Hide file tree
Showing 2 changed files with 114 additions and 205 deletions.
131 changes: 40 additions & 91 deletions 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.
*/
Expand Down Expand Up @@ -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)];
}

/**
Expand Down
188 changes: 74 additions & 114 deletions 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 = [
{
Expand Down Expand Up @@ -29,7 +62,7 @@ const seededRuns = [
cardinalDirection: 'East',
cardinalDirectionAbbr: 'E',
timeZone: 'Europe/Amsterdam',
nearbyGpsCoordinates: ['-0.0394', '0.0396'],
nearbyGpsCoordinates: ['0.0814', '-0.0809'],
},
},
{
Expand Down Expand Up @@ -59,7 +92,7 @@ const seededRuns = [
cardinalDirection: 'East',
cardinalDirectionAbbr: 'E',
timeZone: 'Africa/Casablanca',
nearbyGpsCoordinates: ['-0.0042', '0.0557'],
nearbyGpsCoordinates: ['0.0806', '-0.0061'],
},
},
{
Expand Down Expand Up @@ -89,7 +122,7 @@ const seededRuns = [
cardinalDirection: 'West',
cardinalDirectionAbbr: 'W',
timeZone: 'Asia/Magadan',
nearbyGpsCoordinates: ['0.0503', '-0.0242'],
nearbyGpsCoordinates: ['-0.0287', '0.0596'],
},
},
];
Expand Down Expand Up @@ -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
// );
});
}
});
}
});
Expand Down

0 comments on commit 3e23fc4

Please sign in to comment.