// public/js/map/activities/activityClusters.js // =================================== // MAPBOX ACTIVITY CLUSTERS // =================================== (() => { "use strict"; let mapboxMap = null; const clusterMarkers = new Map(); let renderToken = 0; const SOURCE_ID = "activities"; const CLUSTER_LAYER_ID = "activities-clusters"; const COUNT_LAYER_ID = "activities-cluster-count"; // =================================== // SET MAP // =================================== function setMap(map) { if (!map) { return; } if ( mapboxMap && mapboxMap !== map ) { clear(); } mapboxMap = map; console.log( "[ActivityClusters] setMap", ); } // =================================== // CREA LAYER CLUSTER // =================================== function createLayer() { if (!mapboxMap) { return; } if (!mapboxMap.isStyleLoaded()) { return; } if (!mapboxMap.getSource(SOURCE_ID)) { console.warn( "[ActivityClusters] source activities non presente", ); return; } // --------------------------------- // CLUSTER LAYER // --------------------------------- if (!mapboxMap.getLayer(CLUSTER_LAYER_ID)) { console.log( "[ActivityClusters] creo layer cluster", ); mapboxMap.addLayer({ id: CLUSTER_LAYER_ID, type: "circle", source: SOURCE_ID, filter: [ "has", "point_count", ], paint: { /* * Il layer Mapbox deve rimanere * renderizzato perché viene usato da * queryRenderedFeatures(). * * Il cerchio nativo viene nascosto * perché il cluster visibile è un * marker HTML personalizzato. */ "circle-color": "#ff3333", "circle-radius": [ "step", ["get", "point_count"], 22, 10, 28, 50, 38, ], "circle-stroke-color": "#ffffff", "circle-stroke-width": 3, "circle-opacity": 0.001, "circle-stroke-opacity": 0, }, }); } // --------------------------------- // COUNT LAYER // --------------------------------- if (!mapboxMap.getLayer(COUNT_LAYER_ID)) { console.log( "[ActivityClusters] creo layer conteggio", ); mapboxMap.addLayer({ id: COUNT_LAYER_ID, type: "symbol", source: SOURCE_ID, filter: [ "has", "point_count", ], layout: { "text-field": "{point_count_abbreviated}", "text-size": 14, /* * Permette al simbolo di essere * calcolato senza sovrapporsi ad * altri simboli Mapbox. * * Il testo rimane comunque invisibile. */ "text-allow-overlap": true, "text-ignore-placement": true, }, paint: { /* * Il conteggio visibile viene mostrato * dal marker HTML personalizzato. */ "text-color": "#ffffff", "text-opacity": 0, }, }); } // --------------------------------- // FORZA INVISIBILITÀ // --------------------------------- /* * Questo passaggio è importante quando * createLayer() viene richiamata su layer * già creati in precedenza. * * In quel caso addLayer() non viene eseguito, * quindi impostiamo esplicitamente le proprietà * per evitare che rimanga visibile il vecchio * cerchio Mapbox. */ // --------------------------------- // FORZA INVISIBILITÀ // --------------------------------- if ( mapboxMap.getLayer( CLUSTER_LAYER_ID, ) ) { const circleOpacity = mapboxMap.getPaintProperty( CLUSTER_LAYER_ID, "circle-opacity", ); if (circleOpacity !== 0.001) { mapboxMap.setPaintProperty( CLUSTER_LAYER_ID, "circle-opacity", 0.001, ); } const strokeOpacity = mapboxMap.getPaintProperty( CLUSTER_LAYER_ID, "circle-stroke-opacity", ); if (strokeOpacity !== 0) { mapboxMap.setPaintProperty( CLUSTER_LAYER_ID, "circle-stroke-opacity", 0, ); } } if ( mapboxMap.getLayer( COUNT_LAYER_ID, ) ) { const textOpacity = mapboxMap.getPaintProperty( COUNT_LAYER_ID, "text-opacity", ); if (textOpacity !== 0) { mapboxMap.setPaintProperty( COUNT_LAYER_ID, "text-opacity", 0, ); } } console.log( "[ActivityClusters] layer creati e nascosti", { clusterLayer: !!mapboxMap.getLayer( CLUSTER_LAYER_ID, ), countLayer: !!mapboxMap.getLayer( COUNT_LAYER_ID, ), }, ); } // =================================== // CLUSTER VISIBILI // =================================== function getRenderedClusters() { if (!mapboxMap) { return []; } if (!mapboxMap.isStyleLoaded()) { return []; } if ( !mapboxMap.getSource( SOURCE_ID, ) ) { return []; } if ( !mapboxMap.getLayer( CLUSTER_LAYER_ID, ) ) { return []; } let rendered = []; try { /* * Interroghiamo il layer Mapbox invisibile. * * queryRenderedFeatures() restituisce * solamente i cluster attualmente * renderizzati nella viewport. */ rendered = mapboxMap.queryRenderedFeatures( { layers: [ CLUSTER_LAYER_ID, ], }, ); } catch (error) { console.warn( "[ActivityClusters] errore queryRenderedFeatures", error, ); return []; } const clusters = []; const seen = new Set(); for (const feature of rendered) { if (!feature) { continue; } /* * Ulteriore controllo difensivo. * * Il layer ha già il filtro point_count, * ma verifichiamo comunque che la feature * sia realmente un cluster. */ const pointCount = feature.properties?.point_count; if ( pointCount === undefined || pointCount === null ) { continue; } const rawClusterId = feature.properties?.cluster_id; if ( rawClusterId === undefined || rawClusterId === null ) { continue; } const clusterId = String(rawClusterId); /* * La stessa feature può essere restituita * più volte a causa delle tile Mapbox. * Deduplichiamo tramite cluster_id. */ if (seen.has(clusterId)) { continue; } const coordinates = feature.geometry?.coordinates; if ( !Array.isArray(coordinates) || coordinates.length < 2 ) { continue; } const lng = Number(coordinates[0]); const lat = Number(coordinates[1]); if ( !Number.isFinite(lng) || !Number.isFinite(lat) ) { continue; } seen.add(clusterId); clusters.push(feature); } return clusters; } // =================================== // CREA ELEMENTO HTML // =================================== function createClusterElement(cluster) { const count = Number(cluster.properties?.point_count) || 0; const size = Math.min( 72, Math.round(30 + Math.sqrt(count) * 5), ); const cls = count > 200 ? "cluster-xl" : count > 50 ? "cluster-lg" : count > 10 ? "cluster-md" : "cluster-sm"; const el = document.createElement("div"); el.className = "activity-cluster-wrapper"; el.style.width = `${size}px`; el.style.height = `${size}px`; el.innerHTML = `
${count}
`; return el; } // =================================== // CLICK CLUSTER // =================================== function handleClusterClick( clusterId, coordinates, ) { if (!mapboxMap) { return; } if ( window.MapboxActivitySpiderfy ?.handleClusterClick ) { window.MapboxActivitySpiderfy .handleClusterClick( clusterId, coordinates, ); return; } const source = mapboxMap.getSource( SOURCE_ID, ); if (!source) { return; } source.getClusterExpansionZoom( Number(clusterId), ( error, expansionZoom, ) => { if (error) { console.error( "[ActivityClusters] expansion zoom error", error, ); return; } mapboxMap.easeTo({ center: coordinates, zoom: expansionZoom, duration: 600, }); }, ); } // =================================== // CREA MARKER // =================================== function createClusterMarker(cluster) { if (!mapboxMap) { return null; } const rawClusterId = cluster?.properties?.cluster_id; if ( rawClusterId === undefined || rawClusterId === null ) { return null; } const clusterId = String(rawClusterId); // Evita un secondo marker HTML // per lo stesso cluster. if ( clusterMarkers.has(clusterId) ) { return clusterMarkers.get( clusterId, ); } const coordinates = cluster?.geometry?.coordinates; if ( !Array.isArray(coordinates) || coordinates.length < 2 || !Number.isFinite( Number(coordinates[0]), ) || !Number.isFinite( Number(coordinates[1]), ) ) { return null; } const el = createClusterElement(cluster); if (!el) { return null; } el.dataset.clusterId = clusterId; // Evita che Mapbox inizi il pan // quando si preme sul cluster. el.addEventListener( "pointerdown", (event) => { event.stopPropagation(); }, ); // Click sul cluster. el.addEventListener( "click", (event) => { event.preventDefault(); event.stopPropagation(); handleClusterClick( Number(clusterId), coordinates, ); }, ); const marker = new mapboxgl.Marker({ element: el, anchor: "center", }) .setLngLat([ Number(coordinates[0]), Number(coordinates[1]), ]) .addTo(mapboxMap); marker.__clusterId = clusterId; // La registrazione avviene qui. clusterMarkers.set( clusterId, marker, ); return marker; } // =================================== // UPDATE // =================================== function update() { if ( window.state?.mode !== "activities" ) { clear(); return; } if (!mapboxMap) { return; } if ( !mapboxMap.isStyleLoaded?.() ) { return; } // Non aggiornare cluster normali // mentre Activity Spiderfy è aperto. if ( window.MapboxActivitySpiderfy ?.isOpen?.() ) { return; } // Recupera la source Activities. const source = mapboxMap.getSource( SOURCE_ID, ); if (!source) { clear(); return; } // Crea il layer cluster se manca. if ( !mapboxMap.getLayer( CLUSTER_LAYER_ID, ) ) { createLayer(); } // Se il layer non è ancora disponibile, // elimina eventuali marker HTML obsoleti. if ( !mapboxMap.getLayer( CLUSTER_LAYER_ID, ) ) { clear(); return; } // Invalida eventuali aggiornamenti // precedenti ancora in corso. const token = ++renderToken; // Recupera solo i cluster realmente // renderizzati nella viewport. const clusters = getRenderedClusters(); console.log( "[ActivityClusters] rendered", { clusters: clusters.length, existingMarkers: clusterMarkers.size, zoom: mapboxMap.getZoom?.(), mode: window.state?.mode, sourceFeatures: mapboxMap .querySourceFeatures?.( SOURCE_ID, ) ?.length ?? null, circleOpacity: mapboxMap .getPaintProperty?.( CLUSTER_LAYER_ID, "circle-opacity", ), }, ); // Costruisce l'elenco dei cluster // attualmente renderizzati. const currentIds = new Set( clusters .map( (cluster) => cluster ?.properties ?.cluster_id, ) .filter( (clusterId) => clusterId !== undefined && clusterId !== null, ) .map( (clusterId) => String(clusterId), ), ); // Rimuove i cluster HTML che non sono // più renderizzati al livello di zoom // o nella viewport corrente. for ( const [clusterId, marker] of clusterMarkers ) { if ( !currentIds.has( String(clusterId), ) ) { marker.remove(); clusterMarkers.delete( clusterId, ); } } // Zero cluster è uno stato valido. // La pulizia è già stata eseguita. if (clusters.length === 0) { return; } // Crea oppure aggiorna i cluster correnti. for (const cluster of clusters) { // Se è partito un altro aggiornamento, // interrompe quello precedente. if (token !== renderToken) { return; } const rawClusterId = cluster ?.properties ?.cluster_id; if ( rawClusterId === undefined || rawClusterId === null ) { continue; } const clusterId = String(rawClusterId); const coordinates = cluster ?.geometry ?.coordinates; if ( !Array.isArray(coordinates) || coordinates.length < 2 ) { continue; } const longitude = Number(coordinates[0]); const latitude = Number(coordinates[1]); if ( !Number.isFinite(longitude) || !Number.isFinite(latitude) ) { continue; } // Aggiorna un cluster già esistente. if ( clusterMarkers.has( clusterId, ) ) { const marker = clusterMarkers.get( clusterId, ); marker.setLngLat([ longitude, latitude, ]); const element = marker.getElement?.(); if (element) { element.style.display = ""; } continue; } // createClusterMarker registra già // il marker dentro clusterMarkers. createClusterMarker( cluster, ); } } // =================================== // HIDE // =================================== function hide() { for ( const marker of clusterMarkers.values() ) { marker .getElement() .style.display = "none"; } } // =================================== // SHOW // =================================== function show() { for ( const marker of clusterMarkers.values() ) { marker .getElement() .style.display = ""; } } // =================================== // CLEAR // =================================== function clear() { renderToken++; for ( const marker of clusterMarkers.values() ) { marker.remove(); } clusterMarkers.clear(); } // =================================== // SIZE // =================================== function size() { return clusterMarkers.size; } // =================================== // DEBUG // =================================== function debug() { console.log( "[ActivityClusters] debug", { markers: clusterMarkers.size, rendered: getRenderedClusters().length, layer: !!mapboxMap?.getLayer( CLUSTER_LAYER_ID, ), source: !!mapboxMap?.getSource( SOURCE_ID, ), }, ); } // =================================== // API PUBBLICA // =================================== window.MapboxActivityClusters = { setMap, createLayer, update, hide, show, clear, size, debug, getRenderedClusters, }; })();