/ src / lib / maps / utils / offline-geocoder.ts
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