// public/js/map/activities/activitySpiderfy.js // MAPBOX ACTIVITY SPIDERFY (() => { "use strict"; let mapboxMap = null; let eventsMap = null; const spiderMarkers = []; let spiderfyOpen = false; const SOURCE_ID = "activities"; const CLUSTER_LAYER_ID = "activities-clusters"; // =================================== // SET MAP // =================================== function setMap(map) { if (!map) { return; } if (eventsMap === map) { mapboxMap = map; return; } detachEvents(); mapboxMap = map; eventsMap = map; eventsMap.on( "zoomstart", handleZoomStart, ); eventsMap.on( "moveend", handleMoveEnd, ); eventsMap.on( "click", handleMapClick, ); console.log( "[ActivitySpiderfy] setMap", ); } // =================================== // REMOVE EVENTS // =================================== function detachEvents() { if (!eventsMap) { return; } eventsMap.off( "zoomstart", handleZoomStart, ); eventsMap.off( "moveend", handleMoveEnd, ); eventsMap.off( "click", handleMapClick, ); eventsMap = null; } // =================================== // MAP EVENTS // =================================== function handleZoomStart() { if (!spiderfyOpen) { return; } clear(); } function handleMoveEnd() { if (spiderfyOpen) { return; } window.MapboxActivityClusters ?.update?.(); const activities = getAllActivities(); window.MapboxActivityMarkers ?.update?.( activities, ); } function handleMapClick(event) { if (!spiderfyOpen) { return; } if ( !mapboxMap?.getLayer?.( CLUSTER_LAYER_ID, ) ) { clear(); return; } const hits = mapboxMap.queryRenderedFeatures( event.point, { layers: [ CLUSTER_LAYER_ID, ], }, ); if (hits.length > 0) { return; } clear(); } // =================================== // GET ACTIVITIES // =================================== function getAllActivities() { const controllerActivities = window.MapboxMarkers ?.getActivities?.() || []; if (controllerActivities.length) { return controllerActivities; } if (!mapboxMap) { return []; } if ( !mapboxMap.getSource( SOURCE_ID, ) ) { return []; } return mapboxMap.querySourceFeatures( SOURCE_ID, { filter: [ "!", ["has", "point_count"], ], }, ); } // =================================== // DISTANCE // =================================== function distanceMeters(a, b) { if ( !Array.isArray(a) || !Array.isArray(b) || a.length < 2 || b.length < 2 ) { return Infinity; } const R = 6371000; const lat1 = Number(a[1]) * Math.PI / 180; const lat2 = Number(b[1]) * Math.PI / 180; const dLat = lat2 - lat1; const dLng = ( Number(b[0]) - Number(a[0]) ) * Math.PI / 180; const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2; return ( 2 * R * Math.atan2( Math.sqrt(h), Math.sqrt(1 - h), ) ); } // =================================== // NEARBY ACTIVITIES // =================================== function getNearbyActivities( feature, maxDistance = 3, ) { const center = feature?.geometry?.coordinates; if ( !Array.isArray(center) || center.length < 2 ) { return []; } return getAllActivities().filter( (other) => { const coordinates = other?.geometry?.coordinates; return ( distanceMeters( center, coordinates, ) <= maxDistance ); }, ); } // =================================== // ACTIVITY ITEM // =================================== function isActivityItem(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 ); } function getActivityKey( item, index, ) { return String( item?.id || item?.path || item?.file_name || item?.name || index, ); } function openActivityFeature( feature, ) { const id = String( feature?.properties?.id ?? "", ); if (!id) { return; } const allItems = typeof window.getLocalItems === "function" ? window.getLocalItems() : []; const activityItems = allItems.filter( isActivityItem, ); const itemIndex = activityItems.findIndex( (item, index) => getActivityKey( item, index, ) === id, ); if (itemIndex < 0) { console.warn( "[ActivitySpiderfy] attivitร  non trovata", { id, available: activityItems.length, }, ); return; } if ( typeof window.openActivityModal !== "function" ) { return; } window.openActivityModal( activityItems[itemIndex], activityItems, itemIndex, ); } // =================================== // ICON // =================================== function getActivityIcon( activityName, ) { const sport = String( activityName || "", ) .toLowerCase() .trim(); if ( sport.includes("cycling") || sport.includes("cycle") || sport.includes("ride") || sport.includes("bike") || sport.includes("bici") || sport.includes("cicl") ) { return "๐Ÿšด"; } if ( sport.includes("running") || sport.includes("run") || sport.includes("corsa") ) { return "๐Ÿƒ"; } if ( sport.includes("walking") || sport.includes("walk") || sport.includes("cammin") ) { return "๐Ÿšถ"; } if ( sport.includes("hiking") || sport.includes("hike") || sport.includes("trek") ) { return "๐Ÿฅพ"; } if ( sport.includes("swimming") || sport.includes("swim") || sport.includes("nuoto") ) { return "๐ŸŠ"; } return "๐Ÿ“"; } // =================================== // SPIDER ELEMENT // =================================== function createSpiderElement( feature, ) { const el = document.createElement("div"); el.className = "activity-marker activity-spider-marker"; const inner = document.createElement("div"); inner.className = "activity-marker-inner"; const icon = document.createElement("span"); icon.className = "activity-marker-icon"; icon.textContent = getActivityIcon( feature?.properties?.sport, ); inner.appendChild(icon); el.appendChild(inner); return el; } // =================================== // SPIDER MARKER // =================================== function createSpiderMarker( feature, id, ) { if (!mapboxMap) { return null; } const coordinates = feature?.geometry?.coordinates; if ( !Array.isArray(coordinates) || coordinates.length < 2 ) { return null; } const el = createSpiderElement( feature, ); el.addEventListener( "pointerdown", (event) => { event.stopPropagation(); }, ); el.addEventListener( "click", (event) => { event.preventDefault(); event.stopPropagation(); openActivityFeature( feature, ); clear(); }, ); const marker = new mapboxgl.Marker({ element: el, anchor: "center", }) .setLngLat(coordinates) .addTo(mapboxMap); marker.__originalLngLat = coordinates; marker.__activityId = id; marker.__spider = true; return marker; } // =================================== // HIDE / SHOW CLUSTERS // =================================== function hideClusterMarkers() { window.MapboxActivityClusters ?.hide?.(); } function showClusterMarkers() { window.MapboxActivityClusters ?.show?.(); } // =================================== // CLEAR // =================================== function clear() { for ( const marker of spiderMarkers ) { marker.remove(); } spiderMarkers.length = 0; spiderfyOpen = false; if (mapboxMap) { mapboxMap.triggerRepaint(); } showClusterMarkers(); window.MapboxActivityClusters ?.update?.(); window.MapboxActivityMarkers ?.update?.( getAllActivities(), ); console.log( "[ActivitySpiderfy] chiuso", ); } // =================================== // SPIDERFY // =================================== function spiderfyActivities( features, ) { if ( !mapboxMap || !Array.isArray(features) || features.length === 0 ) { return; } clear(); const center = features[0] ?.geometry ?.coordinates; if ( !Array.isArray(center) || center.length < 2 ) { return; } const count = features.length; const radiusPx = Math.min( 96, Math.max( 30, 28 + count * 6, ), ); const centerPoint = mapboxMap.project( center, ); hideClusterMarkers(); features.forEach( (feature, index) => { const rawId = feature?.properties?.id; if ( rawId === undefined || rawId === null ) { return; } const id = String(rawId); window.MapboxActivityMarkers ?.remove?.( id, ); const marker = createSpiderMarker( feature, id, ); if (!marker) { return; } const angle = ( Math.PI * 2 * index ) / count - Math.PI / 2; const point = { x: centerPoint.x + Math.cos(angle) * radiusPx, y: centerPoint.y + Math.sin(angle) * radiusPx, }; const lngLat = mapboxMap.unproject( point, ); marker.setLngLat( lngLat, ); spiderMarkers.push( marker, ); }, ); spiderfyOpen = spiderMarkers.length > 0; console.log( "[ActivitySpiderfy] aperto", { activities: spiderMarkers.length, radiusPx, }, ); } // =================================== // SMART ZOOM // =================================== function smartZoomCluster( clusterId, center, ) { if (!mapboxMap) { return; } const source = mapboxMap.getSource( SOURCE_ID, ); if (!source) { return; } const MAX_STEPS = 5; let steps = 0; function zoomStep() { if (steps >= MAX_STEPS) { return; } steps++; source.getClusterExpansionZoom( Number(clusterId), ( error, expansionZoom, ) => { if (error) { console.error( "[ActivitySpiderfy] expansion zoom error", error, ); return; } const currentZoom = mapboxMap.getZoom(); const targetZoom = Math.max( expansionZoom, currentZoom + 1, ); mapboxMap.once( "moveend", () => { const clusters = mapboxMap .querySourceFeatures( SOURCE_ID, { filter: [ "has", "point_count", ], }, ); const sameCluster = clusters.some( (feature) => Number( feature ?.properties ?.cluster_id, ) === Number(clusterId), ); if (sameCluster) { zoomStep(); } else { window .MapboxActivityClusters ?.update?.(); window .MapboxActivityMarkers ?.update?.( getAllActivities(), ); } }, ); mapboxMap.easeTo({ center, zoom: targetZoom, duration: 700, }); }, ); } zoomStep(); } // =================================== // CLUSTER CLICK // =================================== function handleClusterClick( clusterId, center, ) { if (!mapboxMap) { return; } const source = mapboxMap.getSource( SOURCE_ID, ); if (!source) { return; } source.getClusterLeaves( Number(clusterId), 100, 0, (error, leaves) => { if (error) { console.error( "[ActivitySpiderfy] cluster leaves error", error, ); return; } if ( !Array.isArray(leaves) || leaves.length === 0 ) { return; } const firstCoordinates = leaves[0] ?.geometry ?.coordinates; if ( !Array.isArray( firstCoordinates, ) ) { return; } const radiusMeters = 30; const allNearby = leaves.every( (feature) => distanceMeters( firstCoordinates, feature ?.geometry ?.coordinates, ) <= radiusMeters, ); console.log( "[ActivitySpiderfy] cluster click", { clusterId, leaves: leaves.length, spider: allNearby, }, ); if (allNearby) { spiderfyActivities( leaves, ); return; } smartZoomCluster( clusterId, center, ); }, ); } // =================================== // STATE // =================================== function isOpen() { return spiderfyOpen; } function size() { return spiderMarkers.length; } function debug() { const result = { open: spiderfyOpen, markers: spiderMarkers.length, map: !!mapboxMap, }; console.log( "[ActivitySpiderfy] debug", result, ); return result; } // =================================== // PUBLIC API // =================================== window.MapboxActivitySpiderfy = { setMap, clear, spiderfyActivities, getNearbyActivities, handleClusterClick, smartZoomCluster, isOpen, size, debug, }; })();