Initial release: qibla v1.0.0

This commit is contained in:
Aric Camarata 2026-03-08 12:43:55 -04:00
parent 24cb7b13db
commit ac27aedaec
17 changed files with 3158 additions and 0 deletions

14
.editorconfig Normal file
View file

@ -0,0 +1,14 @@
root = true
[*]
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{js,mjs,cjs,ts,mts,json,yaml,yml,md}]
indent_style = space
indent_size = 2
[Makefile]
indent_style = tab

82
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,82 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: node --test test.mjs
- run: node --test test-cjs.cjs
lint:
name: Lint & Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run format:check
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run typecheck
pack-check:
name: Pack Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Verify pack contents
run: |
npm pack --dry-run 2>&1 | tee pack-output.txt
grep "dist/index.cjs" pack-output.txt
grep "dist/index.mjs" pack-output.txt
grep "dist/index.d.ts" pack-output.txt
grep "dist/index.d.mts" pack-output.txt
echo "Pack check passed"

18
.gitignore vendored Normal file
View file

@ -0,0 +1,18 @@
node_modules/
dist/
*.tgz
*.log
.DS_Store
.claude/
.env
.env.*
.vscode/*
.idea/
.codex/
.cursor/
.aider/
.aider.chat.history.md
.continue/
.windsurf/
.gemini/
.codeium/

1
.npmrc Normal file
View file

@ -0,0 +1 @@

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
24

1
.prettierrc Normal file
View file

@ -0,0 +1 @@
{}

17
CHANGELOG.md Normal file
View file

@ -0,0 +1,17 @@
# Changelog
All notable changes to this project will be documented in this file.
## [1.0.0] - 2026-03-08
### Added
- `qiblaAngle(lat, lng)` computes bearing to Ka'bah in degrees from north
- `compassDir(bearing)` returns 8-point compass abbreviation
- `compassName(bearing)` returns full compass direction name
- `qiblaGreatCircle(lat, lng, steps?)` generates great-circle waypoints to Ka'bah
- `distanceKm(lat1, lng1, lat2, lng2)` computes haversine distance
- `KAABA_LAT`, `KAABA_LNG`, `EARTH_RADIUS_KM` constants
- Input validation with RangeError for out-of-bounds coordinates
- Dual CJS/ESM build with full TypeScript definitions
- Comprehensive test suite (46 ESM + 14 CJS tests)

95
README.md Normal file
View file

@ -0,0 +1,95 @@
# qibla
[![npm version](https://img.shields.io/npm/v/qibla.svg)](https://www.npmjs.com/package/qibla)
[![CI](https://github.com/acamarata/qibla/actions/workflows/ci.yml/badge.svg)](https://github.com/acamarata/qibla/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Qibla direction, great-circle path, and haversine distance. Pure math, zero dependencies.
## Installation
```bash
npm install qibla
```
## Quick Start
```typescript
import {
qiblaAngle,
compassDir,
distanceKm,
KAABA_LAT,
KAABA_LNG,
} from "qibla";
// Bearing from New York to the Ka'bah
const bearing = qiblaAngle(40.7128, -74.006);
console.log(bearing); // ~58.48
console.log(compassDir(bearing)); // "NE"
// Distance in kilometers
const km = distanceKm(40.7128, -74.006, KAABA_LAT, KAABA_LNG);
console.log(km); // ~9,634
```
## API
### `qiblaAngle(lat, lng): number`
Computes the initial bearing (forward azimuth) from the given coordinates to the Ka'bah.
| Parameter | Type | Description |
| ----------- | -------- | ----------------------------------------------- |
| `lat` | `number` | Latitude in decimal degrees (-90 to 90) |
| `lng` | `number` | Longitude in decimal degrees (-180 to 180) |
| **Returns** | `number` | Bearing in degrees clockwise from north (0-360) |
Throws `RangeError` if coordinates are out of bounds.
### `compassDir(bearing): CompassAbbr`
Eight-point compass abbreviation: N, NE, E, SE, S, SW, W, NW.
### `compassName(bearing): CompassName`
Full compass name: North, Northeast, East, Southeast, South, Southwest, West, Northwest.
### `qiblaGreatCircle(lat, lng, steps?): [number, number][]`
Generates waypoints along the great circle from [lat, lng] to the Ka'bah using spherical linear interpolation (Slerp). Returns `steps + 1` points (default: 121).
Useful for drawing Qibla direction lines on maps.
### `distanceKm(lat1, lng1, lat2, lng2): number`
Haversine distance between two points in kilometers (spherical Earth approximation, R = 6,371 km).
### Constants
| Name | Value | Description |
| ----------------- | --------- | -------------------------------------- |
| `KAABA_LAT` | 21.422511 | Ka'bah center latitude (degrees north) |
| `KAABA_LNG` | 39.826150 | Ka'bah center longitude (degrees east) |
| `EARTH_RADIUS_KM` | 6371 | WGS-84 volumetric mean radius |
## Compatibility
Node.js 20+. Works in browsers and all major bundlers (Webpack, Vite, Rollup, esbuild). Ships as dual CJS/ESM with full TypeScript definitions.
## TypeScript
```typescript
import { qiblaAngle, CompassAbbr, CompassName } from "qibla";
const bearing: number = qiblaAngle(40.7128, -74.006);
```
## Related
- [pray-calc](https://github.com/acamarata/pray-calc) - Islamic prayer times calculator
- [nrel-spa](https://github.com/acamarata/nrel-spa) - NREL Solar Position Algorithm
## License
[MIT](LICENSE)

10
eslint.config.js Normal file
View file

@ -0,0 +1,10 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ["dist/", "*.cjs"],
},
);

80
package.json Normal file
View file

@ -0,0 +1,80 @@
{
"name": "qibla",
"version": "1.0.0",
"description": "Qibla direction, great-circle path, and haversine distance. Pure math, zero dependencies.",
"author": "Aric Camarata",
"license": "MIT",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"sideEffects": false,
"files": [
"dist/index.cjs",
"dist/index.mjs",
"dist/index.d.ts",
"dist/index.d.cts"
],
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit",
"pretest": "tsup",
"test": "node --test test.mjs && node --test test-cjs.cjs",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"prepublishOnly": "tsup"
},
"repository": {
"type": "git",
"url": "git+https://github.com/acamarata/qibla.git"
},
"homepage": "https://github.com/acamarata/qibla#readme",
"bugs": {
"url": "https://github.com/acamarata/qibla/issues"
},
"keywords": [
"qibla",
"kaaba",
"mecca",
"compass",
"bearing",
"direction",
"great-circle",
"haversine",
"distance",
"geodesic",
"islamic",
"prayer",
"geolocation",
"spherical-trigonometry"
],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"devDependencies": {
"@eslint/js": "^9.27.0",
"@types/node": "^22.15.3",
"eslint": "^9.27.0",
"prettier": "^3.5.3",
"tsup": "^8.4.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.1"
}
}

2384
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild

170
src/index.ts Normal file
View file

@ -0,0 +1,170 @@
/**
* Qibla direction utilities. Pure math, zero external dependencies.
*
* Computes the initial bearing (forward azimuth) from any point on Earth to
* the Ka'bah using the spherical law of cosines. Includes compass direction
* lookup, great-circle interpolation, and haversine distance.
*
* Ka'bah coordinates sourced from verified GPS data.
*
* @module
*/
/** Latitude of the Ka'bah center, Masjid al-Haram, Mecca (degrees north). */
export const KAABA_LAT = 21.422511;
/** Longitude of the Ka'bah center, Masjid al-Haram, Mecca (degrees east). */
export const KAABA_LNG = 39.82615;
/** Mean radius of the Earth in kilometers (WGS-84 volumetric mean). */
export const EARTH_RADIUS_KM = 6371;
const DEG = Math.PI / 180;
/**
* Qibla bearing in degrees clockwise from true north.
*
* Uses the forward azimuth formula from spherical trigonometry.
* Result range: [0, 360).
*
* @param lat - Observer latitude in decimal degrees (-90 to 90).
* @param lng - Observer longitude in decimal degrees (-180 to 180).
* @returns Bearing in degrees clockwise from north (0 = N, 90 = E, 180 = S, 270 = W).
* @throws {RangeError} If latitude is outside [-90, 90] or longitude outside [-180, 180].
*/
export function qiblaAngle(lat: number, lng: number): number {
if (lat < -90 || lat > 90) {
throw new RangeError(`Latitude must be between -90 and 90, got ${lat}`);
}
if (lng < -180 || lng > 180) {
throw new RangeError(`Longitude must be between -180 and 180, got ${lng}`);
}
const φ1 = lat * DEG,
λ1 = lng * DEG;
const φ2 = KAABA_LAT * DEG,
λ2 = KAABA_LNG * DEG;
const y = Math.sin(λ2 - λ1) * Math.cos(φ2);
const x =
Math.cos(φ1) * Math.sin(φ2) -
Math.sin(φ1) * Math.cos(φ2) * Math.cos(λ2 - λ1);
return (Math.atan2(y, x) / DEG + 360) % 360;
}
/** Eight-point compass abbreviations. */
const COMPASS_ABBR = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const;
/** Eight-point compass full names. */
const COMPASS_NAMES = [
"North",
"Northeast",
"East",
"Southeast",
"South",
"Southwest",
"West",
"Northwest",
] as const;
/** Compass abbreviation type. */
export type CompassAbbr = (typeof COMPASS_ABBR)[number];
/** Compass full name type. */
export type CompassName = (typeof COMPASS_NAMES)[number];
/**
* Eight-point compass abbreviation for a bearing.
*
* @param bearing - Bearing in degrees (0-360).
* @returns Two-letter compass abbreviation (N, NE, E, SE, S, SW, W, NW).
*/
export function compassDir(bearing: number): CompassAbbr {
return COMPASS_ABBR[Math.round(bearing / 45) % 8];
}
/**
* Full compass direction name for a bearing.
*
* @param bearing - Bearing in degrees (0-360).
* @returns Full direction name (North, Northeast, etc.).
*/
export function compassName(bearing: number): CompassName {
return COMPASS_NAMES[Math.round(bearing / 45) % 8];
}
/**
* Great-circle waypoints from [lat, lng] to the Ka'bah.
*
* Uses the Slerp (spherical linear interpolation) formula. Useful for
* drawing Qibla direction lines on maps.
*
* @param lat - Origin latitude in decimal degrees.
* @param lng - Origin longitude in decimal degrees.
* @param steps - Number of segments (default: 120, producing 121 points).
* @returns Array of [latitude, longitude] pairs in degrees.
* @throws {RangeError} If latitude is outside [-90, 90] or longitude outside [-180, 180].
*/
export function qiblaGreatCircle(
lat: number,
lng: number,
steps = 120,
): [number, number][] {
if (lat < -90 || lat > 90) {
throw new RangeError(`Latitude must be between -90 and 90, got ${lat}`);
}
if (lng < -180 || lng > 180) {
throw new RangeError(`Longitude must be between -180 and 180, got ${lng}`);
}
const φ1 = lat * DEG,
λ1 = lng * DEG;
const φ2 = KAABA_LAT * DEG,
λ2 = KAABA_LNG * DEG;
const d =
2 *
Math.asin(
Math.sqrt(
Math.sin((φ2 - φ1) / 2) ** 2 +
Math.cos(φ1) * Math.cos(φ2) * Math.sin((λ2 - λ1) / 2) ** 2,
),
);
if (d === 0) return [[lat, lng]];
const points: [number, number][] = [];
for (let i = 0; i <= steps; i++) {
const f = i / steps;
const A = Math.sin((1 - f) * d) / Math.sin(d);
const B = Math.sin(f * d) / Math.sin(d);
const x = A * Math.cos(φ1) * Math.cos(λ1) + B * Math.cos(φ2) * Math.cos(λ2);
const y = A * Math.cos(φ1) * Math.sin(λ1) + B * Math.cos(φ2) * Math.sin(λ2);
const z = A * Math.sin(φ1) + B * Math.sin(φ2);
points.push([
Math.atan2(z, Math.sqrt(x * x + y * y)) / DEG,
Math.atan2(y, x) / DEG,
]);
}
return points;
}
/**
* Haversine distance between two coordinate pairs.
*
* @param lat1 - First point latitude in decimal degrees.
* @param lng1 - First point longitude in decimal degrees.
* @param lat2 - Second point latitude in decimal degrees.
* @param lng2 - Second point longitude in decimal degrees.
* @returns Distance in kilometers (spherical Earth approximation).
*/
export function distanceKm(
lat1: number,
lng1: number,
lat2: number,
lng2: number,
): number {
const dLat = (lat2 - lat1) * DEG;
const dLng = (lng2 - lng1) * DEG;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * DEG) * Math.cos(lat2 * DEG) * Math.sin(dLng / 2) ** 2;
return EARTH_RADIUS_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

65
test-cjs.cjs Normal file
View file

@ -0,0 +1,65 @@
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const {
qiblaAngle,
compassDir,
compassName,
qiblaGreatCircle,
distanceKm,
KAABA_LAT,
KAABA_LNG,
EARTH_RADIUS_KM,
} = require("./dist/index.cjs");
describe("CJS: qiblaAngle", () => {
it("NYC bearing is ~58° NE", () => {
const angle = qiblaAngle(40.7128, -74.006);
assert.ok(angle > 50 && angle < 70);
});
it("London bearing is ~119° SE", () => {
const angle = qiblaAngle(51.5074, -0.1278);
assert.ok(angle > 110 && angle < 130);
});
it("throws for invalid latitude", () => {
assert.throws(() => qiblaAngle(91, 0), RangeError);
});
});
describe("CJS: compassDir", () => {
it("N for 0°", () => assert.strictEqual(compassDir(0), "N"));
it("NE for 45°", () => assert.strictEqual(compassDir(45), "NE"));
it("S for 180°", () => assert.strictEqual(compassDir(180), "S"));
});
describe("CJS: compassName", () => {
it("North for 0°", () => assert.strictEqual(compassName(0), "North"));
it("Southeast for 135°", () =>
assert.strictEqual(compassName(135), "Southeast"));
});
describe("CJS: qiblaGreatCircle", () => {
it("returns 121 points by default", () => {
assert.strictEqual(qiblaGreatCircle(40.7128, -74.006).length, 121);
});
it("single point at Ka'bah", () => {
assert.strictEqual(qiblaGreatCircle(KAABA_LAT, KAABA_LNG).length, 1);
});
});
describe("CJS: distanceKm", () => {
it("NYC to Ka'bah ~9600 km", () => {
const km = distanceKm(40.7128, -74.006, KAABA_LAT, KAABA_LNG);
assert.ok(km > 9000 && km < 10500);
});
it("symmetric", () => {
const d1 = distanceKm(40, -74, KAABA_LAT, KAABA_LNG);
const d2 = distanceKm(KAABA_LAT, KAABA_LNG, 40, -74);
assert.ok(Math.abs(d1 - d2) < 0.001);
});
});
describe("CJS: constants", () => {
it("EARTH_RADIUS_KM is 6371", () => {
assert.strictEqual(EARTH_RADIUS_KM, 6371);
});
});

184
test.mjs Normal file
View file

@ -0,0 +1,184 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
qiblaAngle,
compassDir,
compassName,
qiblaGreatCircle,
distanceKm,
KAABA_LAT,
KAABA_LNG,
EARTH_RADIUS_KM,
} from "./dist/index.mjs";
describe("KAABA constants", () => {
it("latitude is approximately 21.42°N", () => {
assert.ok(Math.abs(KAABA_LAT - 21.42) < 0.1);
});
it("longitude is approximately 39.83°E", () => {
assert.ok(Math.abs(KAABA_LNG - 39.83) < 0.1);
});
it("EARTH_RADIUS_KM is 6371", () => {
assert.strictEqual(EARTH_RADIUS_KM, 6371);
});
});
describe("qiblaAngle", () => {
it("returns a number between 0 and 360", () => {
const angle = qiblaAngle(40.7128, -74.006);
assert.ok(angle >= 0 && angle < 360);
});
it("New York City (~58° NE)", () => {
const angle = qiblaAngle(40.7128, -74.006);
assert.ok(angle > 50 && angle < 70, `Expected 50-70, got ${angle}`);
});
it("London (~119° SE)", () => {
const angle = qiblaAngle(51.5074, -0.1278);
assert.ok(angle > 110 && angle < 130, `Expected 110-130, got ${angle}`);
});
it("Tokyo (~293° NW)", () => {
const angle = qiblaAngle(35.6762, 139.6503);
assert.ok(angle > 280 && angle < 310, `Expected 280-310, got ${angle}`);
});
it("Sydney (~277° W)", () => {
const angle = qiblaAngle(-33.8688, 151.2093);
assert.ok(angle > 260 && angle < 300, `Expected 260-300, got ${angle}`);
});
it("Islamabad (~268° W)", () => {
const angle = qiblaAngle(33.6844, 73.0479);
assert.ok(angle > 250 && angle < 290, `Expected 250-290, got ${angle}`);
});
it("returns finite number at Ka'bah (degenerate case)", () => {
const angle = qiblaAngle(KAABA_LAT, KAABA_LNG);
assert.ok(Number.isFinite(angle));
});
it("equator east of Mecca points NW", () => {
const angle = qiblaAngle(0, 80);
assert.ok(angle > 270 && angle < 360, `Expected 270-360, got ${angle}`);
});
it("result is stable (same input gives same output)", () => {
const a = qiblaAngle(40.7128, -74.006);
const b = qiblaAngle(40.7128, -74.006);
assert.strictEqual(a, b);
});
it("throws RangeError for invalid latitude", () => {
assert.throws(() => qiblaAngle(91, 0), RangeError);
assert.throws(() => qiblaAngle(-91, 0), RangeError);
});
it("throws RangeError for invalid longitude", () => {
assert.throws(() => qiblaAngle(0, 181), RangeError);
assert.throws(() => qiblaAngle(0, -181), RangeError);
});
});
describe("compassDir", () => {
it("returns N for 0°", () => assert.strictEqual(compassDir(0), "N"));
it("returns N for 360°", () => assert.strictEqual(compassDir(360), "N"));
it("returns NE for 45°", () => assert.strictEqual(compassDir(45), "NE"));
it("returns E for 90°", () => assert.strictEqual(compassDir(90), "E"));
it("returns SE for 135°", () => assert.strictEqual(compassDir(135), "SE"));
it("returns S for 180°", () => assert.strictEqual(compassDir(180), "S"));
it("returns SW for 225°", () => assert.strictEqual(compassDir(225), "SW"));
it("returns W for 270°", () => assert.strictEqual(compassDir(270), "W"));
it("returns NW for 315°", () => assert.strictEqual(compassDir(315), "NW"));
it("returns NE for NYC Qibla", () => {
const bearing = qiblaAngle(40.7128, -74.006);
assert.strictEqual(compassDir(bearing), "NE");
});
});
describe("compassName", () => {
it("returns North for 0°", () => assert.strictEqual(compassName(0), "North"));
it("returns Northeast for 45°", () =>
assert.strictEqual(compassName(45), "Northeast"));
it("returns East for 90°", () => assert.strictEqual(compassName(90), "East"));
it("returns Southeast for 135°", () =>
assert.strictEqual(compassName(135), "Southeast"));
it("returns South for 180°", () =>
assert.strictEqual(compassName(180), "South"));
it("returns Southwest for 225°", () =>
assert.strictEqual(compassName(225), "Southwest"));
it("returns West for 270°", () =>
assert.strictEqual(compassName(270), "West"));
it("returns Northwest for 315°", () =>
assert.strictEqual(compassName(315), "Northwest"));
it("returns North for 360°", () =>
assert.strictEqual(compassName(360), "North"));
});
describe("qiblaGreatCircle", () => {
it("returns an array of [lat, lng] pairs", () => {
const points = qiblaGreatCircle(40.7128, -74.006);
assert.ok(Array.isArray(points));
assert.ok(points.length > 0);
assert.strictEqual(points[0].length, 2);
});
it("returns 121 points by default", () => {
const points = qiblaGreatCircle(40.7128, -74.006);
assert.strictEqual(points.length, 121);
});
it("respects custom steps parameter", () => {
const points = qiblaGreatCircle(40.7128, -74.006, 60);
assert.strictEqual(points.length, 61);
});
it("first point is close to origin", () => {
const [lat, lng] = qiblaGreatCircle(40.7128, -74.006)[0];
assert.ok(Math.abs(lat - 40.7128) < 0.01);
assert.ok(Math.abs(lng - -74.006) < 0.01);
});
it("last point is close to Ka'bah", () => {
const points = qiblaGreatCircle(40.7128, -74.006);
const [lat, lng] = points[points.length - 1];
assert.ok(Math.abs(lat - KAABA_LAT) < 0.01);
assert.ok(Math.abs(lng - KAABA_LNG) < 0.01);
});
it("all points have valid coordinates", () => {
const points = qiblaGreatCircle(51.5074, -0.1278, 10);
for (const [lat, lng] of points) {
assert.ok(Number.isFinite(lat));
assert.ok(Number.isFinite(lng));
assert.ok(lat >= -90 && lat <= 90);
assert.ok(lng >= -180 && lng <= 180);
}
});
it("returns single point at Ka'bah", () => {
const points = qiblaGreatCircle(KAABA_LAT, KAABA_LNG);
assert.strictEqual(points.length, 1);
assert.ok(Math.abs(points[0][0] - KAABA_LAT) < 0.0001);
assert.ok(Math.abs(points[0][1] - KAABA_LNG) < 0.0001);
});
it("throws RangeError for invalid coordinates", () => {
assert.throws(() => qiblaGreatCircle(91, 0), RangeError);
assert.throws(() => qiblaGreatCircle(0, 181), RangeError);
});
});
describe("distanceKm", () => {
it("returns 0 for the same point", () => {
assert.ok(Math.abs(distanceKm(40.7128, -74.006, 40.7128, -74.006)) < 0.001);
});
it("NYC to Ka'bah is approximately 9600 km", () => {
const km = distanceKm(40.7128, -74.006, KAABA_LAT, KAABA_LNG);
assert.ok(km > 9000 && km < 10500, `Expected 9000-10500, got ${km}`);
});
it("London to Ka'bah is approximately 4950 km", () => {
const km = distanceKm(51.5074, -0.1278, KAABA_LAT, KAABA_LNG);
assert.ok(km > 4500 && km < 5500, `Expected 4500-5500, got ${km}`);
});
it("distance is symmetric", () => {
const d1 = distanceKm(40.7128, -74.006, KAABA_LAT, KAABA_LNG);
const d2 = distanceKm(KAABA_LAT, KAABA_LNG, 40.7128, -74.006);
assert.ok(Math.abs(d1 - d2) < 0.001);
});
it("quarter equator is approximately 10,018 km", () => {
const d = distanceKm(0, 0, 0, 90);
assert.ok(d > 9800 && d < 10200, `Expected 9800-10200, got ${d}`);
});
it("pole to pole is approximately 20,000 km", () => {
const d = distanceKm(90, 0, -90, 0);
assert.ok(d > 19000 && d < 21000, `Expected 19000-21000, got ${d}`);
});
it("returns positive for distinct points", () => {
assert.ok(distanceKm(0, 0, 10, 10) > 0);
});
});

18
tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

16
tsup.config.ts Normal file
View file

@ -0,0 +1,16 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
clean: true,
outDir: "dist",
splitting: false,
sourcemap: false,
target: "es2020",
platform: "neutral",
outExtension({ format }) {
return { js: format === "cjs" ? ".cjs" : ".mjs" };
},
});