864 lines
16 KiB
JavaScript
864 lines
16 KiB
JavaScript
// public/js/map/photos/photoSpiderfy.js
|
|
// ===================================
|
|
// MAPBOX PHOTO SPIDERFY
|
|
// ===================================
|
|
|
|
(() => {
|
|
"use strict";
|
|
|
|
let mapboxMap = null;
|
|
|
|
const spiderMarkers = [];
|
|
|
|
let spiderfyOpen = false;
|
|
|
|
const SOURCE_ID = "photos";
|
|
|
|
// ===================================
|
|
// SET MAP
|
|
// ===================================
|
|
|
|
function setMap(map) {
|
|
mapboxMap = map;
|
|
|
|
console.log("[PhotoSpiderfy] setMap");
|
|
|
|
// ---------------------------------
|
|
// ZOOM
|
|
// ---------------------------------
|
|
|
|
mapboxMap.on("zoomstart", () => {
|
|
if (!spiderfyOpen) {
|
|
return;
|
|
}
|
|
|
|
console.log(
|
|
"[PhotoSpiderfy] zoom iniziato → chiudo",
|
|
);
|
|
|
|
clear();
|
|
});
|
|
|
|
// ---------------------------------
|
|
// MOVEEND
|
|
// ---------------------------------
|
|
|
|
mapboxMap.on("moveend", () => {
|
|
if (spiderfyOpen) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
window.MapboxPhotoClusters
|
|
) {
|
|
window.MapboxPhotoClusters.update();
|
|
}
|
|
|
|
if (
|
|
window.MapboxPhotoMarkers
|
|
) {
|
|
const photos =
|
|
getAllPhotos();
|
|
|
|
window.MapboxPhotoMarkers.update(
|
|
photos,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------
|
|
// CLICK FUORI
|
|
// ---------------------------------
|
|
|
|
mapboxMap.on("click", (event) => {
|
|
if (!spiderfyOpen) {
|
|
return;
|
|
}
|
|
|
|
const hits =
|
|
mapboxMap.queryRenderedFeatures(
|
|
event.point,
|
|
{
|
|
layers: [
|
|
"photos-clusters",
|
|
],
|
|
},
|
|
);
|
|
|
|
if (hits.length) {
|
|
return;
|
|
}
|
|
|
|
clear();
|
|
});
|
|
}
|
|
|
|
// ===================================
|
|
// GET TUTTE LE FOTO
|
|
// ===================================
|
|
|
|
function getAllPhotos() {
|
|
if (!mapboxMap) {
|
|
return [];
|
|
}
|
|
|
|
if (
|
|
!mapboxMap.getSource(
|
|
SOURCE_ID,
|
|
)
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
return mapboxMap.querySourceFeatures(
|
|
SOURCE_ID,
|
|
{
|
|
filter: [
|
|
"!",
|
|
["has", "point_count"],
|
|
],
|
|
},
|
|
);
|
|
}
|
|
|
|
// ===================================
|
|
// DISTANZA
|
|
// ===================================
|
|
|
|
function distanceMeters(a, b) {
|
|
const R = 6371000;
|
|
|
|
const lat1 =
|
|
(a[1] * Math.PI) / 180;
|
|
|
|
const lat2 =
|
|
(b[1] * Math.PI) / 180;
|
|
|
|
const dLat =
|
|
lat2 - lat1;
|
|
|
|
const dLng =
|
|
((b[0] - 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),
|
|
)
|
|
);
|
|
}
|
|
|
|
// ===================================
|
|
// FOTO VICINE
|
|
// ===================================
|
|
|
|
function getNearbyPhotos(
|
|
feature,
|
|
maxDistance = 3,
|
|
) {
|
|
if (!feature) {
|
|
return [];
|
|
}
|
|
|
|
const center =
|
|
feature.geometry.coordinates;
|
|
|
|
/*
|
|
* Preferiamo i dati completi
|
|
* mantenuti dal source/orchestratore.
|
|
*/
|
|
let photos = [];
|
|
|
|
if (
|
|
window.MapboxMarkers
|
|
?.getPhotos
|
|
) {
|
|
photos =
|
|
window.MapboxMarkers.getPhotos();
|
|
}
|
|
|
|
/*
|
|
* Fallback:
|
|
* leggiamo direttamente
|
|
* le feature dalla source.
|
|
*/
|
|
if (!photos.length) {
|
|
photos = getAllPhotos();
|
|
}
|
|
|
|
return photos.filter(
|
|
(other) =>
|
|
distanceMeters(
|
|
center,
|
|
other.geometry.coordinates,
|
|
) <= maxDistance,
|
|
);
|
|
}
|
|
|
|
// ===================================
|
|
// HIDE CLUSTER HTML
|
|
// ===================================
|
|
|
|
function hideClusterMarkers() {
|
|
if (
|
|
!window.MapboxPhotoClusters
|
|
) {
|
|
return;
|
|
}
|
|
|
|
window.MapboxPhotoClusters.hide();
|
|
}
|
|
|
|
// ===================================
|
|
// SHOW CLUSTER HTML
|
|
// ===================================
|
|
|
|
function showClusterMarkers() {
|
|
if (
|
|
!window.MapboxPhotoClusters
|
|
) {
|
|
return;
|
|
}
|
|
|
|
window.MapboxPhotoClusters.show();
|
|
}
|
|
|
|
// ===================================
|
|
// CLEAR SPIDERFY
|
|
// ===================================
|
|
|
|
function clear() {
|
|
console.log(
|
|
"[PhotoSpiderfy] chiusura",
|
|
);
|
|
|
|
// ---------------------------------
|
|
// RIMUOVI MARKER SPIDER
|
|
// ---------------------------------
|
|
|
|
for (
|
|
const marker of spiderMarkers
|
|
) {
|
|
marker.remove();
|
|
|
|
const id =
|
|
marker.__photoId;
|
|
|
|
if (
|
|
id !== undefined &&
|
|
window.MapboxPhotoMarkers
|
|
) {
|
|
/*
|
|
* Rimuoviamo solo il marker
|
|
* spider dal modulo marker.
|
|
*/
|
|
window.MapboxPhotoMarkers.remove(
|
|
id,
|
|
);
|
|
}
|
|
}
|
|
|
|
spiderMarkers.length = 0;
|
|
|
|
spiderfyOpen = false;
|
|
|
|
// ---------------------------------
|
|
// REPAINT
|
|
// ---------------------------------
|
|
|
|
if (mapboxMap) {
|
|
mapboxMap.triggerRepaint();
|
|
}
|
|
|
|
showClusterMarkers();
|
|
|
|
// ---------------------------------
|
|
// RICOSTRUISCI CLUSTER
|
|
// ---------------------------------
|
|
|
|
if (
|
|
window.MapboxPhotoClusters
|
|
) {
|
|
window.MapboxPhotoClusters.update();
|
|
}
|
|
|
|
// ---------------------------------
|
|
// RICOSTRUISCI FOTO
|
|
// ---------------------------------
|
|
|
|
if (
|
|
window.MapboxPhotoMarkers
|
|
) {
|
|
window.MapboxPhotoMarkers.update(
|
|
getAllPhotos(),
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
"[PhotoSpiderfy] chiusa",
|
|
);
|
|
}
|
|
|
|
// ===================================
|
|
// CREA MARKER SPIDER
|
|
// ===================================
|
|
|
|
function createSpiderMarker(
|
|
feature,
|
|
id,
|
|
) {
|
|
const img =
|
|
document.createElement("img");
|
|
|
|
img.src =
|
|
feature.properties?.thumb ||
|
|
"";
|
|
|
|
img.width = 46;
|
|
img.height = 46;
|
|
|
|
img.style.borderRadius = "7px";
|
|
img.style.objectFit = "cover";
|
|
img.style.border =
|
|
"2px solid white";
|
|
img.style.cursor = "pointer";
|
|
|
|
// ---------------------------------
|
|
// CLICK
|
|
// ---------------------------------
|
|
|
|
img.addEventListener(
|
|
"click",
|
|
(event) => {
|
|
event.stopPropagation();
|
|
|
|
const items =
|
|
typeof window.getLocalItems ===
|
|
"function"
|
|
? window.getLocalItems()
|
|
: [];
|
|
|
|
const item = items.find(
|
|
(photo) =>
|
|
String(photo.id) === id,
|
|
);
|
|
|
|
if (item) {
|
|
window.openPhotoMapItem?.(
|
|
item,
|
|
);
|
|
}
|
|
|
|
clear();
|
|
},
|
|
);
|
|
|
|
// ---------------------------------
|
|
// MARKER
|
|
// ---------------------------------
|
|
|
|
const marker =
|
|
new mapboxgl.Marker({
|
|
element: img,
|
|
})
|
|
.setLngLat(
|
|
feature.geometry.coordinates,
|
|
)
|
|
.addTo(mapboxMap);
|
|
|
|
marker.__originalLngLat =
|
|
feature.geometry.coordinates;
|
|
|
|
marker.__spider = true;
|
|
|
|
marker.__photoId = id;
|
|
|
|
return marker;
|
|
}
|
|
|
|
// ===================================
|
|
// SPIDERFY FOTO
|
|
// ===================================
|
|
|
|
function spiderfyPhotos(
|
|
features,
|
|
) {
|
|
if (
|
|
!features?.length ||
|
|
!mapboxMap
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const center =
|
|
features[0].geometry.coordinates;
|
|
|
|
// ---------------------------------
|
|
// RAGGIERA DINAMICA
|
|
// ---------------------------------
|
|
|
|
const count =
|
|
features.length;
|
|
|
|
const radiusPx = Math.min(
|
|
69,
|
|
Math.max(
|
|
20,
|
|
20 + count * 5,
|
|
),
|
|
);
|
|
|
|
console.log(
|
|
"[PhotoSpiderfy] raggiera",
|
|
{
|
|
foto: count,
|
|
radiusPx,
|
|
},
|
|
);
|
|
|
|
// ---------------------------------
|
|
// CENTRO PIXEL
|
|
// ---------------------------------
|
|
|
|
const centerPoint =
|
|
mapboxMap.project(center);
|
|
|
|
hideClusterMarkers();
|
|
|
|
// ---------------------------------
|
|
// CREA MARKER
|
|
// ---------------------------------
|
|
|
|
features.forEach(
|
|
(feature, index) => {
|
|
const id = String(
|
|
feature.properties?.id,
|
|
);
|
|
|
|
/*
|
|
* Se esiste già un marker normale,
|
|
* lo rimuoviamo temporaneamente.
|
|
*/
|
|
if (
|
|
window.MapboxPhotoMarkers
|
|
) {
|
|
const existing =
|
|
window.MapboxPhotoMarkers.get(
|
|
id,
|
|
);
|
|
|
|
if (existing) {
|
|
existing.remove();
|
|
}
|
|
}
|
|
|
|
// --------------------------------
|
|
// CREA MARKER SPIDER
|
|
// --------------------------------
|
|
|
|
const marker =
|
|
createSpiderMarker(
|
|
feature,
|
|
id,
|
|
);
|
|
|
|
// --------------------------------
|
|
// POSIZIONE
|
|
// --------------------------------
|
|
|
|
const angle =
|
|
(Math.PI * 2 * index) /
|
|
features.length;
|
|
|
|
const point = {
|
|
x:
|
|
centerPoint.x +
|
|
Math.cos(angle) *
|
|
radiusPx,
|
|
|
|
y:
|
|
centerPoint.y +
|
|
Math.sin(angle) *
|
|
radiusPx,
|
|
};
|
|
|
|
const lngLat =
|
|
mapboxMap.unproject(
|
|
point,
|
|
);
|
|
|
|
marker.setLngLat(
|
|
lngLat,
|
|
);
|
|
|
|
marker.__originalLngLat =
|
|
feature.geometry.coordinates;
|
|
|
|
spiderMarkers.push(
|
|
marker,
|
|
);
|
|
},
|
|
);
|
|
|
|
spiderfyOpen = true;
|
|
|
|
console.log(
|
|
"[PhotoSpiderfy] aperto:",
|
|
features.length,
|
|
);
|
|
}
|
|
|
|
// ===================================
|
|
// 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
|
|
) {
|
|
console.log(
|
|
"[PhotoSpiderfy] smart zoom limite",
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
steps++;
|
|
|
|
source.getClusterExpansionZoom(
|
|
Number(clusterId),
|
|
(err, expansionZoom) => {
|
|
if (err) {
|
|
console.error(
|
|
"[PhotoSpiderfy] smart zoom error",
|
|
err,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
const currentZoom =
|
|
mapboxMap.getZoom();
|
|
|
|
const targetZoom =
|
|
Math.max(
|
|
expansionZoom,
|
|
currentZoom + 1,
|
|
);
|
|
|
|
console.log(
|
|
"[PhotoSpiderfy] smart zoom step",
|
|
{
|
|
clusterId,
|
|
step: steps,
|
|
currentZoom,
|
|
expansionZoom,
|
|
targetZoom,
|
|
},
|
|
);
|
|
|
|
mapboxMap.once(
|
|
"moveend",
|
|
() => {
|
|
const hits =
|
|
mapboxMap.querySourceFeatures(
|
|
SOURCE_ID,
|
|
{
|
|
filter: [
|
|
"has",
|
|
"point_count",
|
|
],
|
|
},
|
|
);
|
|
|
|
const sameCluster =
|
|
hits.some(
|
|
(feature) =>
|
|
Number(
|
|
feature
|
|
.properties
|
|
?.cluster_id,
|
|
) ===
|
|
Number(
|
|
clusterId,
|
|
),
|
|
);
|
|
|
|
console.log(
|
|
"[PhotoSpiderfy] smart zoom check",
|
|
{
|
|
clusterId,
|
|
sameCluster,
|
|
zoom:
|
|
mapboxMap.getZoom(),
|
|
},
|
|
);
|
|
|
|
if (sameCluster) {
|
|
zoomStep();
|
|
} else {
|
|
console.log(
|
|
"[PhotoSpiderfy] cluster espanso",
|
|
);
|
|
|
|
if (
|
|
window
|
|
.MapboxPhotoClusters
|
|
) {
|
|
window
|
|
.MapboxPhotoClusters
|
|
.update();
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
mapboxMap.easeTo({
|
|
center,
|
|
zoom: targetZoom,
|
|
duration: 700,
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
zoomStep();
|
|
}
|
|
|
|
// ===================================
|
|
// CLICK CLUSTER
|
|
// ===================================
|
|
|
|
function handleClusterClick(
|
|
clusterId,
|
|
center,
|
|
) {
|
|
if (!mapboxMap) {
|
|
return;
|
|
}
|
|
|
|
const source =
|
|
mapboxMap.getSource(
|
|
SOURCE_ID,
|
|
);
|
|
|
|
if (!source) {
|
|
return;
|
|
}
|
|
|
|
console.log(
|
|
"[PhotoSpiderfy] cluster click",
|
|
{
|
|
clusterId,
|
|
center,
|
|
},
|
|
);
|
|
|
|
source.getClusterLeaves(
|
|
Number(clusterId),
|
|
100,
|
|
0,
|
|
(err, leaves) => {
|
|
if (err) {
|
|
console.error(
|
|
"[PhotoSpiderfy] cluster leaves error",
|
|
err,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!leaves.length) {
|
|
return;
|
|
}
|
|
|
|
console.log(
|
|
"[PhotoSpiderfy] cluster leaves:",
|
|
leaves.length,
|
|
);
|
|
|
|
// =================================
|
|
// CONTROLLA SE SONO RAGGRUPPATE
|
|
// =================================
|
|
|
|
const first =
|
|
leaves[0].geometry.coordinates;
|
|
|
|
const firstLng =
|
|
first[0];
|
|
|
|
const firstLat =
|
|
first[1];
|
|
|
|
const radiusMeters = 30;
|
|
|
|
const latDelta =
|
|
radiusMeters / 111320;
|
|
|
|
const lngDelta =
|
|
radiusMeters /
|
|
(
|
|
111320 *
|
|
Math.cos(
|
|
(firstLat * Math.PI) /
|
|
180,
|
|
)
|
|
);
|
|
|
|
const minLat =
|
|
firstLat - latDelta;
|
|
|
|
const maxLat =
|
|
firstLat + latDelta;
|
|
|
|
const minLng =
|
|
firstLng - lngDelta;
|
|
|
|
const maxLng =
|
|
firstLng + lngDelta;
|
|
|
|
let isSpider = true;
|
|
|
|
for (
|
|
let i = 1;
|
|
i < leaves.length;
|
|
i++
|
|
) {
|
|
const coords =
|
|
leaves[i].geometry.coordinates;
|
|
|
|
const lng =
|
|
coords[0];
|
|
|
|
const lat =
|
|
coords[1];
|
|
|
|
if (
|
|
lat < minLat ||
|
|
lat > maxLat ||
|
|
lng < minLng ||
|
|
lng > maxLng
|
|
) {
|
|
isSpider = false;
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
"[PhotoSpiderfy] cluster spider:",
|
|
isSpider,
|
|
);
|
|
|
|
// =================================
|
|
// SPIDERFY
|
|
// =================================
|
|
|
|
if (isSpider) {
|
|
clear();
|
|
|
|
spiderfyPhotos(
|
|
leaves,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
// =================================
|
|
// SMART ZOOM
|
|
// =================================
|
|
|
|
smartZoomCluster(
|
|
clusterId,
|
|
center,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// ===================================
|
|
// IS OPEN
|
|
// ===================================
|
|
|
|
function isOpen() {
|
|
return spiderfyOpen;
|
|
}
|
|
|
|
// ===================================
|
|
// SIZE
|
|
// ===================================
|
|
|
|
function size() {
|
|
return spiderMarkers.length;
|
|
}
|
|
|
|
// ===================================
|
|
// DEBUG
|
|
// ===================================
|
|
|
|
function debug() {
|
|
console.log(
|
|
"[PhotoSpiderfy] debug",
|
|
{
|
|
open: spiderfyOpen,
|
|
|
|
markers:
|
|
spiderMarkers.length,
|
|
},
|
|
);
|
|
}
|
|
|
|
// ===================================
|
|
// API PUBBLICA
|
|
// ===================================
|
|
|
|
window.MapboxPhotoSpiderfy = {
|
|
setMap,
|
|
|
|
clear,
|
|
|
|
spiderfyPhotos,
|
|
|
|
getNearbyPhotos,
|
|
|
|
handleClusterClick,
|
|
|
|
smartZoomCluster,
|
|
|
|
isOpen,
|
|
|
|
size,
|
|
|
|
debug,
|
|
};
|
|
})();
|