diff --git a/.changeset/beige-pots-invite.md b/.changeset/beige-pots-invite.md new file mode 100644 index 0000000..3486218 --- /dev/null +++ b/.changeset/beige-pots-invite.md @@ -0,0 +1,5 @@ +--- +"@bnidev/js-utils": minor +--- + +feat(math): add `distance` utility to calculate the distance between two points diff --git a/.changeset/four-news-stick.md b/.changeset/four-news-stick.md new file mode 100644 index 0000000..7679bc7 --- /dev/null +++ b/.changeset/four-news-stick.md @@ -0,0 +1,5 @@ +--- +"@bnidev/js-utils": minor +--- + +feat(math): add `degreesToRadians` and `radiansToDegrees` utilities to convert between degrees and radians diff --git a/.changeset/full-toys-film.md b/.changeset/full-toys-film.md new file mode 100644 index 0000000..a876fce --- /dev/null +++ b/.changeset/full-toys-film.md @@ -0,0 +1,5 @@ +--- +"@bnidev/js-utils": minor +--- + +feat(math): add `haversineDistance` utility to calculate the distance between two geographic coordinates diff --git a/.changeset/two-bobcats-draw.md b/.changeset/two-bobcats-draw.md new file mode 100644 index 0000000..931b348 --- /dev/null +++ b/.changeset/two-bobcats-draw.md @@ -0,0 +1,5 @@ +--- +"@bnidev/js-utils": minor +--- + +feat(math): add `pointInCircle` utility to check whether a point lies inside or on the boundary of a circle diff --git a/src/index.ts b/src/index.ts index 743ed65..e05f6a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,8 @@ export * from './async' export * from './dom' +export * from './math' + export * from './object' export * from './string' diff --git a/src/math/__tests__/degreesToRadians.test.ts b/src/math/__tests__/degreesToRadians.test.ts new file mode 100644 index 0000000..b8b13c8 --- /dev/null +++ b/src/math/__tests__/degreesToRadians.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { degreesToRadians } from '../degreesToRadians' + +describe('degreesToRadians', () => { + it('converts 0 degrees to 0 radians', () => { + expect(degreesToRadians(0)).toBe(0) + }) + + it('converts 180 degrees to PI radians', () => { + expect(degreesToRadians(180)).toBeCloseTo(Math.PI) + }) + + it('converts 90 degrees to PI/2 radians', () => { + expect(degreesToRadians(90)).toBeCloseTo(Math.PI / 2) + }) + + it('converts negative degrees', () => { + expect(degreesToRadians(-180)).toBeCloseTo(-Math.PI) + }) + + it('throws if input is not a number', () => { + // @ts-expect-error + expect(() => degreesToRadians('foo')).toThrow(TypeError) + }) + + it('throws if input is undefined or null', () => { + // @ts-expect-error + expect(() => degreesToRadians(undefined)).toThrow(TypeError) + // @ts-expect-error + expect(() => degreesToRadians(null)).toThrow(TypeError) + }) +}) diff --git a/src/math/__tests__/distance.test.ts b/src/math/__tests__/distance.test.ts new file mode 100644 index 0000000..c154f97 --- /dev/null +++ b/src/math/__tests__/distance.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { distance } from '../distance' + +describe('distance', () => { + it('calculates distance between (0,0) and (3,4)', () => { + expect(distance(0, 0, 3, 4)).toBe(5) + }) + + it('returns 0 for the same point', () => { + expect(distance(1, 1, 1, 1)).toBe(0) + }) + + it('calculates distance with negative coordinates', () => { + expect(distance(-1, -1, 2, 3)).toBe(5) + }) + + it('calculates distance for horizontal line', () => { + expect(distance(2, 5, 7, 5)).toBe(5) + }) + + it('calculates distance for vertical line', () => { + expect(distance(3, 2, 3, 7)).toBe(5) + }) +}) diff --git a/src/math/__tests__/haversineDistance.test.ts b/src/math/__tests__/haversineDistance.test.ts new file mode 100644 index 0000000..716292a --- /dev/null +++ b/src/math/__tests__/haversineDistance.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { haversineDistance } from '../haversineDistance' + +describe('haversineDistance', () => { + it('returns 0 for identical points', () => { + expect(haversineDistance(10, 20, 10, 20)).toBe(0) + }) + + it('calculates distance in meters', () => { + const dist = haversineDistance( + 52.2296756, + 21.0122287, + 41.89193, + 12.51133, + 'meters' + ) + expect(dist).toBeGreaterThan(1_200_000) + expect(dist).toBeLessThan(1_250_000) + }) + + it('calculates distance in kilometers', () => { + const dist = haversineDistance( + 52.2296756, + 21.0122287, + 41.89193, + 12.51133, + 'kilometers' + ) + expect(dist).toBeGreaterThan(1220) + expect(dist).toBeLessThan(1230) + }) + + it('calculates distance in miles', () => { + const dist = haversineDistance( + 52.2296756, + 21.0122287, + 41.89193, + 12.51133, + 'miles' + ) + expect(dist).toBeGreaterThan(750) + expect(dist).toBeLessThan(770) + }) + + it('calculates distance in yards', () => { + const dist = haversineDistance( + 52.2296756, + 21.0122287, + 41.89193, + 12.51133, + 'yards' + ) + expect(dist).toBeGreaterThan(1_320_000) + expect(dist).toBeLessThan(1_340_000) + }) + + it('does not throws for valid unit', () => { + expect(() => haversineDistance(0, 0, 1, 1, 'miles')).not.toThrow() + expect(() => haversineDistance(0, 0, 1, 1, 'kilometers')).not.toThrow() + expect(() => haversineDistance(0, 0, 1, 1, 'meters')).not.toThrow() + expect(() => haversineDistance(0, 0, 1, 1, 'yards')).not.toThrow() + }) + + it('throws for invalid unit', () => { + // @ts-expect-error + expect(() => haversineDistance(0, 0, 1, 1, 'feet')).toThrow(TypeError) + }) + + it('throws for missing coordinates', () => { + // @ts-expect-error + expect(() => haversineDistance(undefined, 0, 1, 1)).toThrow() + }) + + it('throws when coordinates are not numbers', () => { + // @ts-expect-error + expect(() => haversineDistance('a', 0, 1, 1)).toThrow() + // @ts-expect-error + expect(() => haversineDistance(0, 'b', 1, 1)).toThrow() + // @ts-expect-error + expect(() => haversineDistance(0, 0, 'c', 1)).toThrow() + // @ts-expect-error + expect(() => haversineDistance(0, 0, 1, 'd')).toThrow() + }) +}) diff --git a/src/math/__tests__/pointInCircle.test.ts b/src/math/__tests__/pointInCircle.test.ts new file mode 100644 index 0000000..2611bc1 --- /dev/null +++ b/src/math/__tests__/pointInCircle.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { pointInCircle } from '../pointInCircle' + +describe('pointInCircle', () => { + it('returns true for a point inside the circle', () => { + expect(pointInCircle(1, 1, 0, 0, 2)).toBe(true) + }) + + it('returns true for a point on the boundary of the circle', () => { + expect(pointInCircle(2, 0, 0, 0, 2)).toBe(true) + }) + + it('returns false for a point outside the circle', () => { + expect(pointInCircle(3, 0, 0, 0, 2)).toBe(false) + }) + + it('works with negative coordinates', () => { + expect(pointInCircle(-1, -1, 0, 0, 2)).toBe(true) + expect(pointInCircle(-3, 0, 0, 0, 2)).toBe(false) + }) + + it('throws if any parameter is missing', () => { + // @ts-expect-error + expect(() => pointInCircle(1, 1, 0, 0)).toThrow(TypeError) + }) + + it('throws if any parameter is not a number', () => { + // @ts-expect-error + expect(() => pointInCircle('a', 1, 0, 0, 2)).toThrow(TypeError) + }) +}) diff --git a/src/math/__tests__/radiansToDegrees.test.ts b/src/math/__tests__/radiansToDegrees.test.ts new file mode 100644 index 0000000..ce044bd --- /dev/null +++ b/src/math/__tests__/radiansToDegrees.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { radiansToDegrees } from '../radiansToDegrees' + +describe('radiansToDegrees', () => { + it('converts 0 radians to 0 degrees', () => { + expect(radiansToDegrees(0)).toBe(0) + }) + + it('converts PI radians to 180 degrees', () => { + expect(radiansToDegrees(Math.PI)).toBe(180) + }) + + it('converts PI/2 radians to 90 degrees', () => { + expect(radiansToDegrees(Math.PI / 2)).toBe(90) + }) + + it('converts negative radians', () => { + expect(radiansToDegrees(-Math.PI)).toBe(-180) + }) + + it('throws if input is not a number', () => { + // @ts-expect-error + expect(() => radiansToDegrees('foo')).toThrow(TypeError) + }) + + it('throws if input is undefined or null', () => { + // @ts-expect-error + expect(() => radiansToDegrees(undefined)).toThrow(TypeError) + // @ts-expect-error + expect(() => radiansToDegrees(null)).toThrow(TypeError) + }) +}) diff --git a/src/math/degreesToRadians.ts b/src/math/degreesToRadians.ts new file mode 100644 index 0000000..41fac21 --- /dev/null +++ b/src/math/degreesToRadians.ts @@ -0,0 +1,28 @@ +/** + * Converts degrees to radians. + * @param degrees - The angle in degrees. + * @returns The angle in radians. + * + * @category Math + * + * @example Imports + * ```ts + * // ES Module + * import { degreesToRadians } from '@bnidev/js-utils' + * + * // CommonJS + * const { degreesToRadians } = require('@bnidev/js-utils') + * ``` + * + * @example Usage + * ```ts + * const radians = degreesToRadians(180) // radians will be π (approximately 3.14159) + * ``` + */ +export function degreesToRadians(degrees: number): number { + if (typeof degrees !== 'number') { + throw new TypeError('Input must be a number') + } + + return degrees * (Math.PI / 180) +} diff --git a/src/math/distance.ts b/src/math/distance.ts new file mode 100644 index 0000000..2f623fa --- /dev/null +++ b/src/math/distance.ts @@ -0,0 +1,34 @@ +/** + * Calculates the Euclidean distance between two points in a 2D space. + * @param x1 - The x-coordinate of the first point. + * @param y1 - The y-coordinate of the first point. + * @param x2 - The x-coordinate of the second point. + * @param y2 - The y-coordinate of the second point. + * @returns The distance between the two points. + * + * @category Math + * + * @example Imports + * ```ts + * // ES Module + * import { distance } from '@bnidev/js-utils' + * + * // CommonJS + * const { distance } = require('@bnidev/js-utils') + * ``` + * + * @example Usage + * ```ts + * const dist = distance(0, 0, 3, 4) // dist will be 5 + * ``` + */ +export function distance( + x1: number, + y1: number, + x2: number, + y2: number +): number { + const dx = x2 - x1 + const dy = y2 - y1 + return Math.sqrt(dx * dx + dy * dy) +} diff --git a/src/math/haversineDistance.ts b/src/math/haversineDistance.ts new file mode 100644 index 0000000..8ba02a8 --- /dev/null +++ b/src/math/haversineDistance.ts @@ -0,0 +1,132 @@ +import { degreesToRadians as toRadians } from './degreesToRadians' + +/** + * The radius of the Earth in meters. + */ +const EARTH_RADIUS_METERS = 6_371_000 + +/** + * Unit of measurement for distance. + * + * @remarks + * This type defines the units that can be used for distance calculations in the `haversineDistance` function. + * + * @category Math + * + * @example Imports + * ```ts + * // ES Module + * import { DistanceUnit } from '@bnidev/js-utils' + * + * // CommonJS + * const { DistanceUnit } = require('@bnidev/js-utils') + * ``` + * + * @example Usage + * ```ts + * const unit: DistanceUnit = 'kilometers' + * // This can be used in the haversineDistance function to specify the unit of measurement. + * ``` + */ +export type DistanceUnit = 'meters' | 'kilometers' | 'miles' | 'yards' + +/** + * Converts a distance in meters to the specified unit. + * + * @internal + */ +const convertRadius = (meters: number, toUnit: DistanceUnit): number => { + const conversions = { + meters: 1, + kilometers: 1 / 1_000, + miles: 1 / 1_609.344, + yards: 1.09361 + } + + return meters * conversions[toUnit] +} + +/** + * Calculates the Haversine distance between two geographical points. + * @param lat1 - Latitude of the first point in degrees. + * @param lon1 - Longitude of the first point in degrees. + * @param lat2 - Latitude of the second point in degrees. + * @param lon2 - Longitude of the second point in degrees. + * @param unit - The unit of measurement for the result ('meters', 'kilometers', or 'miles'). + * @returns The distance between the two points in the specified unit. + * + * @category Math + * + * @example Imports + * ```ts + * // ES Module + * import { haversineDistance } from '@bnidev/js-utils' + * + * // CommonJS + * const { haversineDistance } = require('@bnidev/js-utils') + * ``` + * + * @example Usage + * ```ts + * const dist = haversineDistance(52.2296756, 21.0122287, 41.8919300, 12.5113300, 'kilometers') + * // dist will be approximately 1317.19 kilometers + * ``` + */ +export function haversineDistance( + lat1: number, + lon1: number, + lat2: number, + lon2: number, + unit: DistanceUnit = 'meters' +): number { + if ( + lat1 === undefined || + lat1 === null || + lon1 === undefined || + lon1 === null || + lat2 === undefined || + lat2 === null || + lon2 === undefined || + lon2 === null + ) { + throw new TypeError('Latitude and longitude must be provided') + } + + if (lat1 === lat2 && lon1 === lon2) { + return 0 + } + + if ( + typeof lat1 !== 'number' || + typeof lon1 !== 'number' || + typeof lat2 !== 'number' || + typeof lon2 !== 'number' + ) { + throw new TypeError('Latitude and longitude must be numbers') + } + + const validUnits: DistanceUnit[] = ['meters', 'kilometers', 'miles', 'yards'] + + if (typeof unit !== 'string' || !validUnits.includes(unit)) { + throw new TypeError( + 'Unit must be one of "meters", "kilometers", "miles", or "yards"' + ) + } + + const R = convertRadius(EARTH_RADIUS_METERS, unit) + const lat1Rad = toRadians(lat1) + const lat2Rad = toRadians(lat2) + const deltaLat = toRadians(lat2 - lat1) + const deltaLon = toRadians(lon2 - lon1) + + const a = + Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2) + + Math.cos(lat1Rad) * + Math.cos(lat2Rad) * + Math.sin(deltaLat / 2) * + Math.sin(deltaLat / 2) + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + + return R * c +} diff --git a/src/math/index.ts b/src/math/index.ts new file mode 100644 index 0000000..6735355 --- /dev/null +++ b/src/math/index.ts @@ -0,0 +1,7 @@ +// Export all modules from the 'math' directory + +export * from './degreesToRadians' +export * from './distance' +export * from './haversineDistance' +export * from './pointInCircle' +export * from './radiansToDegrees' diff --git a/src/math/pointInCircle.ts b/src/math/pointInCircle.ts new file mode 100644 index 0000000..995b71f --- /dev/null +++ b/src/math/pointInCircle.ts @@ -0,0 +1,58 @@ +/** + * Checks if a point is inside or on the boundary of a circle. + * + * @param {number} pointX - The x-coordinate of the point. + * @param {number} pointY - The y-coordinate of the point. + * @param {number} circleX - The x-coordinate of the circle's center. + * @param {number} circleY - The y-coordinate of the circle's center. + * @param {number} radius - The radius of the circle. + * @returns {boolean} True if the point is inside or on the boundary of the circle, false otherwise. + * @throws {TypeError} If any parameter is missing or not a number. + * + * @category Math + * + * @example Imports + * ```ts + * // ES Module + * import { pointInCircle } from '@bnidev/js-utils' + * + * // CommonJS + * const { pointInCircle } = require('@bnidev/js-utils') + * ``` + * + * @example Usage + * ```ts + * const isInside = pointInCircle(1, 1, 0, 0, 2) // true, since (1,1) is inside the circle centered at (0,0) with radius 2 + * const isOnBoundary = pointInCircle(2, 0, 0, 0, 2) // true, since (2,0) is on the boundary of the circle centered at (0,0) with radius 2 + */ +export function pointInCircle( + pointX: number, + pointY: number, + circleX: number, + circleY: number, + radius: number +): boolean { + if ( + pointX === undefined || + pointY === undefined || + circleX === undefined || + circleY === undefined || + radius === undefined + ) { + throw new TypeError('All parameters must be provided') + } + + if ( + typeof pointX !== 'number' || + typeof pointY !== 'number' || + typeof circleX !== 'number' || + typeof circleY !== 'number' || + typeof radius !== 'number' + ) { + throw new TypeError('All parameters must be numbers') + } + + const dx = pointX - circleX + const dy = pointY - circleY + return dx * dx + dy * dy <= radius * radius +} diff --git a/src/math/radiansToDegrees.ts b/src/math/radiansToDegrees.ts new file mode 100644 index 0000000..b6dae36 --- /dev/null +++ b/src/math/radiansToDegrees.ts @@ -0,0 +1,33 @@ +/** + * Converts an angle from radians to degrees. + * + * @param {number} radians - The angle in radians to convert. + * @returns {number} The angle in degrees. + * @throws {TypeError} If the input is not a number. + * + * @category Math + * + * @example Imports + * ```ts + * // ES Module + * import { radiansToDegrees } from '@bnidev/js-utils' + * + * // CommonJS + * const { radiansToDegrees } = require('@bnidev/js-utils') + * ``` + * + * @example Usage + * ```ts + * const degrees = radiansToDegrees(Math.PI) // degrees will be 180 + * // Convert 90 degrees to radians and back + * const radians = Math.PI / 2 + * const degrees = radiansToDegrees(radians) // degrees will be 90 + * ``` + */ +export function radiansToDegrees(radians: number): number { + if (typeof radians !== 'number') { + throw new TypeError('Input must be a number') + } + + return radians * (180 / Math.PI) +} diff --git a/tsconfig.json b/tsconfig.json index a0ee8c2..0fb48e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, + "strictNullChecks": true, "noImplicitAny": true, "noUncheckedIndexedAccess": true, "noUnusedLocals": true,