feat: real-time bus ETA engine via distance approximation and routing
This commit is contained in:
163
frontend/src/components/ETACard.vue
Normal file
163
frontend/src/components/ETACard.vue
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="isOpen" class="fixed inset-x-0 bottom-0 z-[9999] sm:max-w-md sm:mx-auto">
|
||||||
|
<!-- Overlay transparente oscuro en fondo -->
|
||||||
|
<div class="fixed inset-0 bg-black/40 transition-opacity" @click="closeCard"></div>
|
||||||
|
|
||||||
|
<!-- Bottom Sheet container -->
|
||||||
|
<div
|
||||||
|
class="relative bg-white dark:bg-gray-900 rounded-t-3xl shadow-2xl p-5 transform transition-transform duration-300 ease-out flex flex-col gap-4 max-h-[85vh] overflow-y-auto"
|
||||||
|
:class="isOpen ? 'translate-y-0' : 'translate-y-full'"
|
||||||
|
>
|
||||||
|
<!-- Indicador de arrastre (visual) -->
|
||||||
|
<div class="absolute top-3 left-1/2 -translate-x-1/2 w-12 h-1.5 bg-gray-300 dark:bg-gray-600 rounded-full cursor-pointer" @click="closeCard"></div>
|
||||||
|
|
||||||
|
<!-- Cabecera de la parada -->
|
||||||
|
<div class="mt-4 flex items-start gap-4 pb-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<div class="bg-blue-100 dark:bg-blue-900/40 p-3 rounded-2xl flex-shrink-0">
|
||||||
|
<span class="material-icons text-blue-600 dark:text-blue-400 text-3xl">place</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-sm font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1">Tu parada asignada</h3>
|
||||||
|
<h2 class="text-xl font-black text-gray-900 dark:text-white leading-tight">
|
||||||
|
{{ stopName }}
|
||||||
|
</h2>
|
||||||
|
<div class="flex items-center gap-2 mt-2 text-sm text-gray-600 dark:text-gray-300 font-medium">
|
||||||
|
<span class="material-icons text-sm text-gray-400">directions_walk</span>
|
||||||
|
A {{ Math.round(walkDuration / 60) }} min caminando
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">•</span>
|
||||||
|
{{ Math.round(walkDistance) }} metros
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenido de buses -->
|
||||||
|
<div class="flex flex-col gap-3 py-2">
|
||||||
|
<!-- Estado de carga -->
|
||||||
|
<div v-if="isLoading" class="flex flex-col items-center justify-center py-8">
|
||||||
|
<div class="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<span class="mt-4 text-gray-500 dark:text-gray-400 font-medium animate-pulse">Calculando satélites...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sin servicio -->
|
||||||
|
<div v-else-if="buses.length === 0" class="bg-gray-50 dark:bg-gray-800/50 rounded-2xl p-6 text-center border dashed border-gray-200 dark:border-gray-700">
|
||||||
|
<span class="material-icons text-4xl text-gray-400 mb-2">directions_bus</span>
|
||||||
|
<h4 class="text-gray-700 dark:text-gray-300 font-bold mb-1">Sin servicio programado</h4>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">No hay buses en ruta para hoy en esta línea.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista de llegadas (Max 3) -->
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="(bus, index) in buses"
|
||||||
|
:key="bus.horario_id"
|
||||||
|
class="group bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-2xl p-4 flex items-center justify-between"
|
||||||
|
:class="{ 'ring-2 ring-green-500/50 dark:ring-green-400/50 bg-green-50/30 dark:bg-green-900/10': bus.estado === 'en_camino' }"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- Icono dinámico según estado -->
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-full flex items-center justify-center text-white shrink-0"
|
||||||
|
:class="{
|
||||||
|
'bg-green-500': bus.estado === 'en_camino',
|
||||||
|
'bg-blue-500': bus.estado === 'próximo',
|
||||||
|
'bg-gray-400 dark:bg-gray-600': bus.estado === 'pasó'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="material-icons">{{ bus.estado === 'pasó' ? 'history' : 'directions_bus' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-sm font-bold text-gray-900 dark:text-white line-clamp-1">
|
||||||
|
{{ index === 0 ? 'Bus más cercano' : 'Siguiente bus' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 flex items-center gap-1 mt-0.5">
|
||||||
|
<span class="material-icons" style="font-size: 14px">schedule</span>
|
||||||
|
Salió de terminal a las {{ bus.hora_salida }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end text-right shrink-0 ml-4">
|
||||||
|
<!-- ETA gigante -->
|
||||||
|
<div v-if="bus.estado !== 'pasó'" class="text-2xl font-black text-gray-900 dark:text-white flex items-baseline gap-1" :class="{ 'text-green-600 dark:text-green-400': bus.estado === 'en_camino' }">
|
||||||
|
<span>~{{ bus.etaMinutos }}</span>
|
||||||
|
<span class="text-sm font-bold text-gray-500 dark:text-gray-400">min</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Badges de estado -->
|
||||||
|
<div class="mt-1">
|
||||||
|
<span v-if="bus.estado === 'en_camino'" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-400 text-[10px] font-black uppercase tracking-wider">
|
||||||
|
<span class="w-1.5 h-1.5 bg-green-500 rounded-full animate-ping"></span>
|
||||||
|
En Vía
|
||||||
|
</span>
|
||||||
|
<span v-else-if="bus.estado === 'próximo'" class="inline-flex px-2.5 py-1 rounded-lg bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-400 text-[10px] font-black uppercase tracking-wider">
|
||||||
|
Programado
|
||||||
|
</span>
|
||||||
|
<span v-else class="inline-flex px-2.5 py-1 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 text-[10px] font-black uppercase tracking-wider line-through">
|
||||||
|
Ya pasó
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legal Disclaimer Intocable -->
|
||||||
|
<div class="mt-2 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-xl flex items-start gap-3 border border-yellow-100 dark:border-yellow-900/50">
|
||||||
|
<span class="material-icons text-yellow-600 dark:text-yellow-500 text-lg mt-0.5 shrink-0">info</span>
|
||||||
|
<p class="text-[11px] leading-snug text-yellow-800 dark:text-yellow-600/90 font-medium">
|
||||||
|
<strong>Aviso:</strong> Este es un tiempo estimado basado en la velocidad promedio de las unidades en la ciudad. No existe rastreo GPS en tiempo real.
|
||||||
|
El tráfico y paradas intermedias pueden alterar el tiempo de llegada dramáticamente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted } from 'vue';
|
||||||
|
import type { BusETA } from '@/composables/useETA';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
isOpen: boolean;
|
||||||
|
stopName: string;
|
||||||
|
walkDistance: number;
|
||||||
|
walkDuration: number; // en segundos
|
||||||
|
buses: BusETA[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
(e: 'refresh'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let intervalId: number;
|
||||||
|
|
||||||
|
function closeCard() {
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Refresca el panel de ETAs cada 60 segundos
|
||||||
|
intervalId = window.setInterval(() => {
|
||||||
|
emit('refresh');
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Transición extra refinada a lo material you */
|
||||||
|
.translate-y-full {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
.translate-y-0 {
|
||||||
|
transform: translateY(0%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -11,7 +11,10 @@ import { analyticsService } from "@/services/analyticsService";
|
|||||||
import { getImageUrl } from "@/utils/imageUrl";
|
import { getImageUrl } from "@/utils/imageUrl";
|
||||||
|
|
||||||
import { useDirectionsRoute } from "@/composables/useDirectionsRoute";
|
import { useDirectionsRoute } from "@/composables/useDirectionsRoute";
|
||||||
|
import { useParadaCercana } from "@/composables/useParadaCercana";
|
||||||
|
import { useETA } from "@/composables/useETA";
|
||||||
const BusStopInfoModal = defineAsyncComponent(() => import("@/components/BusStopInfoModal.vue"));
|
const BusStopInfoModal = defineAsyncComponent(() => import("@/components/BusStopInfoModal.vue"));
|
||||||
|
const ETACard = defineAsyncComponent(() => import("@/components/ETACard.vue"));
|
||||||
import type { BusStop } from '@/types'
|
import type { BusStop } from '@/types'
|
||||||
|
|
||||||
const router = useRouter();
|
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 { map, isLoaded, error: mapsError, initMap, addNumberedMarker, addHtmlMarker, fitBounds, setCenter, setZoom, addMarker, clearAllOverlays } = useGoogleMaps();
|
||||||
const { trazarRuta, limpiarRuta, estasCargando: estasCargandoRuta, errorRuta } = useDirectionsRoute();
|
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<any[]>([]);
|
const markers = ref<any[]>([]);
|
||||||
const promoMarkers = ref<any[]>([]);
|
const promoMarkers = ref<any[]>([]);
|
||||||
@ -145,6 +152,8 @@ async function clearAllMapData() {
|
|||||||
optimalStopPulse.value = null;
|
optimalStopPulse.value = null;
|
||||||
}
|
}
|
||||||
limpiarRuta();
|
limpiarRuta();
|
||||||
|
limpiarCaminata();
|
||||||
|
showETACard.value = false;
|
||||||
|
|
||||||
// 7. Restaurar Solo Usuario tras un breve respiro
|
// 7. Restaurar Solo Usuario tras un breve respiro
|
||||||
await nextTick();
|
await nextTick();
|
||||||
@ -449,6 +458,8 @@ function clearMapMarkers() {
|
|||||||
|
|
||||||
// Clear directions route
|
// Clear directions route
|
||||||
limpiarRuta();
|
limpiarRuta();
|
||||||
|
limpiarCaminata();
|
||||||
|
showETACard.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateMapMarkers() {
|
async function updateMapMarkers() {
|
||||||
@ -718,45 +729,26 @@ function locateUser() {
|
|||||||
* Encuentra la parada más cercana dentro de la ruta seleccionada
|
* Encuentra la parada más cercana dentro de la ruta seleccionada
|
||||||
* y la resalta para el usuario.
|
* y la resalta para el usuario.
|
||||||
*/
|
*/
|
||||||
function highlightOptimalStopForRoute() {
|
async function highlightOptimalStopForRoute() {
|
||||||
if (!userCoords.value || routeStore.selectedRouteStops.length === 0) {
|
if (!userCoords.value || routeStore.selectedRouteStops.length === 0) {
|
||||||
console.warn('🤖 JARVIS: Sin ubicación o paradas para calcular parada óptima.');
|
console.warn('🤖 JARVIS: Sin ubicación o paradas para calcular parada óptima.');
|
||||||
return;
|
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;
|
// Encontrar parada real y añadir ruta peatonal azul punteada
|
||||||
let minDistance = Infinity;
|
await encontrarParadaCercana(userCoords.value, routeStore.selectedRouteStops as BusStop[], map.value || undefined);
|
||||||
|
|
||||||
const getDistance = (l1: any, l2: any) => {
|
if (paradaCercana.value) {
|
||||||
const R = 6371; // Radio de la Tierra en km
|
const stopObj = paradaCercana.value as BusStop;
|
||||||
const dLat = (l2.lat - l1.lat) * Math.PI / 180;
|
console.log(`🤖 JARVIS: Parada óptima detectada: ${stopObj.name}`);
|
||||||
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 => {
|
// Centrar mapa
|
||||||
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)`);
|
|
||||||
|
|
||||||
// Centrar mapa en la parada para guiar al usuario
|
|
||||||
setCenter(stopObj.latitude, stopObj.longitude);
|
setCenter(stopObj.latitude, stopObj.longitude);
|
||||||
setZoom(17);
|
setZoom(17);
|
||||||
|
|
||||||
// Añadir el PULSO NARANJA de "Aborda aquí"
|
// Añadir el PULSO NARANJA
|
||||||
if (optimalStopPulse.value && typeof optimalStopPulse.value.setMap === 'function') {
|
if (optimalStopPulse.value && typeof optimalStopPulse.value.setMap === 'function') {
|
||||||
optimalStopPulse.value.setMap(null);
|
optimalStopPulse.value.setMap(null);
|
||||||
}
|
}
|
||||||
@ -767,12 +759,16 @@ function highlightOptimalStopForRoute() {
|
|||||||
{ x: -30, y: -30 }
|
{ x: -30, y: -30 }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mini-notificación informativa
|
// Mini-notificación (Opcional, se cubre ahora también con ETA card)
|
||||||
navigationInfo.value = {
|
navigationInfo.value = {
|
||||||
distance: minDistance < 1 ? `${(minDistance * 1000).toFixed(0)} m` : `${minDistance.toFixed(1)} km`,
|
distance: distanciaMetros.value < 1000 ? `${distanciaMetros.value.toFixed(0)} m` : `${(distanciaMetros.value/1000).toFixed(1)} km`,
|
||||||
duration: "Más cercana",
|
duration: "Calculada",
|
||||||
targetName: stopObj.name
|
targetName: stopObj.name
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calcular ETAs
|
||||||
|
await calcularETA(routeStore.selectedRouteId!, stopObj);
|
||||||
|
showETACard.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1218,6 +1214,17 @@ function clearNavigation() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
<ETACard
|
||||||
|
:is-open="showETACard"
|
||||||
|
:stop-name="paradaCercana?.name || ''"
|
||||||
|
:walk-distance="distanciaMetros"
|
||||||
|
:walk-duration="duracionCaminata"
|
||||||
|
:buses="busesActivos"
|
||||||
|
:is-loading="etaCargando"
|
||||||
|
@close="showETACard = false"
|
||||||
|
@refresh="paradaCercana && routeStore.selectedRouteId ? calcularETA(routeStore.selectedRouteId, paradaCercana) : null"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user