feat: real-time bus ETA engine via distance approximation and routing
This commit is contained in:
127
frontend/src/composables/useETA.ts
Normal file
127
frontend/src/composables/useETA.ts
Normal file
@ -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<BusETA[]>([]);
|
||||
const cargando = ref<boolean>(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 };
|
||||
}
|
||||
129
frontend/src/composables/useParadaCercana.ts
Normal file
129
frontend/src/composables/useParadaCercana.ts
Normal file
@ -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<BusStop | null>(null);
|
||||
const distanciaMetros = ref<number>(0);
|
||||
const duracionCaminata = ref<number>(0);
|
||||
const caminandoPolyline = ref<google.maps.Polyline | null>(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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user