import { useCallback, useEffect, useRef, useState } from 'react' // Permission-gated orientation listener with iOS support. iOS 13+ requires // an explicit user gesture to request permission, so the caller triggers // this from the "enable location" button click. // eslint-disable-next-line @typescript-eslint/no-explicit-any type DeviceOrientationEventIOS = typeof DeviceOrientationEvent & { requestPermission?: () => Promise<'granted' | 'denied'> } export interface GeoPosition { lat: number lng: number accuracy: number // meters heading: number | null // 0-360°, null when unavailable (stationary, indoor, no sensor) speed: number | null timestamp: number } export type TrackingMode = 'off' | 'show' | 'follow' export interface UseGeolocationReturn { position: GeoPosition | null mode: TrackingMode error: string | null /** Toggle through off → show → follow → off. Also triggers iOS orientation permission on first call. */ cycleMode: () => Promise /** Force-set mode. Accepts a function for derived updates like `prev => prev === 'follow' ? 'show' : prev`. */ setMode: (m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => void } // Keep a tiny EMA on heading so the compass cone doesn't jitter on every // device orientation event. Mobile sensors fire at 60Hz and raw readings // swing ±5° even when the phone is still — smoothing to ~0.25 weight // gives a stable-but-responsive needle. function smoothAngle(prev: number | null, next: number, alpha = 0.25): number { if (prev === null) return next // Take the shortest angular distance so we don't lerp the long way around let delta = next - prev if (delta > 180) delta -= 360 if (delta < -180) delta += 360 return (prev + delta * alpha + 360) % 360 } export function useGeolocation(): UseGeolocationReturn { const [position, setPosition] = useState(null) const [mode, setModeState] = useState('off') const [error, setError] = useState(null) const watchIdRef = useRef(null) const orientationHandlerRef = useRef<((e: DeviceOrientationEvent) => void) | null>(null) const headingRef = useRef(null) const stopWatch = useCallback(() => { if (watchIdRef.current !== null) { try { navigator.geolocation.clearWatch(watchIdRef.current) } catch { /* noop */ } watchIdRef.current = null } if (orientationHandlerRef.current) { window.removeEventListener('deviceorientationabsolute', orientationHandlerRef.current as EventListener) window.removeEventListener('deviceorientation', orientationHandlerRef.current as EventListener) orientationHandlerRef.current = null } headingRef.current = null }, []) const startWatch = useCallback(async () => { if (!('geolocation' in navigator)) { setError('Geolocation is not supported in this browser') return false } setError(null) // iOS: ask for orientation permission up front; on Android and desktop // no prompt is needed and the method is undefined. const DOE = (window.DeviceOrientationEvent || {}) as DeviceOrientationEventIOS if (typeof DOE.requestPermission === 'function') { try { const res = await DOE.requestPermission() if (res !== 'granted') { // Permission denied — we still enable location, just no heading cone. } } catch { /* older webkit throws — ignore and proceed */ } } // Device orientation → compass heading. `alpha` is rotation around the // Z-axis (0 = facing magnetic north on most devices). The webkit-only // `webkitCompassHeading` is already geographic north + clockwise, so // prefer it when available. const onOrientation = (e: DeviceOrientationEvent) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const ev = e as any let heading: number | null = null if (typeof ev.webkitCompassHeading === 'number') { heading = ev.webkitCompassHeading } else if (e.absolute && typeof e.alpha === 'number') { // alpha is CCW from North; convert to CW heading heading = (360 - e.alpha) % 360 } else if (typeof e.alpha === 'number') { // Non-absolute orientation: better than nothing but drifts over time heading = (360 - e.alpha) % 360 } if (heading === null || Number.isNaN(heading)) return headingRef.current = smoothAngle(headingRef.current, heading) // Merge into position without triggering a refetch setPosition(p => p ? { ...p, heading: headingRef.current } : p) } orientationHandlerRef.current = onOrientation // Prefer "absolute" which is tied to magnetic north; fall back to plain. window.addEventListener('deviceorientationabsolute', onOrientation as EventListener) window.addEventListener('deviceorientation', onOrientation as EventListener) watchIdRef.current = navigator.geolocation.watchPosition( (pos) => { setPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude, accuracy: pos.coords.accuracy, // GPS heading is reliable when moving; keep compass reading // otherwise so the arrow still points correctly when stationary. heading: pos.coords.heading ?? headingRef.current, speed: pos.coords.speed ?? null, timestamp: pos.timestamp, }) }, (err) => { setError(err.message || 'Location unavailable') // Stay subscribed so a later fix can still recover (e.g. GPS // lock takes a while indoors). Only fully stop on permission denial. if (err.code === err.PERMISSION_DENIED) { stopWatch() setModeState('off') } }, { enableHighAccuracy: true, maximumAge: 2000, timeout: 15000, } ) return true }, [stopWatch]) const setMode = useCallback((m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => { setModeState(prev => { const next = typeof m === 'function' ? m(prev) : m if (next === 'off') { stopWatch() setPosition(null) } else if (watchIdRef.current === null) { // started externally but no watch yet — start it startWatch() } return next }) }, [startWatch, stopWatch]) const cycleMode = useCallback(async () => { if (mode === 'off') { const ok = await startWatch() if (ok) setModeState('show') } else if (mode === 'show') { setModeState('follow') } else { setModeState('off') stopWatch() setPosition(null) } }, [mode, startWatch, stopWatch]) useEffect(() => stopWatch, [stopWatch]) return { position, mode, error, cycleMode, setMode } }