561 lines
18 KiB
TypeScript
561 lines
18 KiB
TypeScript
/** Composable for Google Maps integration */
|
|
import { ref, shallowRef, onMounted } from 'vue'
|
|
import { setOptions, importLibrary } from '@googlemaps/js-api-loader'
|
|
import { useMapState } from './useMapState'
|
|
|
|
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() {
|
|
const map = shallowRef<google.maps.Map | null>(null)
|
|
const isLoaded = ref(false)
|
|
const error = ref<string | null>(null)
|
|
const {
|
|
registrarMarker,
|
|
registrarRenderer,
|
|
registrarPolyline,
|
|
registrarCallbackLimpieza,
|
|
limpiarMapa: limpiarTodoCentralizado
|
|
} = useMapState()
|
|
|
|
// 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())
|
|
}
|
|
|
|
// Registrar callback para limpiar globalOverlays cuando useMapState.limpiarMapa() sea llamado
|
|
registrarCallbackLimpieza(() => {
|
|
if (map.value && globalOverlays.has(map.value)) {
|
|
clearAllOverlaysForMap(map.value)
|
|
}
|
|
})
|
|
}
|
|
|
|
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,
|
|
})
|
|
|
|
registrarMarker(marker)
|
|
|
|
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',
|
|
},
|
|
})
|
|
registrarMarker(marker)
|
|
|
|
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' | 'pasada',
|
|
onClick?: () => void
|
|
): google.maps.Marker | null {
|
|
if (!map.value) {
|
|
console.error('Map not initialized');
|
|
return null;
|
|
}
|
|
|
|
const iconoParadaNormal = {
|
|
path: google.maps.SymbolPath.CIRCLE,
|
|
fillColor: '#FBBF24', // amarillo
|
|
fillOpacity: 1,
|
|
strokeColor: '#FFFFFF',
|
|
strokeWeight: 2,
|
|
scale: 7
|
|
};
|
|
|
|
const iconoParadaCercana = {
|
|
path: google.maps.SymbolPath.CIRCLE,
|
|
fillColor: '#F59E0B', // amarillo intenso
|
|
fillOpacity: 1,
|
|
strokeColor: '#FFFFFF',
|
|
strokeWeight: 3,
|
|
scale: 12
|
|
};
|
|
|
|
const iconoOrigen = {
|
|
path: google.maps.SymbolPath.CIRCLE,
|
|
fillColor: '#FBBF24', // amarillo
|
|
fillOpacity: 1,
|
|
strokeColor: '#FFFFFF',
|
|
strokeWeight: 3,
|
|
scale: 10
|
|
};
|
|
|
|
const iconoDestino = {
|
|
path: google.maps.SymbolPath.CIRCLE,
|
|
fillColor: '#D97706', // amarillo oscuro
|
|
fillOpacity: 1,
|
|
strokeColor: '#FFFFFF',
|
|
strokeWeight: 3,
|
|
scale: 10
|
|
};
|
|
|
|
const iconoParadaPasada = {
|
|
path: google.maps.SymbolPath.CIRCLE,
|
|
fillColor: '#FDE68A', // amarillo tenue
|
|
fillOpacity: 0.5,
|
|
strokeColor: '#FFFFFF',
|
|
strokeWeight: 1,
|
|
scale: 5
|
|
};
|
|
|
|
const iconos = {
|
|
normal: iconoParadaNormal,
|
|
cercana: iconoParadaCercana,
|
|
origen: iconoOrigen,
|
|
destino: iconoDestino,
|
|
pasada: iconoParadaPasada
|
|
};
|
|
|
|
const marker = new google.maps.Marker({
|
|
position,
|
|
map: map.value,
|
|
title,
|
|
icon: iconos[type],
|
|
});
|
|
registrarMarker(marker);
|
|
|
|
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 },
|
|
html: string,
|
|
offset: { x: number, y: number } = { x: 0, y: 0 }
|
|
) {
|
|
if (!map.value) return null;
|
|
|
|
const overlay = new google.maps.OverlayView();
|
|
let container: HTMLDivElement | null = null;
|
|
|
|
overlay.onAdd = function () {
|
|
container = document.createElement('div');
|
|
container.style.position = 'absolute';
|
|
container.innerHTML = html;
|
|
const panes = this.getPanes();
|
|
if (panes) panes.overlayMouseTarget.appendChild(container);
|
|
};
|
|
|
|
overlay.draw = function () {
|
|
const projection = this.getProjection();
|
|
if (!projection || !container) return;
|
|
const pos = projection.fromLatLngToDivPixel(new google.maps.LatLng(position.lat, position.lng));
|
|
if (pos) {
|
|
container.style.left = (pos.x + offset.x) + 'px';
|
|
container.style.top = (pos.y + offset.y) + 'px';
|
|
}
|
|
};
|
|
|
|
overlay.onRemove = function () {
|
|
if (container && container.parentNode) {
|
|
container.parentNode.removeChild(container);
|
|
}
|
|
container = null;
|
|
};
|
|
|
|
overlay.setMap(map.value);
|
|
registrarMarker(overlay as any);
|
|
|
|
// Track in global overlay tracker
|
|
if (!globalOverlays.has(map.value)) {
|
|
globalOverlays.set(map.value, new Set());
|
|
}
|
|
globalOverlays.get(map.value)!.add(overlay as any);
|
|
|
|
return overlay;
|
|
}
|
|
|
|
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,
|
|
})
|
|
registrarPolyline(polyline)
|
|
|
|
// 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
|
|
}
|
|
|
|
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 []
|
|
}
|
|
|
|
// Limpiar antes de dibujar una nueva ruta para evitar acumulación
|
|
limpiarTodoCentralizado()
|
|
if (map.value && globalOverlays.has(map.value)) {
|
|
clearAllOverlaysForMap(map.value)
|
|
}
|
|
|
|
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: '#FBBF24', // Amarillo consistente con paradas
|
|
strokeWeight: 5,
|
|
strokeOpacity: 0.95
|
|
}
|
|
});
|
|
|
|
renderer.setDirections(response);
|
|
renderizadoresActivos.push(renderer);
|
|
registrarRenderer(renderer); // Registrar para limpieza centralizada
|
|
|
|
// 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;
|
|
}
|
|
|
|
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
|
|
}
|
|
limpiarTodoCentralizado()
|
|
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()
|
|
}
|
|
if ('onRemove' in overlay && typeof overlay.onRemove === 'function') {
|
|
overlay.onRemove()
|
|
}
|
|
} 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,
|
|
addPolyline,
|
|
addRoutePolyline,
|
|
fitBounds,
|
|
setCenter,
|
|
setZoom,
|
|
clearAllOverlays,
|
|
}
|
|
}
|
|
|