mirror of
https://github.com/acamarata/qibla.git
synced 2026-06-30 19:04:28 +00:00
Initial release: qibla v1.0.0
This commit is contained in:
parent
24cb7b13db
commit
ac27aedaec
17 changed files with 3158 additions and 0 deletions
14
.editorconfig
Normal file
14
.editorconfig
Normal 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
82
.github/workflows/ci.yml
vendored
Normal 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
18
.gitignore
vendored
Normal 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
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
24
|
||||
1
.prettierrc
Normal file
1
.prettierrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
17
CHANGELOG.md
Normal file
17
CHANGELOG.md
Normal 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
95
README.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# qibla
|
||||
|
||||
[](https://www.npmjs.com/package/qibla)
|
||||
[](https://github.com/acamarata/qibla/actions/workflows/ci.yml)
|
||||
[](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
10
eslint.config.js
Normal 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
80
package.json
Normal 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
2384
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
170
src/index.ts
Normal file
170
src/index.ts
Normal 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
65
test-cjs.cjs
Normal 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
184
test.mjs
Normal 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
18
tsconfig.json
Normal 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
16
tsup.config.ts
Normal 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" };
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue