From 914b78e2c8651ef39f0fbb6c73ad2c6139d229c7 Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 25 Apr 2026 21:18:15 -0700 Subject: [PATCH 1/3] feat(compass): device-orientation compass widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a top-right compass widget that shows where the device is physically facing relative to the (always-north-up) map. Most useful when stationary or at a junction — GPS course is NaN at low speeds, but the device compass works regardless. Helps the user align their physical orientation with the map's north-up frame. - src/orientation.ts: thin wrapper around DeviceOrientationEvent. extractHeading() prefers iOS webkitCompassHeading (true-north calibrated) and falls back to (360 - alpha). requestOrientation- Permission() handles the iOS 13+ user-gesture-only permission. subscribeOrientation() uses 'deviceorientationabsolute' when available so headings don't drift without calibration. - src/compass.ts: Leaflet control with an SVG compass rose. First tap requests permission; once granted, the rose rotates by -heading via a --heading-deg CSS custom property. Hidden when DeviceOrientationEvent is unavailable (desktop). - src/types.ts: lastDeviceHeadingDeg + compassPermission on AppState. - src/style.css: .compass-rose styles with active / unavailable variants and a 0.12s rotation transition for smooth tracking. - src/orientation.test.ts: 5 unit tests covering heading extraction edge cases (iOS preference, normalisation, NaN fallback). Closes #163 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/compass.ts | 67 +++++++++++++++++++++++++++++++++++++++++ src/main.ts | 2 ++ src/orientation.test.ts | 34 +++++++++++++++++++++ src/orientation.ts | 59 ++++++++++++++++++++++++++++++++++++ src/style.css | 25 +++++++++++++++ src/types.ts | 7 +++++ 6 files changed, 194 insertions(+) create mode 100644 src/compass.ts create mode 100644 src/orientation.test.ts create mode 100644 src/orientation.ts diff --git a/src/compass.ts b/src/compass.ts new file mode 100644 index 0000000..0399a01 --- /dev/null +++ b/src/compass.ts @@ -0,0 +1,67 @@ +/** + * Intent: Compass widget showing where the device is facing relative to the (north-up) map + * Context: Mounted top-right; first tap requests DeviceOrientation permission, then updates passively + * Pattern: Leaflet control + inline SVG; rotation driven by --heading-deg CSS custom property + */ +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 { + 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); + + let unsubscribe: (() => void) | null = null; + + 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) => { + state.lastDeviceHeadingDeg = 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); + } + }); + }); + + // Hide the button entirely when the API isn't present (desktop without sensors) + if (typeof DeviceOrientationEvent === 'undefined') { + button.classList.add('compass-rose--unavailable'); + state.compassPermission = 'unsupported'; + } + + // Bind cleanup to the map's remove event so we don't leak the listener if the map is torn down + map.on('unload', () => { + if (unsubscribe !== null) unsubscribe(); + }); + + return button; + }, + }); + + 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..72fa664 --- /dev/null +++ b/src/orientation.ts @@ -0,0 +1,59 @@ +/** + * Intent: Wrap DeviceOrientationEvent with the iOS permission flow + heading extraction + * Context: Used by compass.ts; iOS 13+ requires DeviceOrientationEvent.requestPermission() from a user gesture + * Pattern: Permission is one-shot; subscribeOrientation returns an unsubscribe function for clean teardown + */ + +export type OrientationPermission = 'unknown' | 'unsupported' | 'granted' | 'denied'; + +interface DeviceOrientationEventStatic { + requestPermission?: () => Promise<'granted' | 'denied'>; +} + +/** + * intent: Pull a compass heading (0–360°, clockwise from True North) out of a DeviceOrientationEvent + * method: Prefer iOS webkitCompassHeading (already true-north calibrated); fall back to W3C alpha (anti-clockwise around z) + * effect: Returns null when neither field is usable (NaN / unsupported) + */ +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; +} + +/** + * intent: Resolve to the current OrientationPermission state, prompting iOS if needed + * method: iOS exposes a static requestPermission; non-iOS browsers grant by default + * effect: Must be called 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'; + } +} + +/** + * intent: Stream compass headings until the returned unsubscribe is called + * method: Listen for deviceorientationabsolute when available (true-north without calibration), fall back to deviceorientation + * effect: Caller is responsible for invoking the returned function to detach the listener + */ +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..7ae325b 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; } +.compass-rose--unavailable { display: none; } + diff --git a/src/types.ts b/src/types.ts index 68a1821..72d86be 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,10 @@ export interface AppState { lastValidHeadingDeg: number | null; lastValidHeadingMs: number; + // Device-orientation compass: most recent heading from DeviceOrientationEvent (true-north clockwise) + lastDeviceHeadingDeg: number | null; + 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 +103,8 @@ export function createInitialState(): AppState { lastGpsAccuracy: null, lastValidHeadingDeg: null, lastValidHeadingMs: 0, + lastDeviceHeadingDeg: null, + compassPermission: 'unknown', gpsWeakStreak: 0, gpsStrongStreak: 0, gpsWeakBadgeVisible: false, From c5a3e9d76351831372900cdc102ec5de0f9a6808 Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 25 Apr 2026 21:23:18 -0700 Subject: [PATCH 2/3] fix: address PR #170 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Leaflet onRemove() lifecycle hook to clean up the orientation subscription if the control is ever removed (was relying on map's 'unload' event which leaks if the listener is added/removed without a full map teardown). - Drop lastDeviceHeadingDeg from AppState — the field was written but never read. The visual rotation is driven directly via the --heading-deg CSS custom property; if a future feature needs the numeric value, it can be added then. - Trim multi-line JSDoc-style comment blocks in orientation.ts and compass.ts to single-line per CLAUDE.md convention. Skipped (advisory): - webkitCompassAccuracy gating — defer until field reports of jittery readings show it is needed. - 'DeviceOrientationAbsoluteEvent' in window detection vs 'ondeviceorientationabsolute' — current detection works in practice and the fallback path covers iOS regardless. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/compass.ts | 33 +++++++++++++++------------------ src/orientation.ts | 24 ++++-------------------- src/types.ts | 4 +--- 3 files changed, 20 insertions(+), 41 deletions(-) diff --git a/src/compass.ts b/src/compass.ts index 0399a01..8c43034 100644 --- a/src/compass.ts +++ b/src/compass.ts @@ -1,8 +1,4 @@ -/** - * Intent: Compass widget showing where the device is facing relative to the (north-up) map - * Context: Mounted top-right; first tap requests DeviceOrientation permission, then updates passively - * Pattern: Leaflet control + inline SVG; rotation driven by --heading-deg CSS custom property - */ +// 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'; @@ -15,6 +11,8 @@ const COMPASS_HTML = `