feat(map): clean stop markers and route dimming

This commit is contained in:
2026-02-26 22:05:55 -05:00
parent 1f0229461b
commit c9a260ab23
7 changed files with 535 additions and 363 deletions

View File

@ -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<google.maps.Polyline | null>(null);
const walkingPolyline = ref<google.maps.Polyline | null>(null);
const walkingPolylineBorder = ref<google.maps.Polyline | null>(null); // Borde blanco estilo Google Maps
const optimalStopPulse = ref<any>(null); // Radar para la parada óptima
const navigationInfo = ref<{ distance: string, duration: string, targetName: string } | null>(null);
const showRouteDropdown = ref(false);
const routeCardRef = ref<HTMLElement | null>(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 = `
</div>
`;
function locateUser() {
if (navigator.geolocation) {
function locateUser(): Promise<void> {
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;
}
</script>
<template>
@ -893,7 +850,7 @@ function clearNavigation() {
<!-- Main Map Container -->
<div class="map-side">
<div class="map-view">
<!-- Status overlay para SIBU Directions API -->
<!-- Status overlay para SIBU Directions API -->
<div v-if="estasCargandoRuta || errorRuta" class="status-indicator">
<div v-if="estasCargandoRuta" class="loading-pill">
Calculando ruta real...
@ -903,6 +860,32 @@ function clearNavigation() {
</div>
</div>
<!-- Banner de Parada Más Cercana Inteligente -->
<div
v-if="paradaCercana && routeStore.selectedRouteId"
class="fixed left-0 right-0 z-40 px-3 transition-transform duration-300 pointer-events-none"
:style="{ top: alturaNavbar + 'px' }"
>
<div class="bg-white dark:bg-gray-900 rounded-b-2xl shadow-xl border-t-4 border-blue-600 border-t-blue-600 p-3 flex items-center gap-3 pointer-events-auto">
<div class="bg-blue-100 dark:bg-blue-900/40 rounded-full p-2 shrink-0">
<span class="material-icons text-blue-600 dark:text-blue-400">directions_bus</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-[11px] text-gray-500 font-bold uppercase">Parada más cercana</p>
<p class="text-sm font-bold text-gray-800 dark:text-white truncate">
{{ paradaCercana?.name }}
</p>
<p class="text-xs text-blue-600 dark:text-blue-400 font-medium whitespace-nowrap overflow-hidden text-ellipsis">
{{ (distanciaMetros && distanciaMetros < 1000) ? Math.round(distanciaMetros) + ' m' : (distanciaMetros ? (distanciaMetros / 1000).toFixed(1) + ' km' : '') }}
<span v-if="duracionCaminata">· {{ Math.round(duracionCaminata / 60) }} min caminando</span>
</p>
</div>
<button @click="paradaCercana = null" class="text-gray-400 hover:text-gray-600 shrink-0 p-1">
<span class="material-icons">close</span>
</button>
</div>
</div>
<div class="map-container">
<!-- Floating Offers Button at exact location -->
<div v-if="mapsError" class="error">
@ -975,27 +958,6 @@ function clearNavigation() {
</div>
<!-- Google Maps Style Navigation Summary Card -->
<Transition name="uber-slide">
<div v-if="navigationInfo" class="navigation-summary-card">
<div class="nav-card-accent"></div>
<div class="nav-content">
<div class="nav-left">
<div class="nav-stats">
<span class="nav-time">{{ navigationInfo.duration }}</span>
<span class="nav-dist">{{ navigationInfo.distance }}</span>
</div>
<div class="nav-destination">Parada: {{ navigationInfo.targetName }}</div>
</div>
<div class="nav-actions">
<button class="nav-btn-close" @click="clearNavigation">
<span class="material-icons">close</span>
</button>
</div>
</div>
</div>
</Transition>
<!-- Uber-style Search Panel -->
<Transition name="uber-slide">
<div v-if="showUberSearch" class="uber-search-panel" :class="{ 'is-focused': isInputFocused }">