offline-geocoder.ts
1 /** 2 * Módulo para geocodificación offline 3 * Permite buscar lugares por nombre sin conexión a Internet 4 */ 5 import { browser } from '$app/environment'; 6 import type { Place } from '../models'; 7 8 // Interfaz para el índice de lugares 9 interface PlaceIndex { 10 version: string; 11 lastUpdated: number; 12 placeCount: number; 13 places: Record<string, Place>; 14 } 15 16 // Datos de lugares simplificados para búsqueda offline 17 // En una implementación real, estos datos se cargarían de un archivo 18 // generado previamente o se extraerían de OpenStreetMap 19 const SAMPLE_PLACES: Place[] = [ 20 { 21 id: 'madrid', 22 name: 'Madrid', 23 lat: 40.416775, 24 lng: -3.703790, 25 type: 'city', 26 importance: 0.9, 27 address: { 28 city: 'Madrid', 29 state: 'Comunidad de Madrid', 30 country: 'España' 31 } 32 }, 33 { 34 id: 'barcelona', 35 name: 'Barcelona', 36 lat: 41.385064, 37 lng: 2.173404, 38 type: 'city', 39 importance: 0.9, 40 address: { 41 city: 'Barcelona', 42 state: 'Cataluña', 43 country: 'España' 44 } 45 }, 46 { 47 id: 'sevilla', 48 name: 'Sevilla', 49 lat: 37.389092, 50 lng: -5.984459, 51 type: 'city', 52 importance: 0.85, 53 address: { 54 city: 'Sevilla', 55 state: 'Andalucía', 56 country: 'España' 57 } 58 }, 59 { 60 id: 'valencia', 61 name: 'Valencia', 62 lat: 39.469907, 63 lng: -0.376288, 64 type: 'city', 65 importance: 0.85, 66 address: { 67 city: 'Valencia', 68 state: 'Comunidad Valenciana', 69 country: 'España' 70 } 71 }, 72 { 73 id: 'museum_prado', 74 name: 'Museo del Prado', 75 lat: 40.413561, 76 lng: -3.692479, 77 type: 'poi', 78 importance: 0.8, 79 address: { 80 street: 'Paseo del Prado', 81 city: 'Madrid', 82 country: 'España' 83 } 84 }, 85 { 86 id: 'sagrada_familia', 87 name: 'Sagrada Familia', 88 lat: 41.403692, 89 lng: 2.174344, 90 type: 'poi', 91 importance: 0.8, 92 address: { 93 street: 'Carrer de Mallorca', 94 city: 'Barcelona', 95 country: 'España' 96 } 97 }, 98 { 99 id: 'alhambra', 100 name: 'Alhambra', 101 lat: 37.176079, 102 lng: -3.588304, 103 type: 'poi', 104 importance: 0.8, 105 address: { 106 city: 'Granada', 107 state: 'Andalucía', 108 country: 'España' 109 } 110 }, 111 { 112 id: 'puerta_del_sol', 113 name: 'Puerta del Sol', 114 lat: 40.416705, 115 lng: -3.703582, 116 type: 'poi', 117 importance: 0.7, 118 address: { 119 city: 'Madrid', 120 country: 'España' 121 } 122 }, 123 { 124 id: 'plaza_mayor_madrid', 125 name: 'Plaza Mayor', 126 lat: 40.415557, 127 lng: -3.707233, 128 type: 'poi', 129 importance: 0.7, 130 address: { 131 city: 'Madrid', 132 country: 'España' 133 } 134 }, 135 { 136 id: 'park_retiro', 137 name: 'Parque del Retiro', 138 lat: 40.415504, 139 lng: -3.682909, 140 type: 'park', 141 importance: 0.7, 142 address: { 143 city: 'Madrid', 144 country: 'España' 145 } 146 }, 147 { 148 id: 'park_guell', 149 name: 'Parque Güell', 150 lat: 41.414154, 151 lng: 2.152668, 152 type: 'park', 153 importance: 0.7, 154 address: { 155 city: 'Barcelona', 156 country: 'España' 157 } 158 }, 159 { 160 id: 'catedral_sevilla', 161 name: 'Catedral de Sevilla', 162 lat: 37.385826, 163 lng: -5.993009, 164 type: 'poi', 165 importance: 0.8, 166 address: { 167 city: 'Sevilla', 168 country: 'España' 169 } 170 } 171 ]; 172 173 // Inicializar índice con datos de muestra 174 const initialPlaceIndex: PlaceIndex = { 175 version: '1.0', 176 lastUpdated: Date.now(), 177 placeCount: SAMPLE_PLACES.length, 178 places: SAMPLE_PLACES.reduce((acc, place) => { 179 acc[place.id] = place; 180 return acc; 181 }, {} as Record<string, Place>) 182 }; 183 184 // Cargar el índice de lugares 185 export async function loadPlaceIndex(): Promise<PlaceIndex> { 186 if (!browser) { 187 return initialPlaceIndex; 188 } 189 190 try { 191 // En una implementación real, cargaríamos desde IndexedDB 192 // Para esta demostración, usamos el índice estático 193 return initialPlaceIndex; 194 } catch (error) { 195 console.error('[offline-geocoder] Error al cargar índice de lugares:', error); 196 return initialPlaceIndex; 197 } 198 } 199 200 // Buscar lugares por nombre 201 export async function findPlaceOffline(query: string): Promise<Place[]> { 202 if (!query || query.length < 2) { 203 return []; 204 } 205 206 try { 207 // Cargar índice 208 const placeIndex = await loadPlaceIndex(); 209 210 // Normalizar consulta: minúsculas y sin acentos 211 const normalizedQuery = query.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); 212 213 // Buscar coincidencias 214 const allPlaces = Object.values(placeIndex.places); 215 216 const results = allPlaces.filter(place => { 217 // Normalizar nombre del lugar 218 const normalizedName = place.name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); 219 220 // Buscar en nombre 221 if (normalizedName.includes(normalizedQuery)) { 222 return true; 223 } 224 225 // Buscar en dirección 226 if (place.address) { 227 const addressValues = Object.values(place.address); 228 for (const value of addressValues) { 229 if (value && typeof value === 'string' && value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").includes(normalizedQuery)) { 230 return true; 231 } 232 } 233 } 234 235 return false; 236 }); 237 238 // Ordenar por importancia y relevancia 239 return results.sort((a, b) => { 240 // Función para calcular relevancia respecto a la consulta 241 const getRelevance = (place: Place) => { 242 const name = place.name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); 243 244 // Coincidencia exacta en el nombre 245 if (name === normalizedQuery) { 246 return 3; 247 } 248 249 // Coincidencia al inicio del nombre 250 if (name.startsWith(normalizedQuery)) { 251 return 2; 252 } 253 254 // Coincidencia en cualquier parte 255 return 1; 256 }; 257 258 const relevanceA = getRelevance(a); 259 const relevanceB = getRelevance(b); 260 261 // Primero ordenar por relevancia 262 if (relevanceA !== relevanceB) { 263 return relevanceB - relevanceA; 264 } 265 266 // Si la relevancia es igual, ordenar por importancia 267 return (b.importance || 0) - (a.importance || 0); 268 }); 269 } catch (error) { 270 console.error('[offline-geocoder] Error al buscar lugares:', error); 271 return []; 272 } 273 } 274 275 // Obtener lugares cercanos 276 export async function findNearbyPlaces( 277 lat: number, 278 lng: number, 279 radius: number = 5000, // Radio en metros 280 types?: string[] 281 ): Promise<Place[]> { 282 try { 283 // Cargar índice 284 const placeIndex = await loadPlaceIndex(); 285 const allPlaces = Object.values(placeIndex.places); 286 287 // Calcular distancia de cada lugar al punto dado 288 const placesWithDistance = allPlaces.map(place => { 289 const distance = calculateDistance( 290 lat, 291 lng, 292 place.lat, 293 place.lng 294 ); 295 296 return { ...place, distance }; 297 }); 298 299 // Filtrar por distancia y tipo (si se especifica) 300 const nearbyPlaces = placesWithDistance.filter(place => { 301 // Filtro por distancia 302 const isNearby = place.distance <= radius; 303 304 // Filtro por tipo (si se especifica) 305 const matchesType = !types || types.length === 0 || 306 (place.type && types.includes(place.type)); 307 308 return isNearby && matchesType; 309 }); 310 311 // Ordenar por distancia 312 return nearbyPlaces.sort((a, b) => 313 (a.distance || Infinity) - (b.distance || Infinity) 314 ); 315 } catch (error) { 316 console.error('[offline-geocoder] Error al buscar lugares cercanos:', error); 317 return []; 318 } 319 } 320 321 // Calcular distancia en metros entre dos puntos usando la fórmula de Haversine 322 function calculateDistance( 323 lat1: number, 324 lon1: number, 325 lat2: number, 326 lon2: number 327 ): number { 328 const R = 6371000; // Radio de la Tierra en metros 329 const dLat = toRadians(lat2 - lat1); 330 const dLon = toRadians(lon2 - lon1); 331 332 const a = 333 Math.sin(dLat/2) * Math.sin(dLat/2) + 334 Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * 335 Math.sin(dLon/2) * Math.sin(dLon/2); 336 337 const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); 338 const distance = R * c; 339 340 return distance; 341 } 342 343 // Convertir grados a radianes 344 function toRadians(degrees: number): number { 345 return degrees * Math.PI / 180; 346 } 347