diff --git a/src/compass.ts b/src/compass.ts
new file mode 100644
index 0000000..8c43034
--- /dev/null
+++ b/src/compass.ts
@@ -0,0 +1,64 @@
+// Compass widget — top-right SVG rose that rotates by -deviceHeading so N points at true north.
+import L from 'leaflet';
+import type { AppState } from './types';
+import { requestOrientationPermission, subscribeOrientation } from './orientation';
+
+const COMPASS_HTML = ``;
+
+export function addCompassControl(map: L.Map, state: AppState): void {
+ let unsubscribe: (() => void) | null = null;
+
+ const Control = L.Control.extend({
+ onAdd() {
+ const button = L.DomUtil.create('button', 'compass-rose') as HTMLButtonElement;
+ button.type = 'button';
+ button.title = 'Compass — tap to enable';
+ button.setAttribute('aria-label', 'Compass — tap to enable');
+ button.innerHTML = COMPASS_HTML;
+
+ L.DomEvent.disableClickPropagation(button);
+ L.DomEvent.disableScrollPropagation(button);
+
+ // Hide entirely if the platform doesn't expose orientation events at all (desktop).
+ if (typeof DeviceOrientationEvent === 'undefined') {
+ button.classList.add('compass-rose--unavailable');
+ state.compassPermission = 'unsupported';
+ return button;
+ }
+
+ L.DomEvent.on(button, 'click', () => {
+ if (state.compassPermission === 'granted') return;
+ void requestOrientationPermission().then((permission) => {
+ state.compassPermission = permission;
+ if (permission === 'granted') {
+ button.classList.add('compass-rose--active');
+ button.title = 'Compass';
+ button.setAttribute('aria-label', 'Compass');
+ unsubscribe = subscribeOrientation((heading) => {
+ button.style.setProperty('--heading-deg', `${-heading}deg`);
+ });
+ } else {
+ button.classList.add('compass-rose--unavailable');
+ button.title = permission === 'denied' ? 'Compass permission denied' : 'Compass not supported';
+ button.setAttribute('aria-label', button.title);
+ }
+ });
+ });
+
+ return button;
+ },
+ onRemove() {
+ if (unsubscribe !== null) {
+ unsubscribe();
+ unsubscribe = null;
+ }
+ },
+ });
+
+ new Control({ position: 'topright' }).addTo(map);
+}
diff --git a/src/main.ts b/src/main.ts
index 0f3bf57..6065f91 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -28,6 +28,7 @@ import { onLocationFound, onLocationError, clearLocationMarkers } from './locati
import { startWatching, stopWatching } from './timer';
import { addOfflineDownloadControl } from './offline-download';
import { addGuidanceControl } from './guidance';
+import { addCompassControl } from './compass';
import { initBattery } from './battery';
import { registerSW } from 'virtual:pwa-register';
@@ -305,6 +306,7 @@ addOfflineDownloadControl(map, showToast);
addSearchControl(map, state, showToast);
addReverseGeocoding(map, state, showToast);
addGuidanceControl(map, state, activatePolling, deactivatePolling);
+addCompassControl(map, state);
// ── Version badge + changelog panel ───────────────────────────────────────────
const versionBadge = document.createElement('button');
diff --git a/src/orientation.test.ts b/src/orientation.test.ts
new file mode 100644
index 0000000..768428a
--- /dev/null
+++ b/src/orientation.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from 'vitest';
+import { extractHeading } from './orientation';
+
+function fakeEvent(props: Partial & { webkitCompassHeading?: number }): DeviceOrientationEvent {
+ return props as DeviceOrientationEvent;
+}
+
+describe('extractHeading', () => {
+ it('prefers iOS webkitCompassHeading when present', () => {
+ const e = fakeEvent({ alpha: 200, webkitCompassHeading: 90 });
+ expect(extractHeading(e)).toBe(90);
+ });
+
+ it('falls back to (360 - alpha) when webkitCompassHeading is absent', () => {
+ expect(extractHeading(fakeEvent({ alpha: 90 }))).toBe(270);
+ expect(extractHeading(fakeEvent({ alpha: 0 }))).toBe(0);
+ expect(extractHeading(fakeEvent({ alpha: 359 }))).toBe(1);
+ });
+
+ it('normalises negative or out-of-range webkitCompassHeading into 0–360', () => {
+ expect(extractHeading(fakeEvent({ alpha: 0, webkitCompassHeading: -10 }))).toBe(350);
+ expect(extractHeading(fakeEvent({ alpha: 0, webkitCompassHeading: 720 }))).toBe(0);
+ });
+
+ it('returns null when neither field is usable', () => {
+ expect(extractHeading(fakeEvent({ alpha: null }))).toBeNull();
+ expect(extractHeading(fakeEvent({ alpha: NaN }))).toBeNull();
+ expect(extractHeading(fakeEvent({ alpha: NaN, webkitCompassHeading: NaN }))).toBeNull();
+ });
+
+ it('skips webkitCompassHeading if it is NaN and falls back to alpha', () => {
+ expect(extractHeading(fakeEvent({ alpha: 90, webkitCompassHeading: NaN }))).toBe(270);
+ });
+});
diff --git a/src/orientation.ts b/src/orientation.ts
new file mode 100644
index 0000000..b3047c4
--- /dev/null
+++ b/src/orientation.ts
@@ -0,0 +1,43 @@
+// DeviceOrientationEvent wrapper: iOS-13+ permission gate + heading extraction. Used by compass.ts.
+
+export type OrientationPermission = 'unknown' | 'unsupported' | 'granted' | 'denied';
+
+interface DeviceOrientationEventStatic {
+ requestPermission?: () => Promise<'granted' | 'denied'>;
+}
+
+// iOS webkitCompassHeading is already true-north clockwise; W3C alpha is anti-clockwise so flip it.
+export function extractHeading(event: DeviceOrientationEvent): number | null {
+ const ios = (event as DeviceOrientationEvent & { webkitCompassHeading?: number }).webkitCompassHeading;
+ if (typeof ios === 'number' && !isNaN(ios)) {
+ return ((ios % 360) + 360) % 360;
+ }
+ if (event.alpha !== null && !isNaN(event.alpha)) {
+ return ((360 - event.alpha) % 360 + 360) % 360;
+ }
+ return null;
+}
+
+// Must be called from inside a user-gesture handler on iOS or the prompt is suppressed.
+export async function requestOrientationPermission(): Promise {
+ if (typeof DeviceOrientationEvent === 'undefined') return 'unsupported';
+ const cls = DeviceOrientationEvent as unknown as DeviceOrientationEventStatic;
+ if (typeof cls.requestPermission !== 'function') return 'granted';
+ try {
+ const result = await cls.requestPermission();
+ return result;
+ } catch {
+ return 'denied';
+ }
+}
+
+// 'deviceorientationabsolute' yields true-north headings without calibration when available.
+export function subscribeOrientation(onHeading: (heading: number) => void): () => void {
+ const handler = (event: Event): void => {
+ const heading = extractHeading(event as DeviceOrientationEvent);
+ if (heading !== null) onHeading(heading);
+ };
+ const eventName = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';
+ window.addEventListener(eventName, handler);
+ return () => window.removeEventListener(eventName, handler);
+}
diff --git a/src/style.css b/src/style.css
index 0ef08ba..8493d94 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1714,3 +1714,28 @@ body { height: 100%; width: 100%; padding: 0; margin: 0; }
margin: 8px 24px;
}
+/* Device-orientation compass (top-right). Initial state is muted; first tap requests permission
+ on iOS, after which .compass-rose--active rotates the SVG by --heading-deg. */
+.compass-rose {
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ opacity: 0.55;
+ transition: opacity 0.2s ease;
+ display: block;
+}
+.compass-rose:hover,
+.compass-rose:focus-visible { opacity: 0.9; }
+.compass-rose svg {
+ width: 100%;
+ height: 100%;
+ display: block;
+ transform: rotate(var(--heading-deg, 0deg));
+ transition: transform 0.12s linear;
+}
+.compass-rose--active { opacity: 1; cursor: default; }
+.compass-rose--unavailable { display: none; }
+
diff --git a/src/types.ts b/src/types.ts
index 68a1821..816e6d4 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -7,6 +7,7 @@
import type L from 'leaflet';
import type { Keepalive } from './keepalive';
import type { Costing, Route } from './routing';
+import type { OrientationPermission } from './orientation';
// Three-state location button: off → active (following) → passive (dot visible, not following)
export type LocateState = 'off' | 'active' | 'passive';
@@ -58,6 +59,9 @@ export interface AppState {
lastValidHeadingDeg: number | null;
lastValidHeadingMs: number;
+ // Device-orientation compass: permission state cached so subsequent taps skip the prompt
+ compassPermission: OrientationPermission;
+
// Hysteresis state for GPS weak-signal badge — prevents flicker in marginal signal
gpsWeakStreak: number; // consecutive fixes with accuracy > TRAIL_MAX_ACCURACY_M
gpsStrongStreak: number; // consecutive fixes with accuracy < (TRAIL_MAX_ACCURACY_M - 5)
@@ -98,6 +102,7 @@ export function createInitialState(): AppState {
lastGpsAccuracy: null,
lastValidHeadingDeg: null,
lastValidHeadingMs: 0,
+ compassPermission: 'unknown',
gpsWeakStreak: 0,
gpsStrongStreak: 0,
gpsWeakBadgeVisible: false,