import { ref, computed, watch, onMounted, onBeforeUnmount, defineComponent, PropType, shallowRef, } from 'vue'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { useGeocoding } from '../../composables/useGeocoding'; import type { Location, Waypoint, Coordinates } from '../../types'; // Fix per icone Leaflet con Vite import iconUrl from 'leaflet/dist/images/marker-icon.png'; import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png'; import shadowUrl from 'leaflet/dist/images/marker-shadow.png'; // @ts-ignore delete L.Icon.Default.prototype._getIconUrl; L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl, }); interface LocalRouteInfo { distance: number; duration: number; durationFormatted: string; } export default defineComponent({ name: 'RideMap', props: { departure: { type: Object as PropType, default: null, }, destination: { type: Object as PropType, default: null, }, waypoints: { type: Array as PropType, default: () => [], }, mapHeight: { type: String, default: '400px', }, showRoute: { type: Boolean, default: true, }, showRouteInfo: { type: Boolean, default: true, }, showLegend: { type: Boolean, default: true, }, showFullscreenButton: { type: Boolean, default: true, }, interactive: { type: Boolean, default: true, }, autoFit: { type: Boolean, default: true, }, }, emits: ['route-calculated', 'marker-click'], setup(props, { emit }) { const { calculateRoute } = useGeocoding(); // State const mapContainer = ref(null); const loading = ref(false); const isFullscreen = ref(false); const routeInfo = shallowRef(null); // NON reactive - evita problemi con Leaflet let map: L.Map | null = null; let routeLayer: L.Polyline | null = null; let markersLayer: L.LayerGroup | null = null; // Flags per prevenire loop let isUpdating = false; let lastRouteKey = ''; // Custom icons const createIcon = (emoji: string, size: number = 32) => { return L.divIcon({ className: 'ride-map-marker', html: `
${emoji}
`, iconSize: [size, size], iconAnchor: [size / 2, size], popupAnchor: [0, -size], }); }; const startIcon = createIcon('🟒', 36); const endIcon = createIcon('πŸ”΄', 36); const waypointIcon = createIcon('πŸ“', 30); // Computed const formattedDuration = computed(() => { if (!routeInfo.value) return ''; return routeInfo.value.durationFormatted; }); // Helper: format duration const formatDuration = (seconds: number): string => { const totalMinutes = Math.round(seconds / 60); const hours = Math.floor(totalMinutes / 60); const mins = totalMinutes % 60; if (hours === 0) return `${mins} min`; if (mins === 0) return `${hours} h`; return `${hours} h ${mins} min`; }; // Helper: genera chiave unica per il percorso const getRouteKey = (): string => { const dep = props.departure?.coordinates; const dest = props.destination?.coordinates; if (!dep || !dest) return ''; const wps = props.waypoints .filter(wp => wp.location?.coordinates) .map(wp => `${wp.location!.coordinates.lat.toFixed(4)},${wp.location!.coordinates.lng.toFixed(4)}`) .join('|'); return `${dep.lat.toFixed(4)},${dep.lng.toFixed(4)}-${wps}-${dest.lat.toFixed(4)},${dest.lng.toFixed(4)}`; }; // Initialize map const initMap = () => { if (!mapContainer.value || map) return; console.log('πŸ—ΊοΈ Initializing map...'); map = L.map(mapContainer.value, { center: [41.9028, 12.4964], zoom: 6, zoomControl: true, attributionControl: true, dragging: props.interactive, touchZoom: props.interactive, scrollWheelZoom: props.interactive, doubleClickZoom: props.interactive, }); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19, }).addTo(map); markersLayer = L.layerGroup().addTo(map); // Update iniziale updateMapContent(); }; // Separato updateMarkers da calculateRoute const updateMapContent = () => { if (isUpdating) return; isUpdating = true; console.log('πŸ“ Updating map content...', { hasDeparture: !!props.departure?.coordinates, hasDestination: !!props.destination?.coordinates, waypointsCount: props.waypoints.length }); try { updateMarkersOnly(); const currentKey = getRouteKey(); if (props.showRoute && currentKey && currentKey !== lastRouteKey) { console.log('πŸ›£οΈ Route key changed, calculating new route...'); lastRouteKey = currentKey; setTimeout(() => { calculateAndShowRoute(); }, 0); } else if (props.autoFit) { fitBounds(); } } finally { isUpdating = false; } }; // Solo aggiornamento markers const updateMarkersOnly = () => { if (!map || !markersLayer) return; markersLayer.clearLayers(); if (props.departure?.coordinates) { const { lat, lng } = props.departure.coordinates; const marker = L.marker([lat, lng], { icon: startIcon }); marker.bindPopup(`Partenza
${props.departure.city}`); marker.on('click', () => emit('marker-click', 'departure', props.departure)); markersLayer.addLayer(marker); console.log('βœ… Added departure marker:', lat, lng); } props.waypoints.forEach((wp, index) => { if (wp.location?.coordinates) { const { lat, lng } = wp.location.coordinates; const marker = L.marker([lat, lng], { icon: waypointIcon }); marker.bindPopup(`Tappa ${index + 1}
${wp.location.city}`); marker.on('click', () => emit('marker-click', 'waypoint', wp, index)); markersLayer.addLayer(marker); } }); if (props.destination?.coordinates) { const { lat, lng } = props.destination.coordinates; const marker = L.marker([lat, lng], { icon: endIcon }); marker.bindPopup(`Arrivo
${props.destination.city}`); marker.on('click', () => emit('marker-click', 'destination', props.destination)); markersLayer.addLayer(marker); console.log('βœ… Added destination marker:', lat, lng); } }; // Calculate and show route const calculateAndShowRoute = async () => { if (!map) { console.warn('❌ Map not initialized'); return; } if (!props.departure?.coordinates || !props.destination?.coordinates) { console.warn('❌ Missing coordinates'); return; } loading.value = true; console.log('πŸš— Calculating route...'); try { const waypointCoords: Coordinates[] = props.waypoints .filter((wp) => wp.location?.coordinates) .sort((a, b) => a.order - b.order) .map((wp) => wp.location!.coordinates); console.log('πŸ“ Route params:', { from: props.departure.coordinates, to: props.destination.coordinates, waypoints: waypointCoords }); const result = await calculateRoute( props.departure.coordinates, props.destination.coordinates, waypointCoords.length > 0 ? waypointCoords : undefined ); // console.log('πŸ“¦ Route API result:', JSON.stringify(result, null, 2)); if (!result) { console.warn('❌ No route result, drawing fallback line'); drawFallbackLine(waypointCoords); return; } // Estrai distance e duration dal risultato // Il backend restituisce: { distance: km, duration: minuti, geometry: {...} } const distanceKm = result.distance || 0; const durationMinutes = result.duration || 0; const newRouteInfo: LocalRouteInfo = { distance: Math.round(distanceKm * 10) / 10, duration: durationMinutes, durationFormatted: formatDuration(durationMinutes * 60), // Converti in secondi per formatDuration }; console.log('πŸ“Š Route info:', newRouteInfo); routeInfo.value = newRouteInfo; emit('route-calculated', { ...newRouteInfo }); // Rimuovi percorso precedente if (routeLayer && map) { map.removeLayer(routeLayer); routeLayer = null; } // Disegna il percorso const coordinates = extractCoordinates(result); console.log(`πŸ“ Extracted ${coordinates.length} coordinates`); if (coordinates.length > 0) { routeLayer = L.polyline(coordinates, { color: '#1976D2', weight: 5, opacity: 0.8, smoothFactor: 1, lineJoin: 'round', lineCap: 'round', }).addTo(map); console.log('βœ… Route drawn successfully'); if (props.autoFit) { fitBounds(); } } else { console.warn('❌ No coordinates extracted, drawing fallback'); drawFallbackLine(waypointCoords); } } catch (error) { console.error('❌ Errore calcolo percorso:', error); drawFallbackLine([]); } finally { loading.value = false; } }; // Extract coordinates from various API response formats const extractCoordinates = (result: any): L.LatLngTuple[] => { console.log('πŸ” Extracting coordinates from:', Object.keys(result)); // Formato 1: geometry.coordinates (GeoJSON LineString) if (result.geometry?.coordinates && Array.isArray(result.geometry.coordinates)) { console.log('πŸ“ Format: GeoJSON LineString'); return result.geometry.coordinates.map( (coord: number[]) => [coord[1], coord[0]] as L.LatLngTuple // [lng, lat] -> [lat, lng] ); } // Formato 2: geometry Γ¨ una stringa (polyline encoded) if (typeof result.geometry === 'string') { console.log('πŸ“ Format: Encoded polyline string'); return decodePolyline(result.geometry); } // Formato 3: polyline separato if (typeof result.polyline === 'string' && result.polyline.length > 0) { console.log('πŸ“ Format: Separate polyline field'); return decodePolyline(result.polyline); } // Formato 4: coordinates array diretto if (Array.isArray(result.coordinates)) { console.log('πŸ“ Format: Direct coordinates array'); return result.coordinates.map((coord: number[]) => { if (Math.abs(coord[0]) <= 90) { return [coord[0], coord[1]] as L.LatLngTuple; } return [coord[1], coord[0]] as L.LatLngTuple; }); } // Formato 5: routes array (OSRM format) if (result.routes?.[0]?.geometry) { const geom = result.routes[0].geometry; console.log('πŸ“ Format: OSRM routes array'); if (typeof geom === 'string') { return decodePolyline(geom); } if (geom.coordinates) { return geom.coordinates.map( (coord: number[]) => [coord[1], coord[0]] as L.LatLngTuple ); } } // Formato 6: segments con steps (OpenRouteService format) if (result.segments && Array.isArray(result.segments)) { console.log('πŸ“ Format: ORS segments'); // Se abbiamo segments ma non geometry, probabilmente manca qualcosa console.warn('⚠️ Segments found but no geometry'); } console.warn('❌ Could not extract coordinates, keys:', Object.keys(result)); return []; }; // Draw fallback straight line const drawFallbackLine = (waypointCoords: Coordinates[]) => { if (!map || !props.departure?.coordinates || !props.destination?.coordinates) return; console.log('πŸ“ Drawing fallback straight line'); if (routeLayer) { map.removeLayer(routeLayer); routeLayer = null; } const points: L.LatLngTuple[] = [ [props.departure.coordinates.lat, props.departure.coordinates.lng], ...waypointCoords.map((c) => [c.lat, c.lng] as L.LatLngTuple), [props.destination.coordinates.lat, props.destination.coordinates.lng], ]; routeLayer = L.polyline(points, { color: '#FF9800', weight: 3, opacity: 0.7, dashArray: '10, 10', }).addTo(map); if (props.autoFit) { fitBounds(); } }; // Decode Google/OSRM polyline format const decodePolyline = (encoded: string): L.LatLngTuple[] => { const points: L.LatLngTuple[] = []; let index = 0; let lat = 0; let lng = 0; while (index < encoded.length) { let shift = 0; let result = 0; let byte: number; do { byte = encoded.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20); lat += result & 1 ? ~(result >> 1) : result >> 1; shift = 0; result = 0; do { byte = encoded.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20); lng += result & 1 ? ~(result >> 1) : result >> 1; points.push([lat / 1e5, lng / 1e5]); } console.log(`πŸ“ Decoded ${points.length} points from polyline`); return points; }; // Fit bounds const fitBounds = () => { if (!map) return; const allPoints: L.LatLngTuple[] = []; if (props.departure?.coordinates) { allPoints.push([props.departure.coordinates.lat, props.departure.coordinates.lng]); } props.waypoints.forEach((wp) => { if (wp.location?.coordinates) { allPoints.push([wp.location.coordinates.lat, wp.location.coordinates.lng]); } }); if (props.destination?.coordinates) { allPoints.push([props.destination.coordinates.lat, props.destination.coordinates.lng]); } if (allPoints.length === 0) return; if (allPoints.length === 1) { map.setView(allPoints[0], 13); } else { map.fitBounds(L.latLngBounds(allPoints), { padding: [50, 50], maxZoom: 14, animate: true, }); } }; // Center on user const centerOnUser = () => { if (!map || !navigator.geolocation) return; navigator.geolocation.getCurrentPosition( (pos) => map!.setView([pos.coords.latitude, pos.coords.longitude], 13), (err) => console.error('Geolocation error:', err) ); }; // Toggle fullscreen const toggleFullscreen = () => { const container = mapContainer.value?.parentElement; if (!container) return; if (!document.fullscreenElement) { container.requestFullscreen().then(() => { isFullscreen.value = true; setTimeout(() => map?.invalidateSize(), 100); }); } else { document.exitFullscreen().then(() => { isFullscreen.value = false; setTimeout(() => map?.invalidateSize(), 100); }); } }; // Watch con debounce let watchTimeout: ReturnType | null = null; watch( () => [ props.departure?.coordinates?.lat, props.departure?.coordinates?.lng, props.destination?.coordinates?.lat, props.destination?.coordinates?.lng, props.waypoints.length, props.waypoints.map(w => w.location?.coordinates?.lat).join(','), ], () => { if (watchTimeout) { clearTimeout(watchTimeout); } watchTimeout = setTimeout(() => { updateMapContent(); }, 200); }, { flush: 'post' } ); // Lifecycle onMounted(() => { setTimeout(initMap, 100); }); onBeforeUnmount(() => { if (watchTimeout) clearTimeout(watchTimeout); if (map) { map.remove(); map = null; } }); return { mapContainer, loading, isFullscreen, routeInfo, formattedDuration, fitBounds, centerOnUser, toggleFullscreen, }; }, });