// public/js/map/activitiesLayer.js // =============================== // LAYER MAPPA ATTIVITÀ // =============================== (function () { const ACTIVITY_ROUTE_COLORS = [ "#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#46f0f0", "#f032e6", "#bcf60c", "#008080", "#9a6324", "#800000", "#000075", "#ff6b00", "#00a8e8", "#8d6e63", "#2e7d32", ]; const MAX_GLOBAL_ACTIVITY_ROUTES = 80; let activityRoutesLayer = null; let selectedActivityLayer = null; let selectedActivityEndMarker = null; let showAllActivityRoutes = false; let cachedRouteData = []; let lastRenderToken = 0; let activityStartMarkers = []; // =============================== // CLUSTER ATTIVITÀ // =============================== window.createActivityClusterIcon = function (cluster) { const count = cluster.getChildCount(); const cls = count > 200 ? "cluster-xl" : count > 50 ? "cluster-lg" : count > 10 ? "cluster-md" : "cluster-sm"; return L.divIcon({ html: `
${count}
`, className: "activity-leaflet-cluster", iconSize: L.point(48, 48), iconAnchor: L.point(24, 24), }); }; // =============================== // PULIZIA LAYER ATTIVITÀ // =============================== window.clearActivityMapLayers = function () { clearActivityStartMarkers(); if (activityRoutesLayer) { activityRoutesLayer.clearLayers(); } if (selectedActivityLayer) { selectedActivityLayer.remove(); selectedActivityLayer = null; } if (selectedActivityEndMarker) { selectedActivityEndMarker.remove(); selectedActivityEndMarker = null; } }; // =============================== // PULIZIA MARKERS // =============================== function clearActivityStartMarkers() { const markersToRemove = [...activityStartMarkers]; activityStartMarkers.length = 0; for (const marker of markersToRemove) { try { window.globalMarkers?.removeLayer?.(marker); /* * Rimuove direttamente il marker * dalla mappa Leaflet, se presente. */ if (window.globalMap?.hasLayer?.(marker)) { window.globalMap.removeLayer(marker); } marker.remove?.(); } catch (error) { console.warn( "[activitiesLayer] errore rimozione marker Leaflet", error, ); } } } // =============================== // TOGGLE TRACCE // =============================== function ensureActivityRoutesToggle() { let btn = document.getElementById("toggleActivityRoutesBtn"); if (!btn) { btn = document.createElement("button"); btn.id = "toggleActivityRoutesBtn"; btn.type = "button"; btn.className = "map-toggle-routes"; btn.addEventListener("click", async (event) => { event.preventDefault(); event.stopPropagation(); showAllActivityRoutes = !showAllActivityRoutes; updateActivityRoutesToggle(); const isMapbox3D = !!window.MapTerrain3D?.enabled?.(); if (isMapbox3D) { updateMapboxActivityRoutes(); } else { await renderAllActivityRoutes(); } }); } moveActivityRoutesToggle(btn); updateActivityRoutesToggle(); return btn; } function moveActivityRoutesToggle(btn) { if (!btn) { return; } const isMapbox3D = !!window.MapTerrain3D?.enabled?.(); const targetContainer = isMapbox3D ? document.getElementById("terrainMap") : document.getElementById("globalMap"); if (!targetContainer) { return; } if (btn.parentElement !== targetContainer) { targetContainer.appendChild(btn); } } function updateActivityRoutesToggle() { const btn = document.getElementById("toggleActivityRoutesBtn"); if (!btn) { return; } btn.classList.toggle("active", showAllActivityRoutes); btn.textContent = showAllActivityRoutes ? "Nascondi tracce" : "Mostra tracce"; btn.setAttribute("aria-pressed", showAllActivityRoutes ? "true" : "false"); btn.setAttribute( "aria-label", showAllActivityRoutes ? "Nascondi tutte le tracce" : "Mostra tutte le tracce", ); btn.title = showAllActivityRoutes ? "Nascondi tutte le tracce" : "Mostra tutte le tracce"; } window.hideActivityRoutesToggle = function (resetState = false) { const btn = document.getElementById( "toggleActivityRoutesBtn", ); if (btn) { btn.style.display = "none"; btn.style.visibility = "hidden"; btn.style.pointerEvents = "none"; } if (!resetState) { return; } showAllActivityRoutes = false; updateActivityRoutesToggle(); if ( window.MapTerrain3D ?.enabled?.() ) { window.MapboxMarkers ?.setRoutes?.([]); } activityRoutesLayer ?.clearLayers?.(); }; function showActivityRoutesToggle() { const btn = ensureActivityRoutesToggle(); if (!btn) { return; } moveActivityRoutesToggle( btn, ); btn.style.display = "inline-flex"; btn.style.visibility = "visible"; btn.style.pointerEvents = "auto"; updateActivityRoutesToggle(); } window.showActivityRoutesToggle = showActivityRoutesToggle; // =============================== // ICONA MARKER ATTIVITÀ // =============================== function createActivityStartIcon(item) { const sport = String(item?.sport || "") .toLowerCase() .trim(); let emoji = "●"; let cls = "activity-marker-generic"; if (sport.includes("run") || sport.includes("corsa")) { emoji = "🏃"; cls = "activity-marker-run"; } else if ( sport.includes("bike") || sport.includes("bici") || sport.includes("cycle") || sport.includes("cycling") || sport.includes("ride") || sport.includes("cicl") ) { emoji = "🚴"; cls = "activity-marker-bike"; } else if (sport.includes("walk") || sport.includes("cammin")) { emoji = "🚶"; cls = "activity-marker-walk"; } else if ( sport.includes("hike") || sport.includes("hiking") || sport.includes("trek") ) { emoji = "🥾"; cls = "activity-marker-hiking"; } else if (sport.includes("swim") || sport.includes("nuoto")) { emoji = "🏊"; cls = "activity-marker-swimming"; } return L.divIcon({ html: `
${emoji}
`, className: "activity-leaflet-marker", iconSize: L.point(42, 42), iconAnchor: L.point(21, 21), }); } // =============================== // RENDER ATTIVITÀ // =============================== window.renderActivityMapLayer = async function () { if ( window.state?.mode !== "activities" ) { return; } const renderMode = window.state.mode; const isMapbox3D = !!window.MapTerrain3D ?.enabled?.(); if ( !isMapbox3D && ( !window.globalMap || !window.globalMarkers ) ) { return; } const token = ++lastRenderToken; clearActivityStartMarkers(); showActivityRoutesToggle(); /* * Il pulsante e i layer Leaflet vengono * gestiti soltanto in modalità standard. */ if (!isMapbox3D) { if (!activityRoutesLayer) { activityRoutesLayer = L.layerGroup().addTo(window.globalMap); } activityRoutesLayer.clearLayers(); clearSelectedActivityOnly(); } else { /* * Durante la transizione al 3D pulisce * eventuali residui senza richiedere * che Leaflet sia ancora disponibile. */ activityRoutesLayer?.clearLayers?.(); clearSelectedActivityOnly(); } // ================================= // RECUPERA LE ATTIVITÀ // ================================= const items = typeof window.getLocalItems === "function" ? window.getLocalItems() : []; const activityItems = items.filter(isProbablyActivity); cachedRouteData = []; // ================================= // CARICA I PUNTI DEI PERCORSI // ================================= const routeDataList = await Promise.all( activityItems.map(async (item, index) => { const points = await getRoutePointsSafe(item); if (!Array.isArray(points) || points.length < 2) { return null; } const latlngs = pointsToLatLngs(points); if (!Array.isArray(latlngs) || latlngs.length < 2) { return null; } return { key: getActivityKey(item, index), index, item, points, latlngs, start: latlngs[0], end: latlngs[latlngs.length - 1], cells: getRouteCells(points), }; }), ); /* * Se nel frattempo è iniziato un altro * rendering, questo risultato è obsoleto. */ if ( token !== lastRenderToken || window.state?.mode !== renderMode ) { return; } cachedRouteData = routeDataList.filter(Boolean); console.log("[activitiesLayer] route caricate", { inputItems: items.length, activityItems: activityItems.length, routeResults: routeDataList.length, validRoutes: cachedRouteData.length, isMapbox3D, }); // ================================= // MODALITÀ MAPBOX 3D // ================================= if (isMapbox3D) { activityRoutesLayer?.clearLayers?.(); clearSelectedActivityOnly(); const activityFeatures = typeof window.getActivityMapGeoJSON === "function" ? window.getActivityMapGeoJSON() : []; const routeFeatures = typeof window.getActivityRoutesGeoJSON === "function" ? window.getActivityRoutesGeoJSON() : []; console.log("[activitiesLayer] invio dati a Mapbox", { cachedActivities: cachedRouteData.length, activityFeatures: activityFeatures.length, routeFeatures: routeFeatures.length, }); if (typeof window.MapboxMarkers?.setActivities === "function") { window.MapboxMarkers.setActivities(activityFeatures); } else { console.warn( "[activitiesLayer] MapboxMarkers.setActivities non disponibile", ); } if (typeof window.MapboxMarkers?.setRoutes === "function") { window.MapboxMarkers.setRoutes( showAllActivityRoutes ? routeFeatures : [], ); } } else { // ================================= // MODALITÀ LEAFLET // ================================= console.log("[activitiesLayer] rendering marker Leaflet", { routes: cachedRouteData.length, globalMap: !!window.globalMap, globalMarkers: !!window.globalMarkers, mapEngineType: window.MapEngine?.type, mapEngineMapIsLeaflet: window.MapEngine?.map === window.globalMap, markerLayerIsGlobalMarkers: window.MapEngine?.markerLayer === window.globalMarkers, }); if ( window.globalMap && window.globalMarkers && !window.globalMap.hasLayer(window.globalMarkers) ) { console.warn( "[activitiesLayer] cluster group non presente, lo ripristino", ); window.globalMarkers.addTo(window.globalMap); } for (const route of cachedRouteData) { const marker = L.marker(route.start, { icon: createActivityStartIcon(route.item), title: route.item.display_name || route.item.route_name || route.item.name || route.item.file_name || "", }); marker.__item = route.item; marker.__routeData = route; marker.on( "click", (event) => { event?.originalEvent ?.preventDefault?.(); event?.originalEvent ?.stopPropagation?.(); const activityList = cachedRouteData.map( (routeData) => routeData.item, ); const activityIndex = cachedRouteData.findIndex( (routeData) => routeData.key === route.key, ); window.openActivityModal?.( route.item, activityList, activityIndex >= 0 ? activityIndex : 0, ); }, ); if (window.globalMarkers?.addLayer) { window.globalMarkers.addLayer(marker); } else { console.warn("[activitiesLayer] globalMarkers non disponibile", { globalMarkers: window.globalMarkers, mapEngineType: window.MapEngine?.type, markerLayer: window.MapEngine?.markerLayer, }); } activityStartMarkers.push(marker); } console.log("[activitiesLayer] marker Leaflet aggiunti", { trackedMarkers: activityStartMarkers.length, clusterLayers: window.globalMarkers?.getLayers?.()?.length ?? null, clusterOnMap: !!( window.globalMap && window.globalMarkers && window.globalMap.hasLayer(window.globalMarkers) ), mode: window.state?.mode, }); } // ================================= // ADATTA LA MAPPA ALLE ATTIVITÀ // ================================= if ( token !== lastRenderToken || window.state?.mode !== renderMode ) { return; } /* * fitBounds automatico soltanto nella * modalità standard. * * In Mapbox 3D può provocare un ulteriore * cambio zoom proprio mentre cluster e * marker vengono aggiornati. */ if (!isMapbox3D && window.globalMap && cachedRouteData.length > 0) { const bounds = L.latLngBounds( cachedRouteData.map((route) => route.start), ); if (bounds.isValid()) { window.globalMap.fitBounds(bounds, { padding: [60, 60], maxZoom: 12, }); } } // ================================= // MOSTRA TUTTE LE TRACCE // ================================= /* * Le tracce Leaflet devono essere * renderizzate soltanto nella modalità * standard. In 3D vengono gestite dalla * source routes di Mapbox. */ if (showAllActivityRoutes && !isMapbox3D) { await renderAllActivityRoutes(); } if ( token !== lastRenderToken || window.state?.mode !== renderMode ) { return; } console.log("[activitiesLayer] render completato", { mode: isMapbox3D ? "mapbox-3d" : "standard", activities: cachedRouteData.length, token, }); }; // =============================== // TRACCIA SELEZIONATA // =============================== function showSelectedActivityOnGlobalMap(route) { clearSelectedActivityOnly(); selectedActivityLayer = L.polyline(route.latlngs, { color: "#ff6b00", weight: 5, opacity: 1, lineCap: "round", lineJoin: "round", }).addTo(window.globalMap); selectedActivityLayer.on("click", () => { window.openActivityModal?.(route.item); }); selectedActivityEndMarker = L.circleMarker(route.end, { radius: 7, color: "#b00020", weight: 2, fillColor: "#e32636", fillOpacity: 1, }) .bindTooltip("Arrivo") .addTo(window.globalMap); selectedActivityEndMarker.on("click", () => { window.openActivityModal?.(route.item); }); const popupHtml = createActivityPopupHtml(route.item); selectedActivityLayer.bindPopup(popupHtml).openPopup(); window.globalMap.fitBounds(selectedActivityLayer.getBounds(), { padding: [50, 50], maxZoom: 15, }); setTimeout(() => { const btn = document.querySelector( `[data-open-activity-key="${route.key}"]`, ); btn?.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); window.openActivityModal?.(route.item); }); }, 0); } function clearSelectedActivityOnly() { if (selectedActivityLayer) { selectedActivityLayer.remove(); selectedActivityLayer = null; } if (selectedActivityEndMarker) { selectedActivityEndMarker.remove(); selectedActivityEndMarker = null; } } function updateMapboxActivityRoutes() { if (!window.MapTerrain3D?.enabled?.()) { return; } if (typeof window.MapboxMarkers?.setRoutes !== "function") { console.warn("[activitiesLayer] MapboxMarkers.setRoutes non disponibile"); return; } if (!showAllActivityRoutes) { window.MapboxMarkers.setRoutes([]); console.log("[activitiesLayer] tracce Mapbox nascoste"); return; } const routeFeatures = typeof window.getActivityRoutesGeoJSON === "function" ? window.getActivityRoutesGeoJSON() : []; window.MapboxMarkers.setRoutes(routeFeatures); console.log("[activitiesLayer] tracce Mapbox visibili", { routes: routeFeatures.length, }); } // =============================== // TOGGLE TUTTE LE TRACCE // =============================== async function renderAllActivityRoutes() { if (!activityRoutesLayer) { activityRoutesLayer = L.layerGroup().addTo(window.globalMap); } activityRoutesLayer.clearLayers(); if (!showAllActivityRoutes) return; let routesToDraw = cachedRouteData; if (routesToDraw.length > MAX_GLOBAL_ACTIVITY_ROUTES) { const bounds = window.globalMap.getBounds(); routesToDraw = routesToDraw.filter((route) => bounds.contains(route.start), ); } if (routesToDraw.length > MAX_GLOBAL_ACTIVITY_ROUTES) { console.warn( `[activity map] Troppe tracce da disegnare: ${routesToDraw.length}. Zooma di più.`, ); return; } const colors = assignSmartRouteColors(routesToDraw); routesToDraw.forEach((route) => { const color = colors.get(route.key) || ACTIVITY_ROUTE_COLORS[route.index % ACTIVITY_ROUTE_COLORS.length]; const polyline = L.polyline(route.latlngs, { color, weight: 5, opacity: 0.75, lineCap: "round", lineJoin: "round", }); polyline.on("click", () => { showSelectedActivityOnGlobalMap(route); }); activityRoutesLayer.addLayer(polyline); }); } // =============================== // COLORI INTELLIGENTI // =============================== function assignSmartRouteColors(routes) { const cellToColors = new Map(); const result = new Map(); routes.forEach((route, index) => { const usedNearbyColors = new Set(); route.cells.forEach((cell) => { const colors = cellToColors.get(cell); if (!colors) return; colors.forEach((color) => usedNearbyColors.add(color)); }); const color = ACTIVITY_ROUTE_COLORS.find((c) => !usedNearbyColors.has(c)) || ACTIVITY_ROUTE_COLORS[index % ACTIVITY_ROUTE_COLORS.length]; result.set(route.key, color); route.cells.forEach((cell) => { if (!cellToColors.has(cell)) { cellToColors.set(cell, new Set()); } cellToColors.get(cell).add(color); }); }); return result; } function getRouteCells(points, precision = 3) { const cells = new Set(); points.forEach((point) => { const lat = Number(point.lat); const lng = Number(point.lng); if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; cells.add(`${lat.toFixed(precision)}:${lng.toFixed(precision)}`); }); return cells; } // =============================== // HELPERS // =============================== function isProbablyActivity(item) { if (!item) return false; const format = String( item.format || item.file_extension || item.mime_type || item.file_name || item.name || "", ).toLowerCase(); return ( format.includes("fit") || format.includes("gpx") || format.includes("tcx") || item.distance_meters != null || item.duration_millis != null || item.track_preview != null ); } async function getRoutePointsSafe(item) { try { if (typeof window.getActivityRoutePoints === "function") { return await window.getActivityRoutePoints(item); } if (typeof getActivityRoutePoints === "function") { return await getActivityRoutePoints(item); } } catch (error) { console.warn("[activity map] errore caricamento punti:", error); } return []; } function pointsToLatLngs(points) { return points .map((point) => [Number(point.lat), Number(point.lng)]) .filter( (point) => Number.isFinite(point[0]) && Number.isFinite(point[1]), ); } function getActivityKey(item, index) { const value = item?.id ?? item?.path ?? item?.file_name ?? item?.name ?? index; return String(value); } window.getActivityMapKey = getActivityKey; function createActivityPopupHtml(item) { const key = escapeHtml( getActivityKey( item, "", ), ); const title = item.display_name || item.route_name || item.title || item.file_name || item.name || "Attività"; const distance = Number(item.distance_meters); const duration = Number(item.duration_millis); const distanceText = Number.isFinite(distance) ? `${(distance / 1000).toFixed(1)} km` : "-"; const durationText = Number.isFinite(duration) && typeof window.formatDurationShort === "function" ? window.formatDurationShort(duration) : formatDurationFallback(duration); return `
${escapeHtml(title)} ${item.sport ? `
${escapeHtml(item.sport)}
` : ""}
${distanceText} · ${durationText}
`; } function formatDurationFallback(milliseconds) { const value = Number(milliseconds); if (!Number.isFinite(value)) return "-"; const totalSeconds = Math.round(value / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } // =============================== // GEOJSON MAPBOX ATTIVITÀ // =============================== window.getActivityMapGeoJSON = function () { const features = []; for (const route of cachedRouteData) { if (!route.start) { continue; } features.push({ type: "Feature", geometry: { type: "Point", // route.start = [lat, lng] coordinates: [ route.start[1], // lng route.start[0], // lat ], }, properties: { id: route.key, type: "activity", name: route.item.display_name || route.item.route_name || route.item.name || "Attività", sport: route.item.sport, distance: route.item.distance_meters, duration: route.item.duration_millis, }, }); } return features; }; // =============================== // LINEE PERCORSI MAPBOX // =============================== window.getActivityRoutesGeoJSON = function () { const features = []; for (const route of cachedRouteData) { if (!route.latlngs || route.latlngs.length < 2) { continue; } features.push({ type: "Feature", geometry: { type: "LineString", // route.latlngs contiene [lat, lng] coordinates: route.latlngs.map((p) => [ p[1], // lng p[0], // lat ]), }, properties: { id: route.key, type: "route", }, }); } return features; }; window.areActivityRoutesVisible = function () { return showAllActivityRoutes; }; })();