184 lines
5.2 KiB
JavaScript
184 lines
5.2 KiB
JavaScript
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`;
|
|
}
|