Spaces:
Running
Running
| 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<void> | |
| /** 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<GeoPosition | null>(null) | |
| const [mode, setModeState] = useState<TrackingMode>('off') | |
| const [error, setError] = useState<string | null>(null) | |
| const watchIdRef = useRef<number | null>(null) | |
| const orientationHandlerRef = useRef<((e: DeviceOrientationEvent) => void) | null>(null) | |
| const headingRef = useRef<number | null>(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 } | |
| } | |