react-native-nitro-compass 1.2.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 (2) hide show
  1. package/README.md +346 -249
  2. package/package.json +1 -1
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**: raw `TYPE_MAGNETIC_FIELD_UNCALIBRATED` + `TYPE_ACCELEROMETER` fed through `SensorManager.getRotationMatrix()` + `getOrientation()`, with a `TYPE_GAME_ROTATION_VECTOR` complementary filter on top for steady-state smoothness. This path is **stateless** — when a magnet or laptop is removed, the very next sample produces the correct heading instead of waiting for OS-level fusion to re-converge. We also detect OS hard-iron-bias jumps as a separate interference signal, so weak magnet events that don't push field magnitude out-of-band still register. Sensor delivery on a dedicated `HandlerThread` never blocks the UI thread.
6
- - **iOS**: `CLLocationManager` heading via `CLHeading.magneticHeading` for direction; `CMDeviceMotion.magneticField` (calibrated) for field strength + interference detection. Apple's stack handles sensor fusion natively.
7
- - **JS API**: type-safe Nitro callbacks — no `NativeEventEmitter`, no string event names. Optional `useCompass()` React hook bundles subscription lifecycle, calibration / interference observation, and live-tuneable knobs into one ergonomic call.
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 Android's `TYPE_ROTATION_VECTOR`, which feels great until you put a magnet, a phone, or a laptop next to the device — then the OS-level Kalman filter 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. We trade a few degrees of steady-state jitter for stateless behaviour, then add back smoothness via two layers: an adaptive input-side low-pass on the accel and mag vectors, plus a `TYPE_GAME_ROTATION_VECTOR` (gyro+accel) complementary filter that integrates Δyaw between events and lets mag samples pull it back to absolute. The end result tracks fast turns without lag, ignores transient magnet events, and snaps back instantly when interference clears.
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,372 +62,435 @@ 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
85
+ ### Android
53
86
 
54
- NitroCompass.setFilter(degrees: number): void
55
- NitroCompass.setSmoothing(alpha: number): void
56
- NitroCompass.setDeclination(degrees: number): void
57
- NitroCompass.setLocation(latitude: number, longitude: number): void
58
- NitroCompass.setPauseOnBackground(enabled: boolean): void
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):
59
88
 
60
- NitroCompass.getCurrentHeading(): CompassSample | undefined
61
- NitroCompass.getDiagnostics(): SensorDiagnostics | undefined
62
- NitroCompass.getDebugInfo(): DebugInfo
89
+ ```xml
90
+ <!-- android/app/src/main/AndroidManifest.xml -->
91
+ <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
92
+ ```
63
93
 
64
- NitroCompass.setOnCalibrationNeeded(onChange: (quality: AccuracyQuality) => void): void
65
- NitroCompass.setOnInterferenceDetected(onChange: (interferenceDetected: boolean) => void): void
94
+ ## Quick start
66
95
 
67
- NitroCompass.recalibrate(): void
68
- NitroCompass.getPermissionStatus(): PermissionStatus
69
- NitroCompass.requestPermission(): Promise<PermissionStatus>
96
+ ```tsx
97
+ import { Text, View } from 'react-native'
98
+ import { useCompass } from 'react-native-nitro-compass'
70
99
 
71
- interface CompassSample {
72
- heading: number // degrees, [0, 360); magnetic by default, true-north if setDeclination was called
73
- accuracy: number // degrees, smaller is better; -1 if unknown
74
- fieldStrengthMicroTesla: number // µT magnitude of the local magnetic field; -1 until first reading
75
- }
100
+ function Compass() {
101
+ const { reading, quality, interfering, hasCompass } = useCompass()
76
102
 
77
- type AccuracyQuality = 'high' | 'medium' | 'low' | 'unreliable'
78
- type PermissionStatus = 'granted' | 'denied' | 'unknown'
79
- type SensorKind =
80
- | 'magnetometer'
81
- | 'coreLocation'
82
- | 'rotationVector' // legacy, no longer returned
83
- | 'geomagneticRotationVector' // legacy, no longer returned
84
- interface SensorDiagnostics { sensor: SensorKind }
85
- interface DebugInfo {
86
- interferenceActive: boolean
87
- msSinceLastBiasJump: number // -1 if never seen / iOS
88
- expectedFieldMicroTesla: number // -1 if setLocation not called
89
- lastFieldMicroTesla: number // -1 if no reading
90
- fusedYawDeg: number // NaN before first sample / iOS
91
- lastYawRateDegPerS: number // 0 if game-RV unavailable
92
- hasGameRotationVector: boolean // false on iOS
93
- usingUncalibratedMag: boolean // false on iOS
103
+ if (!hasCompass) return <Text>No compass on this device.</Text>
104
+ if (!reading) return <Text>Acquiring heading…</Text>
105
+
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
+ )
94
113
  }
95
114
  ```
96
115
 
97
- - `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.
98
- - `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.
99
- - `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.
100
- - `getDiagnostics()` reports which sensor would produce headings on this device. On Android this is always `magnetometer` for current builds (older versions returned `rotationVector` / `geomagneticRotationVector`); on iOS it's `coreLocation`. Safe to call before `start()`.
101
- - `accuracy` is a numeric uncertainty (degrees). On iOS it comes from `CLHeading.headingAccuracy` directly. On Android it's a coarse degree estimate derived from the magnetometer's `SensorManager.SENSOR_STATUS_*` accuracy bucket — Android's figure-8 calibration signal — mapped to `HIGH→5°`, `MEDIUM→15°`, `LOW→30°`.
102
- - `fieldStrengthMicroTesla` is the magnitude of the local magnetic field in µT, or `-1` until the first reading lands. Earth's field is normally 25–65 µT — values well outside this band signal external interference (laptops, monitors, magnets, ferrous metal). Useful for rendering a field-strength meter à la consumer compass apps.
103
- - `getCurrentHeading()` returns the most recently emitted sample (with declination already applied), or `undefined` if not started yet or no sample has arrived.
104
- - `setLocation(lat, lon)` lets the library tighten the interference detection band on Android. With a valid location, the generic 20–70 µT "Earth field" band is replaced by `expectedField ± 15 µT`, where `expectedField` comes from the WMM2025 model bundled in `GeomagneticField`. This catches weak interference at high/low latitudes where Earth's natural field is near or above 60 µT. Pass `NaN` or out-of-range values to revert to the generic band. **No-op on iOS** — `CLLocationManager` already uses GPS-derived location internally.
105
- - `recalibrate()` is a manual nudge for stuck calibration state. On Android it re-registers the sensor listeners (often nudges the magnetometer driver to re-evaluate soft/hard-iron calibration); on iOS it dismisses the system heading-calibration overlay and stops/restarts heading updates. Idempotent; safe to call before `start()`. The user still has to move the device through varying orientations — this just clears cached state so progress is reflected promptly. Useful behind a "Refresh" button in your calibration UI.
106
- - `getDebugInfo()` returns a live snapshot of internal pipeline state. Intended for diagnosing user-reported issues — not needed for normal operation. The bundled `<DebugPanel />` in [example/components/DebugPanel.tsx](./example/components/DebugPanel.tsx) polls it at 4 Hz behind a collapsible footer; copy/adapt it into your debug build to make user bug reports self-diagnosing. Most fields are Android-only — see the inline JSDoc on the `DebugInfo` interface.
107
- - `getPermissionStatus()` / `requestPermission()` map to platform location permission. Always `'granted'` on Android (sensors require no permission). On iOS, `getPermissionStatus()` reads `CLLocationManager.authorizationStatus`; `requestPermission()` prompts the system "Allow location" dialog if status is `'unknown'` and resolves once the user makes a choice. iOS does not re-prompt — subsequent calls resolve immediately with the cached status.
116
+ That's the whole API for 90% of apps. Read on for true-north, smoother animation, and tunable behavior.
108
117
 
109
- ### Calibration
118
+ ## `useCompass()` hook
110
119
 
111
- `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:
120
+ ```ts
121
+ function useCompass(options?: UseCompassOptions): UseCompassResult
122
+ ```
112
123
 
113
- - **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.
114
- - **Android** uses the magnetometer's `SensorManager.SENSOR_STATUS_*` bucket from `onAccuracyChanged` directly (`HIGH` / `MEDIUM` / `LOW` / `UNRELIABLE`) — Android's signal that the user should do (or has done) a figure-8 to recalibrate. **When magnetic interference is currently detected, the surfaced bucket is downgraded by one notch** (`HIGH→MEDIUM`, `MEDIUM→LOW`, `LOW→UNRELIABLE`) — calibration ("the magnetometer needs to be tuned") and interference ("the field is currently being skewed by something nearby") are independent signals, and surfacing `quality='high'` alongside `interfering=true` is contradictory UX.
124
+ Wraps the entire surface subscription lifecycle, calibration/interference callbacks, live-tuneable knobs, permission flow into one ergonomic call. Multiple `useCompass()` mounts safely share the same underlying native subscription.
115
125
 
116
- Both platforms can plausibly emit `'high'` on a clean device — the threshold split just reflects each OS's reporting style.
126
+ ### Options
117
127
 
118
- ```ts
119
- NitroCompass.setOnCalibrationNeeded((q) => {
120
- if (q === 'unreliable') showCalibrationToast()
121
- })
122
- ```
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. |
123
135
 
124
- ### Magnetic interference
136
+ `filterDegrees`, `smoothingAlpha`, `declination`, and `pauseOnBackground` map to global state on `NitroCompass` — if multiple hooks set them, last-write-wins.
137
+
138
+ ### Result
125
139
 
126
- `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.
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
- Interference is surfaced three ways: (1) directly via this callback, (2) on Android, the calibration bucket emitted by `setOnCalibrationNeeded` is downgraded by one notch while interference is detected (see the Calibration section above), and (3) every `CompassSample` carries `fieldStrengthMicroTesla` so you can render a live strength meter. On iOS, the calibration downgrade is skipped — `CLLocationManager`'s own accuracy reporting already responds to magnetometer disturbance, so a separate downgrade would double-count.
153
+ The four function fields all have stable identities (via `useCallback`) so consumers' `useEffect` deps don't churn on every render.
154
+
155
+ ## Listener helpers
156
+
157
+ For non-React code, three reference-counted listener primitives are exported. The first heading listener calls `start()` natively; the last `unsubscribe()` calls `stop()`.
129
158
 
130
159
  ```ts
131
- NitroCompass.setOnInterferenceDetected((interfering) => {
132
- if (interfering) showInterferenceWarning()
133
- else hideInterferenceWarning()
160
+ import {
161
+ addHeadingListener,
162
+ addCalibrationListener,
163
+ addInterferenceListener,
164
+ } from 'react-native-nitro-compass'
165
+
166
+ const off = addHeadingListener(({ heading, accuracy, fieldStrengthMicroTesla }) => {
167
+ // …
134
168
  })
169
+ // later
170
+ off()
135
171
  ```
136
172
 
137
- Detection on **Android** combines two signals: (1) the raw magnetic field magnitude leaving the Earth band, and (2) recent OS hard-iron-bias jumps on `TYPE_MAGNETIC_FIELD_UNCALIBRATED`. 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. If you call `setLocation(lat, lon)`, the magnitude band tightens to `expectedField ± 15 µT` for a more sensitive gate at high or low latitudes.
173
+ Mixing listener helpers with direct `NitroCompass.setOnCalibrationNeeded()` / `setOnInterferenceDetected()` will clobber the multiplex's internal callback slotpick one path. `useCompass()` itself uses these helpers, so mixing the hook with `addHeadingListener` is fine.
138
174
 
139
- 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.
175
+ ## Imperative API
140
176
 
141
- Only triggered while `start()` is active; no debounce, so brief excursions still fire.
177
+ For full control, drive the native HybridObject directly:
142
178
 
143
- ### Location-aware interference (recipe)
179
+ ```ts
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()
186
+ ```
144
187
 
145
- `setLocation(lat, lon)` tightens the Android interference gate from the generic 20–70 µT band to `expectedField ± 15 µT`, where `expectedField` comes from the WMM2025 model bundled in `GeomagneticField`. This catches weak interference at high or low latitudes where Earth's natural field is near or above 60 µT — exactly the cases where the generic band is too loose to detect, say, another phone placed nearby.
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):
146
215
 
147
- Pair it with any geolocation library — the example below uses [`react-native-geolocation-service`](https://github.com/Agontuk/react-native-geolocation-service):
216
+ ```sh
217
+ yarn add react-native-geolocation-service geomagnetism
218
+ ```
148
219
 
149
220
  ```tsx
150
221
  import { useEffect } from 'react'
151
222
  import { Platform, PermissionsAndroid } from 'react-native'
152
223
  import Geolocation from 'react-native-geolocation-service'
224
+ import geomagnetism from 'geomagnetism'
153
225
  import { useCompass } from 'react-native-nitro-compass'
154
226
 
155
227
  async function ensureLocationPermission(): Promise<boolean> {
156
228
  if (Platform.OS === 'android') {
157
- const granted = await PermissionsAndroid.request(
229
+ const result = await PermissionsAndroid.request(
158
230
  PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION,
159
231
  )
160
- return granted === PermissionsAndroid.RESULTS.GRANTED
232
+ return result === PermissionsAndroid.RESULTS.GRANTED
161
233
  }
162
- // iOS — useCompass already prompts for location auth for the compass
163
- // itself, so a granted permission lets us read position too.
164
234
  return true
165
235
  }
166
236
 
167
237
  function CompassScreen() {
168
- const compass = useCompass({ enabled: true })
238
+ const compass = useCompass()
169
239
  const { setLocation } = compass
170
240
 
171
241
  useEffect(() => {
172
242
  let cancelled = false
173
- let watchId: number | undefined
174
-
175
243
  void (async () => {
176
244
  if (!(await ensureLocationPermission()) || cancelled) return
177
245
 
178
- // One-shot fix at start. Coarse accuracy is fine — Earth's
179
- // field varies < 0.5 % per km, so a city-block-resolution
180
- // position is more than enough for the ±15 µT tolerance.
181
246
  Geolocation.getCurrentPosition(
182
247
  ({ coords }) => {
183
- if (!cancelled) setLocation(coords.latitude, coords.longitude)
184
- },
185
- () => {
186
- /* swallow falls back to the generic 20–70 µT band */
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)
187
254
  },
255
+ () => {/* fall back to magnetic heading + generic interference band */},
188
256
  { enableHighAccuracy: false, timeout: 15_000, maximumAge: 60_000 },
189
257
  )
190
-
191
- // Optional: keep `expectedField` in sync if the user moves
192
- // long-distance. A 50 km move shifts the WMM-derived field by
193
- // ~0.3 µT — well inside the tolerance — so a coarse 10 km /
194
- // 10 minute filter is plenty.
195
- watchId = Geolocation.watchPosition(
196
- ({ coords }) => {
197
- if (!cancelled) setLocation(coords.latitude, coords.longitude)
198
- },
199
- () => {},
200
- {
201
- enableHighAccuracy: false,
202
- distanceFilter: 10_000,
203
- interval: 10 * 60 * 1000,
204
- },
205
- )
206
258
  })()
207
-
208
- return () => {
209
- cancelled = true
210
- if (watchId !== undefined) Geolocation.clearWatch(watchId)
211
- }
259
+ return () => { cancelled = true }
212
260
  }, [setLocation])
213
261
 
214
- // …render compass.reading, compass.quality, etc.
262
+ return <Text>{compass.reading?.heading.toFixed(0)}° true</Text>
215
263
  }
216
264
  ```
217
265
 
218
266
  A few notes:
219
267
 
220
- - **`setLocation` from `useCompass()` has a stable identity**, so listing it in the effect dependency array is safeit won't re-run on every compass tick.
221
- - **No location permission required** for the compass itself on Android (sensors are unrestricted). The location permission requested above is purely so `react-native-geolocation-service` can give you a fix; if it's denied, the compass still works — it just falls back to the generic interference band.
222
- - **iOS is a no-op** for `setLocation` — `CLLocationManager` already uses GPS-derived location internally for all field-related reasoning, so calling it changes nothing on iOS. The recipe still works cross-platform; it's just that on iOS the call is effectively wasted. You can guard with `if (Platform.OS === 'android')` if you'd rather skip the geolocation request entirely on iOS.
223
- - **One-shot vs watch**: if your app is stationary (typical phone use), the one-shot `getCurrentPosition` is enough. The `watchPosition` only matters if your user is driving / flying long distances; the field strength changes slowly enough that a coarse low-frequency watch is fine.
224
- - **Pass `NaN` to revert** to the generic band if the location becomes stale or the user revokes permission: `setLocation(NaN, NaN)`.
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.
225
271
 
226
- ### Magnetic vs true north
272
+ ### Smooth dial animation (Reanimated)
227
273
 
228
- 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.
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:
229
275
 
230
- ```ts
231
- import geomagnetism from 'geomagnetism'
276
+ ```tsx
277
+ import { useEffect, useRef } from 'react'
278
+ import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'
279
+ import { addHeadingListener } from 'react-native-nitro-compass'
232
280
 
233
- const declination = geomagnetism.model().point([lat, lon]).decl
281
+ function Dial() {
282
+ const angle = useSharedValue(0)
283
+ const last = useRef(0)
234
284
 
235
- // Option A JS-side
236
- const trueHeading = (heading + declination + 360) % 360
285
+ useEffect(() => addHeadingListener(({ heading }) => {
286
+ // unwrap so 359° animates +2°, not -358°
287
+ const wrapped = ((last.current % 360) + 360) % 360
288
+ let delta = heading - wrapped
289
+ if (delta > 180) delta -= 360
290
+ else if (delta < -180) delta += 360
291
+ last.current += delta
292
+ angle.value = withTiming(last.current, { duration: 80, easing: Easing.out(Easing.quad) })
293
+ }), [angle])
237
294
 
238
- // Option B native-side (subsequent samples are true-north)
239
- NitroCompass.setDeclination(declination)
295
+ const style = useAnimatedStyle(() => ({ transform: [{ rotate: `${-angle.value}deg` }] }))
296
+ return <Animated.View style={[styles.dial, style]}>{/* ticks */}</Animated.View>
297
+ }
240
298
  ```
241
299
 
242
- Pass `0` to revert to magnetic. Declination survives `stop()`/`start()` cycles.
300
+ The pattern is used in [`example/components/Compass.tsx`](./example/components/Compass.tsx).
243
301
 
244
- ### Smoothing
302
+ ### Location-tightened interference
245
303
 
246
- Android's raw accelerometer + magnetometer heading jitters by `±1–3°` even at rest. 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.
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 µTexactly the cases where the generic band is too loose.
247
305
 
248
- Tune live:
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.
249
307
 
250
- ```ts
251
- NitroCompass.setSmoothing(0.2) // default — kills jitter, ~100 ms latency
252
- NitroCompass.setSmoothing(0.4) // snappier, more visible jitter
253
- NitroCompass.setSmoothing(1.0) // disabled every sample passes through
308
+ ### Calibration UI
309
+
310
+ ```tsx
311
+ import { Pressable, Text, View } from 'react-native'
312
+ import { useCompass } from 'react-native-nitro-compass'
313
+
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
+ }
254
327
  ```
255
328
 
256
- `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.
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.
257
330
 
258
- ### Background pause
331
+ ### Custom diagnostics panel
259
332
 
260
- 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):
333
+ For self-diagnosing user bug reports, poll `getDebugInfo()` behind a hidden footer:
261
334
 
262
- ```ts
263
- NitroCompass.setPauseOnBackground(false)
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
+ }
264
349
  ```
265
350
 
266
- ### `useCompass()` hook
351
+ A complete implementation lives at [`example/components/DebugPanel.tsx`](./example/components/DebugPanel.tsx).
267
352
 
268
- 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.
353
+ ## Types
269
354
 
270
- ```tsx
271
- import { useCompass } from 'react-native-nitro-compass'
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
+ }
272
361
 
273
- function CompassView() {
274
- const { reading, quality, interfering, hasCompass } = useCompass({
275
- filterDegrees: 1,
276
- smoothingAlpha: 0.2,
277
- declination: 0,
278
- pauseOnBackground: true,
279
- enabled: true,
280
- })
362
+ type AccuracyQuality = 'high' | 'medium' | 'low' | 'unreliable'
281
363
 
282
- if (!hasCompass) return <Text>No compass on this device.</Text>
283
- if (!reading) return <Text>Acquiring heading…</Text>
364
+ type PermissionStatus = 'granted' | 'denied' | 'unknown'
284
365
 
285
- return (
286
- <View>
287
- <Text>{reading.heading.toFixed(0)}° (±{reading.accuracy.toFixed(0)}°)</Text>
288
- {quality === 'unreliable' && <Text>Calibration needed</Text>}
289
- {interfering && <Text>Magnetic interference</Text>}
290
- </View>
291
- )
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
292
374
  }
293
- ```
294
375
 
295
- ```ts
296
- function useCompass(options?: UseCompassOptions): UseCompassResult
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
+ }
297
386
  ```
298
387
 
299
- #### Options
388
+ ### `AccuracyQuality` thresholds
300
389
 
301
- | Option | Type | Default | Description |
302
- | --- | --- | --- | --- |
303
- | `filterDegrees` | `number` | `1` | Minimum change between successive samples in degrees. Pass `0` for "every event". Updated live via `NitroCompass.setFilter()` whenever the prop changes. |
304
- | `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). |
305
- | `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. |
306
- | `pauseOnBackground` | `boolean` | `true` | Pause the underlying sensor / location-manager subscription while the app is backgrounded and resume on foreground. |
307
- | `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). |
390
+ The bucket is derived from a numeric heading-accuracy estimate on both platforms, but the thresholds differ because the underlying scales disagree:
308
391
 
309
- `filterDegrees`, `smoothingAlpha`, `declination`, and `pauseOnBackground` map to global state on `NitroCompass` if multiple hooks set them, last-write-wins.
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`.
310
394
 
311
- #### Result
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.
312
396
 
313
- | Field | Type | Description |
314
- | --- | --- | --- |
315
- | `reading` | `CompassSample \| null` | Latest emitted sample (`{ heading, accuracy, fieldStrengthMicroTesla }`), or `null` until the first arrives. Heading is true-north when `declination` is set, magnetic otherwise. |
316
- | `quality` | `AccuracyQuality \| null` | Coarse calibration bucket — `'high'`, `'medium'`, `'low'`, or `'unreliable'`. `null` until the first transition. Show your own calibration UI on `'unreliable'`. |
317
- | `interfering` | `boolean` | `true` while external magnetic interference is detected (field-magnitude band check + Android bias-jump grace). See [Magnetic interference](#magnetic-interference). |
318
- | `hasCompass` | `boolean` | Hardware availability — read once on first render. Render a fallback when `false`. |
319
- | `diagnostics` | `SensorDiagnostics \| undefined` | Which sensor backs the readings on this device (`magnetometer` on Android, `coreLocation` on iOS). Useful for explaining quality differences. |
320
- | `permission` | `PermissionStatus` | Latest platform permission state. Always `'granted'` on Android. On iOS may transition `'unknown'` → `'granted'`/`'denied'` after `requestPermission()` resolves. |
321
- | `getCurrentHeading` | `() => CompassSample \| undefined` | Synchronous read of the most recent sample. Stable identity across renders. Useful inside event handlers without forcing a re-render. |
322
- | `recalibrate` | `() => void` | Force a best-effort sensor recalibration (Refresh button behind a calibration banner). Stable identity. See [`NitroCompass.recalibrate()`](#api). |
323
- | `setLocation` | `(lat: number, lon: number) => void` | Tighten the interference gate using `expectedField ± 15 µT` (Android only). No-op on iOS. Stable identity. |
324
- | `requestPermission` | `() => Promise<PermissionStatus>` | Prompt the platform permission dialog (iOS) and update the hook's `permission` field. Resolves with the resulting status. Stable identity. |
397
+ ## Architecture
325
398
 
326
- 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.
399
+ ### Why not `TYPE_ROTATION_VECTOR`
327
400
 
328
- ### Smooth dial animation (Reanimated)
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.
329
402
 
330
- `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:
403
+ We trade a few degrees of steady-state jitter for stateless behavior, then add back smoothness via two layers:
331
404
 
332
- ```tsx
333
- import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'
334
- import { addHeadingListener } from 'react-native-nitro-compass'
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.
335
407
 
336
- function Dial() {
337
- const angle = useSharedValue(0)
338
- const last = useRef(0)
408
+ The output is then run through an EMA on `(sin θ, cos θ)` (handles 359°→0° wraparound cleanly) before delivery — tunable via `setSmoothing()`.
339
409
 
340
- useEffect(() => addHeadingListener(({ heading }) => {
341
- // unwrap so 359° → 1° animates +2°, not -358°
342
- const wrapped = ((last.current % 360) + 360) % 360
343
- let delta = heading - wrapped
344
- if (delta > 180) delta -= 360
345
- else if (delta < -180) delta += 360
346
- last.current += delta
347
- angle.value = withTiming(last.current, { duration: 80, easing: Easing.out(Easing.quad) })
348
- }), [angle])
410
+ ### Magnetic interference
349
411
 
350
- const style = useAnimatedStyle(() => ({ transform: [{ rotate: `${-angle.value}deg` }] }))
351
- return <Animated.View style={[styles.dial, style]}>{/* ticks */}</Animated.View>
352
- }
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)
353
427
  ```
354
428
 
355
- The same pattern is used in [example/components/Compass.tsx](./example/components/Compass.tsx).
429
+ ## Troubleshooting
356
430
 
357
- ## Permissions
431
+ ### Heading is consistently off by N degrees
358
432
 
359
- - **iOS**: requires `NSLocationWhenInUseUsageDescription` in `Info.plist`. `CLLocationManager` only emits headings when location permission is granted.
360
- - **Android**: no permission required for the magnetometer or accelerometer.
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).
361
434
 
362
- ## Example app
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()`.
363
436
 
364
- 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.
437
+ ### Heading is jittery
365
438
 
366
- First-time setup:
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)).
367
442
 
368
- ```sh
369
- cd example
370
- npm install # symlinks ../ as react-native-nitro-compass
371
- cd ios && bundle install && bundle exec pod install && cd ..
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()
372
456
  ```
373
457
 
374
- Run on a device:
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.
375
472
 
376
473
  ```sh
377
- # Terminal 1 — Metro
378
- 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
379
477
 
380
- # Terminal 2 — build & launch
381
- npm run ios -- --device # physical iPhone
382
- 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
383
482
  ```
384
483
 
385
- 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:
386
485
 
387
486
  ```sh
388
487
  # from the repo root
389
488
  npm run codegen
390
489
  # then in example/
391
- cd ios && bundle exec pod install && cd .. # iOS only
490
+ cd ios && bundle exec pod install && cd ..
392
491
  npm run ios # or npm run android
393
492
  ```
394
493
 
395
- The example imports `NitroCompass` directly from the workspace `src/` (via Metro `watchFolders`), so editing TypeScript only requires a Metro reload.
396
-
397
494
  ## Acknowledgments
398
495
 
399
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.
@@ -402,4 +499,4 @@ Bootstrapped with [create-nitro-module](https://github.com/patrickkabwe/create-n
402
499
 
403
500
  ## License
404
501
 
405
- MIT — see [LICENSE](./LICENSE).
502
+ [MIT](./LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-nitro-compass",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "engines": {
5
5
  "node": ">=18"
6
6
  },