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' // Typed counterpart to the raw error string. The string is whatever the // browser produced (raw WebKit English on iOS), so the UI keys off this // code to show a localized, actionable message instead. export type GeoWatchErrorCode = 'permission-denied' | 'unavailable' | 'timeout' | 'unsupported' | 'insecure-context' export interface UseGeolocationReturn { position: GeoPosition | null mode: TrackingMode error: string | null errorCode: GeoWatchErrorCode | 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 type GeoOnceErrorCode = 'unsupported' | 'insecure-context' | 'permission-denied' | 'unavailable' | 'timeout' export class GeoOnceError extends Error { code: GeoOnceErrorCode constructor(code: GeoOnceErrorCode, message?: string) { super(message || code) this.name = 'GeoOnceError' this.code = code } } // One-shot position fix for form fields (e.g. journal entry location). // Unlike the watch below, a cached fix up to a minute old is fine here — // the user is standing where they're journaling — so maximumAge is // generous to make the button feel instant when the OS already has a fix. const ONCE_OPTIONS: PositionOptions = { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 } export function getCurrentPositionOnce( options: PositionOptions = ONCE_OPTIONS, ): Promise { return new Promise((resolve, reject) => { if (!('geolocation' in navigator)) { reject(new GeoOnceError('unsupported')) return } // Browsers hard-block geolocation outside secure contexts; surface that // explicitly since self-hosted instances served over plain HTTP hit it. if (typeof window !== 'undefined' && window.isSecureContext === false) { reject(new GeoOnceError('insecure-context')) return } navigator.geolocation.getCurrentPosition( (pos) => resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude, accuracy: pos.coords.accuracy, heading: pos.coords.heading ?? null, speed: pos.coords.speed ?? null, timestamp: pos.timestamp, }), (err) => reject(new GeoOnceError( err.code === err.PERMISSION_DENIED ? 'permission-denied' : err.code === err.TIMEOUT ? 'timeout' : 'unavailable', err.message, )), options, ) }) } export function useGeolocation(): UseGeolocationReturn { const [position, setPosition] = useState(null) const [mode, setModeState] = useState('off') // What setMode's updater form reads instead of a state updater's argument: // React may run an updater more than once per commit, and subscribing from // inside one would leak a watch (see setMode below). const modeRef = useRef(mode) modeRef.current = mode const [error, setError] = useState(null) const [errorCode, setErrorCode] = useState(null) const watchIdRef = useRef(null) // True between the start of startWatch and the watchPosition call it awaits. const startingRef = useRef(false) // Bumped by every start and by every stop, so a run parked on the iOS prompt can // tell it has been superseded — clearing startingRef alone can't, a start that came // after the stop has set it again by then. const startRunRef = useRef(0) const orientationHandlerRef = useRef<((e: DeviceOrientationEvent) => void) | null>(null) const headingRef = useRef(null) const stopWatch = useCallback(() => { startingRef.current = false startRunRef.current++ 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') setErrorCode('unsupported') return false } // Same block getCurrentPositionOnce guards against: on a plain-HTTP origin // the browser answers PERMISSION_DENIED, which would otherwise read as a // revoked device permission and send the user into their OS settings. if (window.isSecureContext === false) { setError('Location requires a secure (HTTPS) connection') setErrorCode('insecure-context') return false } // Already watching, or still waiting on the iOS prompt below: a second // subscription would overwrite watchIdRef and leave the first one running // with nobody left holding its id. if (startingRef.current || watchIdRef.current !== null) return true startingRef.current = true const run = ++startRunRef.current setError(null) setErrorCode(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 */ } } // Stopped (or restarted) while the prompt was open: subscribing now would leave a // watch running that nobody holds the id of any more. if (startRunRef.current !== run) return false // 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) startingRef.current = false watchIdRef.current = navigator.geolocation.watchPosition( (pos) => { // A fix arriving means any earlier failure (timeout, no signal) // has recovered, so clear the error state alongside it. setError(null) setErrorCode(null) 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') setErrorCode( err.code === err.PERMISSION_DENIED ? 'permission-denied' : err.code === err.POSITION_UNAVAILABLE ? 'unavailable' : err.code === err.TIMEOUT ? 'timeout' : null, ) // 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)) => { const next = typeof m === 'function' ? m(modeRef.current) : m // Kept in step here as well, so two setMode calls batched into one commit // still see each other's result. modeRef.current = next setModeState(next) if (next === 'off') { stopWatch() setPosition(null) } else if (watchIdRef.current === null) { // started externally but no watch yet — start it void startWatch() } }, [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, errorCode, cycleMode, setMode } }