Documentation Index Fetch the complete documentation index at: https://mintlify.com/joaoelian204/Portal-Ciudadano-Manta-web/llms.txt
Use this file to discover all available pages before exploring further.
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 );
}
};
4. Address Search
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
Expand/Collapse
Fullscreen Modal
Loading States
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 );
};
Open map in a fullscreen modal for better interaction: const toggleFullscreen = () => {
isFullscreen . value = ! isFullscreen . value ;
if ( isFullscreen . value ) {
setTimeout (() => {
initFullscreenMap ();
}, 100 );
} else {
if ( fullscreenMap ) {
fullscreenMap . remove ();
fullscreenMap = null ;
}
}
};
Smooth loading experience while tiles load: < template >
< div v-if = " loadingMap " class = "absolute inset-0 bg-white bg-opacity-80" >
< div class = "text-center" >
< div class = "animate-spin rounded-full h-10 w-10 border-3 border-blue-200 border-t-blue-600" ></ div >
< div > Cargando mapa de Manta </ div >
</ div >
</ div >
</ template >
Search Bar
< 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 >
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.
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.5 rem ;
background-color : #f8fafc ;
}
.map-selector :deep( .leaflet-tile ) {
transition : opacity 0.2 s ease-in-out ;
}
@keyframes pulse {
0% {
box-shadow : 0 2 px 6 px rgba ( 0 , 0 , 0 , 0.3 ), 0 0 0 0 rgba ( 59 , 130 , 246 , 0.7 );
}
70% {
box-shadow : 0 2 px 6 px rgba ( 0 , 0 , 0 , 0.3 ), 0 0 0 10 px rgba ( 59 , 130 , 246 , 0 );
}
100% {
box-shadow : 0 2 px 6 px 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