604 lines
14 KiB
JavaScript
604 lines
14 KiB
JavaScript
// public/js/mapActivitiesLayer.js
|
|
// ===============================
|
|
// LAYER MAPPA ATTIVITÀ
|
|
// ===============================
|
|
|
|
(function () {
|
|
const ACTIVITY_ROUTE_COLORS = [
|
|
"#e6194b",
|
|
"#3cb44b",
|
|
"#4363d8",
|
|
"#f58231",
|
|
"#911eb4",
|
|
"#46f0f0",
|
|
"#f032e6",
|
|
"#bcf60c",
|
|
"#008080",
|
|
"#9a6324",
|
|
"#800000",
|
|
"#000075",
|
|
"#ff6b00",
|
|
"#00a8e8",
|
|
"#8d6e63",
|
|
"#2e7d32",
|
|
];
|
|
|
|
const MAX_GLOBAL_ACTIVITY_ROUTES = 80;
|
|
|
|
let activityRoutesLayer = null;
|
|
let selectedActivityLayer = null;
|
|
let selectedActivityEndMarker = null;
|
|
let showAllActivityRoutes = false;
|
|
let cachedRouteData = [];
|
|
let lastRenderToken = 0;
|
|
|
|
// ===============================
|
|
// CLUSTER ATTIVITÀ
|
|
// ===============================
|
|
window.createActivityClusterIcon = function (cluster) {
|
|
const count = cluster.getChildCount();
|
|
|
|
return L.divIcon({
|
|
html: `
|
|
<div class="activity-cluster">
|
|
${count}
|
|
</div>
|
|
`,
|
|
className: "marker-cluster-wrapper",
|
|
iconSize: L.point(48, 48),
|
|
});
|
|
};
|
|
|
|
// ===============================
|
|
// PULIZIA LAYER ATTIVITÀ
|
|
// ===============================
|
|
window.clearActivityMapLayers = function () {
|
|
if (activityRoutesLayer) {
|
|
activityRoutesLayer.clearLayers();
|
|
}
|
|
|
|
if (selectedActivityLayer) {
|
|
selectedActivityLayer.remove();
|
|
selectedActivityLayer = null;
|
|
}
|
|
|
|
if (selectedActivityEndMarker) {
|
|
selectedActivityEndMarker.remove();
|
|
selectedActivityEndMarker = null;
|
|
}
|
|
};
|
|
|
|
// ===============================
|
|
// TOGGLE TRACCE
|
|
// ===============================
|
|
function ensureActivityRoutesToggle() {
|
|
let btn = document.getElementById("toggleActivityRoutesBtn");
|
|
|
|
if (btn) return btn;
|
|
|
|
btn = document.createElement("button");
|
|
btn.id = "toggleActivityRoutesBtn";
|
|
btn.type = "button";
|
|
btn.className = "map-toggle-routes";
|
|
btn.textContent = "Mostra tracce";
|
|
|
|
btn.addEventListener("click", async (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
showAllActivityRoutes = !showAllActivityRoutes;
|
|
|
|
btn.classList.toggle("active", showAllActivityRoutes);
|
|
btn.textContent = showAllActivityRoutes
|
|
? "Nascondi tracce"
|
|
: "Mostra tracce";
|
|
|
|
await renderAllActivityRoutes();
|
|
});
|
|
|
|
document.getElementById("globalMap")?.appendChild(btn);
|
|
|
|
return btn;
|
|
}
|
|
|
|
window.hideActivityRoutesToggle = function () {
|
|
const btn = document.getElementById("toggleActivityRoutesBtn");
|
|
|
|
if (btn) {
|
|
btn.style.display = "none";
|
|
}
|
|
|
|
showAllActivityRoutes = false;
|
|
};
|
|
|
|
function showActivityRoutesToggle() {
|
|
const btn = ensureActivityRoutesToggle();
|
|
|
|
btn.style.display = "inline-flex";
|
|
btn.classList.toggle("active", showAllActivityRoutes);
|
|
btn.textContent = showAllActivityRoutes
|
|
? "Nascondi tracce"
|
|
: "Mostra tracce";
|
|
}
|
|
|
|
// ===============================
|
|
// ICONA MARKER ATTIVITÀ
|
|
// ===============================
|
|
function createActivityStartIcon(item) {
|
|
const sport = String(item.sport || "").toLowerCase();
|
|
|
|
let emoji = "●";
|
|
let cls = "activity-marker-generic";
|
|
|
|
if (sport.includes("run") || sport.includes("corsa")) {
|
|
emoji = "🏃";
|
|
cls = "activity-marker-run";
|
|
} else if (
|
|
sport.includes("bike") ||
|
|
sport.includes("bici") ||
|
|
sport.includes("cycle")
|
|
) {
|
|
emoji = "🚴";
|
|
cls = "activity-marker-bike";
|
|
} else if (sport.includes("walk") || sport.includes("cammin")) {
|
|
emoji = "🚶";
|
|
cls = "activity-marker-walk";
|
|
}
|
|
|
|
return L.divIcon({
|
|
html: `
|
|
<div class="activity-start-marker ${cls}">
|
|
<span>${emoji}</span>
|
|
</div>
|
|
`,
|
|
className: "marker-cluster-wrapper",
|
|
iconSize: L.point(42, 42),
|
|
iconAnchor: L.point(21, 21),
|
|
});
|
|
}
|
|
|
|
// ===============================
|
|
// RENDER ATTIVITÀ
|
|
// ===============================
|
|
window.renderActivityMapLayer = async function () {
|
|
if (!window.globalMap || !window.globalMarkers) return;
|
|
|
|
const token = ++lastRenderToken;
|
|
|
|
showActivityRoutesToggle();
|
|
|
|
if (!activityRoutesLayer) {
|
|
activityRoutesLayer = L.layerGroup().addTo(window.globalMap);
|
|
}
|
|
|
|
activityRoutesLayer.clearLayers();
|
|
clearSelectedActivityOnly();
|
|
|
|
const items = getLocalItems();
|
|
const activityItems = items.filter(isProbablyActivity);
|
|
|
|
cachedRouteData = [];
|
|
|
|
const routeDataList = await Promise.all(
|
|
activityItems.map(async (item, index) => {
|
|
const points = await getRoutePointsSafe(item);
|
|
|
|
if (!points || points.length < 2) {
|
|
return null;
|
|
}
|
|
|
|
const latlngs = pointsToLatLngs(points);
|
|
|
|
if (latlngs.length < 2) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
key: getActivityKey(item, index),
|
|
index,
|
|
item,
|
|
points,
|
|
latlngs,
|
|
start: latlngs[0],
|
|
end: latlngs[latlngs.length - 1],
|
|
cells: getRouteCells(points),
|
|
};
|
|
}),
|
|
);
|
|
|
|
if (token !== lastRenderToken) return;
|
|
|
|
cachedRouteData = routeDataList.filter(Boolean);
|
|
|
|
cachedRouteData.forEach((route) => {
|
|
const marker = L.marker(route.start, {
|
|
icon: createActivityStartIcon(route.item),
|
|
title:
|
|
route.item.display_name ||
|
|
route.item.route_name ||
|
|
route.item.name ||
|
|
route.item.file_name ||
|
|
"",
|
|
});
|
|
|
|
marker.__item = route.item;
|
|
marker.__routeData = route;
|
|
|
|
marker.on("click", () => {
|
|
showSelectedActivityOnGlobalMap(route);
|
|
});
|
|
|
|
window.MapEngine?.addMarker(marker);
|
|
});
|
|
|
|
if (cachedRouteData.length > 0) {
|
|
const bounds = L.latLngBounds(
|
|
cachedRouteData.map((route) => route.start),
|
|
);
|
|
|
|
if (bounds.isValid()) {
|
|
window.globalMap.fitBounds(bounds, {
|
|
padding: [60, 60],
|
|
maxZoom: 12,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (showAllActivityRoutes) {
|
|
await renderAllActivityRoutes();
|
|
}
|
|
};
|
|
|
|
// ===============================
|
|
// TRACCIA SELEZIONATA
|
|
// ===============================
|
|
function showSelectedActivityOnGlobalMap(route) {
|
|
clearSelectedActivityOnly();
|
|
|
|
selectedActivityLayer = L.polyline(route.latlngs, {
|
|
color: "#ff6b00",
|
|
weight: 5,
|
|
opacity: 1,
|
|
lineCap: "round",
|
|
lineJoin: "round",
|
|
}).addTo(window.globalMap);
|
|
|
|
selectedActivityLayer.on("click", () => {
|
|
window.openActivityModal?.(route.item);
|
|
});
|
|
|
|
selectedActivityEndMarker = L.circleMarker(route.end, {
|
|
radius: 7,
|
|
color: "#b00020",
|
|
weight: 2,
|
|
fillColor: "#e32636",
|
|
fillOpacity: 1,
|
|
})
|
|
.bindTooltip("Arrivo")
|
|
.addTo(window.globalMap);
|
|
|
|
selectedActivityEndMarker.on("click", () => {
|
|
window.openActivityModal?.(route.item);
|
|
});
|
|
|
|
const popupHtml = createActivityPopupHtml(route.item);
|
|
|
|
selectedActivityLayer.bindPopup(popupHtml).openPopup();
|
|
|
|
window.globalMap.fitBounds(selectedActivityLayer.getBounds(), {
|
|
padding: [50, 50],
|
|
maxZoom: 15,
|
|
});
|
|
|
|
setTimeout(() => {
|
|
const btn = document.querySelector(
|
|
`[data-open-activity-key="${route.key}"]`,
|
|
);
|
|
|
|
btn?.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
window.openActivityModal?.(route.item);
|
|
});
|
|
}, 0);
|
|
}
|
|
|
|
function clearSelectedActivityOnly() {
|
|
if (selectedActivityLayer) {
|
|
selectedActivityLayer.remove();
|
|
selectedActivityLayer = null;
|
|
}
|
|
|
|
if (selectedActivityEndMarker) {
|
|
selectedActivityEndMarker.remove();
|
|
selectedActivityEndMarker = null;
|
|
}
|
|
}
|
|
|
|
// ===============================
|
|
// TOGGLE TUTTE LE TRACCE
|
|
// ===============================
|
|
async function renderAllActivityRoutes() {
|
|
if (!activityRoutesLayer) {
|
|
activityRoutesLayer = L.layerGroup().addTo(window.globalMap);
|
|
}
|
|
|
|
activityRoutesLayer.clearLayers();
|
|
|
|
if (!showAllActivityRoutes) return;
|
|
|
|
let routesToDraw = cachedRouteData;
|
|
|
|
if (routesToDraw.length > MAX_GLOBAL_ACTIVITY_ROUTES) {
|
|
const bounds = window.globalMap.getBounds();
|
|
|
|
routesToDraw = routesToDraw.filter((route) =>
|
|
bounds.contains(route.start),
|
|
);
|
|
}
|
|
|
|
if (routesToDraw.length > MAX_GLOBAL_ACTIVITY_ROUTES) {
|
|
console.warn(
|
|
`[activity map] Troppe tracce da disegnare: ${routesToDraw.length}. Zooma di più.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const colors = assignSmartRouteColors(routesToDraw);
|
|
|
|
routesToDraw.forEach((route) => {
|
|
const color =
|
|
colors.get(route.key) ||
|
|
ACTIVITY_ROUTE_COLORS[route.index % ACTIVITY_ROUTE_COLORS.length];
|
|
|
|
const polyline = L.polyline(route.latlngs, {
|
|
color,
|
|
weight: 5,
|
|
opacity: 0.75,
|
|
lineCap: "round",
|
|
lineJoin: "round",
|
|
});
|
|
|
|
polyline.on("click", () => {
|
|
showSelectedActivityOnGlobalMap(route);
|
|
});
|
|
|
|
activityRoutesLayer.addLayer(polyline);
|
|
});
|
|
}
|
|
|
|
// ===============================
|
|
// COLORI INTELLIGENTI
|
|
// ===============================
|
|
function assignSmartRouteColors(routes) {
|
|
const cellToColors = new Map();
|
|
const result = new Map();
|
|
|
|
routes.forEach((route, index) => {
|
|
const usedNearbyColors = new Set();
|
|
|
|
route.cells.forEach((cell) => {
|
|
const colors = cellToColors.get(cell);
|
|
|
|
if (!colors) return;
|
|
|
|
colors.forEach((color) => usedNearbyColors.add(color));
|
|
});
|
|
|
|
const color =
|
|
ACTIVITY_ROUTE_COLORS.find((c) => !usedNearbyColors.has(c)) ||
|
|
ACTIVITY_ROUTE_COLORS[index % ACTIVITY_ROUTE_COLORS.length];
|
|
|
|
result.set(route.key, color);
|
|
|
|
route.cells.forEach((cell) => {
|
|
if (!cellToColors.has(cell)) {
|
|
cellToColors.set(cell, new Set());
|
|
}
|
|
|
|
cellToColors.get(cell).add(color);
|
|
});
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
function getRouteCells(points, precision = 3) {
|
|
const cells = new Set();
|
|
|
|
points.forEach((point) => {
|
|
const lat = Number(point.lat);
|
|
const lng = Number(point.lng);
|
|
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return;
|
|
|
|
cells.add(`${lat.toFixed(precision)}:${lng.toFixed(precision)}`);
|
|
});
|
|
|
|
return cells;
|
|
}
|
|
|
|
// ===============================
|
|
// HELPERS
|
|
// ===============================
|
|
function isProbablyActivity(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
|
|
);
|
|
}
|
|
|
|
async function getRoutePointsSafe(item) {
|
|
try {
|
|
if (typeof window.getActivityRoutePoints === "function") {
|
|
return await window.getActivityRoutePoints(item);
|
|
}
|
|
|
|
if (typeof getActivityRoutePoints === "function") {
|
|
return await getActivityRoutePoints(item);
|
|
}
|
|
} catch (error) {
|
|
console.warn("[activity map] errore caricamento punti:", error);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function pointsToLatLngs(points) {
|
|
return points
|
|
.map((point) => [Number(point.lat), Number(point.lng)])
|
|
.filter(
|
|
(point) => Number.isFinite(point[0]) && Number.isFinite(point[1]),
|
|
);
|
|
}
|
|
|
|
function getActivityKey(item, index) {
|
|
return String(item.id || item.path || item.file_name || item.name || index);
|
|
}
|
|
|
|
function createActivityPopupHtml(item) {
|
|
const key = escapeHtml(
|
|
String(item.id || item.path || item.file_name || item.name || ""),
|
|
);
|
|
|
|
const title =
|
|
item.display_name ||
|
|
item.route_name ||
|
|
item.title ||
|
|
item.file_name ||
|
|
item.name ||
|
|
"Attività";
|
|
|
|
const distance = Number(item.distance_meters);
|
|
const duration = Number(item.duration_millis);
|
|
|
|
const distanceText = Number.isFinite(distance)
|
|
? `${(distance / 1000).toFixed(1)} km`
|
|
: "-";
|
|
|
|
const durationText =
|
|
Number.isFinite(duration) &&
|
|
typeof window.formatDurationShort === "function"
|
|
? window.formatDurationShort(duration)
|
|
: formatDurationFallback(duration);
|
|
|
|
return `
|
|
<div class="activity-map-popup">
|
|
<strong>${escapeHtml(title)}</strong>
|
|
|
|
${item.sport ? `<div>${escapeHtml(item.sport)}</div>` : ""}
|
|
|
|
<div>${distanceText} · ${durationText}</div>
|
|
|
|
<button
|
|
type="button"
|
|
class="activity-popup-open"
|
|
data-open-activity-key="${key}"
|
|
>
|
|
Apri percorso
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function formatDurationFallback(milliseconds) {
|
|
const value = Number(milliseconds);
|
|
|
|
if (!Number.isFinite(value)) return "-";
|
|
|
|
const totalSeconds = Math.round(value / 1000);
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
|
|
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
// ===============================
|
|
// GEOJSON MAPBOX ATTIVITÀ
|
|
// ===============================
|
|
window.getActivityMapGeoJSON = function () {
|
|
const features = [];
|
|
|
|
for (const route of cachedRouteData) {
|
|
if (!route.start) {
|
|
continue;
|
|
}
|
|
|
|
features.push({
|
|
type: "Feature",
|
|
|
|
geometry: {
|
|
type: "Point",
|
|
coordinates: [route.start.lng, route.start.lat],
|
|
},
|
|
|
|
properties: {
|
|
id: route.key,
|
|
type: "activity",
|
|
name:
|
|
route.item.display_name ||
|
|
route.item.route_name ||
|
|
route.item.name ||
|
|
"Attività",
|
|
},
|
|
});
|
|
}
|
|
|
|
return features;
|
|
};
|
|
|
|
// ===============================
|
|
// LINEE PERCORSI MAPBOX
|
|
// ===============================
|
|
window.getActivityRoutesGeoJSON = function () {
|
|
const features = [];
|
|
|
|
for (const route of cachedRouteData) {
|
|
if (!route.latlngs || route.latlngs.length < 2) {
|
|
continue;
|
|
}
|
|
|
|
features.push({
|
|
type: "Feature",
|
|
|
|
geometry: {
|
|
type: "LineString",
|
|
|
|
coordinates: route.latlngs.map((p) => [p.lng, p.lat]),
|
|
},
|
|
|
|
properties: {
|
|
id: route.key,
|
|
type: "route",
|
|
},
|
|
});
|
|
}
|
|
|
|
return features;
|
|
};
|
|
})();
|