Skip to content

Commit 0b7d277

Browse files
feat: add compass accuracy threshold for iOS (#422)
Introduce compassAccuracyThreshold option (default 45, in degrees) to filter inaccurate iOS compass readings using webkitCompassAccuracy. Uncalibrated readings (-1) are always rejected. Set to false to disable filtering. Has no effect on Android, which exposes no standardized accuracy field. Adds tests for accuracy filtering behavior and a TypeScript declaration for the new option. Refs #332
1 parent 28eea30 commit 0b7d277

4 files changed

Lines changed: 107 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ Possible options are listed in the following table. More details are [in the cod
104104
| `returnToPrevBounds` | `boolean` | If set, save the map bounds just before centering to the user's location. When control is disabled, set the view back to the bounds that were saved. | `false` |
105105
| `cacheLocation` | `boolean` | Keep a cache of the location after the user deactivates the control. If set to false, the user has to wait until the locate API returns a new location before they see where they are again. | `true` |
106106
| `showCompass` | `boolean` | Show the compass bearing on top of the location marker | `true` |
107+
| `compassAccuracyThreshold` | `number` or `false` | Maximum allowed iOS compass accuracy in degrees (`webkitCompassAccuracy`) for displaying the compass. `-1` (uncalibrated) is always rejected. Set to `false` to always show the compass when heading data is available. | `45` |
107108
| `drawCircle` | `boolean` | If set, a circle that shows the location accuracy is drawn. | `true` |
108109
| `drawMarker` | `boolean` | If set, the marker at the users' location is drawn. | `true` |
109110
| `markerClass` | `class` | The class to be used to create the marker. | `LocationMarker` |

spec/L.Control.Locate.spec.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,89 @@ describe("LocateControl", () => {
605605
assert.strictEqual(control._compassHeading, 0, "Should treat 0 as a valid iOS heading");
606606
});
607607

608+
describe("iOS compass accuracy filter (compassAccuracyThreshold)", () => {
609+
it("should reject uncalibrated iOS readings (webkitCompassAccuracy === -1) with default threshold", () => {
610+
const control = new LocateControl({ showCompass: true });
611+
map.addControl(control);
612+
control._active = true;
613+
control._compassHeading = 123;
614+
615+
control._onDeviceOrientation({ webkitCompassHeading: 90, webkitCompassAccuracy: -1, alpha: null });
616+
617+
assert.strictEqual(control._compassHeading, null, "Uncalibrated reading should clear compass");
618+
});
619+
620+
it("should reject iOS readings above default threshold (45)", () => {
621+
const control = new LocateControl({ showCompass: true });
622+
map.addControl(control);
623+
control._active = true;
624+
625+
control._onDeviceOrientation({ webkitCompassHeading: 90, webkitCompassAccuracy: 50, alpha: null });
626+
627+
assert.strictEqual(control._compassHeading, null, "Inaccurate reading should be rejected");
628+
});
629+
630+
it("should accept iOS readings within default threshold", () => {
631+
const control = new LocateControl({ showCompass: true });
632+
map.addControl(control);
633+
control._active = true;
634+
635+
control._onDeviceOrientation({ webkitCompassHeading: 90, webkitCompassAccuracy: 10, alpha: null });
636+
637+
assert.strictEqual(control._compassHeading, 90, "Accurate reading should pass through");
638+
});
639+
640+
it("should respect custom compassAccuracyThreshold", () => {
641+
const control = new LocateControl({ showCompass: true, compassAccuracyThreshold: 5 });
642+
map.addControl(control);
643+
control._active = true;
644+
645+
control._onDeviceOrientation({ webkitCompassHeading: 90, webkitCompassAccuracy: 10, alpha: null });
646+
647+
assert.strictEqual(control._compassHeading, null, "Reading above custom threshold should be rejected");
648+
});
649+
650+
it("should disable filter when compassAccuracyThreshold is false", () => {
651+
const control = new LocateControl({ showCompass: true, compassAccuracyThreshold: false });
652+
map.addControl(control);
653+
control._active = true;
654+
655+
control._onDeviceOrientation({ webkitCompassHeading: 90, webkitCompassAccuracy: -1, alpha: null });
656+
657+
assert.strictEqual(control._compassHeading, 90, "Filter disabled: even uncalibrated reading passes through");
658+
});
659+
660+
it("should disable filter when compassAccuracyThreshold is not a number", () => {
661+
const control = new LocateControl({ showCompass: true, compassAccuracyThreshold: null });
662+
map.addControl(control);
663+
control._active = true;
664+
665+
control._onDeviceOrientation({ webkitCompassHeading: 90, webkitCompassAccuracy: 99, alpha: null });
666+
667+
assert.strictEqual(control._compassHeading, 90, "Non-numeric threshold should disable filtering, not block all readings");
668+
});
669+
670+
it("should pass through iOS readings when webkitCompassAccuracy is missing", () => {
671+
const control = new LocateControl({ showCompass: true });
672+
map.addControl(control);
673+
control._active = true;
674+
675+
control._onDeviceOrientation({ webkitCompassHeading: 45, alpha: null });
676+
677+
assert.strictEqual(control._compassHeading, 45, "Missing accuracy field should not block reading (backward compatible)");
678+
});
679+
680+
it("should not affect Android readings (alpha-based)", () => {
681+
const control = new LocateControl({ showCompass: true, compassAccuracyThreshold: 5 });
682+
map.addControl(control);
683+
control._active = true;
684+
685+
control._onDeviceOrientation({ alpha: 90, absolute: true });
686+
687+
assert.strictEqual(control._compassHeading, 270, "Android reading should not be filtered");
688+
});
689+
});
690+
608691
it("should compensate iOS heading with screen orientation angle", () => {
609692
const control = new LocateControl({ showCompass: true });
610693
map.addControl(control);

src/L.Control.Locate.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export interface LocateOptions extends ControlOptions {
4141
drawCircle?: boolean | undefined;
4242
drawMarker?: boolean | undefined;
4343
showCompass?: boolean | undefined;
44+
compassAccuracyThreshold?: number | false | undefined;
4445
markerClass?: any;
4546
compassClass?: any;
4647
circleStyle?: PathOptions | undefined;

src/L.Control.Locate.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,13 @@ const LocateControl = Control.extend({
274274
drawMarker: true,
275275
/** If set and supported then show the compass heading */
276276
showCompass: true,
277+
/**
278+
* iOS-only compass accuracy threshold (degrees).
279+
* Show compass only when `webkitCompassAccuracy <= value`.
280+
* `-1` (uncalibrated) is always rejected.
281+
* Set to `false` to disable filtering. No effect on Android.
282+
*/
283+
compassAccuracyThreshold: 45,
277284
/** The class to be used to create the marker. For example L.CircleMarker or L.Marker */
278285
markerClass: LocationMarker,
279286
/** The class us be used to create the compass bearing arrow */
@@ -899,7 +906,10 @@ const LocateControl = Control.extend({
899906
},
900907

901908
/**
902-
* Process and normalise compass events
909+
* Process and normalise compass events.
910+
*
911+
* On iOS, optionally filters out inaccurate readings based on `compassAccuracyThreshold`.
912+
* Android has no equivalent accuracy field and is therefore not filtered.
903913
*/
904914
_onDeviceOrientation(e) {
905915
if (!this._active) {
@@ -908,11 +918,21 @@ const LocateControl = Control.extend({
908918

909919
if (e.webkitCompassHeading != null) {
910920
// iOS: webkitCompassHeading is relative to device top.
921+
const threshold = this.options.compassAccuracyThreshold;
922+
const filterEnabled = typeof threshold === "number" && e.webkitCompassAccuracy != null;
923+
// -1 means uncalibrated, always reject when filtering is on.
924+
const tooInaccurate = filterEnabled && (e.webkitCompassAccuracy < 0 || e.webkitCompassAccuracy > threshold);
925+
926+
if (tooInaccurate) {
927+
this._setCompassHeading();
928+
return;
929+
}
930+
911931
// Compensate using current screen orientation when available.
912932
const screenAngle = window.screen?.orientation?.angle ?? 0;
913933
this._setCompassHeading((e.webkitCompassHeading + screenAngle) % 360);
914934
} else if (e.alpha !== null) {
915-
// Android
935+
// Android: no standardized accuracy field, reading is shown as-is.
916936
this._setCompassHeading(360 - e.alpha);
917937
}
918938
},

0 commit comments

Comments
 (0)