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,