Skip to main content

Overview

The Geolocation System provides an interactive map interface using Leaflet and OpenStreetMap for precise location selection. It includes address search, reverse geocoding, current location detection, and a smooth user experience with loading states and error handling.

MapSelector Component

The core component for location selection is MapSelector.vue, which provides a full-featured map interface.

Component Props

interface Props {
  ubicacion: IUbicacion;                // v-model binding
  required?: boolean;                   // Field validation
}

interface IUbicacion {
  latitud: number;
  longitud: number;
  direccion: string;                    // Human-readable address
  barrio?: string;                      // Neighborhood
  sector?: string;                      // Sector/district
  referencias?: string;                 // Optional landmarks
}

Usage Example

<script setup lang="ts">
import { ref } from "vue";
import MapSelector from "@/components/maps/MapSelector.vue";

const ubicacion = ref({
  latitud: -0.9536,   // Default: Manta city center
  longitud: -80.7217,
  direccion: "",
});
</script>

<template>
  <MapSelector 
    v-model:ubicacion="ubicacion"
    :required="true"
  />
  
  <!-- Access selected location -->
  <p>Lat: {{ ubicacion.latitud }}, Lng: {{ ubicacion.longitud }}</p>
  <p>Dirección: {{ ubicacion.direccion }}</p>
</template>

Core Features

1. Interactive Map

The map uses Leaflet with CartoDB Voyager tiles for a clean, modern appearance:
const initMap = () => {
  if (!mapContainer.value) return;

  configureLeafletIcons();
  
  // Create map with optimized options
  map = L.map(mapContainer.value, {
    center: MANTA_COORDS,  // [-0.9536, -80.7217]
    zoom: 13,
    zoomControl: true,
    attributionControl: true,
    preferCanvas: true,
    fadeAnimation: true,
    zoomAnimation: true,
    markerZoomAnimation: true
  });

  // Use CartoDB tiles for better performance
  const tileLayer = L.tileLayer(
    'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
    {
      attribution: '© OpenStreetMap contributors, © CARTO',
      maxZoom: 18,
      minZoom: 11,
      tileSize: 256,
      crossOrigin: true,
      updateWhenZooming: false,
      updateWhenIdle: true,
      keepBuffer: 5,
      maxNativeZoom: 18,
      subdomains: ['a', 'b', 'c', 'd'],
    }
  );

  tileLayer.addTo(map);

  // Handle map clicks
  map.on('click', handleMapClick);
};

2. Click-to-Select Location

const handleMapClick = (e: L.LeafletMouseEvent) => {
  const { lat, lng } = e.latlng;
  updateMapLocation(lat, lng);
  
  // Debounce geocoding to avoid excessive API calls
  if (geocodeTimeout) {
    clearTimeout(geocodeTimeout);
  }
  
  geocodeTimeout = setTimeout(() => {
    reverseGeocode(lat, lng);
  }, 500);
};

const updateMapLocation = (lat: number, lng: number, emitUpdate: boolean = true) => {
  if (!map) return;

  // Remove previous marker
  if (marker) {
    map.removeLayer(marker);
  }

  // Create custom marker with pulse animation
  const customIcon = L.divIcon({
    className: 'custom-marker',
    html: `
      <div style="
        width: 25px;
        height: 25px;
        background: #3b82f6;
        border: 2px solid white;
        border-radius: 50%;
        box-shadow: 0 2px 6px rgba(0,0,0,0.3);
        position: relative;
        animation: pulse 2s infinite;
      ">
        <div style="
          position: absolute;
          top: 50%;
          left: 50%;
          transform: translate(-50%, -50%);
          width: 8px;
          height: 8px;
          background: white;
          border-radius: 50%;
        "></div>
      </div>
    `,
    iconSize: [25, 25],
    iconAnchor: [12.5, 12.5]
  });
  
  marker = L.marker([lat, lng], { icon: customIcon }).addTo(map);
  
  // Center map on new location
  map.setView([lat, lng], Math.max(map.getZoom(), 15));

  if (emitUpdate) {
    const nuevaUbicacion: IUbicacion = {
      ...props.ubicacion,
      latitud: lat,
      longitud: lng
    };
    emit('update:ubicacion', nuevaUbicacion);
  }
};

3. Reverse Geocoding

Convert coordinates to human-readable addresses using Nominatim:
const reverseGeocode = async (lat: number, lng: number) => {
  // Rate limiting (1 second cooldown)
  const now = Date.now();
  if (now - lastGeocodeTime < GEOCODE_COOLDOWN) {
    console.log('Geocodificación omitida por rate limiting');
    return;
  }
  lastGeocodeTime = now;
  
  try {
    const response = await fetch(
      `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=es&zoom=18&addressdetails=1`,
      {
        headers: {
          'User-Agent': 'PortalCiudadanoManta/1.0',
          'Accept': 'application/json',
          'Content-Type': 'application/json'
        }
      }
    );
    
    if (!response.ok) {
      console.warn('Geocodificación falló con status:', response.status);
      return;
    }
    
    const data = await response.json();
    
    if (data.display_name) {
      currentAddress.value = data.display_name;
      
      // Extract specific address components
      const address = data.address || {};
      const nuevaUbicacion: IUbicacion = {
        ...props.ubicacion,
        latitud: lat,
        longitud: lng,
        direccion: data.display_name,
        barrio: address.neighbourhood || address.suburb || props.ubicacion.barrio || '',
        sector: address.city_district || address.district || props.ubicacion.sector || ''
      };
      emit('update:ubicacion', nuevaUbicacion);
    }
  } catch (error) {
    console.warn('Error en geocodificación inversa:', error);
    // Fallback: update only coordinates
    const nuevaUbicacion: IUbicacion = {
      ...props.ubicacion,
      latitud: lat,
      longitud: lng,
      direccion: props.ubicacion.direccion || `${lat.toFixed(6)}, ${lng.toFixed(6)}`
    };
    emit('update:ubicacion', nuevaUbicacion);
  }
};
Search for locations by name or address:
const searchLocation = async () => {
  if (!searchQuery.value.trim() || !map) return;

  loadingLocation.value = true;
  
  try {
    const query = `${searchQuery.value.trim()}, Manta, Ecuador`;
    const response = await fetch(
      `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1&accept-language=es&addressdetails=1`,
      {
        headers: {
          'User-Agent': 'PortalCiudadanoManta/1.0',
          'Accept': 'application/json',
          'Content-Type': 'application/json'
        }
      }
    );
    
    if (!response.ok) {
      alert(t('reportes.error_busqueda'));
      return;
    }

    const data = await response.json();

    if (data && data.length > 0) {
      const result = data[0];
      const lat = parseFloat(result.lat);
      const lng = parseFloat(result.lon);
      
      updateMapLocation(lat, lng);
      currentAddress.value = result.display_name;
      searchQuery.value = '';  // Clear search
    } else {
      alert(t('reportes.ubicacion_no_encontrada'));
    }
  } catch (error) {
    console.error('Error buscando ubicación:', error);
    alert(t('reportes.error_busqueda'));
  } finally {
    loadingLocation.value = false;
  }
};

5. Current Location Detection

Get the user’s current location using the Geolocation API:
const getCurrentLocation = () => {
  if (!navigator.geolocation) {
    alert(t('reportes.geolocalizacion_no_soportada'));
    return;
  }

  loadingLocation.value = true;

  navigator.geolocation.getCurrentPosition(
    (position) => {
      const { latitude, longitude } = position.coords;
      updateMapLocation(latitude, longitude);
      
      // Debounce geocoding
      if (geocodeTimeout) {
        clearTimeout(geocodeTimeout);
      }
      
      geocodeTimeout = setTimeout(() => {
        reverseGeocode(latitude, longitude);
      }, 300);
      
      loadingLocation.value = false;
    },
    (error) => {
      console.error('Error obteniendo ubicación:', error);
      alert(t('reportes.error_obteniendo_ubicacion'));
      loadingLocation.value = false;
    },
    {
      enableHighAccuracy: true,
      timeout: 10000,
      maximumAge: 300000  // Cache for 5 minutes
    }
  );
};

UI Features

Map Controls

Toggle between 320px and 384px height:
const toggleExpand = () => {
  isExpanded.value = !isExpanded.value;
  
  setTimeout(() => {
    if (map) {
      map.invalidateSize({ animate: true });
      setTimeout(() => {
        map?.invalidateSize({ animate: false });
      }, 100);
    }
  }, 350);
};
<template>
  <div class="flex gap-2">
    <input
      v-model="searchQuery"
      type="text"
      placeholder="Buscar dirección en Manta..."
      @keyup.enter="searchLocation"
    />
    <button @click="searchLocation" :disabled="!searchQuery.trim()">
      Buscar
    </button>
    <button @click="getCurrentLocation" :disabled="loadingLocation">
      Mi ubicación
    </button>
  </div>
</template>

Performance Optimization

Tile Loading Strategy

// Tile loading events
let tilesLoading = 0;

tileLayer.on('tileloadstart', () => {
  tilesLoading++;
  if (tilesLoading > 0) {
    loadingMap.value = true;
  }
});

tileLayer.on('tileload', () => {
  tilesLoading--;
  if (tilesLoading <= 0) {
    setTimeout(() => {
      loadingMap.value = false;
    }, 150);
  }
});

tileLayer.on('tileerror', () => {
  tilesLoading--;
  if (tilesLoading <= 0) {
    setTimeout(() => {
      loadingMap.value = false;
    }, 150);
  }
});

Debouncing

Prevent excessive API calls:
const GEOCODE_COOLDOWN = 1000; // 1 second
let lastGeocodeTime = 0;
let geocodeTimeout: number | null = null;

// In click handler:
if (geocodeTimeout) {
  clearTimeout(geocodeTimeout);
}

geocodeTim eout = setTimeout(() => {
  reverseGeocode(lat, lng);
}, 500);

Map Invalidation

Ensure proper rendering after DOM changes:
// Multiple invalidation attempts for reliability
map.whenReady(() => {
  setTimeout(() => map?.invalidateSize(), 50);
  setTimeout(() => map?.invalidateSize(), 200);
  setTimeout(() => {
    map?.invalidateSize();
    if (tilesLoading <= 0) {
      loadingMap.value = false;
    }
  }, 400);
});

Leaflet Configuration

Icon Setup

const configureLeafletIcons = () => {
  delete (L.Icon.Default.prototype as any)._getIconUrl;
  L.Icon.Default.mergeOptions({
    iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
    iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
    shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
  });
};

Best Practices

Always implement rate limiting for geocoding APIs. Nominatim has a 1 request/second limit. Store lastGeocodeTime and check before making requests.
Always include a User-Agent header when calling Nominatim to identify your application. This is required by their usage policy.
Provide fallback coordinate values when geocoding fails. Use the raw coordinates as the address string.
Always remove map instances in onUnmounted to prevent memory leaks:
onUnmounted(() => {
  if (map) map.remove();
  if (fullscreenMap) fullscreenMap.remove();
});

Manta Coordinates

Default coordinates for Manta, Ecuador:
const MANTA_COORDS: [number, number] = [-0.9536, -80.7217];

// Boundary validation
const isInManta = (lat: number, lng: number): boolean => {
  return lat >= -1.1 && lat <= -0.8 && lng >= -80.9 && lng <= -80.5;
};

Styling

@import 'leaflet/dist/leaflet.css';

.map-selector :deep(.leaflet-container) {
  border-radius: 0.5rem;
  background-color: #f8fafc;
}

.map-selector :deep(.leaflet-tile) {
  transition: opacity 0.2s ease-in-out;
}

@keyframes pulse {
  0% {
    box-shadow: 0 2px 6px rgba(0,0,0,0.3), 0 0 0 0 rgba(59, 130, 246, 0.7);
  }
  70% {
    box-shadow: 0 2px 6px rgba(0,0,0,0.3), 0 0 0 10px rgba(59, 130, 246, 0);
  }
  100% {
    box-shadow: 0 2px 6px rgba(0,0,0,0.3), 0 0 0 0 rgba(59, 130, 246, 0);
  }
}

Reports System

Using MapSelector for report location

Leaflet Documentation

Official Leaflet API reference

Nominatim API

Geocoding service documentation

OpenStreetMap

Map data provider

Build docs developers (and LLMs) love