File size: 6,823 Bytes
cd99321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
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 }
}