964 lines
16 KiB
JavaScript
964 lines
16 KiB
JavaScript
// 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,
|
|
};
|
|
})();
|