From 30c3f092d862736bf23139485ef2b84bb5ecb4a1 Mon Sep 17 00:00:00 2001 From: Hanzo_dev <2002samudiojohan@gmail.com> Date: Thu, 26 Feb 2026 13:13:56 -0500 Subject: [PATCH] feat: real-time bus ETA engine via distance approximation and routing --- frontend/src/components/ETACard.vue | 163 +++++++++++++++++++ frontend/src/composables/useETA.ts | 127 +++++++++++++++ frontend/src/composables/useParadaCercana.ts | 129 +++++++++++++++ frontend/src/views/MapView.vue | 69 ++++---- 4 files changed, 457 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/ETACard.vue create mode 100644 frontend/src/composables/useETA.ts create mode 100644 frontend/src/composables/useParadaCercana.ts diff --git a/frontend/src/components/ETACard.vue b/frontend/src/components/ETACard.vue new file mode 100644 index 0000000..c696d3c --- /dev/null +++ b/frontend/src/components/ETACard.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/frontend/src/composables/useETA.ts b/frontend/src/composables/useETA.ts new file mode 100644 index 0000000..ebb14ae --- /dev/null +++ b/frontend/src/composables/useETA.ts @@ -0,0 +1,127 @@ +import { ref } from 'vue'; +import { supabase } from '@/supabase'; +import type { BusStop } from '@/types'; + +export interface BusETA { + horario_id: string; + hora_salida: string; + etaMinutos: number; + estado: 'próximo' | 'en_camino' | 'pasó'; +} + +export function useETA() { + const busesActivos = ref([]); + const cargando = ref(false); + + const calcularETA = async (ruta_id: string, parada_cercana: BusStop) => { + cargando.value = true; + busesActivos.value = []; + + try { + // 1. Obtener horarios activos de la ruta para el día de hoy + const diaActual = new Date().getDay(); // 0 = Domingo, 1 = Lunes... + const dias = ['domingo', 'lunes', 'martes', 'miercoles', 'jueves', 'viernes', 'sabado']; + const diaString = dias[diaActual]; + const tipoDia = (diaActual === 0 || diaActual === 6) ? 'weekend' : 'weekday'; + + // Consulta flexible a supabase + const { data: horarios, error } = await supabase + .from('bus_schedules') + .select('*') + .eq('route_id', ruta_id) + .eq('is_active', true); + + if (error) throw error; + + const horariosHoy = (horarios || []).filter(h => { + if (h.dias_operacion) { + return h.dias_operacion.includes('todos') || h.dias_operacion.includes(diaString); + } + return h.schedule_type === tipoDia || h.schedule_type === 'todos' || !h.schedule_type; + }); + + // 2. Parámetros físicos e información horaria + const VELOCIDAD_PROMEDIO_KMH = 30; // velocidad promedio 30 km/h + // Distancia promedio en km entre la parada de origen (índice 0) hasta llegar a esta. + // Si la base de datos no tiene un "orden" numérico, usamos índice o un estimado global de 0.5km por orden de parada. + const ordenParada = typeof parada_cercana.stop_order === 'number' ? parada_cercana.stop_order : 1; + const DISTANCIA_PROMEDIO_PARADA_KM = 0.5; // asumiendo que cada parada dista 500m + const DISTANCIA_TOTAL_RUTA_KM = 15; // Estimado ruta ciudad + + const ahora = new Date(); + const horasAhora = ahora.getHours(); + const minsAhora = ahora.getMinutes(); + const tiempoActualMinutos = horasAhora * 60 + minsAhora; + + const resultados: BusETA[] = []; + + for (const h of horariosHoy) { + const horaSalida = h.departure_time || h.hora_salida; + if (!horaSalida) continue; + + const [hSalidaStr, mSalidaStr] = horaSalida.split(':'); + const tiempoSalidaMins = parseInt(hSalidaStr, 10) * 60 + parseInt(mSalidaStr, 10); + + const tiempoTranscurridoMins = tiempoActualMinutos - tiempoSalidaMins; + + // ¿Qué tan lejos está la parada del usuario desde el inicio? + const distanciaParadaUsuario = ordenParada * DISTANCIA_PROMEDIO_PARADA_KM; + + // Si el bus sale en el futuro + if (tiempoTranscurridoMins < 0) { + const tiempoA_ParadaMins = (distanciaParadaUsuario / VELOCIDAD_PROMEDIO_KMH) * 60; + const etaReal = Math.abs(tiempoTranscurridoMins) + tiempoA_ParadaMins; + + resultados.push({ + horario_id: h.id, + hora_salida: horaSalida.slice(0, 5), // Solo HH:mm + etaMinutos: Math.round(etaReal), + estado: 'próximo' + }); + } else { + // El bus ya salió. ¿Dónde está? + const kmsRecorridos = (tiempoTranscurridoMins / 60) * VELOCIDAD_PROMEDIO_KMH; + + // Ya pasó la parada + if (kmsRecorridos > distanciaParadaUsuario) { + // Si no ha terminado la ruta general + if (kmsRecorridos <= DISTANCIA_TOTAL_RUTA_KM) { + resultados.push({ + horario_id: h.id, + hora_salida: horaSalida.slice(0, 5), + etaMinutos: 0, + estado: 'pasó' + }); + } + } else { + // En camino + const kmsRestantes = distanciaParadaUsuario - kmsRecorridos; + const etaMinutos = (kmsRestantes / VELOCIDAD_PROMEDIO_KMH) * 60; + resultados.push({ + horario_id: h.id, + hora_salida: horaSalida.slice(0, 5), + etaMinutos: Math.round(etaMinutos), + estado: 'en_camino' + }); + } + } + } + + // Ordenar por prioridad (en_camino < próximo < pasó) y tiempo + resultados.sort((a, b) => { + if (a.estado === 'pasó' && b.estado !== 'pasó') return 1; + if (b.estado === 'pasó' && a.estado !== 'pasó') return -1; + return a.etaMinutos - b.etaMinutos; + }); + + busesActivos.value = resultados.slice(0, 3); // Max 3 buses + + } catch (e) { + console.error("Error calculando ETA", e); + } finally { + cargando.value = false; + } + }; + + return { calcularETA, busesActivos, cargando }; +} diff --git a/frontend/src/composables/useParadaCercana.ts b/frontend/src/composables/useParadaCercana.ts new file mode 100644 index 0000000..7be72fa --- /dev/null +++ b/frontend/src/composables/useParadaCercana.ts @@ -0,0 +1,129 @@ +import { ref } from 'vue'; +import type { BusStop } from '@/types'; + +// Fórmula Haversine para distancia en línea recta (km) +function getHaversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371; // Radio de la Tierra en km + const dLat = (lat2 - lat1) * (Math.PI / 180); + const dLon = (lon2 - lon1) * (Math.PI / 180); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(lat1 * (Math.PI / 180)) * Math.cos(lat2 * (Math.PI / 180)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +export function useParadaCercana() { + const paradaCercana = ref(null); + const distanciaMetros = ref(0); + const duracionCaminata = ref(0); + const caminandoPolyline = ref(null); + + const limpiarCaminata = () => { + if (caminandoPolyline.value) { + caminandoPolyline.value.setMap(null); + caminandoPolyline.value = null; + } + paradaCercana.value = null; + distanciaMetros.value = 0; + duracionCaminata.value = 0; + }; + + const encontrarParadaCercana = async ( + ubicacionUsuario: { lat: number; lng: number }, + paradas: BusStop[], + map: google.maps.Map | undefined + ) => { + if (!paradas || paradas.length === 0 || !ubicacionUsuario) return; + limpiarCaminata(); + + // 1. Pre-filtar (Haversine) - Top 5 más cercanas + const paradasConDistLineal = paradas.map(p => ({ + parada: p, + distancia: getHaversineDistance(ubicacionUsuario.lat, ubicacionUsuario.lng, p.latitude, p.longitude) + })); + + paradasConDistLineal.sort((a, b) => a.distancia - b.distancia); + const top5 = paradasConDistLineal.slice(0, 5).map(item => item.parada); + + // 2. Usar Directions API para encontrar la más cercana por calles reales + let mejorParada: BusStop | null = null; + let minimaDistanciaCalles = Infinity; + let mejorDuracion = 0; + let mejorRutaPuntos: google.maps.LatLng[] = []; + + const directionsService = new google.maps.DirectionsService(); + + for (const stop of top5) { + try { + const response = await directionsService.route({ + origin: new google.maps.LatLng(ubicacionUsuario.lat, ubicacionUsuario.lng), + destination: new google.maps.LatLng(stop.latitude, stop.longitude), + travelMode: google.maps.TravelMode.DRIVING // Calles reales + }); + + if (response.routes && response.routes.length > 0) { + const route = response.routes[0]; + let distTotal = 0; + let durTotal = 0; + + if (route.legs) { + for (const leg of route.legs) { + distTotal += leg.distance?.value || 0; + durTotal += leg.duration?.value || 0; + } + } + + if (distTotal < minimaDistanciaCalles) { + minimaDistanciaCalles = distTotal; + mejorDuracion = durTotal; + mejorParada = stop; + mejorRutaPuntos = route.overview_path; + } + } + } catch (e) { + console.warn('Error calculando ruta a parada', stop.name, e); + } + } + + // 3. Fallback a la más cercana lineal si falla API + if (!mejorParada) { + mejorParada = top5[0]; + minimaDistanciaCalles = paradasConDistLineal[0].distancia * 1000; + mejorDuracion = (minimaDistanciaCalles / 1000) / 5 * 60 * 60; // asumiendo caminata a 5km/h + } + + paradaCercana.value = mejorParada; + distanciaMetros.value = minimaDistanciaCalles; + duracionCaminata.value = mejorDuracion; // en segundos + + // 4. Dibujar polilínea de caminata punteada azul + if (map && mejorRutaPuntos.length > 0) { + caminandoPolyline.value = new google.maps.Polyline({ + path: mejorRutaPuntos, + strokeColor: '#1E40AF', + strokeOpacity: 0, + strokeWeight: 4, + icons: [{ + icon: { + path: 'M 0,-1 0,1', + strokeOpacity: 1, + scale: 3 + }, + offset: '0', + repeat: '20px' + }], + map: map + }); + } + }; + + return { + encontrarParadaCercana, + limpiarCaminata, + paradaCercana, + distanciaMetros, + duracionCaminata + }; +} diff --git a/frontend/src/views/MapView.vue b/frontend/src/views/MapView.vue index 30f9e03..88a3151 100644 --- a/frontend/src/views/MapView.vue +++ b/frontend/src/views/MapView.vue @@ -11,7 +11,10 @@ import { analyticsService } from "@/services/analyticsService"; import { getImageUrl } from "@/utils/imageUrl"; import { useDirectionsRoute } from "@/composables/useDirectionsRoute"; +import { useParadaCercana } from "@/composables/useParadaCercana"; +import { useETA } from "@/composables/useETA"; const BusStopInfoModal = defineAsyncComponent(() => import("@/components/BusStopInfoModal.vue")); +const ETACard = defineAsyncComponent(() => import("@/components/ETACard.vue")); import type { BusStop } from '@/types' const router = useRouter(); @@ -24,6 +27,10 @@ const couponStore = useCouponStore(); const { map, isLoaded, error: mapsError, initMap, addNumberedMarker, addHtmlMarker, fitBounds, 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(); + +const showETACard = ref(false); const markers = ref([]); const promoMarkers = ref([]); @@ -145,6 +152,8 @@ async function clearAllMapData() { optimalStopPulse.value = null; } limpiarRuta(); + limpiarCaminata(); + showETACard.value = false; // 7. Restaurar Solo Usuario tras un breve respiro await nextTick(); @@ -449,6 +458,8 @@ function clearMapMarkers() { // Clear directions route limpiarRuta(); + limpiarCaminata(); + showETACard.value = false; } async function updateMapMarkers() { @@ -718,45 +729,26 @@ function locateUser() { * Encuentra la parada más cercana dentro de la ruta seleccionada * y la resalta para el usuario. */ -function highlightOptimalStopForRoute() { +async function highlightOptimalStopForRoute() { if (!userCoords.value || routeStore.selectedRouteStops.length === 0) { console.warn('🤖 JARVIS: Sin ubicación o paradas para calcular parada óptima.'); return; } - console.log('🤖 JARVIS: Calculando punto de abordaje óptimo sobre la ruta...'); + console.log('🤖 JARVIS: Calculando punto de abordaje óptimo sobre la ruta mediante calles...'); - let nearestStop = null; - let minDistance = Infinity; + // Encontrar parada real y añadir ruta peatonal azul punteada + await encontrarParadaCercana(userCoords.value, routeStore.selectedRouteStops as BusStop[], map.value || undefined); - const getDistance = (l1: any, l2: any) => { - const R = 6371; // Radio de la Tierra en km - const dLat = (l2.lat - l1.lat) * Math.PI / 180; - const dLon = (l2.lng - l1.lng) * Math.PI / 180; - const a = Math.sin(dLat/2) * Math.sin(dLat/2) + - Math.cos(l1.lat * Math.PI / 180) * Math.cos(l2.lat * Math.PI / 180) * - Math.sin(dLon/2) * Math.sin(dLon/2); - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); - return R * c; - }; - - routeStore.selectedRouteStops.forEach(stop => { - const dist = getDistance(userCoords.value, { lat: stop.latitude, lng: stop.longitude }); - if (dist < minDistance) { - minDistance = dist; - nearestStop = stop; - } - }); - - if (nearestStop) { - const stopObj = nearestStop as BusStop; - console.log(`🤖 JARVIS: Parada óptima detectada: ${stopObj.name} (${minDistance.toFixed(2)} km)`); + if (paradaCercana.value) { + const stopObj = paradaCercana.value as BusStop; + console.log(`🤖 JARVIS: Parada óptima detectada: ${stopObj.name}`); - // Centrar mapa en la parada para guiar al usuario + // Centrar mapa setCenter(stopObj.latitude, stopObj.longitude); setZoom(17); - // Añadir el PULSO NARANJA de "Aborda aquí" + // Añadir el PULSO NARANJA if (optimalStopPulse.value && typeof optimalStopPulse.value.setMap === 'function') { optimalStopPulse.value.setMap(null); } @@ -767,12 +759,16 @@ function highlightOptimalStopForRoute() { { x: -30, y: -30 } ); - // Mini-notificación informativa + // Mini-notificación (Opcional, se cubre ahora también con ETA card) navigationInfo.value = { - distance: minDistance < 1 ? `${(minDistance * 1000).toFixed(0)} m` : `${minDistance.toFixed(1)} km`, - duration: "Más cercana", + 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; } } @@ -1218,6 +1214,17 @@ function clearNavigation() { + +