diff --git a/.github/wiki/_Sidebar.md b/.github/wiki/_Sidebar.md index 1079ec8..81e0612 100644 --- a/.github/wiki/_Sidebar.md +++ b/.github/wiki/_Sidebar.md @@ -2,12 +2,32 @@ **[Home](Home)** +**Guides** +- [Quick Start](guides/quickstart) +- [Advanced](guides/advanced) + **Reference** - [API Reference](API-Reference) - [Architecture](Architecture) +- [Benchmarks](benchmarks/index) + +**API — Functions** +- [qiblaAngle](api/qiblaAngle) +- [compassDir](api/compassDir) +- [compassName](api/compassName) +- [qiblaGreatCircle](api/qiblaGreatCircle) +- [distanceKm](api/distanceKm) + +**API — Constants & Types** +- [Constants](api/constants) +- [Types](api/types) + +**Examples** +- [Qibla lookup](examples/qibla-lookup) +- [Great-circle path](examples/great-circle-path) **Contributing** -- [Contributing](Contributing) +- [Contributing](CONTRIBUTING) - [Code of Conduct](CODE_OF_CONDUCT) - [Security](SECURITY) diff --git a/.github/wiki/benchmarks/index.md b/.github/wiki/benchmarks/index.md new file mode 100644 index 0000000..5a4665c --- /dev/null +++ b/.github/wiki/benchmarks/index.md @@ -0,0 +1,57 @@ +# Benchmarks + +Measured on Apple M-series hardware, Node.js v25, using 1,000,000 iterations with a 1,000-call warm-up. + +## Bundle size + +| Format | Minified | Gzipped | +| ------ | -------- | ------- | +| ESM (`index.mjs`) | 2.7 KB | 949 B | +| CJS (`index.cjs`) | 3.8 KB | 1.3 KB | + +The library has zero external dependencies. Both formats are shipped in the npm package. + +## Throughput + +| Operation | Ops / sec | +| --------- | --------- | +| `qiblaAngle` (forward azimuth) | ~180,000,000 | +| `distanceKm` (haversine) | ~407,000,000 | + +These numbers reflect the V8 JIT at peak — real applications with diverse inputs and cold starts will see lower throughput. The point is that neither function is a bottleneck: both run in nanoseconds per call. + +## Reproducing the benchmark + +```js +import { qiblaAngle, distanceKm, KAABA_LAT, KAABA_LNG } from '@acamarata/qibla'; + +const N = 1_000_000; +const LATS = Array.from({ length: 100 }, (_, i) => (i - 50) * 1.8); +const LNGS = Array.from({ length: 100 }, (_, i) => (i - 50) * 3.6); + +// Warm up +for (let i = 0; i < 1000; i++) qiblaAngle(LATS[i % 100], LNGS[i % 100]); + +// qiblaAngle +const t0 = performance.now(); +for (let i = 0; i < N; i++) qiblaAngle(LATS[i % 100], LNGS[i % 100]); +const t1 = performance.now(); + +// distanceKm +const t2 = performance.now(); +for (let i = 0; i < N; i++) distanceKm(LATS[i % 100], LNGS[i % 100], KAABA_LAT, KAABA_LNG); +const t3 = performance.now(); + +console.log(`qiblaAngle: ${Math.round(N / ((t1 - t0) / 1000)).toLocaleString()} ops/s`); +console.log(`distanceKm: ${Math.round(N / ((t3 - t2) / 1000)).toLocaleString()} ops/s`); +``` + +## Notes + +- V8's JIT optimizer eliminates much of the trig overhead at peak throughput. Expect 40-60% of these values in production with cold module loads. +- The library works identically in browsers (V8/SpiderMonkey/WebKit), Deno, and Bun. Performance will vary by runtime and optimization tier. +- `qiblaGreatCircle` is not benchmarked here because throughput depends on the step count. At the default of 120 steps, it runs in roughly 2-5 microseconds per call. + +--- + +[Home](../Home) | [API Reference](../API-Reference) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66c3365..26b2bdb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,3 +79,24 @@ jobs: grep "CHANGELOG.md" pack-output.txt grep "LICENSE" pack-output.txt echo "Pack check passed" + + coverage: + name: Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Enable corepack + run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: pnpm run coverage + - name: Upload to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./coverage/lcov.info + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/README.md b/README.md index 9deb22b..23f4e5f 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [](https://www.npmjs.com/package/%40acamarata%2Fqibla) [](https://github.com/acamarata/qibla/actions/workflows/ci.yml) [](LICENSE) +[](https://github.com/acamarata/qibla/wiki) Qibla direction, great-circle path, and haversine distance. Pure math, zero dependencies. diff --git a/coverage/lcov-report/base.css b/coverage/lcov-report/base.css new file mode 100644 index 0000000..f418035 --- /dev/null +++ b/coverage/lcov-report/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} diff --git a/coverage/lcov-report/block-navigation.js b/coverage/lcov-report/block-navigation.js new file mode 100644 index 0000000..530d1ed --- /dev/null +++ b/coverage/lcov-report/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selector that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); diff --git a/coverage/lcov-report/favicon.png b/coverage/lcov-report/favicon.png new file mode 100644 index 0000000..c1525b8 Binary files /dev/null and b/coverage/lcov-report/favicon.png differ diff --git a/coverage/lcov-report/index.html b/coverage/lcov-report/index.html new file mode 100644 index 0000000..115ecb5 --- /dev/null +++ b/coverage/lcov-report/index.html @@ -0,0 +1,131 @@ + + + + +
++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ ++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 | 1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +15x +2x +2x +15x +2x +2x +11x +11x +11x +11x +11x +11x +11x +11x +11x +11x +1x +1x +1x +1x +1x +1x +1x +1x +10x +10x +10x +10x +1x +1x +1x +1x +1x +1x +1x +1x +9x +9x +9x +9x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +9x +9x +9x +9x +9x +1x +1x +9x +1x +1x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +9x +6x +6x +9x +556x +556x +556x +556x +556x +556x +556x +556x +556x +556x +556x +6x +6x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x +8x + | /**
+ * 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
+ */
+
+export * from "./types.js";
+
+import {
+ KAABA_LAT,
+ KAABA_LNG,
+ EARTH_RADIUS_KM,
+ COMPASS_ABBR,
+ COMPASS_NAMES,
+ type CompassAbbr,
+ type CompassName,
+} from "./types.js";
+
+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 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 {
+ // Non-null assertion: index is always 0-7 (Math.round(bearing/45) % 8), which is within COMPASS_ABBR bounds.
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ 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 {
+ // Non-null assertion: index is always 0-7 (Math.round(bearing/45) % 8), which is within COMPASS_NAMES bounds.
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ 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));
+}
+ |