From 7b3141e5e9e286b08b02ec1216e660a359f2a67c Mon Sep 17 00:00:00 2001 From: Hanzo_dev <2002samudiojohan@gmail.com> Date: Thu, 26 Feb 2026 12:39:15 -0500 Subject: [PATCH] perf: optimization for maps & network --- .../src/composables/useDirectionsRoute.ts | 110 ++++++++++++++++++ frontend/src/composables/useGoogleMaps.ts | 72 +++++++++++- frontend/src/views/AdminRoutes.vue | 14 ++- frontend/src/views/MapView.vue | 80 ++++++++++++- frontend/vite.config.ts | 51 +++++--- 5 files changed, 299 insertions(+), 28 deletions(-) create mode 100644 frontend/src/composables/useDirectionsRoute.ts diff --git a/frontend/src/composables/useDirectionsRoute.ts b/frontend/src/composables/useDirectionsRoute.ts new file mode 100644 index 0000000..336d45b --- /dev/null +++ b/frontend/src/composables/useDirectionsRoute.ts @@ -0,0 +1,110 @@ +import { ref } from 'vue'; + +export interface Parada { + id: number; + nombre: string; + latitud: number; + longitud: number; + orden: number; +} + +export function useDirectionsRoute() { + const estasCargando = ref(false); + const errorRuta = ref(null); + const renderizadoresActivos = ref([]); + + // Limpia los tramos anteriores dibujados en el mapa + const limpiarRuta = () => { + if (renderizadoresActivos.value.length > 0) { + renderizadoresActivos.value.forEach((renderer) => { + renderer.setMap(null); + }); + renderizadoresActivos.value = []; + } + errorRuta.value = null; + }; + + // Función utilitaria para pausar ejecución + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + const trazarRuta = async (paradas: Parada[], map: google.maps.Map) => { + if (!paradas || paradas.length < 2) { + errorRuta.value = 'Se requieren al menos 2 paradas para trazar una ruta.'; + return; + } + + limpiarRuta(); + estasCargando.value = true; + errorRuta.value = null; + + try { + const directionsService = new google.maps.DirectionsService(); + // Límite de la API de Google Maps: Origen, Destino, y hasta 23 waypoints (25 puntos total por request) + const maxPuntosPorChunk = 25; + const overlaps = 1; + + // Iteramos sobre las paradas dividiéndolas en chunks con 1 punto en común ("overlap") + // para asegurar que las secciones se conecten correctamente. + for (let i = 0; i < paradas.length - 1; i += (maxPuntosPorChunk - overlaps)) { + const chunk = paradas.slice(i, i + maxPuntosPorChunk); + + // Si el chunk es muy pequeño (último fragmento o vector final), detenemos + if (chunk.length < 2) break; + + const origen = new google.maps.LatLng(chunk[0]!.latitud, chunk[0]!.longitud); + const destino = new google.maps.LatLng(chunk[chunk.length - 1]!.latitud, chunk[chunk.length - 1]!.longitud); + + // Excluimos el primero y último para que sean los waypoints intermedios + const waypoints: google.maps.DirectionsWaypoint[] = chunk.slice(1, -1).map(p => ({ + location: new google.maps.LatLng(p.latitud, p.longitud), + stopover: true + })); + + const request: google.maps.DirectionsRequest = { + origin: origen, + destination: destino, + waypoints: waypoints, + travelMode: google.maps.TravelMode.DRIVING, + optimizeWaypoints: false + }; + + try { + const response = await directionsService.route(request); + + const renderer = new google.maps.DirectionsRenderer({ + map: map, + suppressMarkers: true, // SIBU maneja los suyos propios + preserveViewport: true, // No auto centrar en cada tramo para evitar parpadeos visuales + polylineOptions: { + strokeColor: '#1E40AF', // Azul (Tailwind blue-800) + strokeWeight: 5, + strokeOpacity: 0.8 + } + }); + + renderer.setDirections(response); + renderizadoresActivos.value.push(renderer); + + } catch (err: any) { + console.warn(`SIBU | Tramo ${i} falló: `, err); + // La ruta continúa renderizando los siguientes tramos disponibles, no paramos todo. + } + + // Retardo para evitar sobrecargar a la API y el error "OVER_QUERY_LIMIT" + await delay(300); + } + } catch (err: any) { + errorRuta.value = `Error crítico al trazar la ruta: ${err.message || String(err)}`; + console.error(errorRuta.value); + } finally { + estasCargando.value = false; + } + }; + + return { + trazarRuta, + limpiarRuta, + estasCargando, + errorRuta + }; +} diff --git a/frontend/src/composables/useGoogleMaps.ts b/frontend/src/composables/useGoogleMaps.ts index cf75ceb..5b533ee 100644 --- a/frontend/src/composables/useGoogleMaps.ts +++ b/frontend/src/composables/useGoogleMaps.ts @@ -1,5 +1,5 @@ /** Composable for Google Maps integration */ -import { ref, onMounted } from 'vue' +import { ref, shallowRef, onMounted } from 'vue' import { setOptions, importLibrary } from '@googlemaps/js-api-loader' const getApiKey = () => import.meta.env.VITE_GOOGLE_MAPS_API_KEY || '' @@ -10,7 +10,7 @@ let mapsLoaded = false const globalOverlays = new Map>() export function useGoogleMaps() { - const map = ref(null) + const map = shallowRef(null) const isLoaded = ref(false) const error = ref(null) @@ -234,6 +234,73 @@ export function useGoogleMaps() { return polyline } + async function addRoutePolyline(paradas: Array<{ lat: number; lng: number }>) { + if (!map.value) { + console.error('Map not initialized') + return [] + } + if (!paradas || paradas.length < 2) { + console.warn("Se necesitan al menos 2 paradas para trazar una ruta."); + return [] + } + + const directionsService = new google.maps.DirectionsService(); + const renderizadoresActivos: google.maps.DirectionsRenderer[] = []; + const tamañoChunk = 25; + const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + for (let i = 0; i < paradas.length - 1; i += (tamañoChunk - 1)) { + const chunk = paradas.slice(i, i + tamañoChunk); + if (chunk.length < 2) break; + + const origen = { lat: chunk[0]!.lat, lng: chunk[0]!.lng }; + const destino = { lat: chunk[chunk.length - 1]!.lat, lng: chunk[chunk.length - 1]!.lng }; + + const waypoints = chunk.slice(1, -1).map(p => ({ + location: { lat: p.lat, lng: p.lng }, + stopover: true + })); + + const request = { + origin: origen, + destination: destino, + waypoints: waypoints, + travelMode: google.maps.TravelMode.DRIVING, + optimizeWaypoints: false + }; + + try { + const response = await directionsService.route(request); + + const renderer = new google.maps.DirectionsRenderer({ + map: map.value, + suppressMarkers: true, + preserveViewport: true, // Siempre conservar la vista ya que trazamos fragmentos + polylineOptions: { + strokeColor: '#0057FF', // Azul + strokeWeight: 4, + strokeOpacity: 0.8 + } + }); + + renderer.setDirections(response); + renderizadoresActivos.push(renderer); + + // Registrar en global overlays para limpiarlos después + if (!globalOverlays.has(map.value)) { + globalOverlays.set(map.value, new Set()) + } + globalOverlays.get(map.value)!.add(renderer as any); + + } catch (error) { + console.error(`Error trazando el tramo (Paradas ${i} a ${i + chunk.length}):`, error); + } + + await delay(200); + } + return renderizadoresActivos; + } + function fitBounds(path: Array<{ lat: number; lng: number }>) { if (!map.value || path.length === 0) { return @@ -378,6 +445,7 @@ export function useGoogleMaps() { addHtmlMarker, addNumberedMarker, addPolyline, + addRoutePolyline, fitBounds, setCenter, setZoom, diff --git a/frontend/src/views/AdminRoutes.vue b/frontend/src/views/AdminRoutes.vue index a6555a2..227326b 100644 --- a/frontend/src/views/AdminRoutes.vue +++ b/frontend/src/views/AdminRoutes.vue @@ -14,7 +14,7 @@
info - Haz clic en el mapa para crear una nueva parada o en una existente para añadirla a la ruta. + Haz clic en el mapa para crear una nueva parada en las coordenadas seleccionadas. Puedes añadir paradas registradas desde la lista a la derecha.
@@ -199,7 +199,7 @@ const isFormValid = computed(() => { }) // Map integration -const { initMap, addNumberedMarker, addMarker, addPolyline, clearAllOverlays, isLoaded: mapsLoaded, map: gmap } = useGoogleMaps() +const { initMap, addNumberedMarker, addRoutePolyline, clearAllOverlays, isLoaded: mapsLoaded, map: gmap } = useGoogleMaps() onMounted(async () => { await loadInitialData() @@ -289,13 +289,13 @@ async function initRouteMap() { updateMapOverlays() } -function updateMapOverlays() { +async function updateMapOverlays() { clearAllOverlays() - // 1. Draw Polyline + // 1. Draw Polyline (Real Route tracing) if (routeStops.value.length > 1) { const path = routeStops.value.map(s => ({ lat: s.latitude, lng: s.longitude })) - addPolyline(path) + await addRoutePolyline(path) } // 2. Add Route Markers (Yellow Numbered) @@ -307,7 +307,8 @@ function updateMapOverlays() { ) }) - // 3. Add Available Markers (Small Gray Dots) + // 3. Add Available Markers (Small Gray Dots) - Oculto por petición del usuario + /* availableStops.value.forEach(stop => { const marker = addMarker( { lat: stop.latitude, lng: stop.longitude }, @@ -329,6 +330,7 @@ function updateMapOverlays() { }) } }) + */ } // Actions diff --git a/frontend/src/views/MapView.vue b/frontend/src/views/MapView.vue index 40d988a..db32927 100644 --- a/frontend/src/views/MapView.vue +++ b/frontend/src/views/MapView.vue @@ -10,6 +10,7 @@ import { useGoogleMaps } from "@/composables/useGoogleMaps"; import { analyticsService } from "@/services/analyticsService"; import { getImageUrl } from "@/utils/imageUrl"; +import { useDirectionsRoute } from "@/composables/useDirectionsRoute"; import BusStopInfoModal from "@/components/BusStopInfoModal.vue"; import type { BusStop } from '@/types' @@ -22,6 +23,8 @@ const busStopStore = useBusStopStore(); 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 markers = ref([]); const promoMarkers = ref([]); const userMarker = ref(null); @@ -141,6 +144,7 @@ async function clearAllMapData() { try { if (optimalStopPulse.value.setMap) optimalStopPulse.value.setMap(null); } catch(e){} optimalStopPulse.value = null; } + limpiarRuta(); // 7. Restaurar Solo Usuario tras un breve respiro await nextTick(); @@ -442,6 +446,9 @@ function clearMapMarkers() { } optimalStopPulse.value = null; } + + // Clear directions route + limpiarRuta(); } async function updateMapMarkers() { @@ -571,11 +578,27 @@ function updateMarkersStyles(force = false) { 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); + } } // La función drawRouteOnRoad ha sido eliminada por petición del usuario (línea azul removida) + async function updatePromoMarkers() { if (!isLoaded.value) return; @@ -874,7 +897,18 @@ function clearNavigation() {
-
+ +
+
+ Calculando ruta real... +
+
+ {{ errorRuta }} +
+
+ +
+

⚠️ Error al cargar mapa

@@ -1196,6 +1230,50 @@ function clearNavigation() { position: relative; } +/* SIBU Directions API status tags */ +.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-color: #1e40af; /* Tailwind blue-800 */ + color: white; + padding: 0.5rem 1rem; + border-radius: 9999px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + border: 2px solid white; + font-size: 0.875rem; + font-weight: 500; + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +.error-pill { + background-color: #dc2626; /* Tailwind red-600 */ + color: white; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + font-size: 0.875rem; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: .5; + } +} + + .map-side { width: 100%; height: 100%; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 54b70a1..2a977b0 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -44,35 +44,48 @@ export default defineConfig(() => { }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], + // ⚡ EVITA LAS PANTALLAS BLANCAS: No cachees el backend u orígenes API + navigateFallbackDenylist: [/^\/api/, /^\/rest\/v1/], runtimeCaching: [ { - urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, + // ASSETS EXTERNOS E IMÁGENES SUPERBASE + urlPattern: /^https:\/\/(.*\.(png|jpg|jpeg|svg|webp|woff2|css))/, handler: 'CacheFirst', options: { - cacheName: 'google-fonts-cache', - expiration: { - maxEntries: 10, - maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year - }, - cacheableResponse: { - statuses: [0, 200], - }, + cacheName: 'assets-estaticos-sibu', + expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 30 }, // 30 días + cacheableResponse: { statuses: [0, 200] }, }, }, { - urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i, - handler: 'CacheFirst', + // LLAMADAS API SEMI-ESTÁTICAS (Supabase listas que no mutan tan rápido) + urlPattern: /^https:\/\/.*\.supabase\.co\/rest\/v1\/(routes|bus_stops)/, + handler: 'StaleWhileRevalidate', options: { - cacheName: 'gstatic-fonts-cache', - expiration: { - maxEntries: 10, - maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year - }, - cacheableResponse: { - statuses: [0, 200], - }, + cacheName: 'api-estatica-sibu', + cacheableResponse: { statuses: [0, 200] }, }, }, + { + // LLAMADAS API REALTIME / DELUXE + urlPattern: /^https:\/\/.*\.supabase\.co\/rest\/v1\/(shuttles|locations|users)/, + handler: 'NetworkFirst', + options: { + cacheName: 'api-dinamica', + networkTimeoutSeconds: 5, // Vital en zonas rurales: si el 3G no responde en 5s, muestra la caché + cacheableResponse: { statuses: [0, 200] }, + }, + }, + { + // FONT CACHE (Google) + urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i, + handler: 'CacheFirst', + options: { + cacheName: 'google-fonts', + expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 }, + cacheableResponse: { statuses: [0, 200] }, + } + } ], }, devOptions: {