react-native-nitro-compass 1.2.0 → 1.2.2
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 +346 -249
- package/android/build.gradle +1 -1
- package/package.json +3 -3
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` 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
|
-
|
|
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
|
-
|
|
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,372 +62,435 @@ 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
|
|
85
|
+
### Android
|
|
53
86
|
|
|
54
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
89
|
+
```xml
|
|
90
|
+
<!-- android/app/src/main/AndroidManifest.xml -->
|
|
91
|
+
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
|
92
|
+
```
|
|
63
93
|
|
|
64
|
-
|
|
65
|
-
NitroCompass.setOnInterferenceDetected(onChange: (interferenceDetected: boolean) => void): void
|
|
94
|
+
## Quick start
|
|
66
95
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
96
|
+
```tsx
|
|
97
|
+
import { Text, View } from 'react-native'
|
|
98
|
+
import { useCompass } from 'react-native-nitro-compass'
|
|
70
99
|
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
## `useCompass()` hook
|
|
110
119
|
|
|
111
|
-
|
|
120
|
+
```ts
|
|
121
|
+
function useCompass(options?: UseCompassOptions): UseCompassResult
|
|
122
|
+
```
|
|
112
123
|
|
|
113
|
-
|
|
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
|
-
|
|
126
|
+
### Options
|
|
117
127
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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.
|
|
138
174
|
|
|
139
|
-
|
|
175
|
+
## Imperative API
|
|
140
176
|
|
|
141
|
-
|
|
177
|
+
For full control, drive the native HybridObject directly:
|
|
142
178
|
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
229
|
+
const result = await PermissionsAndroid.request(
|
|
158
230
|
PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION,
|
|
159
231
|
)
|
|
160
|
-
return
|
|
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(
|
|
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 (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
262
|
+
return <Text>{compass.reading?.heading.toFixed(0)}° true</Text>
|
|
215
263
|
}
|
|
216
264
|
```
|
|
217
265
|
|
|
218
266
|
A few notes:
|
|
219
267
|
|
|
220
|
-
-
|
|
221
|
-
- **
|
|
222
|
-
- **iOS is a no-op
|
|
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
|
-
###
|
|
272
|
+
### Smooth dial animation (Reanimated)
|
|
227
273
|
|
|
228
|
-
|
|
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
|
-
```
|
|
231
|
-
import
|
|
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
|
-
|
|
281
|
+
function Dial() {
|
|
282
|
+
const angle = useSharedValue(0)
|
|
283
|
+
const last = useRef(0)
|
|
234
284
|
|
|
235
|
-
|
|
236
|
-
|
|
285
|
+
useEffect(() => addHeadingListener(({ heading }) => {
|
|
286
|
+
// unwrap so 359° → 1° 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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
300
|
+
The pattern is used in [`example/components/Compass.tsx`](./example/components/Compass.tsx).
|
|
243
301
|
|
|
244
|
-
###
|
|
302
|
+
### Location-tightened interference
|
|
245
303
|
|
|
246
|
-
|
|
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.
|
|
247
305
|
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
`
|
|
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
|
-
###
|
|
331
|
+
### Custom diagnostics panel
|
|
259
332
|
|
|
260
|
-
|
|
333
|
+
For self-diagnosing user bug reports, poll `getDebugInfo()` behind a hidden footer:
|
|
261
334
|
|
|
262
|
-
```
|
|
263
|
-
|
|
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
|
-
|
|
351
|
+
A complete implementation lives at [`example/components/DebugPanel.tsx`](./example/components/DebugPanel.tsx).
|
|
267
352
|
|
|
268
|
-
|
|
353
|
+
## Types
|
|
269
354
|
|
|
270
|
-
```
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
if (!reading) return <Text>Acquiring heading…</Text>
|
|
364
|
+
type PermissionStatus = 'granted' | 'denied' | 'unknown'
|
|
284
365
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
388
|
+
### `AccuracyQuality` thresholds
|
|
300
389
|
|
|
301
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
399
|
+
### Why not `TYPE_ROTATION_VECTOR`
|
|
327
400
|
|
|
328
|
-
|
|
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
|
-
|
|
403
|
+
We trade a few degrees of steady-state jitter for stateless behavior, then add back smoothness via two layers:
|
|
331
404
|
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
429
|
+
## Troubleshooting
|
|
356
430
|
|
|
357
|
-
|
|
431
|
+
### Heading is consistently off by N degrees
|
|
358
432
|
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
437
|
+
### Heading is jittery
|
|
365
438
|
|
|
366
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
378
|
-
npm
|
|
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
|
-
#
|
|
381
|
-
npm
|
|
382
|
-
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
|
|
383
482
|
```
|
|
384
483
|
|
|
385
|
-
If you change the
|
|
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 ..
|
|
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
|
|
502
|
+
[MIT](./LICENSE)
|
package/android/build.gradle
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-nitro-compass",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=18"
|
|
6
6
|
},
|
|
@@ -74,12 +74,12 @@
|
|
|
74
74
|
"@semantic-release/changelog": "^6.0.3",
|
|
75
75
|
"@semantic-release/git": "^10.0.1",
|
|
76
76
|
"@types/jest": "^30.0.0",
|
|
77
|
-
"@types/react": "19.2.
|
|
77
|
+
"@types/react": "19.2.14",
|
|
78
78
|
"babel-jest": "^30.3.0",
|
|
79
79
|
"conventional-changelog-conventionalcommits": "^9.1.0",
|
|
80
80
|
"jest": "^30.3.0",
|
|
81
81
|
"nitrogen": "0.35.6",
|
|
82
|
-
"react": "19.2.
|
|
82
|
+
"react": "19.2.6",
|
|
83
83
|
"react-native": "0.85.3",
|
|
84
84
|
"react-native-builder-bob": "^0.40.18",
|
|
85
85
|
"react-native-nitro-modules": "0.35.6",
|