From 55023d2c6c546d5051b46026b3ed06ce2a1aa08d Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 8 Mar 2026 12:58:34 -0400 Subject: [PATCH] Initial release: qibla v1.0.0 --- .github/workflows/ci.yml | 41 +++++++ .gitignore | 22 ++++ CHANGELOG.md | 13 +++ README.md | 82 ++++++++++++++ analysis_options.yaml | 1 + lib/qibla.dart | 7 ++ lib/src/qibla.dart | 150 ++++++++++++++++++++++++++ pubspec.yaml | 21 ++++ test/qibla_test.dart | 225 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 562 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 lib/qibla.dart create mode 100644 lib/src/qibla.dart create mode 100644 pubspec.yaml create mode 100644 test/qibla_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5a49098 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + sdk: [stable, beta] + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: ${{ matrix.sdk }} + - run: dart pub get + - run: dart analyze + - run: dart test + + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - run: dart format --set-exit-if-changed . + + publish-dry-run: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - run: dart pub get + - run: dart pub publish --dry-run diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65e20ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +.dart_tool/ +.packages +build/ +pubspec.lock +doc/api/ +*.iml +*.ipr +*.iws +.idea/ +.DS_Store +.claude/ +.env +.env.* +.vscode/* +.codex/ +.cursor/ +.aider/ +.aider.chat.history.md +.continue/ +.windsurf/ +.gemini/ +.codeium/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..72b507f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## 1.0.0 - 2026-03-08 + +### Added + +- `qiblaAngle(lat, lng)` computes the initial bearing to the Ka'bah in degrees. +- `compassDir(bearing)` returns an eight-point compass abbreviation (N, NE, E, etc.). +- `compassName(bearing)` returns the full compass direction name (North, Northeast, etc.). +- `qiblaGreatCircle(lat, lng, [steps])` generates waypoints along the great circle to the Ka'bah via spherical linear interpolation. +- `distanceKm(lat1, lng1, lat2, lng2)` computes haversine distance between two points. +- Constants: `kaabaLat`, `kaabaLng`, `earthRadiusKm`. +- RangeError validation for all coordinate inputs. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec7120a --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# qibla + +[![pub package](https://img.shields.io/pub/v/qibla.svg)](https://pub.dev/packages/qibla) +[![CI](https://github.com/acamarata/qibla-dart/actions/workflows/ci.yml/badge.svg)](https://github.com/acamarata/qibla-dart/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +Qibla direction, great-circle path, and haversine distance for Dart and Flutter. Pure math, zero dependencies. + +## Installation + +```yaml +dependencies: + qibla: ^1.0.0 +``` + +## Quick Start + +```dart +import 'package:qibla/qibla.dart'; + +// Bearing from New York to the Ka'bah +final bearing = qiblaAngle(40.7128, -74.006); +print(bearing); // ~58.48 +print(compassDir(bearing)); // NE + +// Distance in kilometers +final km = distanceKm(40.7128, -74.006, kaabaLat, kaabaLng); +print(km); // ~9634 +``` + +## API + +### `qiblaAngle(lat, lng)` + +Computes the initial bearing (forward azimuth) from the given coordinates to the Ka'bah. + +| Parameter | Type | Description | +| ------------ | -------- | ----------------------------------------------- | +| `lat` | `double` | Latitude in decimal degrees (-90 to 90) | +| `lng` | `double` | Longitude in decimal degrees (-180 to 180) | +| **Returns** | `double` | Bearing in degrees clockwise from north (0-360) | + +Throws `RangeError` if coordinates are out of bounds. + +### `compassDir(bearing)` + +Eight-point compass abbreviation: N, NE, E, SE, S, SW, W, NW. + +### `compassName(bearing)` + +Full compass name: North, Northeast, East, Southeast, South, Southwest, West, Northwest. + +### `qiblaGreatCircle(lat, lng, [steps])` + +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)` + +Haversine distance between two points in kilometers (spherical Earth approximation, R = 6,371 km). + +### Constants + +| Name | Value | Description | +| -------------- | --------- | -------------------------------------- | +| `kaabaLat` | 21.422511 | Ka'bah center latitude (degrees north) | +| `kaabaLng` | 39.826150 | Ka'bah center longitude (degrees east) | +| `earthRadiusKm`| 6371 | WGS-84 volumetric mean radius | + +## Compatibility + +Dart 3.7+. Works with Flutter and standalone Dart applications. + +## Related + +- [qibla](https://www.npmjs.com/package/qibla) (npm) - The TypeScript version of this package. +- [pray-calc](https://github.com/acamarata/pray-calc) - Islamic prayer times calculator. + +## License + +[MIT](LICENSE) diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..572dd23 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/lib/qibla.dart b/lib/qibla.dart new file mode 100644 index 0000000..6d8133b --- /dev/null +++ b/lib/qibla.dart @@ -0,0 +1,7 @@ +/// Qibla direction, great-circle path, and haversine distance for Dart. +/// +/// Pure math, zero external dependencies. Computes the initial bearing +/// from any point on Earth to the Ka'bah using spherical trigonometry. +library; + +export 'src/qibla.dart'; diff --git a/lib/src/qibla.dart b/lib/src/qibla.dart new file mode 100644 index 0000000..90ef94e --- /dev/null +++ b/lib/src/qibla.dart @@ -0,0 +1,150 @@ +/// 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. +library; + +import 'dart:math'; + +/// Latitude of the Ka'bah center, Masjid al-Haram, Mecca (degrees north). +const double kaabaLat = 21.422511; + +/// Longitude of the Ka'bah center, Masjid al-Haram, Mecca (degrees east). +const double kaabaLng = 39.82615; + +/// Mean radius of the Earth in kilometers (WGS-84 volumetric mean). +const double earthRadiusKm = 6371; + +const double _deg = pi / 180; + +/// Eight-point compass abbreviations. +const List _compassAbbr = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + +/// Eight-point compass full names. +const List _compassNames = [ + 'North', + 'Northeast', + 'East', + 'Southeast', + 'South', + 'Southwest', + 'West', + 'Northwest', +]; + +/// Qibla bearing in degrees clockwise from true north. +/// +/// Uses the forward azimuth formula from spherical trigonometry. +/// Result range: [0, 360). +/// +/// [lat] is the observer latitude in decimal degrees (-90 to 90). +/// [lng] is the observer longitude in decimal degrees (-180 to 180). +/// +/// Returns the 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]. +double qiblaAngle(double lat, double lng) { + if (lat < -90 || lat > 90) { + throw RangeError('Latitude must be between -90 and 90, got $lat'); + } + if (lng < -180 || lng > 180) { + throw RangeError('Longitude must be between -180 and 180, got $lng'); + } + final phi1 = lat * _deg; + final lam1 = lng * _deg; + final phi2 = kaabaLat * _deg; + final lam2 = kaabaLng * _deg; + final y = sin(lam2 - lam1) * cos(phi2); + final x = cos(phi1) * sin(phi2) - sin(phi1) * cos(phi2) * cos(lam2 - lam1); + return (atan2(y, x) / _deg + 360) % 360; +} + +/// Eight-point compass abbreviation for a bearing. +/// +/// [bearing] is the bearing in degrees (0-360). +/// +/// Returns a compass abbreviation: N, NE, E, SE, S, SW, W, or NW. +String compassDir(double bearing) { + return _compassAbbr[(bearing / 45).round() % 8]; +} + +/// Full compass direction name for a bearing. +/// +/// [bearing] is the bearing in degrees (0-360). +/// +/// Returns the full direction name (North, Northeast, etc.). +String compassName(double bearing) { + return _compassNames[(bearing / 45).round() % 8]; +} + +/// Great-circle waypoints from ([lat], [lng]) to the Ka'bah. +/// +/// Uses Slerp (spherical linear interpolation). Useful for drawing Qibla +/// direction lines on maps. +/// +/// [lat] is the origin latitude in decimal degrees. +/// [lng] is the origin longitude in decimal degrees. +/// [steps] is the number of segments (default: 120, producing 121 points). +/// +/// Returns a list of [lat, lng] pairs in degrees. +/// +/// Throws [RangeError] if coordinates are out of bounds. +List> qiblaGreatCircle(double lat, double lng, [int steps = 120]) { + if (lat < -90 || lat > 90) { + throw RangeError('Latitude must be between -90 and 90, got $lat'); + } + if (lng < -180 || lng > 180) { + throw RangeError('Longitude must be between -180 and 180, got $lng'); + } + final phi1 = lat * _deg; + final lam1 = lng * _deg; + final phi2 = kaabaLat * _deg; + final lam2 = kaabaLng * _deg; + + final d = + 2 * + asin( + sqrt( + pow(sin((phi2 - phi1) / 2), 2) + + cos(phi1) * cos(phi2) * pow(sin((lam2 - lam1) / 2), 2), + ), + ); + + if (d == 0) { + return [ + [lat, lng], + ]; + } + + final points = >[]; + for (var i = 0; i <= steps; i++) { + final f = i / steps; + final a = sin((1 - f) * d) / sin(d); + final b = sin(f * d) / sin(d); + final x = a * cos(phi1) * cos(lam1) + b * cos(phi2) * cos(lam2); + final y = a * cos(phi1) * sin(lam1) + b * cos(phi2) * sin(lam2); + final z = a * sin(phi1) + b * sin(phi2); + points.add([atan2(z, sqrt(x * x + y * y)) / _deg, atan2(y, x) / _deg]); + } + return points; +} + +/// Haversine distance between two coordinate pairs. +/// +/// [lat1], [lng1] define the first point in decimal degrees. +/// [lat2], [lng2] define the second point in decimal degrees. +/// +/// Returns the distance in kilometers (spherical Earth approximation). +double distanceKm(double lat1, double lng1, double lat2, double lng2) { + final dLat = (lat2 - lat1) * _deg; + final dLng = (lng2 - lng1) * _deg; + final a = + pow(sin(dLat / 2), 2) + + cos(lat1 * _deg) * cos(lat2 * _deg) * pow(sin(dLng / 2), 2); + return earthRadiusKm * 2 * atan2(sqrt(a), sqrt(1 - a)); +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..b5daa97 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,21 @@ +name: qibla +description: > + Qibla direction, great-circle path, and haversine distance for Dart and + Flutter. Pure math, zero dependencies. Computes bearing to the Ka'bah + from any point on Earth. +version: 1.0.0 +repository: https://github.com/acamarata/qibla-dart +issue_tracker: https://github.com/acamarata/qibla-dart/issues +topics: + - qibla + - islamic + - compass + - geodesic + - geolocation + +environment: + sdk: ^3.7.0 + +dev_dependencies: + lints: ^5.0.0 + test: ^1.25.8 diff --git a/test/qibla_test.dart b/test/qibla_test.dart new file mode 100644 index 0000000..3185fa4 --- /dev/null +++ b/test/qibla_test.dart @@ -0,0 +1,225 @@ +import 'package:qibla/qibla.dart'; +import 'package:test/test.dart'; + +void main() { + group('KAABA constants', () { + test('latitude is approximately 21.42 N', () { + expect((kaabaLat - 21.42).abs(), lessThan(0.1)); + }); + test('longitude is approximately 39.83 E', () { + expect((kaabaLng - 39.83).abs(), lessThan(0.1)); + }); + test('earthRadiusKm is 6371', () { + expect(earthRadiusKm, equals(6371)); + }); + }); + + group('qiblaAngle', () { + test('returns a number between 0 and 360', () { + final angle = qiblaAngle(40.7128, -74.006); + expect(angle, greaterThanOrEqualTo(0)); + expect(angle, lessThan(360)); + }); + + test('New York City (~58 NE)', () { + final angle = qiblaAngle(40.7128, -74.006); + expect(angle, greaterThan(50)); + expect(angle, lessThan(70)); + }); + + test('London (~119 SE)', () { + final angle = qiblaAngle(51.5074, -0.1278); + expect(angle, greaterThan(110)); + expect(angle, lessThan(130)); + }); + + test('Tokyo (~293 NW)', () { + final angle = qiblaAngle(35.6762, 139.6503); + expect(angle, greaterThan(280)); + expect(angle, lessThan(310)); + }); + + test('Sydney (~277 W)', () { + final angle = qiblaAngle(-33.8688, 151.2093); + expect(angle, greaterThan(260)); + expect(angle, lessThan(300)); + }); + + test('Islamabad (~268 W)', () { + final angle = qiblaAngle(33.6844, 73.0479); + expect(angle, greaterThan(250)); + expect(angle, lessThan(290)); + }); + + test('returns finite number at Ka\'bah (degenerate case)', () { + final angle = qiblaAngle(kaabaLat, kaabaLng); + expect(angle.isFinite, isTrue); + }); + + test('equator east of Mecca points NW', () { + final angle = qiblaAngle(0, 80); + expect(angle, greaterThan(270)); + expect(angle, lessThan(360)); + }); + + test('result is stable (same input gives same output)', () { + final a = qiblaAngle(40.7128, -74.006); + final b = qiblaAngle(40.7128, -74.006); + expect(a, equals(b)); + }); + + test('throws RangeError for invalid latitude', () { + expect(() => qiblaAngle(91, 0), throwsRangeError); + expect(() => qiblaAngle(-91, 0), throwsRangeError); + }); + + test('throws RangeError for invalid longitude', () { + expect(() => qiblaAngle(0, 181), throwsRangeError); + expect(() => qiblaAngle(0, -181), throwsRangeError); + }); + }); + + group('compassDir', () { + test('returns N for 0', () => expect(compassDir(0), equals('N'))); + test('returns N for 360', () => expect(compassDir(360), equals('N'))); + test('returns NE for 45', () => expect(compassDir(45), equals('NE'))); + test('returns E for 90', () => expect(compassDir(90), equals('E'))); + test('returns SE for 135', () => expect(compassDir(135), equals('SE'))); + test('returns S for 180', () => expect(compassDir(180), equals('S'))); + test('returns SW for 225', () => expect(compassDir(225), equals('SW'))); + test('returns W for 270', () => expect(compassDir(270), equals('W'))); + test('returns NW for 315', () => expect(compassDir(315), equals('NW'))); + test('returns NE for NYC Qibla', () { + final bearing = qiblaAngle(40.7128, -74.006); + expect(compassDir(bearing), equals('NE')); + }); + }); + + group('compassName', () { + test('returns North for 0', () { + expect(compassName(0), equals('North')); + }); + test('returns Northeast for 45', () { + expect(compassName(45), equals('Northeast')); + }); + test('returns East for 90', () { + expect(compassName(90), equals('East')); + }); + test('returns Southeast for 135', () { + expect(compassName(135), equals('Southeast')); + }); + test('returns South for 180', () { + expect(compassName(180), equals('South')); + }); + test('returns Southwest for 225', () { + expect(compassName(225), equals('Southwest')); + }); + test('returns West for 270', () { + expect(compassName(270), equals('West')); + }); + test('returns Northwest for 315', () { + expect(compassName(315), equals('Northwest')); + }); + test('returns North for 360', () { + expect(compassName(360), equals('North')); + }); + }); + + group('qiblaGreatCircle', () { + test('returns a list of [lat, lng] pairs', () { + final points = qiblaGreatCircle(40.7128, -74.006); + expect(points, isNotEmpty); + expect(points[0].length, equals(2)); + }); + + test('returns 121 points by default', () { + final points = qiblaGreatCircle(40.7128, -74.006); + expect(points.length, equals(121)); + }); + + test('respects custom steps parameter', () { + final points = qiblaGreatCircle(40.7128, -74.006, 60); + expect(points.length, equals(61)); + }); + + test('first point is close to origin', () { + final point = qiblaGreatCircle(40.7128, -74.006)[0]; + expect((point[0] - 40.7128).abs(), lessThan(0.01)); + expect((point[1] - -74.006).abs(), lessThan(0.01)); + }); + + test('last point is close to Ka\'bah', () { + final points = qiblaGreatCircle(40.7128, -74.006); + final last = points[points.length - 1]; + expect((last[0] - kaabaLat).abs(), lessThan(0.01)); + expect((last[1] - kaabaLng).abs(), lessThan(0.01)); + }); + + test('all points have valid coordinates', () { + final points = qiblaGreatCircle(51.5074, -0.1278, 10); + for (final point in points) { + expect(point[0].isFinite, isTrue); + expect(point[1].isFinite, isTrue); + expect(point[0], greaterThanOrEqualTo(-90)); + expect(point[0], lessThanOrEqualTo(90)); + expect(point[1], greaterThanOrEqualTo(-180)); + expect(point[1], lessThanOrEqualTo(180)); + } + }); + + test('returns single point at Ka\'bah', () { + final points = qiblaGreatCircle(kaabaLat, kaabaLng); + expect(points.length, equals(1)); + expect((points[0][0] - kaabaLat).abs(), lessThan(0.0001)); + expect((points[0][1] - kaabaLng).abs(), lessThan(0.0001)); + }); + + test('throws RangeError for invalid coordinates', () { + expect(() => qiblaGreatCircle(91, 0), throwsRangeError); + expect(() => qiblaGreatCircle(0, 181), throwsRangeError); + }); + }); + + group('distanceKm', () { + test('returns 0 for the same point', () { + expect( + distanceKm(40.7128, -74.006, 40.7128, -74.006).abs(), + lessThan(0.001), + ); + }); + + test('NYC to Ka\'bah is approximately 9600 km', () { + final km = distanceKm(40.7128, -74.006, kaabaLat, kaabaLng); + expect(km, greaterThan(9000)); + expect(km, lessThan(10500)); + }); + + test('London to Ka\'bah is approximately 4950 km', () { + final km = distanceKm(51.5074, -0.1278, kaabaLat, kaabaLng); + expect(km, greaterThan(4500)); + expect(km, lessThan(5500)); + }); + + test('distance is symmetric', () { + final d1 = distanceKm(40.7128, -74.006, kaabaLat, kaabaLng); + final d2 = distanceKm(kaabaLat, kaabaLng, 40.7128, -74.006); + expect((d1 - d2).abs(), lessThan(0.001)); + }); + + test('quarter equator is approximately 10,018 km', () { + final d = distanceKm(0, 0, 0, 90); + expect(d, greaterThan(9800)); + expect(d, lessThan(10200)); + }); + + test('pole to pole is approximately 20,000 km', () { + final d = distanceKm(90, 0, -90, 0); + expect(d, greaterThan(19000)); + expect(d, lessThan(21000)); + }); + + test('returns positive for distinct points', () { + expect(distanceKm(0, 0, 10, 10), greaterThan(0)); + }); + }); +}