export function haversineKm(lat1, lon1, lat2, lon2) { const R = 6371; const dLat = ((lat2 - lat1) * Math.PI) / 180; const dLon = ((lon2 - lon1) * Math.PI) / 180; const a = Math.sin(dLat / 2) ** 2 + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } // --- Position Cache (localStorage) --- const POSITION_CACHE_KEY = 'farmafinder_last_position'; const POSITION_CACHE_MAX_AGE = 5 * 60 * 1000; // 5 minutes function getCachedPosition() { try { const raw = localStorage.getItem(POSITION_CACHE_KEY); if (!raw) return null; const cached = JSON.parse(raw); if (Date.now() - cached.timestamp > POSITION_CACHE_MAX_AGE) return null; return { lat: cached.lat, lon: cached.lon }; } catch { return null; } } function setCachedPosition(lat, lon) { try { localStorage.setItem(POSITION_CACHE_KEY, JSON.stringify({ lat, lon, timestamp: Date.now() })); } catch {} } function clearCachedPosition() { try { localStorage.removeItem(POSITION_CACHE_KEY); } catch {} } // --- Position Manager --- let pendingPositionRequest = null; /** * Get user position with caching strategy: * 1. Return cached position if fresh (< 5 min) * 2. Request browser geolocation in background * 3. If browser fails, fall back to cached (up to 15 min) * * This makes the first request nearly instant for repeat users. */ export function getUserPosition({ timeout = 15000, maximumAge = 300000, retries = 1 } = {}) { // Cancel any pending request before starting a new one if (pendingPositionRequest) { pendingPositionRequest.cancel(); pendingPositionRequest = null; } // 1. Fast path: return fresh cached position immediately const cached = getCachedPosition(); if (cached) { // Also refresh in background (don't await) refreshPositionInBackground(timeout, maximumAge); return Promise.resolve(cached); } // 2. Slow path: request from browser return requestBrowserPosition({ timeout, maximumAge, retries }).then(pos => { setCachedPosition(pos.lat, pos.lon); return pos; }); } /** * Refresh position silently in background — updates cache for next call. */ function refreshPositionInBackground(timeout, maximumAge) { if (!navigator.geolocation) return; navigator.geolocation.getCurrentPosition( pos => { const newPos = { lat: pos.coords.latitude, lon: pos.coords.longitude }; setCachedPosition(newPos.lat, newPos.lon); }, () => {}, // silently fail — we already have a cached position { timeout, maximumAge, enableHighAccuracy: false } ); } /** * Core browser geolocation request with retry logic. */ function requestBrowserPosition({ timeout = 15000, maximumAge = 300000, retries = 1 } = {}) { return new Promise((resolve, reject) => { if (!navigator.geolocation) { reject(new Error('Geolocalización no compatible con este navegador')); return; } if (typeof window !== 'undefined' && window.isSecureContext === false) { reject(new Error('La geolocalización requiere HTTPS (o localhost)')); return; } let attempts = 0; let watchId = null; let settled = false; function cleanup() { if (watchId !== null) { navigator.geolocation.clearWatch(watchId); watchId = null; } if (pendingPositionRequest?.id === requestId) { pendingPositionRequest = null; } } function succeed(pos) { if (settled) return; settled = true; cleanup(); const newPos = { lat: pos.coords.latitude, lon: pos.coords.longitude }; setCachedPosition(newPos.lat, newPos.lon); resolve(newPos); } function fail(err) { if (settled) return; attempts++; if (attempts <= retries && err?.code === 3) { const delay = Math.min(1000 * Math.pow(2, attempts - 1), 5000); console.warn(`[geo] timeout, retrying in ${delay}ms (attempt ${attempts}/${retries})`); setTimeout(() => { if (!settled) startWatching(); }, delay); } else { settled = true; cleanup(); console.error('[geo] getCurrentPosition failed — code:', err?.code, 'message:', err?.message); reject(err); } } function startWatching() { if (settled) return; watchId = navigator.geolocation.watchPosition(succeed, fail, { timeout, maximumAge, enableHighAccuracy: false, }); setTimeout(() => { if (!settled && watchId !== null) { fail({ code: 3, message: 'Timeout' }); } }, timeout + 2000); } const requestId = Date.now(); pendingPositionRequest = { id: requestId, cancel: () => { settled = true; cleanup(); } }; startWatching(); }); } /** * Clear cached position (e.g. on logout or when user explicitly wants fresh location). */ export function clearPositionCache() { clearCachedPosition(); } /** * Check if we have any cached position (for UI hints). */ export function hasCachedPosition() { return getCachedPosition() !== null; } export function formatDistance(km) { if (km < 1) return `${Math.round(km * 1000)} m`; if (km < 10) return `${km.toFixed(1)} km`; return `${Math.round(km)} km`; }