Skip to content

Commit d6fab17

Browse files
committed
feat: getGeoDiff
1 parent c52ff35 commit d6fab17

File tree

6 files changed

+505
-1
lines changed

6 files changed

+505
-1
lines changed

src/lib/geo-diff/geo-diff.test.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { getGeoDiff } from "./index";
2+
import { GeoCoordinates, GeoDiff, GeoDirection, GeoStatus, GeoUnit } from "@models/geo";
3+
4+
const PARIS: GeoCoordinates = [2.3522, 48.8566];
5+
const LONDON: GeoCoordinates = [-0.1278, 51.5074];
6+
const TOKYO: GeoCoordinates = [139.6917, 35.6895];
7+
const PARIS_VERY_CLOSE: GeoCoordinates = [2.35221, 48.85661]; // ~1 meter away
8+
9+
describe("getGeoDiff", () => {
10+
it("returns EQUAL when no previous and current coordinates are provided", () => {
11+
expect(getGeoDiff(null, undefined)).toStrictEqual({
12+
type: "geo",
13+
status: GeoStatus.EQUAL,
14+
diff: {
15+
previousCoordinates: null,
16+
coordinates: null,
17+
direction: GeoDirection.Stationary,
18+
distance: 0,
19+
label: "0 kilometers",
20+
unit: "kilometer"
21+
}
22+
})
23+
});
24+
it("returns ADDED when no previous coordinates are provided", () => {
25+
expect(getGeoDiff(null, PARIS)).toStrictEqual({
26+
type: "geo",
27+
status: GeoStatus.ADDED,
28+
diff: {
29+
previousCoordinates: null,
30+
coordinates: PARIS,
31+
direction: GeoDirection.Stationary,
32+
distance: 0,
33+
label: "0 kilometers",
34+
unit: "kilometer"
35+
}
36+
})
37+
});
38+
it("returns DELETED when no current coordinates are provided", () => {
39+
expect(getGeoDiff(PARIS, undefined)).toStrictEqual({
40+
type: "geo",
41+
status: GeoStatus.DELETED,
42+
diff: {
43+
previousCoordinates: PARIS,
44+
coordinates: null,
45+
direction: GeoDirection.Stationary,
46+
distance: 0,
47+
label: "0 kilometers",
48+
unit: "kilometer"
49+
}
50+
})
51+
});
52+
it("returns ERROR when coordinates are invalid", () => {
53+
//@ts-expect-error - we want to test invalid coordinates
54+
expect(getGeoDiff([1, 2, 3], [1, "5"])).toStrictEqual({
55+
type: "geo",
56+
status: GeoStatus.ERROR,
57+
diff: {
58+
previousCoordinates: [1, 2, 3],
59+
coordinates: [1, "5"],
60+
direction: GeoDirection.Stationary,
61+
distance: 0,
62+
label: "0 kilometers",
63+
unit: "kilometer"
64+
}
65+
})
66+
});
67+
it("returns EQUAL when coordinates are identical", () => {
68+
expect(getGeoDiff(PARIS, PARIS)).toStrictEqual({
69+
type: "geo",
70+
status: GeoStatus.EQUAL,
71+
diff: {
72+
coordinates: PARIS,
73+
previousCoordinates: PARIS,
74+
direction: GeoDirection.Stationary,
75+
distance: 0,
76+
label: "0 kilometers",
77+
unit: "kilometer",
78+
},
79+
});
80+
});
81+
it("returns UPDATED when coordinates are slightly different - normal accuracy", () => {
82+
expect(getGeoDiff(PARIS, PARIS_VERY_CLOSE, { unit: "meter", accuracy: "normal" })).toStrictEqual({
83+
type: "geo",
84+
status: GeoStatus.UPDATED,
85+
diff: {
86+
coordinates: PARIS_VERY_CLOSE,
87+
previousCoordinates: PARIS,
88+
direction: GeoDirection.NorthEast,
89+
distance: 1.33,
90+
label: "1.33 meters",
91+
unit: "meter",
92+
},
93+
})
94+
});
95+
it("returns UPDATED when coordinates are slightly different - high accuracy", () => {
96+
expect(getGeoDiff(PARIS, PARIS_VERY_CLOSE, { unit: "meter", accuracy: "high" })).toStrictEqual({
97+
type: "geo",
98+
status: GeoStatus.UPDATED,
99+
diff: {
100+
coordinates: PARIS_VERY_CLOSE,
101+
previousCoordinates: PARIS,
102+
direction: GeoDirection.NorthEast,
103+
distance: 1.33,
104+
label: "1.33 meters",
105+
unit: "meter",
106+
},
107+
})
108+
});
109+
it("returns UPDATED when coordinates are different - normal accuracy", () => {
110+
expect(getGeoDiff(PARIS, LONDON)).toStrictEqual({
111+
"type": "geo",
112+
"status": "updated",
113+
"diff": {
114+
"coordinates": [-0.1278, 51.5074],
115+
"previousCoordinates": [2.3522, 48.8566],
116+
"direction": GeoDirection.NorthWest,
117+
"distance": 343.56,
118+
"label": "343.56 kilometers",
119+
"unit": "kilometer"
120+
}
121+
})
122+
expect(getGeoDiff(PARIS, TOKYO)).toStrictEqual({
123+
"type": "geo",
124+
"status": "updated",
125+
"diff": {
126+
"coordinates": [139.6917, 35.6895],
127+
"previousCoordinates": [2.3522, 48.8566],
128+
"direction": GeoDirection.NorthEast,
129+
"distance": 9712.07,
130+
"label": "9,712.07 kilometers",
131+
"unit": "kilometer"
132+
}
133+
})
134+
})
135+
it("returns UPDATED when coordinates are different - high accuracy", () => {
136+
expect(getGeoDiff(PARIS, LONDON, { accuracy: "high" })).toStrictEqual({
137+
"type": "geo",
138+
"status": "updated",
139+
"diff": {
140+
"coordinates": [-0.1278, 51.5074],
141+
"previousCoordinates": [2.3522, 48.8566],
142+
"direction": GeoDirection.NorthWest,
143+
"distance": 343.92,
144+
"label": "343.92 kilometers",
145+
"unit": "kilometer"
146+
}
147+
})
148+
expect(getGeoDiff(PARIS, TOKYO, { accuracy: "high" })).toStrictEqual({
149+
"type": "geo",
150+
"status": "updated",
151+
"diff": {
152+
"coordinates": [139.6917, 35.6895],
153+
"previousCoordinates": [2.3522, 48.8566],
154+
"direction": GeoDirection.NorthEast,
155+
"distance": 9735.66,
156+
"label": "9,735.66 kilometers",
157+
"unit": "kilometer"
158+
}
159+
})
160+
})
161+
it("properly compute different units", () => {
162+
const formatDiff = (distance: number, label: string, unit: GeoUnit): GeoDiff => ({
163+
type: "geo",
164+
status: GeoStatus.UPDATED,
165+
diff: {
166+
coordinates: LONDON,
167+
previousCoordinates: PARIS,
168+
direction: GeoDirection.NorthWest,
169+
distance,
170+
label,
171+
unit
172+
},
173+
})
174+
expect(getGeoDiff(PARIS, LONDON, { unit: "centimeter", maxDecimals: 0 })).toStrictEqual(formatDiff(34355606, "34,355,606 centimeters", "centimeter"))
175+
expect(getGeoDiff(PARIS, LONDON, { unit: "foot", maxDecimals: 0 })).toStrictEqual(formatDiff(1127152, "1,127,152 feet", "foot"))
176+
expect(getGeoDiff(PARIS, LONDON, { unit: "inch", maxDecimals: 0 })).toStrictEqual(formatDiff(13525836, "13,525,836 inches", "inch"))
177+
expect(getGeoDiff(PARIS, LONDON, { unit: "kilometer", maxDecimals: 0 })).toStrictEqual(formatDiff(344, "344 kilometers", "kilometer"))
178+
expect(getGeoDiff(PARIS, LONDON, { unit: "meter", maxDecimals: 0 })).toStrictEqual(formatDiff(343556, "343,556 meters", "meter"))
179+
expect(getGeoDiff(PARIS, LONDON, { unit: "mile", maxDecimals: 0 })).toStrictEqual(formatDiff(213, "213 miles", "mile"))
180+
expect(getGeoDiff(PARIS, LONDON, { unit: "mile-scandinavian", maxDecimals: 0 })).toStrictEqual(formatDiff(34, "34 miles-scandinavian", "mile-scandinavian"))
181+
expect(getGeoDiff(PARIS, LONDON, { unit: "millimeter", maxDecimals: 0 })).toStrictEqual(formatDiff(343556060, "343,556,060 millimeters", "millimeter"))
182+
expect(getGeoDiff(PARIS, LONDON, { unit: "yard", maxDecimals: 0 })).toStrictEqual(formatDiff(375716, "375,716 yards", "yard"))
183+
});
184+
it("return locale-aware labels", () => {
185+
expect(getGeoDiff(PARIS, LONDON, {
186+
unit: "kilometer",
187+
locale: "it-IT",
188+
maxDecimals: 0,
189+
}).diff.label).toStrictEqual("344 chilometri")
190+
expect(getGeoDiff(PARIS, LONDON, {
191+
unit: "kilometer",
192+
locale: "zh",
193+
maxDecimals: 0,
194+
}).diff.label).toBe("344公里")
195+
});
196+
it("handles maxDecimals", () => {
197+
expect(getGeoDiff(PARIS, LONDON, { unit: "meter", maxDecimals: 0 }).diff.distance).toStrictEqual(343556)
198+
expect(getGeoDiff(PARIS, LONDON, { unit: "meter", maxDecimals: 1 }).diff.distance).toStrictEqual(343556.1)
199+
expect(getGeoDiff(PARIS, LONDON, { unit: "meter", maxDecimals: 5 }).diff.distance).toStrictEqual(343556.06034)
200+
// 2 decimals by default
201+
expect(getGeoDiff(PARIS, LONDON, { unit: "meter" }).diff.distance).toStrictEqual(343556.06)
202+
})
203+
it("falls back to Haversine when Vincenty throws", async () => {
204+
jest.resetModules();
205+
jest.doMock("./vincenty", () => ({
206+
getVincentyDistance: jest.fn(() => {
207+
throw new Error("Vincenty convergence failed");
208+
}),
209+
}));
210+
// eslint-disable-next-line @typescript-eslint/no-require-imports
211+
const { getGeoDiff } = require(".");
212+
expect(getGeoDiff(PARIS, LONDON, { accuracy: "high" })).toStrictEqual({
213+
"type": "geo",
214+
"status": "updated",
215+
"diff": {
216+
"coordinates": [-0.1278, 51.5074],
217+
"previousCoordinates": [2.3522, 48.8566],
218+
"direction": "north-west",
219+
"distance": 343.56,
220+
"label": "343.56 kilometers",
221+
"unit": "kilometer"
222+
}
223+
})
224+
});
225+
});

src/lib/geo-diff/haversine.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { GeoCoordinates } from "@models/geo";
2+
3+
const EARTH_RADIUS_IN_KM = 6371;
4+
5+
export function getHaversineDistance(
6+
previousCoordinates: GeoCoordinates,
7+
coordinates: GeoCoordinates,
8+
): number {
9+
const [userLongitude, userLatitude] = previousCoordinates;
10+
const [targetLongitude, targetLatitude] = coordinates;
11+
const toRadians = (deg: number) => deg * (Math.PI / 180);
12+
const latitudeDifference = toRadians(targetLatitude - userLatitude);
13+
const longitudeDifference = toRadians(targetLongitude - userLongitude);
14+
const sphereScore =
15+
Math.sin(latitudeDifference / 2) * Math.sin(latitudeDifference / 2) +
16+
Math.cos(toRadians(userLatitude)) *
17+
Math.cos(toRadians(targetLatitude)) *
18+
Math.sin(longitudeDifference / 2) *
19+
Math.sin(longitudeDifference / 2);
20+
const arcTangeant =
21+
2 * Math.atan2(Math.sqrt(sphereScore), Math.sqrt(1 - sphereScore));
22+
return EARTH_RADIUS_IN_KM * arcTangeant;
23+
}

src/lib/geo-diff/index.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { DEFAULT_GEODIFF_OPTIONS, GeoCoordinates, GeoDiff, GeoDiffOptions, GeoDirection, GeoStatus, GeoUnit } from "@models/geo";
2+
import { getHaversineDistance } from "./haversine";
3+
import { getVincentyDistance } from "./vincenty";
4+
5+
function getDistanceLabel(distance: number, options: GeoDiffOptions): string {
6+
return new Intl.NumberFormat(options?.locale || DEFAULT_GEODIFF_OPTIONS.locale, {
7+
style: "unit",
8+
unit: options?.unit || "kilometer",
9+
unitDisplay: "long",
10+
maximumFractionDigits: options?.maxDecimals || 2
11+
}).format(distance);
12+
}
13+
14+
function areValidCoordinates(coordinates: GeoCoordinates | null | undefined): coordinates is GeoCoordinates {
15+
if (Array.isArray(coordinates)) {
16+
return coordinates.length === 2 && typeof coordinates[0] === "number" && !isNaN(coordinates[0]) && typeof coordinates[1] === "number" && !isNaN(coordinates[1])
17+
}
18+
return false;
19+
}
20+
21+
function getGeoDirection(previous: GeoCoordinates, current: GeoCoordinates): GeoDirection {
22+
const toRadians = (degrees: number) => degrees * Math.PI / 180;
23+
24+
const [prevLon, prevLat] = previous;
25+
const [currLon, currLat] = current;
26+
27+
const prevLatRad = toRadians(prevLat);
28+
const currLatRad = toRadians(currLat);
29+
const deltaLonRad = toRadians(currLon - prevLon);
30+
31+
const y = Math.sin(deltaLonRad) * Math.cos(currLatRad);
32+
const x = Math.cos(prevLatRad) * Math.sin(currLatRad) -
33+
Math.sin(prevLatRad) * Math.cos(currLatRad) * Math.cos(deltaLonRad);
34+
35+
let bearingDegrees = Math.atan2(y, x) * (180 / Math.PI);
36+
bearingDegrees = (bearingDegrees + 360) % 360; // normalize to 0–360°
37+
38+
// 8 compass directions, 45° sectors (22.5° from center)
39+
if (bearingDegrees >= 337.5 || bearingDegrees < 22.5) return GeoDirection.North;
40+
if (bearingDegrees < 67.5) return GeoDirection.NorthEast;
41+
if (bearingDegrees < 112.5) return GeoDirection.East;
42+
if (bearingDegrees < 157.5) return GeoDirection.SouthEast;
43+
if (bearingDegrees < 202.5) return GeoDirection.South;
44+
if (bearingDegrees < 247.5) return GeoDirection.SouthWest;
45+
if (bearingDegrees < 292.5) return GeoDirection.West;
46+
return GeoDirection.NorthWest;
47+
}
48+
49+
function convertKilometersToUnit(distanceKm: number, unit: GeoUnit): number {
50+
if (unit === "meter") return distanceKm * 1000;
51+
if (unit === "centimeter") return distanceKm * 100000;
52+
if (unit === "millimeter") return distanceKm * 1000000;
53+
if (unit === "mile") return distanceKm * 0.621371;
54+
if (unit === "foot") return distanceKm * 3280.84;
55+
if (unit === "yard") return distanceKm * 1093.61;
56+
if (unit === "inch") return distanceKm * 39370.1;
57+
if (unit === "mile-scandinavian") return distanceKm * 0.1;
58+
return distanceKm;
59+
}
60+
61+
/**
62+
*Compares two coordinates and returns a the distance between them.
63+
* @param {GeoCoordinates | null | undefined} previousCoordinates - The original coordinates.
64+
* @param {GeoCoordinates | null | undefined} coordinates - The current or target coordinates.
65+
* @param {TextDiffOptions} options - Options to refine your output.
66+
- `unit`: centimeters, feet, inches, kilometers, meters, miles, scandinavian miles, millimeters or yards.
67+
- `accuracy`:
68+
- `normal` (default): fastest mode, with a small error margin, based on Haversine formula.
69+
- `high`: slower but exact distance. Based on Vincenty formula.
70+
- `maxDecimals`: maximal decimals for the distance. Defaults to 2.
71+
- `locale`: the locale of your distance. Enables a locale‑aware distance label.
72+
* @returns GeoDiff
73+
*/
74+
export function getGeoDiff(previousCoordinates: GeoCoordinates | undefined | null, coordinates: GeoCoordinates | undefined | null, options: GeoDiffOptions = DEFAULT_GEODIFF_OPTIONS): GeoDiff {
75+
const unit: GeoUnit = options?.unit || DEFAULT_GEODIFF_OPTIONS.unit;
76+
const maxDecimals: number = options?.maxDecimals ?? DEFAULT_GEODIFF_OPTIONS.maxDecimals;
77+
if (areValidCoordinates(previousCoordinates) && areValidCoordinates(coordinates)) {
78+
let distanceKm: number;
79+
if (options.accuracy === "high") {
80+
try {
81+
distanceKm = getVincentyDistance(previousCoordinates, coordinates);
82+
if (typeof distanceKm !== "number" || isNaN(distanceKm)) throw Error
83+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
84+
} catch (_: unknown) {
85+
distanceKm = getHaversineDistance(previousCoordinates, coordinates);
86+
}
87+
} else {
88+
distanceKm = getHaversineDistance(previousCoordinates, coordinates);
89+
}
90+
const distanceNormalized = Number(convertKilometersToUnit(distanceKm, unit).toFixed(maxDecimals));
91+
return {
92+
type: "geo",
93+
status: distanceKm === 0 ? GeoStatus.EQUAL : GeoStatus.UPDATED,
94+
diff: {
95+
coordinates,
96+
previousCoordinates,
97+
direction: distanceKm === 0 ? GeoDirection.Stationary : getGeoDirection(previousCoordinates, coordinates),
98+
distance: distanceNormalized,
99+
label: getDistanceLabel(distanceNormalized, options),
100+
unit
101+
}
102+
}
103+
}
104+
return {
105+
type: "geo",
106+
status: !previousCoordinates && !coordinates ? GeoStatus.EQUAL : !previousCoordinates ? GeoStatus.ADDED : !coordinates ? GeoStatus.DELETED : GeoStatus.ERROR,
107+
diff: {
108+
previousCoordinates: previousCoordinates || null,
109+
coordinates: coordinates || null,
110+
direction: GeoDirection.Stationary,
111+
distance: 0,
112+
label: getDistanceLabel(0, options),
113+
unit
114+
}
115+
}
116+
}

0 commit comments

Comments
 (0)