Files
SIB/frontend/src/composables/useGoogleMaps.ts

542 lines
17 KiB
TypeScript
Raw Normal View History

2026-02-21 09:53:31 -05:00
/** Composable for Google Maps integration */
2026-02-26 12:39:15 -05:00
import { ref, shallowRef, onMounted } from 'vue'
2026-02-21 09:53:31 -05:00
import { setOptions, importLibrary } from '@googlemaps/js-api-loader'
const getApiKey = () => import.meta.env.VITE_GOOGLE_MAPS_API_KEY || ''
let mapsLoaded = false
// Global overlay tracker - persists across all composable instances
const globalOverlays = new Map<google.maps.Map, Set<google.maps.Marker | google.maps.Polyline>>()
export function useGoogleMaps() {
2026-02-26 12:39:15 -05:00
const map = shallowRef<google.maps.Map | null>(null)
2026-02-21 09:53:31 -05:00
const isLoaded = ref(false)
const error = ref<string | null>(null)
// Escuchar errores globales de autenticación de Google
if (typeof window !== 'undefined') {
(window as any).gm_auth_failure = () => {
error.value = '⚠️ Error de Autenticación de Google: Revisa que la API de Mapas esté activada y que la facturación de Google Cloud sea válida.';
console.error('❌ Google Maps Auth Failure detected');
};
}
async function loadMaps() {
if (mapsLoaded) {
isLoaded.value = true
error.value = null
return
}
const apiKey = getApiKey()
if (!apiKey || apiKey.length < 10) {
error.value = '❌ Error: VITE_GOOGLE_MAPS_API_KEY no detectada o es inválida.'
console.error(error.value)
return
}
console.log('🌐 Usando Nueva API Funcional de Google Maps...');
try {
// Configuramos las opciones globales como pide el error
setOptions({
key: apiKey,
v: 'weekly'
});
// Cargamos las librerías necesarias una por una
console.log('🛰️ Cargando librerías...');
await importLibrary('maps');
await importLibrary('places');
await importLibrary('geometry');
if (typeof google === 'undefined' || !google.maps) {
throw new Error('Google Maps se cargó pero el espacio de nombres "google.maps" no está disponible.');
}
mapsLoaded = true
isLoaded.value = true
error.value = null
console.log('✅ Google Maps (New API) cargado con éxito');
} catch (e: any) {
console.error('❌ Error crítico en Nueva API:', e)
let msg = 'Error de carga.'
const errStr = String(e).toLowerCase()
if (errStr.includes('apiprojectmaperror')) {
msg = 'Error de Proyecto: API no habilitada o llave incorrecta.'
} else if (errStr.includes('billing')) {
msg = 'Facturación: Revisa tu cuenta en Google Cloud Console.'
} else if (errStr.includes('referer') || errStr.includes('origin')) {
msg = 'Restricción de Origen: La llave no permite peticiones desde esta App.'
} else {
msg = `Detalle: ${e.message || e}`
}
error.value = `⚠️ Google Maps: ${msg}`
}
}
function initMap(
containerId: string,
center: { lat: number; lng: number },
zoom: number = 12
) {
if (!isLoaded.value) {
console.error('Google Maps not loaded yet')
return
}
const container = document.getElementById(containerId)
if (!container) {
console.error(`Map container with id "${containerId}" not found`)
return
}
// Clear any existing overlays for this map before creating a new one
if (map.value && globalOverlays.has(map.value)) {
clearAllOverlaysForMap(map.value)
}
try {
map.value = new google.maps.Map(container, {
center,
zoom,
disableDefaultUI: true,
})
} catch (e: any) {
console.error('❌ Error inicializando el objeto Map:', e);
error.value = `Error de inicialización: ${e.message || e}`;
}
// Initialize overlay tracking for this map
if (map.value && !globalOverlays.has(map.value)) {
globalOverlays.set(map.value, new Set())
}
}
function addMarker(
position: { lat: number; lng: number },
options?: {
title?: string
draggable?: boolean
icon?: google.maps.Icon | google.maps.Symbol | string
onDragEnd?: (pos: { lat: number; lng: number }) => void
}
): google.maps.Marker | null {
if (!map.value) {
console.error('Map not initialized')
return null
}
const marker = new google.maps.Marker({
position,
map: map.value,
title: options?.title,
draggable: options?.draggable,
icon: options?.icon,
})
if (options?.onDragEnd) {
marker.addListener('dragend', () => {
const pos = marker.getPosition()
if (pos) {
options.onDragEnd!({ lat: pos.lat(), lng: pos.lng() })
}
})
}
// Track in global overlay tracker
if (map.value) {
if (!globalOverlays.has(map.value)) {
globalOverlays.set(map.value, new Set())
}
globalOverlays.get(map.value)!.add(marker)
}
return marker
}
function addNumberedMarker(
position: { lat: number; lng: number },
number: number,
title?: string,
onClick?: () => void
): google.maps.Marker | null {
if (!map.value) {
console.error('Map not initialized')
return null
}
// Note: google.maps.Marker is deprecated but still works
// We'll keep using it for now as AdvancedMarkerElement requires additional setup
// TODO: Migrate to google.maps.marker.AdvancedMarkerElement in the future
const marker = new google.maps.Marker({
position,
map: map.value,
title,
icon: {
path: google.maps.SymbolPath.CIRCLE,
fillColor: '#FEE715', // Amarillo marca
fillOpacity: 1,
strokeColor: '#101820', // Negro marca
strokeWeight: 2,
scale: 14,
},
label: {
text: number.toString(),
color: '#101820',
fontSize: '13px',
fontWeight: '900',
},
})
if (onClick) {
marker.addListener('click', onClick)
}
// Track in global overlay tracker
if (map.value) {
if (!globalOverlays.has(map.value)) {
globalOverlays.set(map.value, new Set())
}
globalOverlays.get(map.value)!.add(marker)
}
return marker
}
function addCleanMarker(
position: { lat: number; lng: number },
title: string,
type: 'normal' | 'cercana' | 'origen' | 'destino',
onClick?: () => void
): google.maps.Marker | null {
if (!map.value) {
console.error('Map not initialized');
return null;
}
const iconoParadaNormal = {
path: google.maps.SymbolPath.CIRCLE,
fillColor: '#3B82F6', // azul
fillOpacity: 1,
strokeColor: '#FFFFFF', // borde blanco limpio
strokeWeight: 2,
scale: 7
};
const iconoParadaCercana = {
path: google.maps.SymbolPath.CIRCLE,
fillColor: '#F59E0B', // amarillo/naranja
fillOpacity: 1,
strokeColor: '#FFFFFF',
strokeWeight: 3,
scale: 10
};
const iconoOrigen = {
path: google.maps.SymbolPath.CIRCLE,
fillColor: '#10B981', // verde
fillOpacity: 1,
strokeColor: '#FFFFFF',
strokeWeight: 3,
scale: 10
};
const iconoDestino = {
path: google.maps.SymbolPath.CIRCLE,
fillColor: '#EF4444', // rojo
fillOpacity: 1,
strokeColor: '#FFFFFF',
strokeWeight: 3,
scale: 10
};
const iconos = {
normal: iconoParadaNormal,
cercana: iconoParadaCercana,
origen: iconoOrigen,
destino: iconoDestino
};
const marker = new google.maps.Marker({
position,
map: map.value,
title,
icon: iconos[type],
});
if (onClick) {
const infoWindow = new google.maps.InfoWindow({
content: `
<div style="font-family: sans-serif; padding: 6px 10px; font-size: 13px; font-weight: 600; color: #1E3A5F; white-space: nowrap;">
🚌 ${title}
</div>
`
});
marker.addListener('click', () => {
infoWindow.open(map.value, marker);
onClick();
});
}
if (map.value) {
if (!globalOverlays.has(map.value)) {
globalOverlays.set(map.value, new Set());
}
globalOverlays.get(map.value)!.add(marker);
}
return marker;
}
function addHtmlMarker(
position: { lat: number; lng: number },
htmlContent: string,
offset: { x: number; y: number } = { x: 0, y: 0 }
) {
if (!map.value) return null;
class CustomOverlay extends google.maps.OverlayView {
private div: HTMLElement | null = null;
private pos: google.maps.LatLng;
constructor(pos: google.maps.LatLng) {
super();
this.pos = pos;
}
onAdd() {
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.cursor = 'pointer';
div.innerHTML = htmlContent;
this.div = div;
const panes = this.getPanes();
panes?.overlayMouseTarget.appendChild(div);
}
draw() {
const overlayProjection = this.getProjection();
const point = overlayProjection.fromLatLngToDivPixel(this.pos);
if (point && this.div) {
this.div.style.left = (point.x + offset.x) + 'px';
this.div.style.top = (point.y + offset.y) + 'px';
}
}
onRemove() {
if (this.div) {
try {
// Safer element removal
if (this.div.parentNode) {
this.div.parentNode.removeChild(this.div);
} else {
this.div.remove();
}
} catch (e) {
console.warn('CustomOverlay: element already removed or parent mismatch', e);
}
this.div = null;
}
}
setPosition(newPos: { lat: number; lng: number }) {
this.pos = new google.maps.LatLng(newPos.lat, newPos.lng);
this.draw();
}
}
const overlay = new CustomOverlay(new google.maps.LatLng(position.lat, position.lng));
overlay.setMap(map.value);
// Track for cleanup
if (!globalOverlays.has(map.value)) {
globalOverlays.set(map.value, new Set());
}
globalOverlays.get(map.value)!.add(overlay as any);
return overlay;
}
2026-02-21 09:53:31 -05:00
function addPolyline(path: Array<{ lat: number; lng: number }>): google.maps.Polyline | null {
if (!map.value) {
console.error('Map not initialized')
return null
}
const polyline = new google.maps.Polyline({
path,
geodesic: true,
strokeColor: '#101820', // Negro premium
strokeOpacity: 0.8,
strokeWeight: 5,
map: map.value,
})
// Track in global overlay tracker
if (map.value) {
if (!globalOverlays.has(map.value)) {
globalOverlays.set(map.value, new Set())
}
globalOverlays.get(map.value)!.add(polyline)
}
return polyline
}
2026-02-26 12:39:15 -05:00
async function addRoutePolyline(paradas: Array<{ lat: number; lng: number }>) {
if (!map.value) {
console.error('Map not initialized')
return []
}
if (!paradas || paradas.length < 2) {
console.warn("Se necesitan al menos 2 paradas para trazar una ruta.");
return []
}
const directionsService = new google.maps.DirectionsService();
const renderizadoresActivos: google.maps.DirectionsRenderer[] = [];
const tamañoChunk = 25;
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
for (let i = 0; i < paradas.length - 1; i += (tamañoChunk - 1)) {
const chunk = paradas.slice(i, i + tamañoChunk);
if (chunk.length < 2) break;
const origen = { lat: chunk[0]!.lat, lng: chunk[0]!.lng };
const destino = { lat: chunk[chunk.length - 1]!.lat, lng: chunk[chunk.length - 1]!.lng };
const waypoints = chunk.slice(1, -1).map(p => ({
location: { lat: p.lat, lng: p.lng },
stopover: true
}));
const request = {
origin: origen,
destination: destino,
waypoints: waypoints,
travelMode: google.maps.TravelMode.DRIVING,
optimizeWaypoints: false
};
try {
const response = await directionsService.route(request);
const renderer = new google.maps.DirectionsRenderer({
map: map.value,
suppressMarkers: true,
preserveViewport: true, // Siempre conservar la vista ya que trazamos fragmentos
polylineOptions: {
strokeColor: '#0057FF', // Azul
strokeWeight: 4,
strokeOpacity: 0.8
}
});
renderer.setDirections(response);
renderizadoresActivos.push(renderer);
// Registrar en global overlays para limpiarlos después
if (!globalOverlays.has(map.value)) {
globalOverlays.set(map.value, new Set())
}
globalOverlays.get(map.value)!.add(renderer as any);
} catch (error) {
console.error(`Error trazando el tramo (Paradas ${i} a ${i + chunk.length}):`, error);
}
await delay(200);
}
return renderizadoresActivos;
}
2026-02-21 09:53:31 -05:00
function fitBounds(path: Array<{ lat: number; lng: number }>) {
if (!map.value || path.length === 0) {
return
}
const bounds = new google.maps.LatLngBounds()
path.forEach((point) => {
bounds.extend(new google.maps.LatLng(point.lat, point.lng))
})
map.value.fitBounds(bounds)
}
function setCenter(lat: number, lng: number) {
if (map.value) {
map.value.setCenter({ lat, lng })
}
}
function setZoom(zoom: number) {
if (map.value) {
map.value.setZoom(zoom)
}
}
function clearAllOverlays() {
if (!map.value) {
return
}
clearAllOverlaysForMap(map.value)
}
function clearAllOverlaysForMap(targetMap: google.maps.Map) {
const overlays = globalOverlays.get(targetMap)
// Remove all tracked overlays from the map
if (overlays) {
const overlayCount = overlays.size
overlays.forEach((overlay) => {
if (overlay) {
try {
if ('setMap' in overlay && typeof overlay.setMap === 'function') {
overlay.setMap(null)
}
if ('remove' in overlay && typeof overlay.remove === 'function') {
overlay.remove()
}
} catch (e) {
// Ignore errors when removing overlays
console.warn('Error removing overlay:', e)
}
}
})
// Clear the set
overlays.clear()
console.log(`Cleared ${overlayCount} tracked overlays`)
}
// Manual DOM scraping fallback removed as it causes "removeChild" errors
// with Google Maps' native OverlayView management.
}
onMounted(() => {
loadMaps()
})
return {
map,
isLoaded,
error,
loadMaps,
initMap,
addMarker,
addHtmlMarker,
addNumberedMarker,
addCleanMarker,
2026-02-21 09:53:31 -05:00
addPolyline,
2026-02-26 12:39:15 -05:00
addRoutePolyline,
2026-02-21 09:53:31 -05:00
fitBounds,
setCenter,
setZoom,
clearAllOverlays,
}
}