Initial release: qibla v1.0.0

This commit is contained in:
Aric Camarata 2026-03-08 12:58:34 -04:00
parent 871f701a5a
commit 55023d2c6c
9 changed files with 562 additions and 0 deletions

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

@ -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

22
.gitignore vendored Normal file
View file

@ -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/

13
CHANGELOG.md Normal file
View file

@ -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.

82
README.md Normal file
View file

@ -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)

1
analysis_options.yaml Normal file
View file

@ -0,0 +1 @@
include: package:lints/recommended.yaml

7
lib/qibla.dart Normal file
View file

@ -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';

150
lib/src/qibla.dart Normal file
View file

@ -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<String> _compassAbbr = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
/// Eight-point compass full names.
const List<String> _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<List<double>> 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 = <List<double>>[];
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));
}

21
pubspec.yaml Normal file
View file

@ -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

225
test/qibla_test.dart Normal file
View file

@ -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));
});
});
}