Files
SIB/frontend/src/views/MapView.vue

571 lines
19 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { onMounted, ref, watch, nextTick, onUnmounted, shallowRef, markRaw, defineAsyncComponent } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useRouteStore } from "@/stores/route";
import { useMapStore } from "@/stores/map";
import { useCouponStore } from "@/stores/coupon";
import { useAuthStore } from "@/stores/auth";
import { useGoogleMaps } from "@/composables/useGoogleMaps";
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";
import { useFlujoPrincipal } from "@/composables/useFlujoPrincipal";
import { useMapState } from "@/composables/useMapState";
// Optimized Components (Extracted)
import SearchOverlay from "@/components/map/SearchOverlay.vue";
import PromoCarousel from "@/components/map/PromoCarousel.vue";
import ArrivalBanner from "@/components/map/ArrivalBanner.vue";
const ETACard = defineAsyncComponent(() => import("@/components/ETACard.vue"));
import type { BusStop } from '@/types'
const router = useRouter();
const { t } = useI18n();
const routeStore = useRouteStore();
const mapStore = useMapStore();
const couponStore = useCouponStore();
const authStore = useAuthStore();
const { map, isLoaded, error: mapsError, initMap, addHtmlMarker, setCenter, setZoom, addMarker } = useGoogleMaps();
const { estasCargando: estasCargandoRuta, errorRuta } = useDirectionsRoute();
const { encontrarParadaCercana, paradaCercana, distanciaMetros, duracionCaminata } = useParadaCercana();
const { calcularETA, busesActivos, cargando: etaCargando } = useETA();
const { procesarSeleccionDeRuta } = useFlujoPrincipal();
const { limpiarMapa: limpiarTodoCentralizado } = useMapState();
const showETACard = ref(false);
// PERFORMANCE FIX: Use shallowRef for heavy object arrays and Map objects
const promoMarkers = shallowRef<any[]>([]);
const userMarker = shallowRef<any>(null);
const isUpdatingMarkers = ref(false);
const unitMarkers = shallowRef<Map<string, any>>(new Map());
const unitFetchInterval = ref<any>(null);
const userCoords = ref<{ lat: number; lng: number } | null>(null);
const showUberSearch = ref(false);
const showPromos = ref(false);
const isBannerClosing = ref(false);
const showPromoModal = ref(false);
const selectedPromo = ref<any>(null);
const currentCarouselIndex = ref(0);
const carouselTimer = ref<any>(null);
// Search optimization: Simple debounce implementation
// Helper functions
// performSearch removed
function openUberSearch() {
showPromos.value = false;
showUberSearch.value = true;
}
function closeUberSearch() {
showUberSearch.value = false;
}
function animateAndReload() {
isBannerClosing.value = true;
setTimeout(() => {
routeStore.clearSelection();
isBannerClosing.value = false;
router.replace({ query: {} });
}, 450);
}
function handlePromoClick(promo: any) {
selectedPromo.value = promo;
showPromoModal.value = true;
}
function closePromoModal() {
showPromoModal.value = false;
selectedPromo.value = null;
}
// Map initialization & Lifecycle
onMounted(async () => {
analyticsService.logEvent({ event_name: 'screen_view', properties: { screen_name: 'Map' } })
await Promise.all([
routeStore.loadRoutes(),
couponStore.loadCoupons({ active_only: true })
]);
const queryRouteId = router.currentRoute.value.query.routeId as string;
if (queryRouteId && queryRouteId !== routeStore.selectedRouteId) {
const foundRoute = routeStore.allRoutes.find(r => r.id === queryRouteId);
if (foundRoute) {
await routeStore.selectRoute(foundRoute.id, foundRoute.name);
}
}
if (isLoaded.value) {
await initializeMap();
} else {
const unwatch = watch(isLoaded, async (loaded) => {
if (loaded) {
await initializeMap();
unwatch();
}
});
}
unitFetchInterval.value = setInterval(updateActiveUnits, 15000);
updateActiveUnits();
startCarousel();
});
onUnmounted(() => {
if (unitFetchInterval.value) clearInterval(unitFetchInterval.value);
if (carouselTimer.value) clearInterval(carouselTimer.value);
clearMapMarkers();
unitMarkers.value.forEach(m => m.setMap(null));
unitMarkers.value.clear();
});
async function initializeMap() {
await nextTick();
await new Promise(resolve => setTimeout(resolve, 100));
initMap("map", mapStore.center, mapStore.zoom);
if (map.value) {
// PERFORMANCE: Use passive listeners for native events if added (Google Maps doesn't expose this directly easily)
map.value.addListener('click', () => {
if (showETACard.value) showETACard.value = false;
});
}
if (routeStore.selectedRouteId && routeStore.selectedRouteStops.length > 0 && routeStore.wasSelectedFromMap) {
updateMapMarkers();
} else {
clearMapMarkers();
}
updatePromoMarkers();
if (authStore.userProfile?.auto_location) {
locateUser();
}
if (routeStore.selectedRouteId && routeStore.wasSelectedFromMap) {
highlightOptimalStopForRoute();
}
}
// MARKER RECYCLING & REACTIVITY OPTIMIZATION
function clearMapMarkers() {
limpiarTodoCentralizado()
if (userCoords.value) {
reDrawUserMarker();
}
}
function reDrawUserMarker() {
if (!userCoords.value || !map.value) return;
if (userMarker.value && typeof userMarker.value.setMap === 'function') {
userMarker.value.setMap(null);
}
userMarker.value = markRaw(addHtmlMarker(
{ lat: userCoords.value.lat, lng: userCoords.value.lng },
sonarHtml,
{ x: -30, y: -30 }
)!);
}
async function updateMapMarkers(skipZoom = false) {
if (!isLoaded.value || !map.value || isUpdatingMarkers.value) return;
isUpdatingMarkers.value = true;
const currentRequestRouteId = routeStore.selectedRouteId;
const stops = [...routeStore.selectedRouteStops];
try {
if (!currentRequestRouteId || stops.length === 0) {
clearMapMarkers();
return;
}
const selectedRouteObj = routeStore.allRoutes.find(r => r.id === currentRequestRouteId) || { id: currentRequestRouteId, short_name: currentRequestRouteId };
await procesarSeleccionDeRuta(selectedRouteObj, stops as BusStop[], map.value, skipZoom);
reDrawUserMarker();
if (routeStore.selectedRouteId !== currentRequestRouteId) return;
if (routeStore.wasSelectedFromMap && !skipZoom) {
await highlightOptimalStopForRoute();
}
} finally {
isUpdatingMarkers.value = false;
}
}
async function updatePromoMarkers() {
if (!isLoaded.value) return;
promoMarkers.value.forEach(m => m.setMap(null));
const newMarkers: any[] = [];
const promosWithCoords = couponStore.coupons.filter(c =>
c.is_active && c.business && c.business.latitude && c.business.longitude
);
promosWithCoords.forEach(promo => {
const marker = addMarker(
{ lat: promo.business!.latitude!, lng: promo.business!.longitude! },
{
title: promo.title,
icon: {
path: "M20 6h-2.18c.11-.31.18-.65.18-1 0-1.66-1.34-3-3-3-1.05 0-1.96.54-2.5 1.35l-.5.65-.5-.65C10.96 2.54 10.05 2 9 2 7.34 2 6 3.34 6 5c0 .35.07.69.18 1H4c-1.11 0-1.99.89-1.99 2L2 19c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2zm-5-2c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zM9 4c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm11 15H4v-2h16v2zm0-5H4V8h5.08L7 10.83 8.62 12 11 8.76l1-1.36 1 1.36L15.38 12 17 10.83 14.92 8H20v6z",
fillColor: '#FF4081',
fillOpacity: 1,
strokeColor: '#FFFFFF',
strokeWeight: 2,
anchor: new google.maps.Point(12, 12),
scale: 2
}
}
);
if (marker) {
const rawMarker = markRaw(marker);
rawMarker.addListener('click', () => handlePromoClick(promo));
newMarkers.push(rawMarker);
}
});
promoMarkers.value = newMarkers;
}
// Carousel logic
function startCarousel() {
if (carouselTimer.value) clearInterval(carouselTimer.value);
carouselTimer.value = setInterval(() => {
if (couponStore.coupons.length > 0) {
currentCarouselIndex.value = (currentCarouselIndex.value + 1) % couponStore.coupons.length;
}
}, 5000);
}
function stopCarousel() {
if (carouselTimer.value) clearInterval(carouselTimer.value);
}
function selectRouteAndClose(route: any) {
if (routeStore.selectedRouteId === route.id) {
showUberSearch.value = false;
highlightOptimalStopForRoute();
return;
}
showUberSearch.value = false;
routeStore.wasSelectedFromMap = true;
routeStore.selectRoute(route.id, route.name);
}
async function updateActiveUnits() {
if (!isLoaded.value) return;
if (routeStore.selectedRouteId && paradaCercana.value) {
await calcularETA(routeStore.selectedRouteId, paradaCercana.value as BusStop);
}
}
function locateUser(): Promise<void> {
return new Promise((resolve) => {
if (!navigator.geolocation) { return resolve(); }
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords;
userCoords.value = { lat: latitude, lng: longitude };
setCenter(latitude, longitude);
setZoom(16);
reDrawUserMarker();
resolve();
},
() => {
if (authStore.userProfile?.auto_location) {
authStore.updateProfile({ auto_location: false });
}
resolve();
},
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 30000 }
);
});
}
async function highlightOptimalStopForRoute() {
if (!userCoords.value) { await locateUser(); }
else { reDrawUserMarker(); }
if (!userCoords.value || routeStore.selectedRouteStops.length === 0) return;
try {
await encontrarParadaCercana(userCoords.value, routeStore.selectedRouteStops as BusStop[], map.value || undefined);
} catch (e) {
console.error('Error calculating optimal stop:', e);
}
}
const sonarHtml = `
<div style="position: relative; width: 60px; height: 60px; display: flex; align-items: center; justify-content: center;">
<div style="position: absolute; width: 100%; height: 100%; border-radius: 50%; background-color: rgba(0, 212, 255, 0.5); animation: sonar-pulse 2.5s infinite ease-out;"></div>
<div style="position: absolute; width: 100%; height: 100%; border-radius: 50%; background-color: rgba(0, 212, 255, 0.3); animation: sonar-pulse 2.5s infinite ease-out; animation-delay: 1.25s;"></div>
<div style="width: 16px; height: 16px; background-color: #00d4ff; border-radius: 50%; box-shadow: 0 0 20px #00d4ff, 0 0 40px rgba(0, 212, 255, 0.6); border: 2px solid white; z-index: 2;"></div>
<style> @keyframes sonar-pulse { 0% { transform: scale(0.1); opacity: 0.8; } 100% { transform: scale(4); opacity: 0; } } </style>
</div>
`;
// Watch for route selection changes
watch(() => routeStore.selectedRouteId, (routeId) => {
if (routeId) {
if (routeStore.wasSelectedFromMap) {
updateMapMarkers(false);
} else {
clearMapMarkers();
}
} else {
clearMapMarkers();
}
});
function handleImageError(event: Event) {
(event.target as HTMLImageElement).src = getImageUrl(null, 'coupon');
}
</script>
<template>
<div class="split-view">
<div class="map-side">
<div class="map-view">
<div v-if="estasCargandoRuta || errorRuta" class="status-indicator">
<div v-if="estasCargandoRuta" class="loading-pill">{{ t('map.calculatingRoute') }}</div>
<div v-if="errorRuta" class="error-pill">{{ errorRuta }}</div>
</div>
<div class="map-container">
<div v-if="mapsError" class="error">
<div class="error-content">
<h3> {{ t('map.mapLoadingError') }}</h3>
<div class="error-detail">{{ mapsError }}</div>
</div>
</div>
<div v-else-if="!isLoaded" class="loading">
<p>{{ t('map.loadingMap') }}</p>
</div>
<div id="map" class="map" :style="{ display: isLoaded ? 'block' : 'none' }"></div>
<div class="map-floating-controls">
<button v-if="isLoaded && !showPromos && couponStore.coupons.length > 0" class="offers-fab pulse" @click="showPromos = true">
<span class="material-icons">local_offer</span>
<span v-if="couponStore.coupons.length > 0" class="offers-badge">{{ couponStore.coupons.length }}</span>
</button>
<button v-if="isLoaded && !authStore.userProfile?.auto_location" class="location-loader-btn" @click="locateUser">
<span class="material-icons">my_location</span>
</button>
</div>
</div>
<!-- COMPONENTIZED SEARCH & BANNER -->
<SearchOverlay
:show-panel="showUberSearch"
:is-compact="!!(routeStore.selectedRouteId && routeStore.wasSelectedFromMap)"
:is-route-active="!!routeStore.selectedRouteId"
:all-routes="routeStore.allRoutes"
:selected-route-id="routeStore.selectedRouteId"
:was-selected-from-map="routeStore.wasSelectedFromMap"
@open="openUberSearch"
@close="closeUberSearch"
@select-route="selectRouteAndClose"
>
<template #extra-triggers>
<ArrivalBanner
:is-visible="!!(paradaCercana && routeStore.selectedRouteId && !isBannerClosing && routeStore.wasSelectedFromMap)"
:stop-name="paradaCercana?.name || ''"
:is-loading="etaCargando"
:has-active-buses="busesActivos.length > 0"
:eta-value="busesActivos[0]?.etaMinutos ?? 0"
@close="animateAndReload"
@click="showETACard = true"
/>
</template>
</SearchOverlay>
</div>
</div>
<!-- COMPONENTIZED PROMOS -->
<PromoCarousel
:is-open="showPromos"
:coupons="couponStore.coupons"
:current-index="currentCarouselIndex"
@update:index="currentCarouselIndex = $event"
@close="showPromos = false"
@prev="currentCarouselIndex = (currentCarouselIndex - 1 + couponStore.coupons.length) % couponStore.coupons.length"
@next="currentCarouselIndex = (currentCarouselIndex + 1) % couponStore.coupons.length"
@pause="stopCarousel"
@resume="startCarousel"
@promo-click="handlePromoClick"
/>
<!-- MODALS & CARDS -->
<Transition name="modal-fade">
<div v-if="showPromoModal && selectedPromo" class="promo-modal-overlay" @click="closePromoModal">
<div class="promo-modal-content" @click.stop>
<div class="promo-header-modal">
<img :src="getImageUrl(selectedPromo.image_url, 'coupon')" class="promo-img-modal" @error="handleImageError" />
<div class="promo-badge-modal">{{ t('map.promo') }}</div>
</div>
<div class="promo-body-modal">
<h2 class="promo-title-modal">{{ selectedPromo.title }}</h2>
<div class="promo-biz">{{ selectedPromo.business?.name }}</div>
<p>{{ selectedPromo.description }}</p>
</div>
<div class="promo-actions-modal">
<button class="business-detail-btn-modal" @click="router.push('/business/' + selectedPromo.business_id)">
{{ t('business.viewBusiness') }}
</button>
</div>
</div>
</div>
</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>
</template>
<style scoped>
.split-view {
display: flex;
width: 100%;
height: calc(100vh - 64px);
overflow: hidden;
position: relative;
}
.map-side, .map-view, .map-container, .map {
width: 100%;
height: 100%;
position: relative;
}
.status-indicator {
position: absolute;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.loading-pill {
background: #1e40af;
color: white;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
animation: pulse 2s infinite;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }
.error-content { text-align: center; padding: 20px; }
.error-detail { color: var(--text-primary); background: var(--bg-secondary); padding: 15px; border-radius: 8px; margin-top: 10px; }
.map-floating-controls {
position: fixed;
bottom: 85px;
right: 16px;
display: flex;
flex-direction: column;
gap: 16px;
z-index: 1001;
}
.offers-fab {
width: 56px;
height: 56px;
border-radius: 50%;
background: #fee715;
color: #000;
border: none;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
position: relative;
}
.offers-badge {
position: absolute;
top: -5px; right: -5px;
background: #f44336;
color: white;
padding: 2px 6px;
border-radius: 10px;
border: 2px solid #fff;
}
.location-loader-btn {
background: var(--header-bg);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
width: 50px;
height: 50px;
border-radius: 50%;
color: var(--active-color);
box-shadow: var(--shadow);
}
.promo-modal-overlay {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.8);
z-index: 4000;
display: flex; align-items: center; justify-content: center;
}
.promo-modal-content {
background: var(--card-bg); width: 90%; max-width: 450px;
border-radius: 24px; overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.promo-header-modal { position: relative; height: 200px; }
.promo-img-modal { width: 100%; height: 100%; object-fit: cover; }
.promo-badge-modal { position: absolute; bottom: 0; left: 0; background: #EAB308; color: #000; padding: 5px 15px; font-weight: 800; border-top-right-radius: 12px; }
.promo-body-modal { padding: 25px; }
.promo-title-modal { font-size: 1.5rem; font-weight: 800; margin-bottom: 10px; }
.promo-biz { color: var(--active-color); font-weight: 700; margin-bottom: 15px; }
.promo-actions-modal { padding: 0 25px 25px; }
.business-detail-btn-modal { width: 100%; background: var(--active-color); color: #000; border: none; padding: 15px; border-radius: 12px; font-weight: 800; cursor: pointer; }
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.3s ease; }
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
.pulse { animation: pulse-animation 2s infinite; }
@keyframes pulse-animation {
0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(254, 231, 21, 0.7); }
70% { transform: scale(1.1); box-shadow: 0 0 0 15px rgba(254, 231, 21, 0); }
100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(254, 231, 21, 0); }
}
@media (max-width: 600px) {
.map-floating-controls { bottom: 100px; }
}
</style>