Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: address.nearbyGPSCoordinate #876

Merged
merged 12 commits into from
Apr 27, 2022
131 changes: 40 additions & 91 deletions src/address.ts
Original file line number Diff line number Diff line change
@@ -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.
Shinigami92 marked this conversation as resolved.
Show resolved Hide resolved
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
189 changes: 74 additions & 115 deletions test/address.spec.ts
Original file line number Diff line number Diff line change
@@ -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 =
Shinigami92 marked this conversation as resolved.
Show resolved Hide resolved
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,45 @@ 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);
for (const isMetric of [true, false]) {
for (const radius of times(100)) {
it(`should return random gps coordinate within a distance of another one (${JSON.stringify(
{ isMetric, radius }
)})`, () => {
for (let i = 0; i < 100; i++) {
Shinigami92 marked this conversation as resolved.
Show resolved Hide resolved
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);
}
});
}

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
);

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