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.
- package/README.md +381 -163
- package/android/src/main/java/com/margelo/nitro/nitrocompass/HybridNitroCompass.kt +654 -133
- package/ios/HybridNitroCompass.swift +106 -3
- package/lib/commonjs/hook.js +98 -11
- package/lib/commonjs/hook.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/multiplex.js +23 -2
- package/lib/commonjs/multiplex.js.map +1 -1
- package/lib/module/hook.js +99 -12
- package/lib/module/hook.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/multiplex.js +23 -2
- package/lib/module/multiplex.js.map +1 -1
- package/lib/typescript/src/hook.d.ts +39 -1
- package/lib/typescript/src/hook.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/multiplex.d.ts.map +1 -1
- package/lib/typescript/src/specs/NitroCompass.nitro.d.ts +142 -18
- package/lib/typescript/src/specs/NitroCompass.nitro.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JCompassSample.hpp +7 -3
- package/nitrogen/generated/android/c++/JDebugInfo.hpp +85 -0
- package/nitrogen/generated/android/c++/JHybridNitroCompassSpec.cpp +17 -0
- package/nitrogen/generated/android/c++/JHybridNitroCompassSpec.hpp +3 -0
- package/nitrogen/generated/android/c++/JSensorKind.hpp +6 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/CompassSample.kt +9 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/DebugInfo.kt +86 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/HybridNitroCompassSpec.kt +12 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/SensorKind.kt +4 -3
- package/nitrogen/generated/ios/NitroCompass-Swift-Cxx-Bridge.hpp +12 -0
- package/nitrogen/generated/ios/NitroCompass-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridNitroCompassSpecSwift.hpp +23 -0
- package/nitrogen/generated/ios/swift/CompassSample.swift +7 -2
- package/nitrogen/generated/ios/swift/DebugInfo.swift +64 -0
- package/nitrogen/generated/ios/swift/HybridNitroCompassSpec.swift +3 -0
- package/nitrogen/generated/ios/swift/HybridNitroCompassSpec_cxx.swift +34 -0
- package/nitrogen/generated/ios/swift/SensorKind.swift +8 -4
- package/nitrogen/generated/shared/c++/CompassSample.hpp +6 -2
- package/nitrogen/generated/shared/c++/DebugInfo.hpp +111 -0
- package/nitrogen/generated/shared/c++/HybridNitroCompassSpec.cpp +3 -0
- package/nitrogen/generated/shared/c++/HybridNitroCompassSpec.hpp +6 -0
- package/nitrogen/generated/shared/c++/SensorKind.hpp +10 -6
- package/package.json +2 -2
- package/src/hook.ts +146 -12
- package/src/index.ts +2 -0
- package/src/multiplex.ts +23 -2
- 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
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/react-native-nitro-compass)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](https://reactnative.dev)
|
|
4
6
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
function CompassScreen() {
|
|
13
|
+
const { reading, quality, interfering } = useCompass()
|
|
14
|
+
return <Text>{reading?.heading.toFixed(0)}°</Text>
|
|
15
|
+
}
|
|
16
|
+
```
|
|
18
17
|
|
|
19
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
import { NitroCompass } from 'react-native-nitro-compass'
|
|
67
|
+
## Permissions
|
|
35
68
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
```xml
|
|
90
|
+
<!-- android/app/src/main/AndroidManifest.xml -->
|
|
91
|
+
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
|
71
92
|
```
|
|
72
93
|
|
|
73
|
-
|
|
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
|
-
|
|
96
|
+
```tsx
|
|
97
|
+
import { Text, View } from 'react-native'
|
|
98
|
+
import { useCompass } from 'react-native-nitro-compass'
|
|
83
99
|
|
|
84
|
-
|
|
85
|
-
|
|
100
|
+
function Compass() {
|
|
101
|
+
const { reading, quality, interfering, hasCompass } = useCompass()
|
|
86
102
|
|
|
87
|
-
|
|
103
|
+
if (!hasCompass) return <Text>No compass on this device.</Text>
|
|
104
|
+
if (!reading) return <Text>Acquiring heading…</Text>
|
|
88
105
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
## `useCompass()` hook
|
|
100
119
|
|
|
101
120
|
```ts
|
|
102
|
-
|
|
103
|
-
if (interfering) showInterferenceWarning()
|
|
104
|
-
else hideInterferenceWarning()
|
|
105
|
-
})
|
|
121
|
+
function useCompass(options?: UseCompassOptions): UseCompassResult
|
|
106
122
|
```
|
|
107
123
|
|
|
108
|
-
|
|
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 flow — into one ergonomic call. Multiple `useCompass()` mounts safely share the same underlying native subscription.
|
|
113
125
|
|
|
114
|
-
|
|
115
|
-
import geomagnetism from 'geomagnetism'
|
|
126
|
+
### Options
|
|
116
127
|
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
NitroCompass.setDeclination(declination)
|
|
124
|
-
```
|
|
138
|
+
### Result
|
|
125
139
|
|
|
126
|
-
|
|
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
|
-
|
|
153
|
+
The four function fields all have stable identities (via `useCallback`) so consumers' `useEffect` deps don't churn on every render.
|
|
129
154
|
|
|
130
|
-
|
|
155
|
+
## Listener helpers
|
|
131
156
|
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
175
|
+
## Imperative API
|
|
143
176
|
|
|
144
|
-
|
|
177
|
+
For full control, drive the native HybridObject directly:
|
|
145
178
|
|
|
146
179
|
```ts
|
|
147
|
-
NitroCompass
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
266
|
+
A few notes:
|
|
184
267
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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()`
|
|
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
|
|
300
|
+
The pattern is used in [`example/components/Compass.tsx`](./example/components/Compass.tsx).
|
|
235
301
|
|
|
236
|
-
|
|
302
|
+
### Location-tightened interference
|
|
237
303
|
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
+
### Calibration UI
|
|
244
309
|
|
|
245
|
-
|
|
310
|
+
```tsx
|
|
311
|
+
import { Pressable, Text, View } from 'react-native'
|
|
312
|
+
import { useCompass } from 'react-native-nitro-compass'
|
|
246
313
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
npm
|
|
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
|
-
#
|
|
260
|
-
npm
|
|
261
|
-
npm run
|
|
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
|
|
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 ..
|
|
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
|
|
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
|
|
502
|
+
[MIT](./LICENSE)
|