949 lines
16 KiB
JavaScript
949 lines
16 KiB
JavaScript
// 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 = `
|
|
<div
|
|
class="gp-cluster activity-cluster ${cls}"
|
|
style="
|
|
width:${size}px;
|
|
height:${size}px;
|
|
"
|
|
>
|
|
<div class="gp-count">
|
|
<span>${count}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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,
|
|
};
|
|
})();
|