galleria_photo_face_rknn_op.../public/js/map/activities/activityClusters.js
2026-10-03 12:16:58 +02:00

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,
};
})();