perf: optimization for maps & network

This commit is contained in:
2026-02-26 12:39:15 -05:00
parent ba7631dc9c
commit 7b3141e5e9
5 changed files with 299 additions and 28 deletions

View File

@ -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<boolean>(false);
const errorRuta = ref<string | null>(null);
const renderizadoresActivos = ref<google.maps.DirectionsRenderer[]>([]);
// 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
};
}

View File

@ -1,5 +1,5 @@
/** Composable for Google Maps integration */ /** Composable for Google Maps integration */
import { ref, onMounted } from 'vue' import { ref, shallowRef, onMounted } from 'vue'
import { setOptions, importLibrary } from '@googlemaps/js-api-loader' import { setOptions, importLibrary } from '@googlemaps/js-api-loader'
const getApiKey = () => import.meta.env.VITE_GOOGLE_MAPS_API_KEY || '' const getApiKey = () => import.meta.env.VITE_GOOGLE_MAPS_API_KEY || ''
@ -10,7 +10,7 @@ let mapsLoaded = false
const globalOverlays = new Map<google.maps.Map, Set<google.maps.Marker | google.maps.Polyline>>() const globalOverlays = new Map<google.maps.Map, Set<google.maps.Marker | google.maps.Polyline>>()
export function useGoogleMaps() { export function useGoogleMaps() {
const map = ref<google.maps.Map | null>(null) const map = shallowRef<google.maps.Map | null>(null)
const isLoaded = ref(false) const isLoaded = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@ -234,6 +234,73 @@ export function useGoogleMaps() {
return polyline 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 }>) { function fitBounds(path: Array<{ lat: number; lng: number }>) {
if (!map.value || path.length === 0) { if (!map.value || path.length === 0) {
return return
@ -378,6 +445,7 @@ export function useGoogleMaps() {
addHtmlMarker, addHtmlMarker,
addNumberedMarker, addNumberedMarker,
addPolyline, addPolyline,
addRoutePolyline,
fitBounds, fitBounds,
setCenter, setCenter,
setZoom, setZoom,

View File

@ -14,7 +14,7 @@
<div id="route-map" class="route-map"></div> <div id="route-map" class="route-map"></div>
<div class="map-hint"> <div class="map-hint">
<span class="material-icons">info</span> <span class="material-icons">info</span>
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.
</div> </div>
</div> </div>
@ -199,7 +199,7 @@ const isFormValid = computed(() => {
}) })
// Map integration // 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 () => { onMounted(async () => {
await loadInitialData() await loadInitialData()
@ -289,13 +289,13 @@ async function initRouteMap() {
updateMapOverlays() updateMapOverlays()
} }
function updateMapOverlays() { async function updateMapOverlays() {
clearAllOverlays() clearAllOverlays()
// 1. Draw Polyline // 1. Draw Polyline (Real Route tracing)
if (routeStops.value.length > 1) { if (routeStops.value.length > 1) {
const path = routeStops.value.map(s => ({ lat: s.latitude, lng: s.longitude })) const path = routeStops.value.map(s => ({ lat: s.latitude, lng: s.longitude }))
addPolyline(path) await addRoutePolyline(path)
} }
// 2. Add Route Markers (Yellow Numbered) // 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 => { availableStops.value.forEach(stop => {
const marker = addMarker( const marker = addMarker(
{ lat: stop.latitude, lng: stop.longitude }, { lat: stop.latitude, lng: stop.longitude },
@ -329,6 +330,7 @@ function updateMapOverlays() {
}) })
} }
}) })
*/
} }
// Actions // Actions

View File

@ -10,6 +10,7 @@ import { useGoogleMaps } from "@/composables/useGoogleMaps";
import { analyticsService } from "@/services/analyticsService"; import { analyticsService } from "@/services/analyticsService";
import { getImageUrl } from "@/utils/imageUrl"; import { getImageUrl } from "@/utils/imageUrl";
import { useDirectionsRoute } from "@/composables/useDirectionsRoute";
import BusStopInfoModal from "@/components/BusStopInfoModal.vue"; import BusStopInfoModal from "@/components/BusStopInfoModal.vue";
import type { BusStop } from '@/types' import type { BusStop } from '@/types'
@ -22,6 +23,8 @@ const busStopStore = useBusStopStore();
const couponStore = useCouponStore(); 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 markers = ref<any[]>([]); const markers = ref<any[]>([]);
const promoMarkers = ref<any[]>([]); const promoMarkers = ref<any[]>([]);
const userMarker = ref<any>(null); const userMarker = ref<any>(null);
@ -141,6 +144,7 @@ async function clearAllMapData() {
try { if (optimalStopPulse.value.setMap) optimalStopPulse.value.setMap(null); } catch(e){} try { if (optimalStopPulse.value.setMap) optimalStopPulse.value.setMap(null); } catch(e){}
optimalStopPulse.value = null; optimalStopPulse.value = null;
} }
limpiarRuta();
// 7. Restaurar Solo Usuario tras un breve respiro // 7. Restaurar Solo Usuario tras un breve respiro
await nextTick(); await nextTick();
@ -442,6 +446,9 @@ function clearMapMarkers() {
} }
optimalStopPulse.value = null; optimalStopPulse.value = null;
} }
// Clear directions route
limpiarRuta();
} }
async function updateMapMarkers() { async function updateMapMarkers() {
@ -571,11 +578,27 @@ function updateMarkersStyles(force = false) {
marker.setLabel(null); 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) // La función drawRouteOnRoad ha sido eliminada por petición del usuario (línea azul removida)
async function updatePromoMarkers() { async function updatePromoMarkers() {
if (!isLoaded.value) return; if (!isLoaded.value) return;
@ -874,7 +897,18 @@ function clearNavigation() {
<!-- Main Map Container --> <!-- Main Map Container -->
<div class="map-side"> <div class="map-side">
<div class="map-view"> <div class="map-view">
<div class="map-container"> <!-- Status overlay para SIBU Directions API -->
<div v-if="estasCargandoRuta || errorRuta" class="status-indicator">
<div v-if="estasCargandoRuta" class="loading-pill">
Calculando ruta real...
</div>
<div v-if="errorRuta" class="error-pill">
{{ errorRuta }}
</div>
</div>
<div class="map-container">
<!-- Floating Offers Button at exact location -->
<div v-if="mapsError" class="error"> <div v-if="mapsError" class="error">
<div style="text-align: center; padding: 20px; max-width: 600px; margin: 0 auto;"> <div style="text-align: center; padding: 20px; max-width: 600px; margin: 0 auto;">
<h3 style="color: var(--text-primary); margin-bottom: 15px;"> Error al cargar mapa</h3> <h3 style="color: var(--text-primary); margin-bottom: 15px;"> Error al cargar mapa</h3>
@ -1196,6 +1230,50 @@ function clearNavigation() {
position: relative; 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 { .map-side {
width: 100%; width: 100%;
height: 100%; height: 100%;

View File

@ -44,35 +44,48 @@ export default defineConfig(() => {
}, },
workbox: { workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], 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: [ runtimeCaching: [
{ {
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, // ASSETS EXTERNOS E IMÁGENES SUPERBASE
urlPattern: /^https:\/\/(.*\.(png|jpg|jpeg|svg|webp|woff2|css))/,
handler: 'CacheFirst', handler: 'CacheFirst',
options: { options: {
cacheName: 'google-fonts-cache', cacheName: 'assets-estaticos-sibu',
expiration: { expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 30 }, // 30 días
maxEntries: 10, cacheableResponse: { statuses: [0, 200] },
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
},
cacheableResponse: {
statuses: [0, 200],
},
}, },
}, },
{ {
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i, // LLAMADAS API SEMI-ESTÁTICAS (Supabase listas que no mutan tan rápido)
handler: 'CacheFirst', urlPattern: /^https:\/\/.*\.supabase\.co\/rest\/v1\/(routes|bus_stops)/,
handler: 'StaleWhileRevalidate',
options: { options: {
cacheName: 'gstatic-fonts-cache', cacheName: 'api-estatica-sibu',
expiration: { cacheableResponse: { statuses: [0, 200] },
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
},
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: { devOptions: {