react-native-nitro-compass 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +381 -163
  2. package/android/src/main/java/com/margelo/nitro/nitrocompass/HybridNitroCompass.kt +654 -133
  3. package/ios/HybridNitroCompass.swift +106 -3
  4. package/lib/commonjs/hook.js +98 -11
  5. package/lib/commonjs/hook.js.map +1 -1
  6. package/lib/commonjs/index.js.map +1 -1
  7. package/lib/commonjs/multiplex.js +23 -2
  8. package/lib/commonjs/multiplex.js.map +1 -1
  9. package/lib/module/hook.js +99 -12
  10. package/lib/module/hook.js.map +1 -1
  11. package/lib/module/index.js.map +1 -1
  12. package/lib/module/multiplex.js +23 -2
  13. package/lib/module/multiplex.js.map +1 -1
  14. package/lib/typescript/src/hook.d.ts +39 -1
  15. package/lib/typescript/src/hook.d.ts.map +1 -1
  16. package/lib/typescript/src/index.d.ts +2 -2
  17. package/lib/typescript/src/index.d.ts.map +1 -1
  18. package/lib/typescript/src/multiplex.d.ts.map +1 -1
  19. package/lib/typescript/src/specs/NitroCompass.nitro.d.ts +142 -18
  20. package/lib/typescript/src/specs/NitroCompass.nitro.d.ts.map +1 -1
  21. package/nitrogen/generated/android/c++/JCompassSample.hpp +7 -3
  22. package/nitrogen/generated/android/c++/JDebugInfo.hpp +85 -0
  23. package/nitrogen/generated/android/c++/JHybridNitroCompassSpec.cpp +17 -0
  24. package/nitrogen/generated/android/c++/JHybridNitroCompassSpec.hpp +3 -0
  25. package/nitrogen/generated/android/c++/JSensorKind.hpp +6 -3
  26. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/CompassSample.kt +9 -4
  27. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/DebugInfo.kt +86 -0
  28. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/HybridNitroCompassSpec.kt +12 -0
  29. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/SensorKind.kt +4 -3
  30. package/nitrogen/generated/ios/NitroCompass-Swift-Cxx-Bridge.hpp +12 -0
  31. package/nitrogen/generated/ios/NitroCompass-Swift-Cxx-Umbrella.hpp +3 -0
  32. package/nitrogen/generated/ios/c++/HybridNitroCompassSpecSwift.hpp +23 -0
  33. package/nitrogen/generated/ios/swift/CompassSample.swift +7 -2
  34. package/nitrogen/generated/ios/swift/DebugInfo.swift +64 -0
  35. package/nitrogen/generated/ios/swift/HybridNitroCompassSpec.swift +3 -0
  36. package/nitrogen/generated/ios/swift/HybridNitroCompassSpec_cxx.swift +34 -0
  37. package/nitrogen/generated/ios/swift/SensorKind.swift +8 -4
  38. package/nitrogen/generated/shared/c++/CompassSample.hpp +6 -2
  39. package/nitrogen/generated/shared/c++/DebugInfo.hpp +111 -0
  40. package/nitrogen/generated/shared/c++/HybridNitroCompassSpec.cpp +3 -0
  41. package/nitrogen/generated/shared/c++/HybridNitroCompassSpec.hpp +6 -0
  42. package/nitrogen/generated/shared/c++/SensorKind.hpp +10 -6
  43. package/package.json +2 -2
  44. package/src/hook.ts +146 -12
  45. package/src/index.ts +2 -0
  46. package/src/multiplex.ts +23 -2
  47. package/src/specs/NitroCompass.nitro.ts +147 -18
package/README.md CHANGED
@@ -1,25 +1,59 @@
1
1
  # react-native-nitro-compass
2
2
 
3
- Fast, accurate compass heading for React Native, powered by [Nitro Modules](https://github.com/mrousavy/nitro).
3
+ [![npm version](https://img.shields.io/npm/v/react-native-nitro-compass.svg)](https://www.npmjs.com/package/react-native-nitro-compass)
4
+ [![license](https://img.shields.io/npm/l/react-native-nitro-compass.svg)](./LICENSE)
5
+ [![react-native](https://img.shields.io/badge/react--native-0.76%2B-61dafb)](https://reactnative.dev)
4
6
 
5
- - **Android**: `Sensor.TYPE_ROTATION_VECTOR` (gyro + accel + magnetometer sensor fusion), with `TYPE_GEOMAGNETIC_ROTATION_VECTOR` fallback for gyroless devices. Sensor delivery on a dedicated `HandlerThread` never blocks the UI thread. Heading accuracy taken from `event.values[4]` of the rotation vector.
6
- - **iOS**: `CLLocationManager` heading via `CLHeading.magneticHeading`. Apple's stack already handles sensor fusion natively.
7
- - **JS API**: type-safe Nitro callbacks — no `NativeEventEmitter`, no string event names.
7
+ Fast, accurate compass heading for React Native, powered by [Nitro Modules](https://github.com/mrousavy/nitro). Survives magnetic interference, supports true-north via geomagnetic location lookup, and drives a 60 fps Reanimated dial without thrashing React.
8
8
 
9
- ## Why
10
-
11
- Most React Native compass libraries use the legacy `accelerometer + magnetometer + getRotationMatrix` Android approach, which is laggy, noisy, and requires a figure-8 calibration on every session. This library uses Android's modern fused rotation-vector sensor (recommended by Google since 2013), giving you stable headings without calibration on virtually any modern device.
12
-
13
- ## Requirements
9
+ ```ts
10
+ import { useCompass } from 'react-native-nitro-compass'
14
11
 
15
- - React Native 0.76.0 or higher
16
- - Node 18.0.0 or higher
17
- - `react-native-nitro-modules` peer dependency
12
+ function CompassScreen() {
13
+ const { reading, quality, interfering } = useCompass()
14
+ return <Text>{reading?.heading.toFixed(0)}°</Text>
15
+ }
16
+ ```
18
17
 
19
- ## Install
18
+ ## Features
19
+
20
+ - **Stateless interference recovery.** Heading snaps back the instant a magnet or laptop is removed — no waiting for the OS Kalman filter to unstick.
21
+ - **Gyro complementary fusion.** `TYPE_GAME_ROTATION_VECTOR` carries heading smoothly through rapid turns and transient magnet events; mag samples pull it back to absolute via a ~1 s blend.
22
+ - **Bias-jump interference detection.** Catches weak magnet events the field-magnitude check alone misses — e.g. another phone placed on top, where the corrected magnitude stays in-band but the OS still revises its hard-iron bias.
23
+ - **Location-aware.** `setLocation(lat, lon)` tightens the Android interference band using the bundled WMM2025 model.
24
+ - **Type-safe Nitro callbacks.** No `NativeEventEmitter`, no string event names.
25
+ - **Ergonomic React hook.** `useCompass()` bundles subscription lifecycle, calibration/interference observation, and live-tuneable knobs into one call. Multiple mounts safely share a single native subscription.
26
+ - **Reanimated-friendly.** Direct `addHeadingListener()` for 60 fps animations that run entirely on the UI thread.
27
+ - **Live diagnostics.** `getDebugInfo()` surfaces all internal state for self-diagnosing user reports.
28
+ - **Permission-aware.** Built-in `requestPermission()` / `permission` state — no extra dependency for the iOS authorization flow.
29
+
30
+ ## Table of contents
31
+
32
+ - [Installation](#installation)
33
+ - [Permissions](#permissions)
34
+ - [Quick start](#quick-start)
35
+ - [`useCompass()` hook](#usecompass-hook)
36
+ - [Listener helpers](#listener-helpers)
37
+ - [Imperative API](#imperative-api)
38
+ - [Recipes](#recipes)
39
+ - [True-north heading from location](#true-north-heading-from-location)
40
+ - [Smooth dial animation (Reanimated)](#smooth-dial-animation-reanimated)
41
+ - [Location-tightened interference](#location-tightened-interference)
42
+ - [Calibration UI](#calibration-ui)
43
+ - [Custom diagnostics panel](#custom-diagnostics-panel)
44
+ - [Types](#types)
45
+ - [Architecture](#architecture)
46
+ - [Troubleshooting](#troubleshooting)
47
+ - [Example app](#example-app)
48
+ - [Acknowledgments](#acknowledgments)
49
+ - [License](#license)
50
+
51
+ ## Installation
20
52
 
21
53
  ```sh
22
54
  npm install react-native-nitro-compass react-native-nitro-modules
55
+ # or
56
+ yarn add react-native-nitro-compass react-native-nitro-modules
23
57
  ```
24
58
 
25
59
  iOS:
@@ -28,187 +62,219 @@ iOS:
28
62
  cd ios && pod install
29
63
  ```
30
64
 
31
- ## Usage
65
+ **Requirements**: React Native ≥ 0.76, Node ≥ 18, [`react-native-nitro-modules`](https://github.com/mrousavy/nitro) installed as a peer dependency.
32
66
 
33
- ```ts
34
- import { NitroCompass } from 'react-native-nitro-compass'
67
+ ## Permissions
35
68
 
36
- if (NitroCompass.hasCompass()) {
37
- NitroCompass.start(1, ({ heading, accuracy }) => {
38
- console.log(`heading: ${heading.toFixed(1)}°, accuracy: ±${accuracy}°`)
39
- })
40
- }
69
+ | | iOS | Android |
70
+ | --- | --- | --- |
71
+ | Compass heading | `NSLocationWhenInUseUsageDescription` in `Info.plist` | none — sensors are unrestricted |
72
+ | `setLocation()` (optional) | reuses the same key — no extra permission | `ACCESS_COARSE_LOCATION` in your manifest |
41
73
 
42
- // later…
43
- NitroCompass.stop()
74
+ ### iOS
75
+
76
+ Add to `ios/<YourApp>/Info.plist`:
77
+
78
+ ```xml
79
+ <key>NSLocationWhenInUseUsageDescription</key>
80
+ <string>Used to read the device compass heading.</string>
44
81
  ```
45
82
 
46
- ### API
83
+ `CLLocationManager` only emits headings when location authorization is granted. The hook exposes `permission` and `requestPermission()` so you don't need a separate library to drive the prompt.
47
84
 
48
- ```ts
49
- NitroCompass.start(filterDegrees: number, onHeading: (sample: CompassSample) => void): void
50
- NitroCompass.stop(): void
51
- NitroCompass.isStarted(): boolean
52
- NitroCompass.hasCompass(): boolean
53
-
54
- NitroCompass.setFilter(degrees: number): void
55
- NitroCompass.setSmoothing(alpha: number): void
56
- NitroCompass.getCurrentHeading(): CompassSample | undefined
57
- NitroCompass.getDiagnostics(): SensorDiagnostics | undefined
58
- NitroCompass.setDeclination(degrees: number): void
59
- NitroCompass.setOnCalibrationNeeded(onChange: (quality: AccuracyQuality) => void): void
60
- NitroCompass.setOnInterferenceDetected(onChange: (interferenceDetected: boolean) => void): void
61
- NitroCompass.setPauseOnBackground(enabled: boolean): void
85
+ ### Android
62
86
 
63
- interface CompassSample {
64
- heading: number // degrees, [0, 360); magnetic by default, true-north if setDeclination was called
65
- accuracy: number // degrees, smaller is better; -1 if unknown
66
- }
87
+ The compass itself needs **no permission** — Android exposes the magnetometer and accelerometer to all apps. Only add `ACCESS_COARSE_LOCATION` if you plan to call `setLocation()` (or use the location recipe below):
67
88
 
68
- type AccuracyQuality = 'high' | 'medium' | 'low' | 'unreliable'
69
- type SensorKind = 'rotationVector' | 'geomagneticRotationVector' | 'coreLocation'
70
- interface SensorDiagnostics { sensor: SensorKind }
89
+ ```xml
90
+ <!-- android/app/src/main/AndroidManifest.xml -->
91
+ <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
71
92
  ```
72
93
 
73
- - `filterDegrees` — minimum change between successive samples before the next one is delivered. Pass `0` for "every event"; typical UI values are `1`–`3`. Use `setFilter()` to change live without tearing down the subscription.
74
- - `setSmoothing(alpha)` — low-pass smoothing factor (EMA α) applied to heading samples on Android. Range `(0, 1]`, default `0.2` (~100 ms time constant at 50 Hz). `1.0` disables smoothing; smaller values smooth more (kills jitter, adds a touch of latency). **No-op on iOS** — `CLLocationManager` filters internally with Apple's own algorithm, so layering an EMA on top would only add latency. See [Smoothing](#smoothing) below.
75
- - `start()` is idempotent in the destructive sense — calling it while already started silently replaces the previous subscription with the new callback. `stop()` is idempotent and safe from inside the `onHeading` callback.
76
- - `getDiagnostics()` reports which sensor would produce headings on this device — useful for explaining quality differences (e.g. a phone falling back to `geomagneticRotationVector` will be more susceptible to magnetic interference than one using `rotationVector`). Safe to call before `start()`.
77
- - `accuracy` is a numeric uncertainty (degrees). On iOS it comes from `CLHeading.headingAccuracy` directly. On Android it comes from `event.values[4]` of the rotation-vector sensor; if the sensor stack does not publish that (rare), the module falls back to a coarse degree estimate from `SensorManager.SENSOR_STATUS_*` (`HIGH→5°`, `MEDIUM→15°`, `LOW→30°`).
78
- - `getCurrentHeading()` returns the most recently emitted sample (with declination already applied), or `undefined` if not started yet or no sample has arrived.
79
-
80
- ### Calibration
94
+ ## Quick start
81
95
 
82
- `setOnCalibrationNeeded(cb)` registers a callback fired whenever the calibration bucket transitions. Each platform's bucket is derived from its **native** accuracy semantics, since the underlying values are not directly comparable:
96
+ ```tsx
97
+ import { Text, View } from 'react-native'
98
+ import { useCompass } from 'react-native-nitro-compass'
83
99
 
84
- - **iOS** uses `CLHeading.headingAccuracy` (degrees). Apple is conservative — even well-calibrated iPhones typically report `10–15°` and rarely below `5°` (per [Apple staff on the developer forums](https://developer.apple.com/forums/thread/79687)). Buckets: `<20°` → `'high'`, `<35°` → `'medium'`, `<55°` → `'low'`, otherwise `'unreliable'`. The system's "wave the device in a figure-8" prompt is suppressed and reported to your callback as `'unreliable'` — show your own UI when you receive that bucket.
85
- - **Android** uses `SensorManager.SENSOR_STATUS_*` from `onAccuracyChanged` directly (`HIGH` / `MEDIUM` / `LOW` / `UNRELIABLE`); when `event.values[4]` of the rotation vector publishes a per-sample degree estimate, that's used with thresholds `<5°` / `<15°` / `<30°`. **When magnetic interference is currently detected, the surfaced bucket is downgraded by one notch** (`HIGH→MEDIUM`, `MEDIUM→LOW`, `LOW→UNRELIABLE`) — Android's gyro+accel sensor fusion can keep the OS rotation-vector bucket high even while the magnetometer is being skewed, and surfacing `quality='high'` alongside `interfering=true` is contradictory UX.
100
+ function Compass() {
101
+ const { reading, quality, interfering, hasCompass } = useCompass()
86
102
 
87
- Both platforms can plausibly emit `'high'` on a clean device — the threshold split just reflects each OS's reporting style.
103
+ if (!hasCompass) return <Text>No compass on this device.</Text>
104
+ if (!reading) return <Text>Acquiring heading…</Text>
88
105
 
89
- ```ts
90
- NitroCompass.setOnCalibrationNeeded((q) => {
91
- if (q === 'unreliable') showCalibrationToast()
92
- })
106
+ return (
107
+ <View>
108
+ <Text>{reading.heading.toFixed(0) (±{reading.accuracy.toFixed(0)}°)</Text>
109
+ {quality === 'unreliable' && <Text>Calibration needed</Text>}
110
+ {interfering && <Text>Magnetic interference detected</Text>}
111
+ </View>
112
+ )
113
+ }
93
114
  ```
94
115
 
95
- ### Magnetic interference
96
-
97
- `setOnInterferenceDetected(cb)` fires `true` when the raw magnetic field magnitude leaves the normal Earth band (~20–70 µT) and `false` when it returns. Typical sources are laptops, monitors, car engines, and large steel structures — these can skew heading by tens of degrees.
116
+ That's the whole API for 90% of apps. Read on for true-north, smoother animation, and tunable behavior.
98
117
 
99
- Interference is surfaced two ways: (1) directly via this callback, and (2) on Android, the calibration bucket emitted by `setOnCalibrationNeeded` is downgraded by one notch while interference is detected (see the Calibration section above). On iOS, only the direct callback fires — `CLLocationManager`'s own accuracy reporting already responds to magnetometer disturbance, so a separate downgrade would double-count.
118
+ ## `useCompass()` hook
100
119
 
101
120
  ```ts
102
- NitroCompass.setOnInterferenceDetected((interfering) => {
103
- if (interfering) showInterferenceWarning()
104
- else hideInterferenceWarning()
105
- })
121
+ function useCompass(options?: UseCompassOptions): UseCompassResult
106
122
  ```
107
123
 
108
- Android uses `Sensor.TYPE_MAGNETIC_FIELD` at ~5 Hz. iOS uses `CMDeviceMotion.magneticField` (calibrated, with the device's own hard-iron bias subtracted in real time) note that no transitions are reported on iOS until CoreMotion's bias estimate converges, typically a second or two of normal device movement. Only triggered while `start()` is active; no debounce, so brief excursions still fire.
109
-
110
- ### Magnetic vs true north
111
-
112
- Headings are **magnetic** by default. You can either apply declination in JS, or let the native side do it once via `setDeclination(deg)` so every emitted sample (and `getCurrentHeading()`) is true-north.
124
+ Wraps the entire surface subscription lifecycle, calibration/interference callbacks, live-tuneable knobs, permission flowinto one ergonomic call. Multiple `useCompass()` mounts safely share the same underlying native subscription.
113
125
 
114
- ```ts
115
- import geomagnetism from 'geomagnetism'
126
+ ### Options
116
127
 
117
- const declination = geomagnetism.model().point([lat, lon]).decl
128
+ | Option | Type | Default | Description |
129
+ | --- | --- | --- | --- |
130
+ | `filterDegrees` | `number` | `1` | Minimum change between successive samples in degrees. Pass `0` for "every event". |
131
+ | `smoothingAlpha` | `number` | `0.2` | Low-pass smoothing factor (EMA α) on Android. `1.0` disables smoothing; smaller values smooth more. No-op on iOS. |
132
+ | `declination` | `number` | `0` | Magnetic-to-true offset in signed degrees. When non-zero, every emitted sample is true-north. See [recipe](#true-north-heading-from-location). |
133
+ | `pauseOnBackground` | `boolean` | `true` | Pause the underlying sensor / location-manager subscription while the app is backgrounded. |
134
+ | `enabled` | `boolean` | `true` | Toggle the heading subscription without unmounting. When `false`, `reading` stops updating but calibration/interference observation continues. |
118
135
 
119
- // Option AJS-side
120
- const trueHeading = (heading + declination + 360) % 360
136
+ `filterDegrees`, `smoothingAlpha`, `declination`, and `pauseOnBackground` map to global state on `NitroCompass` if multiple hooks set them, last-write-wins.
121
137
 
122
- // Option B — native-side (subsequent samples are true-north)
123
- NitroCompass.setDeclination(declination)
124
- ```
138
+ ### Result
125
139
 
126
- Pass `0` to revert to magnetic. Declination survives `stop()`/`start()` cycles.
140
+ | Field | Type | Description |
141
+ | --- | --- | --- |
142
+ | `reading` | `CompassSample \| null` | Latest emitted sample, or `null` until the first arrives. |
143
+ | `quality` | `AccuracyQuality \| null` | Coarse calibration bucket. Show your own calibration UI on `'unreliable'`. |
144
+ | `interfering` | `boolean` | `true` while external magnetic interference is detected. |
145
+ | `hasCompass` | `boolean` | Hardware availability — read once on first render. |
146
+ | `diagnostics` | `SensorDiagnostics \| undefined` | Which sensor backs the readings on this device. |
147
+ | `permission` | `PermissionStatus` | Latest platform permission state. iOS may transition `'unknown'` → `'granted'`/`'denied'` after `requestPermission()`. |
148
+ | `getCurrentHeading` | `() => CompassSample \| undefined` | Synchronous read of the most recent sample. Stable identity. |
149
+ | `recalibrate` | `() => void` | Force a best-effort sensor recalibration. Stable identity. |
150
+ | `setLocation` | `(lat: number, lon: number) => void` | Tighten the Android interference gate via WMM2025. No-op on iOS. Stable identity. |
151
+ | `requestPermission` | `() => Promise<PermissionStatus>` | Prompt the platform permission dialog and update the hook's `permission` field. Stable identity. |
127
152
 
128
- ### Smoothing
153
+ The four function fields all have stable identities (via `useCallback`) so consumers' `useEffect` deps don't churn on every render.
129
154
 
130
- Android's raw `TYPE_ROTATION_VECTOR` output jitters by `±1–3°` even at rest, which produces a visibly twitchy compass dial. iOS's `CLLocationManager` filters internally; Android does not. The library applies a circular EMA low-pass filter on `(sin θ, cos θ)` (handles `359°→0°` wraparound cleanly) before delivering samples, with `α = 0.2` by default — the same value used in [phishman3579/android-compass](https://github.com/phishman3579/android-compass/blob/master/src/com/jwetherell/compass/common/LowPassFilter.java) and within the range used by [Trail Sense](https://github.com/kylecorry31/Trail-Sense)'s production compass code.
155
+ ## Listener helpers
131
156
 
132
- Tune live:
157
+ For non-React code, three reference-counted listener primitives are exported. The first heading listener calls `start()` natively; the last `unsubscribe()` calls `stop()`.
133
158
 
134
159
  ```ts
135
- NitroCompass.setSmoothing(0.2) // default — kills jitter, ~100 ms latency
136
- NitroCompass.setSmoothing(0.4) // snappier, more visible jitter
137
- NitroCompass.setSmoothing(1.0) // disabled — every sample passes through
160
+ import {
161
+ addHeadingListener,
162
+ addCalibrationListener,
163
+ addInterferenceListener,
164
+ } from 'react-native-nitro-compass'
165
+
166
+ const off = addHeadingListener(({ heading, accuracy, fieldStrengthMicroTesla }) => {
167
+ // …
168
+ })
169
+ // later
170
+ off()
138
171
  ```
139
172
 
140
- `setSmoothing` is a no-op on iOS — Apple's stack already filters heading internally, so layering an EMA on top would only add latency without removing noise.
173
+ Mixing listener helpers with direct `NitroCompass.setOnCalibrationNeeded()` / `setOnInterferenceDetected()` will clobber the multiplex's internal callback slot pick one path. `useCompass()` itself uses these helpers, so mixing the hook with `addHeadingListener` is fine.
141
174
 
142
- ### Background pause
175
+ ## Imperative API
143
176
 
144
- By default the underlying sensor / location-manager subscription is silently paused while the app is backgrounded and resumed when it returns to the foreground; the JS callback and any declination set via `setDeclination` are preserved across the pause. To opt out (e.g. for a fitness tracker that needs heading while screen-off):
177
+ For full control, drive the native HybridObject directly:
145
178
 
146
179
  ```ts
147
- NitroCompass.setPauseOnBackground(false)
180
+ import { NitroCompass } from 'react-native-nitro-compass'
181
+
182
+ if (NitroCompass.hasCompass()) {
183
+ NitroCompass.start(1, ({ heading }) => console.log(heading))
184
+ }
185
+ NitroCompass.stop()
148
186
  ```
149
187
 
150
- ### `useCompass()` hook
188
+ | Method | Description |
189
+ | --- | --- |
190
+ | `start(filterDegrees, onHeading)` | Begin emitting samples to `onHeading`. Idempotent — replaces any prior subscription. |
191
+ | `stop()` | Stop the subscription. Safe to call when not started. |
192
+ | `isStarted()` | `true` between `start()` and `stop()`. |
193
+ | `hasCompass()` | Hardware availability check. |
194
+ | `setFilter(degrees)` | Update the deadband filter live without restarting. |
195
+ | `setSmoothing(alpha)` | Update the EMA smoothing factor (Android). Range `(0, 1]`. No-op on iOS. |
196
+ | `setDeclination(degrees)` | Apply a magnetic-to-true offset to every emitted heading. |
197
+ | `setLocation(latitude, longitude)` | Tighten the Android interference gate using WMM2025. Pass `NaN` to revert. No-op on iOS. |
198
+ | `setPauseOnBackground(enabled)` | Toggle automatic pause/resume on background. |
199
+ | `getCurrentHeading()` | Most recent sample, or `undefined`. |
200
+ | `getDiagnostics()` | Which sensor backs headings on this device. |
201
+ | `getDebugInfo()` | Live snapshot of internal pipeline state — see [DebugInfo](#types). |
202
+ | `setOnCalibrationNeeded(cb)` | Subscribe to calibration-bucket transitions. |
203
+ | `setOnInterferenceDetected(cb)` | Subscribe to magnetic-interference transitions. |
204
+ | `recalibrate()` | Force a best-effort recalibration (re-register sensors on Android, dismiss heading-calibration overlay on iOS). |
205
+ | `getPermissionStatus()` | Synchronous read of the platform permission state. |
206
+ | `requestPermission()` | Promise — prompts iOS dialog if `notDetermined`, resolves with the resulting status. |
207
+
208
+ ## Recipes
209
+
210
+ ### True-north heading from location
211
+
212
+ Headings are **magnetic** by default. To convert to true-north you need the [magnetic declination](https://en.wikipedia.org/wiki/Magnetic_declination) at the user's location — it varies from ~0° on the agonic line to ±25° in some parts of the world. The library applies an offset for you when you call `setDeclination(deg)`; you compute that offset from a WMM model.
213
+
214
+ Pair any geolocation library with [`geomagnetism`](https://github.com/manuelbieh/geomagnetism) (a static WMM2025 lookup, no native deps):
151
215
 
152
- For React consumers, the bundled hook wraps the entire surface — subscription lifecycle, calibration/interference callbacks, and the live-tuneable knobs — into one ergonomic call. Multiple `useCompass()` mounts safely share the same underlying native subscription via JS-side fan-out, so two screens can both consume heading without clobbering each other.
216
+ ```sh
217
+ yarn add react-native-geolocation-service geomagnetism
218
+ ```
153
219
 
154
220
  ```tsx
221
+ import { useEffect } from 'react'
222
+ import { Platform, PermissionsAndroid } from 'react-native'
223
+ import Geolocation from 'react-native-geolocation-service'
224
+ import geomagnetism from 'geomagnetism'
155
225
  import { useCompass } from 'react-native-nitro-compass'
156
226
 
157
- function CompassView() {
158
- const { reading, quality, interfering, hasCompass } = useCompass({
159
- filterDegrees: 1,
160
- smoothingAlpha: 0.2,
161
- declination: 0,
162
- pauseOnBackground: true,
163
- enabled: true,
164
- })
165
-
166
- if (!hasCompass) return <Text>No compass on this device.</Text>
167
- if (!reading) return <Text>Acquiring heading…</Text>
168
-
169
- return (
170
- <View>
171
- <Text>{reading.heading.toFixed(0)}° (±{reading.accuracy.toFixed(0)}°)</Text>
172
- {quality === 'unreliable' && <Text>Calibration needed</Text>}
173
- {interfering && <Text>Magnetic interference</Text>}
174
- </View>
175
- )
227
+ async function ensureLocationPermission(): Promise<boolean> {
228
+ if (Platform.OS === 'android') {
229
+ const result = await PermissionsAndroid.request(
230
+ PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION,
231
+ )
232
+ return result === PermissionsAndroid.RESULTS.GRANTED
233
+ }
234
+ return true
176
235
  }
177
- ```
178
236
 
179
- ```ts
180
- function useCompass(options?: UseCompassOptions): UseCompassResult
237
+ function CompassScreen() {
238
+ const compass = useCompass()
239
+ const { setLocation } = compass
240
+
241
+ useEffect(() => {
242
+ let cancelled = false
243
+ void (async () => {
244
+ if (!(await ensureLocationPermission()) || cancelled) return
245
+
246
+ Geolocation.getCurrentPosition(
247
+ ({ coords }) => {
248
+ if (cancelled) return
249
+ // Tighten the interference band (Android-only).
250
+ setLocation(coords.latitude, coords.longitude)
251
+ // Apply true-north declination to every subsequent sample.
252
+ const decl = geomagnetism.model().point([coords.latitude, coords.longitude]).decl
253
+ NitroCompass.setDeclination(decl)
254
+ },
255
+ () => {/* fall back to magnetic heading + generic interference band */},
256
+ { enableHighAccuracy: false, timeout: 15_000, maximumAge: 60_000 },
257
+ )
258
+ })()
259
+ return () => { cancelled = true }
260
+ }, [setLocation])
261
+
262
+ return <Text>{compass.reading?.heading.toFixed(0)}° true</Text>
263
+ }
181
264
  ```
182
265
 
183
- #### Options
266
+ A few notes:
184
267
 
185
- | Option | Type | Default | Description |
186
- | --- | --- | --- | --- |
187
- | `filterDegrees` | `number` | `1` | Minimum change between successive samples in degrees. Pass `0` for "every event". Updated live via `NitroCompass.setFilter()` whenever the prop changes. |
188
- | `smoothingAlpha` | `number` | `0.2` | Low-pass smoothing factor (EMA α) on Android. `1.0` disables smoothing; smaller values smooth more. No-op on iOS. See [Smoothing](#smoothing). |
189
- | `declination` | `number` | `0` | Magnetic-to-true offset in signed degrees. Pull from a model like [`geomagnetism`](https://github.com/kahirokunn/geomagnetism) keyed on the user's lat/lon. When non-zero, every emitted sample is true-north. |
190
- | `pauseOnBackground` | `boolean` | `true` | Pause the underlying sensor / location-manager subscription while the app is backgrounded and resume on foreground. |
191
- | `enabled` | `boolean` | `true` | Toggle the heading subscription without unmounting. When `false`, `reading` stops updating but calibration and interference observation continue (so you can still show warnings). |
192
-
193
- `filterDegrees`, `smoothingAlpha`, `declination`, and `pauseOnBackground` map to global state on `NitroCompass` — if multiple hooks set them, last-write-wins.
194
-
195
- #### Result
196
-
197
- | Field | Type | Description |
198
- | --- | --- | --- |
199
- | `reading` | `CompassSample \| null` | Latest emitted sample (`{ heading, accuracy }`), or `null` until the first arrives. Heading is true-north when `declination` is set, magnetic otherwise. |
200
- | `quality` | `AccuracyQuality \| null` | Coarse calibration bucket — `'high'`, `'medium'`, `'low'`, or `'unreliable'`. `null` until the first transition. Show your own calibration UI on `'unreliable'`. |
201
- | `interfering` | `boolean` | `true` while the raw magnetic field magnitude is outside the normal Earth band (~20–70 µT) — laptops, monitors, car engines, steel structures. |
202
- | `hasCompass` | `boolean` | Hardware availability — read once on first render. Render a fallback when `false`. |
203
- | `diagnostics` | `SensorDiagnostics \| undefined` | Which sensor backs the readings on this device (`rotationVector`, `geomagneticRotationVector`, or `coreLocation`). Useful for explaining quality differences. |
204
-
205
- For non-React state managers, lower-level `addHeadingListener(cb): () => void`, `addCalibrationListener(cb): () => void`, and `addInterferenceListener(cb): () => void` are also exported. They are reference-counted: the first heading listener calls `start()`, the last unsubscribe calls `stop()`. Mixing these helpers with direct `NitroCompass.start()` / `setOnCalibrationNeeded()` / `setOnInterferenceDetected()` will clobber the multiplex's internal callback slot — pick one path.
268
+ - **One-shot is enough.** Both declination and `expectedField` vary slowly with position (< 0.5 % per km), so a single fix at app start is fine for stationary users. For long-distance travelers, add a `Geolocation.watchPosition` with `distanceFilter: 10_000` (10 km) and `interval: 600_000` (10 min) — same accuracy, negligible battery cost. **Don't poll every second** — it has zero accuracy benefit and significant battery cost.
269
+ - **Pass `NaN, NaN` and `0`** to revert when the user disables location: `setLocation(NaN, NaN); NitroCompass.setDeclination(0)`.
270
+ - **iOS**: `setLocation` is a no-op (`CLLocationManager` already uses GPS-derived location internally), but `setDeclination` works the same way as on Android both platforms apply it before the heading hits your callback.
206
271
 
207
272
  ### Smooth dial animation (Reanimated)
208
273
 
209
- `useCompass()` returns React state, so each sample re-renders the consumer — fine for a numeric readout, but a rotating dial driven that way will jitter on faster filter values. For 60 fps animations, subscribe with `addHeadingListener` and write directly into a Reanimated shared value on the UI thread:
274
+ `useCompass()` triggers a React render on every emitted sample — fine for a numeric readout, but a rotating dial driven that way will jitter at high sample rates. For 60 fps animation, subscribe with `addHeadingListener` and write directly into a Reanimated shared value on the UI thread:
210
275
 
211
276
  ```tsx
277
+ import { useEffect, useRef } from 'react'
212
278
  import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'
213
279
  import { addHeadingListener } from 'react-native-nitro-compass'
214
280
 
@@ -231,54 +297,206 @@ function Dial() {
231
297
  }
232
298
  ```
233
299
 
234
- The same pattern is used in [example/components/Compass.tsx](./example/components/Compass.tsx).
300
+ The pattern is used in [`example/components/Compass.tsx`](./example/components/Compass.tsx).
235
301
 
236
- ## Permissions
302
+ ### Location-tightened interference
237
303
 
238
- - **iOS**: requires `NSLocationWhenInUseUsageDescription` in `Info.plist`. `CLLocationManager` only emits headings when location permission is granted.
239
- - **Android**: no permission required for the rotation-vector sensor.
304
+ `setLocation(lat, lon)` replaces the generic 20–70 µT "Earth field" band with `expectedField ± 15 µT`, where `expectedField` comes from the WMM2025 model bundled in Android's `GeomagneticField`. This catches weak interference at high or low latitudes where the natural field exceeds 60 µT — exactly the cases where the generic band is too loose.
240
305
 
241
- ## Example app
306
+ The recipe is identical to [True-north heading from location](#true-north-heading-from-location); call both `setLocation` and `setDeclination` with the same fix.
242
307
 
243
- A bare React Native CLI app under [example/](./example) (RN 0.85.3, New Arch enabled) consumes the library via a local symlink. It demos the full surface — `useCompass()` for the readout, calibration / interference banners, and a Reanimated-driven dial that subscribes via `addHeadingListener` so the rotation runs entirely on the UI thread. Use it to test changes on a real device — the iOS Simulator has no compass and the Android emulator's magnetometer is faked.
308
+ ### Calibration UI
244
309
 
245
- First-time setup:
310
+ ```tsx
311
+ import { Pressable, Text, View } from 'react-native'
312
+ import { useCompass } from 'react-native-nitro-compass'
246
313
 
247
- ```sh
248
- cd example
249
- npm install # symlinks ../ as react-native-nitro-compass
250
- cd ios && bundle install && bundle exec pod install && cd ..
314
+ function CalibrationBanner() {
315
+ const { quality, recalibrate } = useCompass()
316
+ if (quality !== 'unreliable' && quality !== 'low') return null
317
+
318
+ return (
319
+ <View style={styles.banner}>
320
+ <Text>Tilt and rotate the device in different directions until accuracy improves.</Text>
321
+ <Pressable onPress={recalibrate}>
322
+ <Text>Refresh</Text>
323
+ </Pressable>
324
+ </View>
325
+ )
326
+ }
327
+ ```
328
+
329
+ `recalibrate()` re-registers the sensor listeners on Android (which often nudges the magnetometer driver to re-evaluate calibration) and dismisses the iOS heading-calibration overlay. The user still has to move the device — this just clears cached state so progress is reflected promptly.
330
+
331
+ ### Custom diagnostics panel
332
+
333
+ For self-diagnosing user bug reports, poll `getDebugInfo()` behind a hidden footer:
334
+
335
+ ```tsx
336
+ import { useEffect, useState } from 'react'
337
+ import { NitroCompass, type DebugInfo } from 'react-native-nitro-compass'
338
+
339
+ function DebugPanel() {
340
+ const [info, setInfo] = useState<DebugInfo | null>(null)
341
+ useEffect(() => {
342
+ const id = setInterval(() => {
343
+ try { setInfo(NitroCompass.getDebugInfo()) } catch {}
344
+ }, 250)
345
+ return () => clearInterval(id)
346
+ }, [])
347
+ // …render info.interferenceActive, info.usingUncalibratedMag, info.fusedYawDeg, etc.
348
+ }
349
+ ```
350
+
351
+ A complete implementation lives at [`example/components/DebugPanel.tsx`](./example/components/DebugPanel.tsx).
352
+
353
+ ## Types
354
+
355
+ ```ts
356
+ interface CompassSample {
357
+ heading: number // [0, 360); magnetic by default, true-north if setDeclination was called
358
+ accuracy: number // degrees; smaller is better; -1 if unknown
359
+ fieldStrengthMicroTesla: number // µT magnitude of the local magnetic field; -1 until first reading
360
+ }
361
+
362
+ type AccuracyQuality = 'high' | 'medium' | 'low' | 'unreliable'
363
+
364
+ type PermissionStatus = 'granted' | 'denied' | 'unknown'
365
+
366
+ type SensorKind =
367
+ | 'magnetometer' // Android raw mag + accel
368
+ | 'coreLocation' // iOS CLLocationManager
369
+ | 'rotationVector' // legacy, no longer returned
370
+ | 'geomagneticRotationVector' // legacy, no longer returned
371
+
372
+ interface SensorDiagnostics {
373
+ sensor: SensorKind
374
+ }
375
+
376
+ interface DebugInfo {
377
+ interferenceActive: boolean
378
+ msSinceLastBiasJump: number // -1 if never seen / iOS
379
+ expectedFieldMicroTesla: number // -1 if setLocation() not called
380
+ lastFieldMicroTesla: number // -1 if no reading
381
+ fusedYawDeg: number // NaN before first sample / iOS
382
+ lastYawRateDegPerS: number // 0 if game-RV unavailable
383
+ hasGameRotationVector: boolean // false on iOS
384
+ usingUncalibratedMag: boolean // false on iOS
385
+ }
251
386
  ```
252
387
 
253
- Run on a device:
388
+ ### `AccuracyQuality` thresholds
389
+
390
+ The bucket is derived from a numeric heading-accuracy estimate on both platforms, but the thresholds differ because the underlying scales disagree:
391
+
392
+ - **Android** — direct mapping from `SensorManager.SENSOR_STATUS_*`: `HIGH` → `high`, `MEDIUM` → `medium`, `LOW` → `low`, `UNRELIABLE`/`NO_CONTACT` → `unreliable`. The numeric `accuracy` field on `CompassSample` is a synthetic upper bound (`<5°`, `<15°`, `<30°`, `-1`).
393
+ - **iOS** — bucketed from `CLHeading.headingAccuracy` (degrees) with relaxed thresholds because Apple's stack rarely reports under 5° even on a perfectly-calibrated compass: `<20°` → `high`, `<35°` → `medium`, `<55°` → `low`, otherwise `unreliable`.
394
+
395
+ When magnetic interference is detected on Android, the surfaced bucket is downgraded by one notch (`high` → `medium`, etc.) — calibration ("the magnetometer needs tuning") and interference ("the field is currently being skewed") are independent signals, and surfacing `quality='high'` alongside `interfering=true` is contradictory UX.
396
+
397
+ ## Architecture
398
+
399
+ ### Why not `TYPE_ROTATION_VECTOR`
400
+
401
+ Most React Native compass libraries use Android's `TYPE_ROTATION_VECTOR`, which feels great until you put a magnet, a phone, or a laptop next to the device — the OS-level Kalman filter then holds a poisoned bias estimate for many seconds after the source is removed. This library computes heading directly from raw `accelerometer + magnetometer` via `getRotationMatrix()` (the same approach used by popular consumer compass apps), so recovery from interference is instant.
402
+
403
+ We trade a few degrees of steady-state jitter for stateless behavior, then add back smoothness via two layers:
404
+
405
+ 1. **Adaptive input low-pass** on the accel and mag *vectors* before they enter `getRotationMatrix()`. Different α per sensor (accel is jerk-noisy, mag is hard-iron-noisy), and α is adaptive on gyro-derived yaw rate — strong filter when still, weak when turning fast.
406
+ 2. **Gyro complementary fusion** on top of the result. `TYPE_GAME_ROTATION_VECTOR` provides a Δyaw between events; we integrate that into a `fusedYawDeg` and let mag samples pull it back to absolute via a small (~1 s time constant) blend. During interference the blend is disabled — gyro alone carries heading until the field clears, then a one-shot snap re-syncs.
407
+
408
+ The output is then run through an EMA on `(sin θ, cos θ)` (handles 359°→0° wraparound cleanly) before delivery — tunable via `setSmoothing()`.
409
+
410
+ ### Magnetic interference
411
+
412
+ Detection on **Android** combines two signals:
413
+
414
+ 1. The raw magnetic field magnitude leaving the Earth band (~20–70 µT, or `expectedField ± 15 µT` if you've called `setLocation`).
415
+ 2. Recent OS hard-iron-bias jumps on `TYPE_MAGNETIC_FIELD_UNCALIBRATED`.
416
+
417
+ The bias-jump signal catches *weak* interference events the magnitude check alone would miss — e.g. another phone placed on top of yours, where the corrected field magnitude stays near 50 µT but the OS still revises its bias estimate. Either signal flips `interfering` to `true`; both must clear (and a 1.5 s grace window expire) before `false` is reported.
418
+
419
+ On **iOS**, detection uses `CMDeviceMotion.magneticField` (calibrated, with the device's own hard-iron bias subtracted in real time). Transitions wait for CoreMotion's bias estimate to converge (5 consecutive non-`uncalibrated` samples — typically a second or two of normal device movement after subscribe) so the first second post-`start()` doesn't fire false positives.
420
+
421
+ ### Background pause
422
+
423
+ By default the underlying sensor / location-manager subscription is silently paused while the app is backgrounded and resumed on foreground — the JS callback, declination, and other settings are preserved across the pause. To opt out (e.g. for a fitness tracker that needs heading while screen-off):
424
+
425
+ ```ts
426
+ NitroCompass.setPauseOnBackground(false)
427
+ ```
428
+
429
+ ## Troubleshooting
430
+
431
+ ### Heading is consistently off by N degrees
432
+
433
+ You're seeing magnetic heading; you wanted true-north. Apply declination from a WMM model — see [True-north heading from location](#true-north-heading-from-location).
434
+
435
+ If the offset is much larger than expected (>30°), the device is likely in a building with steel framing or near a strong electromagnetic source; check `interfering` and `lastFieldMicroTesla` via `getDebugInfo()`.
436
+
437
+ ### Heading is jittery
438
+
439
+ - **Android**: increase `setSmoothing` damping — try `0.1` or `0.05` (default is `0.2`). Smaller α = more smoothing.
440
+ - **Both platforms**: increase `filterDegrees` — `2` or `3` is plenty for a UI dial.
441
+ - For 60 fps animations, subscribe with `addHeadingListener` and write directly into a Reanimated shared value (see the [Reanimated recipe](#smooth-dial-animation-reanimated)).
442
+
443
+ ### Calibration banner won't clear
444
+
445
+ Call `recalibrate()` (or expose a "Refresh" button to the user). On Android this re-registers the sensor listeners, which often nudges the driver to re-evaluate calibration. The user still has to move the device — this just clears cached state so progress is reflected promptly.
446
+
447
+ If the banner clears and immediately re-shows, the device is likely under sustained interference — check `interfering` and walk away from the source.
448
+
449
+ ### iOS: `start()` throws `Location authorization denied`
450
+
451
+ The user has denied location permission in Settings. Drive the prompt via the hook:
452
+
453
+ ```tsx
454
+ const { permission, requestPermission } = useCompass()
455
+ if (permission === 'unknown') requestPermission()
456
+ ```
457
+
458
+ iOS does not re-prompt once permission is denied — direct the user to Settings via `Linking.openSettings()`.
459
+
460
+ ### Android: heading is silent, no events
461
+
462
+ - Verify `hasCompass` is `true`. The Android emulator has a faked magnetometer; on a real device, `getDefaultSensor(TYPE_MAGNETIC_FIELD)` should return non-null.
463
+ - Wrap the start in a try/catch — `subscribe` will throw if either accelerometer or magnetometer is missing on the device (extremely rare on modern hardware).
464
+
465
+ ### Simulator shows no heading
466
+
467
+ The iOS Simulator has no compass hardware — testing requires a physical device. The Android emulator's magnetometer is faked and stationary at a single point; it'll respond to manual rotation in the emulator's "Sensors" panel but won't track movement.
468
+
469
+ ## Example app
470
+
471
+ A bare React Native CLI app under [example/](./example) (RN 0.85.3, New Arch enabled) consumes the library via a local symlink. It demos the full surface — `useCompass()` for the readout, calibration / interference banners, a Reanimated-driven dial, location-tightened interference via `react-native-geolocation-service`, and a collapsible debug panel polling `getDebugInfo()`. Use it to test changes on a real device — the iOS Simulator has no compass.
254
472
 
255
473
  ```sh
256
- # Terminal 1 — Metro
257
- npm start
474
+ cd example
475
+ npm install # symlinks ../ as react-native-nitro-compass
476
+ cd ios && bundle install && bundle exec pod install # iOS only
258
477
 
259
- # Terminal 2 — build & launch
260
- npm run ios -- --device # physical iPhone
261
- npm run android # physical device or emulator
478
+ # back in example/
479
+ npm start # Metro
480
+ npm run ios -- --device # physical iPhone
481
+ npm run android # physical device or emulator
262
482
  ```
263
483
 
264
- If you change the Nitrogen spec or any native source, regenerate and rebuild:
484
+ If you change the Nitro spec or any native source, regenerate and rebuild:
265
485
 
266
486
  ```sh
267
487
  # from the repo root
268
488
  npm run codegen
269
489
  # then in example/
270
- cd ios && bundle exec pod install && cd .. # iOS only
490
+ cd ios && bundle exec pod install && cd ..
271
491
  npm run ios # or npm run android
272
492
  ```
273
493
 
274
- The example imports `NitroCompass` directly from the workspace `src/` (via Metro `watchFolders`), so editing TypeScript only requires a Metro reload.
275
-
276
494
  ## Acknowledgments
277
495
 
278
- The Android rotation-vector pattern (sensor fusion, surface-rotation remapping, `getOrientation` extraction) is adapted from the MIT-licensed [Andromeda](https://github.com/kylecorry31/andromeda) sensor library by Kyle Corry, which powers the [Trail Sense](https://github.com/kylecorry31/Trail-Sense) wilderness navigation app.
496
+ The Android sensor pattern (raw mag + accel fusion via `getRotationMatrix`, surface-rotation remapping, `getOrientation` extraction, EMA on `(sin θ, cos θ)`) is adapted from the MIT-licensed [Andromeda](https://github.com/kylecorry31/andromeda) sensor library by Kyle Corry, which powers the [Trail Sense](https://github.com/kylecorry31/Trail-Sense) wilderness navigation app.
279
497
 
280
498
  Bootstrapped with [create-nitro-module](https://github.com/patrickkabwe/create-nitro-module).
281
499
 
282
500
  ## License
283
501
 
284
- MIT — see [LICENSE](./LICENSE).
502
+ [MIT](./LICENSE)