From c9a260ab236d1db81bdfa48d936b6f9c4022211b Mon Sep 17 00:00:00 2001 From: Hanzo_dev <2002samudiojohan@gmail.com> Date: Thu, 26 Feb 2026 22:05:55 -0500 Subject: [PATCH] feat(map): clean stop markers and route dimming --- frontend/src/components/BottomNav.vue | 27 +- .../src/composables/useDirectionsRoute.ts | 12 +- frontend/src/composables/useGoogleMaps.ts | 224 +++++++++---- frontend/src/router/index.ts | 5 + frontend/src/views/MapView.vue | 310 ++++++++---------- frontend/src/views/ShuttleDetalleView.vue | 207 ++++++++++++ frontend/src/views/TaxiView.vue | 113 +------ 7 files changed, 535 insertions(+), 363 deletions(-) create mode 100644 frontend/src/views/ShuttleDetalleView.vue diff --git a/frontend/src/components/BottomNav.vue b/frontend/src/components/BottomNav.vue index 1f1e0ec..e042319 100644 --- a/frontend/src/components/BottomNav.vue +++ b/frontend/src/components/BottomNav.vue @@ -14,16 +14,24 @@ const navItems = [ { name: 'taxi', path: '/taxi', icon: 'directions_bus' } ] -let isNavigating = false +const isNavigating = ref(false) -const navigateTo = (path: string) => { +const navigateTo = async (path: string) => { // Prevent rapid multiple navigations (debounce guard) - if (isNavigating) return + if (isNavigating.value) return if (route.path === path) return - isNavigating = true - router.push(path).finally(() => { - setTimeout(() => { isNavigating = false }, 300) - }) + + try { + isNavigating.value = true + await router.push(path) + } catch (e: any) { + if (e?.name !== 'NavigationDuplicated') { + console.error('SIBU | Error de navegación en el menú inferior:', e) + } + } finally { + // Add a small delay to prevent rapid double-taps + setTimeout(() => { isNavigating.value = false }, 300) + } } const isActive = (path: string) => { @@ -59,8 +67,9 @@ onUnmounted(() => { v-for="item in navItems" :key="item.name" class="nav-item" - :class="{ active: isActive(item.path) }" - @click="navigateTo(item.path)" + :class="{ active: isActive(item.path), 'opacity-50 pointer-events-none': isNavigating }" + @click.prevent="navigateTo(item.path)" + @touchend.prevent="navigateTo(item.path)" > {{ item.icon }} {{ t('navigation.' + item.name) }} diff --git a/frontend/src/composables/useDirectionsRoute.ts b/frontend/src/composables/useDirectionsRoute.ts index 336d45b..0f53f88 100644 --- a/frontend/src/composables/useDirectionsRoute.ts +++ b/frontend/src/composables/useDirectionsRoute.ts @@ -27,7 +27,7 @@ export function useDirectionsRoute() { // Función utilitaria para pausar ejecución const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - const trazarRuta = async (paradas: Parada[], map: google.maps.Map) => { + const trazarRuta = async (paradas: Parada[], map: google.maps.Map, isPast: boolean = false) => { if (!paradas || paradas.length < 2) { errorRuta.value = 'Se requieren al menos 2 paradas para trazar una ruta.'; return; @@ -75,10 +75,14 @@ export function useDirectionsRoute() { map: map, suppressMarkers: true, // SIBU maneja los suyos propios preserveViewport: true, // No auto centrar en cada tramo para evitar parpadeos visuales - polylineOptions: { - strokeColor: '#1E40AF', // Azul (Tailwind blue-800) + polylineOptions: isPast ? { + strokeColor: '#9CA3AF', // Gris Tailwind 400 + strokeWeight: 3, + strokeOpacity: 0.4 + } : { + strokeColor: '#1D4ED8', // Azul Tailwind 700 strokeWeight: 5, - strokeOpacity: 0.8 + strokeOpacity: 0.95 } }); diff --git a/frontend/src/composables/useGoogleMaps.ts b/frontend/src/composables/useGoogleMaps.ts index 5b533ee..ee3dfee 100644 --- a/frontend/src/composables/useGoogleMaps.ts +++ b/frontend/src/composables/useGoogleMaps.ts @@ -208,6 +208,160 @@ export function useGoogleMaps() { 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: ` +
+ 🚌 ${title} +
+ ` + }); + 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; + } + function addPolyline(path: Array<{ lat: number; lng: number }>): google.maps.Polyline | null { if (!map.value) { console.error('Map not initialized') @@ -362,75 +516,6 @@ export function useGoogleMaps() { // with Google Maps' native OverlayView management. } - 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; - } - onMounted(() => { loadMaps() }) @@ -444,6 +529,7 @@ export function useGoogleMaps() { addMarker, addHtmlMarker, addNumberedMarker, + addCleanMarker, addPolyline, addRoutePolyline, fitBounds, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 3becef4..6a21a2f 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -44,6 +44,11 @@ const router = createRouter({ name: 'taxi', component: () => import('@/views/TaxiView.vue'), }, + { + path: '/shuttle/:id', + name: 'shuttle-details', + component: () => import('@/views/ShuttleDetalleView.vue'), + }, // ─── Vistas de Descubrir ───────────────────────────────────────────── { diff --git a/frontend/src/views/MapView.vue b/frontend/src/views/MapView.vue index e405fab..58854e4 100644 --- a/frontend/src/views/MapView.vue +++ b/frontend/src/views/MapView.vue @@ -25,7 +25,7 @@ const mapStore = useMapStore(); const busStopStore = useBusStopStore(); const couponStore = useCouponStore(); -const { map, isLoaded, error: mapsError, initMap, addNumberedMarker, addHtmlMarker, fitBounds, setCenter, setZoom, addMarker, clearAllOverlays } = useGoogleMaps(); +const { map, isLoaded, error: mapsError, initMap, addCleanMarker, addHtmlMarker, setCenter, setZoom, addMarker, clearAllOverlays } = useGoogleMaps(); const { trazarRuta, limpiarRuta, estasCargando: estasCargandoRuta, errorRuta } = useDirectionsRoute(); const { encontrarParadaCercana, limpiarCaminata, paradaCercana, distanciaMetros, duracionCaminata } = useParadaCercana(); const { calcularETA, busesActivos, cargando: etaCargando } = useETA(); @@ -39,7 +39,6 @@ const polyline = ref(null); const walkingPolyline = ref(null); const walkingPolylineBorder = ref(null); // Borde blanco estilo Google Maps const optimalStopPulse = ref(null); // Radar para la parada óptima -const navigationInfo = ref<{ distance: string, duration: string, targetName: string } | null>(null); const showRouteDropdown = ref(false); const routeCardRef = ref(null); const isUpdatingMarkers = ref(false); @@ -49,7 +48,7 @@ const userCoords = ref<{ lat: number; lng: number } | null>(null); // Store last const currentMarkerMode = ref<'dot' | 'pin' | null>(null); const mappingSequenceId = ref(0); // Atomic ID to prevent race conditions - +const alturaNavbar = ref(64); // Search state const stopSearchQuery = ref(""); const destinationQuery = ref(""); @@ -109,7 +108,6 @@ async function clearAllMapData() { showRoutesToggle.value = false; destinationQuery.value = ""; stopSearchQuery.value = ""; - navigationInfo.value = null; // 2. Invalidar cualquier hilo de dibujo en curso mappingSequenceId.value++; @@ -218,8 +216,14 @@ async function claimPromo() { } } - onMounted(async () => { + const navbar = document.querySelector('#navbar-admin') ?? document.querySelector('nav') ?? document.querySelector('header'); + if (navbar) { + alturaNavbar.value = navbar.getBoundingClientRect().height; + } else { + alturaNavbar.value = 64; + } + analyticsService.logEvent({ event_name: 'screen_view', properties: { screen_name: 'Map' } }) // Add click outside listener document.addEventListener('click', handleClickOutside); @@ -343,7 +347,7 @@ async function initializeMap() { updatePromoMarkers(); // Apply initial styles based on current zoom - updateMarkersStyles(true); + updateMarkersStyles(); } // Watch for route selection changes @@ -392,12 +396,13 @@ watch( if (!oldStops || newStops.length !== oldStops.length || newStops.some((stop, idx) => stop.id !== oldStops[idx]?.id)) { console.log('Route stops changed - updating markers') - await updateMapMarkers(); - // FLOW REFINEMENT: After markers are loaded, find the optimal entrance stop + // FLOW REFINEMENT: Find the optimal entrance stop first if (newStops.length > 0) { - highlightOptimalStopForRoute(); + await highlightOptimalStopForRoute(); } + + await updateMapMarkers(); } } } @@ -445,9 +450,6 @@ function clearMapMarkers() { walkingPolylineBorder.value = null; } - // Clear navigation info - navigationInfo.value = null; - // Clear optimal pulse if (optimalStopPulse.value) { if (typeof optimalStopPulse.value.setMap === 'function') { @@ -474,6 +476,7 @@ async function updateMapMarkers() { if (!currentRequestRouteId || stops.length === 0) { clearMapMarkers(); + limpiarRuta(); return; } @@ -489,121 +492,83 @@ async function updateMapMarkers() { return; } - const newMarkers: any[] = []; - const path: Array<{ lat: number; lng: number }> = []; - - for (let i = 0; i < stops.length; i++) { - const stop = stops[i]; - if (!stop) continue; - - // Verificación atómica en cada paso - if (mappingSequenceId.value !== thisSeq || !routeStore.selectedRouteId) { - newMarkers.forEach(m => { if (m.setMap) m.setMap(null); }); - return; - } - - const marker = addNumberedMarker( - { lat: stop.latitude, lng: stop.longitude }, - i + 1, - stop.name, - () => handleBusStopClick(stop) - ); - - if (marker) newMarkers.push(marker); - path.push({ lat: stop.latitude, lng: stop.longitude }); - } - - // Final check before committing to the map - if (mappingSequenceId.value !== thisSeq || !routeStore.selectedRouteId) { - newMarkers.forEach(m => { if (m.setMap) m.setMap(null); }); - return; - } - clearMapMarkers(); - markers.value = newMarkers; + limpiarRuta(); - if (path.length > 0) fitBounds(path); - - } catch (err) { - console.error('❌ JARVIS: Error en updateMapMarkers:', err); - } finally { - if (mappingSequenceId.value === thisSeq) { - isUpdatingMarkers.value = false; - if (routeStore.selectedRouteId) updateMarkersStyles(true); + let pastStops: any[] = []; + let relevantStops: any[] = [...stops]; + + if (paradaCercana.value) { + const idx = stops.findIndex(s => s.id === paradaCercana.value?.id); + if (idx > 0) { + pastStops = stops.slice(0, idx + 1); // overlap that 1 point for continuous mapping + relevantStops = stops.slice(idx); } } + + const newMarkers: any[] = []; + + // Paradas del tramo relevante: mostrar con clean markers + relevantStops.forEach((stop, index) => { + let tipo: 'normal' | 'cercana' | 'origen' | 'destino' = 'normal'; + + if (paradaCercana.value && stop.id === paradaCercana.value.id) tipo = 'cercana'; + else if (index === relevantStops.length - 1) tipo = 'destino'; + else if (!paradaCercana.value && index === 0) tipo = 'origen'; + + const marker = addCleanMarker( + { lat: stop.latitude, lng: stop.longitude }, + stop.name, + tipo, + () => handleBusStopClick(stop) + ); + if (marker) newMarkers.push(marker); + }); + + markers.value = newMarkers; + + // Dibujar en paralelo ambos tramos + const renderPromises = []; + if (pastStops.length > 1 && map.value) { + renderPromises.push(trazarRuta(pastStops.map((p, i) => ({ + id: i, nombre: p.name, latitud: p.latitude, longitud: p.longitude, orden: i + })), map.value, true)); + } + if (relevantStops.length > 1 && map.value) { + renderPromises.push(trazarRuta(relevantStops.map((p, i) => ({ + id: i, nombre: p.name, latitud: p.latitude, longitud: p.longitude, orden: i + })), map.value, false)); + } + await Promise.all(renderPromises); + + // Zoom automático al tramo que le importa al usuario + if (map.value) { + const bounds = new google.maps.LatLngBounds(); + if (userCoords.value) { + bounds.extend(userCoords.value); + } + relevantStops.forEach(p => bounds.extend({ lat: p.latitude, lng: p.longitude })); + + // Timeout para que los directions renderers también ajusten bounds si preserveViewport estaba false (actualmente es true) + setTimeout(() => { + if (map.value && bounds.getNorthEast() && bounds.getSouthWest()) { + map.value.fitBounds(bounds, { top: 80, bottom: 120, left: 20, right: 20 }); + } + }, 150); + } + + } catch (err) { + console.error('❌ JARVIS: Error en updateMapMarkers:', err); + } finally { + if (mappingSequenceId.value === thisSeq) { + isUpdatingMarkers.value = false; + // updateMarkersStyles NO hace falta para "clean markers". Lo mantenemos en caso sea forzado. + } + } } -/** - * Optimización de rendimiento: Solo actualiza los iconos si cambiamos de modo (punto vs pin) - * o si se fuerza la actualización (ej: al cargar nueva ruta) - */ -function updateMarkersStyles(force = false) { - if (!map.value || markers.value.length === 0 || !routeStore.selectedRouteId) return; - - const currentZoom = map.value.getZoom() || 12; - const newMode = currentZoom >= 15 ? 'pin' : 'dot'; - - if (!force && currentMarkerMode.value === newMode) return; - - currentMarkerMode.value = newMode; - const showNumbers = newMode === 'pin'; - - console.log(`🤖 JARVIS: Actualizando estilos de marcadores a modo: ${newMode}`); - - markers.value.forEach((marker: any, index: number) => { - if (!marker) return; - - // Si la secuencia cambió o la ruta desapareció mientras hacíamos esto, abortamos - if (!routeStore.selectedRouteId) { - if (marker.setMap) marker.setMap(null); - return; - } - - if (showNumbers) { - // MODO PREMIUM: Círculo Amarillo con Borde Negro y Numero Negro - marker.setIcon({ - path: google.maps.SymbolPath.CIRCLE, - fillColor: '#FEE715', - fillOpacity: 1, - strokeColor: '#101820', - strokeWeight: 2.5, - scale: 14, // Tamaño ideal para leer el número dentro - }); - marker.setLabel({ - text: (index + 1).toString(), - color: '#101820', - fontSize: '13px', - fontWeight: '900', - }); - } else { - // MODO COMPACTO: Punto Amarillo brillante con anillo de profundidad - marker.setIcon({ - path: google.maps.SymbolPath.CIRCLE, - fillColor: '#FEE715', - fillOpacity: 1, - strokeColor: '#101820', - strokeWeight: 1.5, - scale: 7, - }); - marker.setLabel(null); - } - }); - - // Dibujar la ruta usando Directions API cuando se actualicen los marcadores - if (routeStore.selectedRouteId && map.value) { - const stopsForDirections = markers.value.map((m, i) => { - const pos = m.getPosition(); - return { - id: i, // ID temporal para trazar logic - nombre: `Stop ${i+1}`, - latitud: pos.lat(), - longitud: pos.lng(), - orden: i - }; - }); - trazarRuta(stopsForDirections, map.value); - } +function updateMarkersStyles() { + // Empty space: Clean markers are static and distinct per requirement. } // La función drawRouteOnRoad ha sido eliminada por petición del usuario (línea azul removida) @@ -652,11 +617,6 @@ function selectRouteAndClose(routeId: string, routeName: string) { routeStore.selectRoute(routeId, routeName); showRouteDropdown.value = false; showUberSearch.value = false; // Close the expanded search panel - - // Si no tenemos ubicación, la pedimos para poder calcular la parada óptima automáticamente - if (!userCoords.value) { - locateUser(); - } } async function updateActiveUnits() { if (!isLoaded.value) return; @@ -690,8 +650,13 @@ const optimalSonarHtml = ` `; -function locateUser() { - if (navigator.geolocation) { +function locateUser(): Promise { + return new Promise((resolve) => { + if (!navigator.geolocation) { + resolve(); + return; + } + navigator.geolocation.getCurrentPosition( (position) => { const { latitude, longitude } = position.coords; @@ -715,13 +680,19 @@ function locateUser() { sonarHtml, { x: -30, y: -30 } ); + resolve(); }, (error) => { - console.error("Error getting location", error); - alert("No se pudo obtener tu ubicación. Por favor, verifica tus permisos de GPS."); + console.warn("SIBU | Geolocalización denegada:", error.message); + resolve(); + }, + { + enableHighAccuracy: true, + timeout: 8000, + maximumAge: 30000 } ); - } + }); } /** @@ -730,6 +701,10 @@ function locateUser() { * y la resalta para el usuario. */ async function highlightOptimalStopForRoute() { + if (!userCoords.value) { + await locateUser(); + } + if (!userCoords.value || routeStore.selectedRouteStops.length === 0) { console.warn('🤖 JARVIS: Sin ubicación o paradas para calcular parada óptima.'); return; @@ -744,9 +719,7 @@ async function highlightOptimalStopForRoute() { const stopObj = paradaCercana.value as BusStop; console.log(`🤖 JARVIS: Parada óptima detectada: ${stopObj.name}`); - // Centrar mapa - setCenter(stopObj.latitude, stopObj.longitude); - setZoom(17); + // Ya no centramos o hacemos zoom aquí manual porque la nueva gráfica de updateMapMarkers ajusta bounds y engloba location. // Añadir el PULSO NARANJA if (optimalStopPulse.value && typeof optimalStopPulse.value.setMap === 'function') { @@ -759,13 +732,6 @@ async function highlightOptimalStopForRoute() { { x: -30, y: -30 } ); - // Mini-notificación (Opcional, se cubre ahora también con ETA card) - navigationInfo.value = { - distance: distanciaMetros.value < 1000 ? `${distanciaMetros.value.toFixed(0)} m` : `${(distanciaMetros.value/1000).toFixed(1)} km`, - duration: "Calculada", - targetName: stopObj.name - }; - // Calcular ETAs await calcularETA(routeStore.selectedRouteId!, stopObj); showETACard.value = true; @@ -837,13 +803,9 @@ function calculateWalkingPath(origin: { lat: number, lng: number }, targetStop: const route = dirResult.routes[0]; const leg = route.legs?.[0]; - // Guardar info de navegación (ETA y Distancia) + // Guardar info de navegación (ETA y Distancia) (Retirado a favor de ETA Card / Parada Cercana Banner) if (leg) { - navigationInfo.value = { - distance: leg.distance?.text || '---', - duration: leg.duration?.text || '---', - targetName: targetStop.name - }; + // console.log('Distancia', leg.distance?.text); } if (walkingPolyline.value) walkingPolyline.value.setMap(null); @@ -881,11 +843,6 @@ function calculateWalkingPath(origin: { lat: number, lng: number }, targetStop: }); } -function clearNavigation() { - clearMapMarkers(); - navigationInfo.value = null; -} -