554 lines
12 KiB
JavaScript
554 lines
12 KiB
JavaScript
// public/js/activityThumbs.js
|
|
// Caricamento, parsing e rendering delle anteprime dei percorsi.
|
|
|
|
(() => {
|
|
"use strict";
|
|
|
|
const routeCache = window.activityRouteCache || new Map();
|
|
window.activityRouteCache = routeCache;
|
|
|
|
function normalizePoint(point) {
|
|
if (!point) return null;
|
|
|
|
if (Number.isFinite(Number(point.lat)) && Number.isFinite(Number(point.lng))) {
|
|
return { lat: Number(point.lat), lng: Number(point.lng) };
|
|
}
|
|
if (Number.isFinite(Number(point.latitude)) && Number.isFinite(Number(point.longitude))) {
|
|
return { lat: Number(point.latitude), lng: Number(point.longitude) };
|
|
}
|
|
if (Number.isFinite(Number(point.lat)) && Number.isFinite(Number(point.lon))) {
|
|
return { lat: Number(point.lat), lng: Number(point.lon) };
|
|
}
|
|
if (Array.isArray(point) && point.length >= 2) {
|
|
const lng = Number(point[0]);
|
|
const lat = Number(point[1]);
|
|
if (Number.isFinite(lat) && Number.isFinite(lng)) return { lat, lng };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function normalizePoints(candidate) {
|
|
if (!Array.isArray(candidate)) {
|
|
return [];
|
|
}
|
|
|
|
return candidate
|
|
.map(normalizePoint)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function extractPreviewPoints(item) {
|
|
if (!item) {
|
|
return [];
|
|
}
|
|
|
|
const preview =
|
|
normalizePoints(
|
|
item.track_preview,
|
|
);
|
|
|
|
if (preview.length >= 2) {
|
|
return preview;
|
|
}
|
|
|
|
return extractFullDirectPoints(
|
|
item,
|
|
);
|
|
}
|
|
|
|
function extractFullDirectPoints(item) {
|
|
if (!item) {
|
|
return [];
|
|
}
|
|
|
|
const candidates = [
|
|
item.points,
|
|
item.track_points,
|
|
item.coordinates,
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const points =
|
|
normalizePoints(
|
|
candidate,
|
|
);
|
|
|
|
if (points.length >= 2) {
|
|
return points;
|
|
}
|
|
}
|
|
|
|
const geojson =
|
|
item.geojson;
|
|
|
|
const coordinates =
|
|
geojson?.type ===
|
|
"LineString"
|
|
? geojson.coordinates
|
|
: geojson?.type ===
|
|
"Feature" &&
|
|
geojson.geometry?.type ===
|
|
"LineString"
|
|
? geojson.geometry
|
|
.coordinates
|
|
: null;
|
|
|
|
return normalizePoints(
|
|
coordinates,
|
|
);
|
|
}
|
|
|
|
function simplifyPoints(points, maxPoints = 350) {
|
|
if (!Array.isArray(points)) return [];
|
|
if (points.length <= maxPoints) return points;
|
|
|
|
const step = Math.ceil(points.length / maxPoints);
|
|
const output = [];
|
|
|
|
for (let index = 0; index < points.length; index += step) {
|
|
output.push(points[index]);
|
|
}
|
|
|
|
const last = points.at(-1);
|
|
const outputLast = output.at(-1);
|
|
if (!outputLast || outputLast.lat !== last.lat || outputLast.lng !== last.lng) {
|
|
output.push(last);
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
async function fetchActivityText(filePath) {
|
|
const headers = {};
|
|
const token = localStorage.getItem("token");
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
|
|
const response = await fetch(filePath, { headers });
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status} caricando ${filePath}`);
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
async function fetchFullActivityPoints(
|
|
item,
|
|
) {
|
|
if (!item) {
|
|
return [];
|
|
}
|
|
|
|
const activityId =
|
|
item.id;
|
|
|
|
if (
|
|
activityId === undefined ||
|
|
activityId === null ||
|
|
activityId === ""
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
const token =
|
|
localStorage.getItem(
|
|
"token",
|
|
) ||
|
|
sessionStorage.getItem(
|
|
"token",
|
|
);
|
|
|
|
const headers = {};
|
|
|
|
if (token) {
|
|
headers.Authorization =
|
|
`Bearer ${token}`;
|
|
}
|
|
|
|
const response =
|
|
await fetch(
|
|
`/activities/${encodeURIComponent(
|
|
activityId,
|
|
)}/route-points`,
|
|
{
|
|
method: "GET",
|
|
|
|
headers,
|
|
|
|
cache: "no-store",
|
|
|
|
credentials:
|
|
"same-origin",
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
let message =
|
|
`HTTP ${response.status}`;
|
|
|
|
try {
|
|
const errorPayload =
|
|
await response.json();
|
|
|
|
message =
|
|
errorPayload.error ||
|
|
errorPayload.details ||
|
|
message;
|
|
} catch {}
|
|
|
|
throw new Error(
|
|
message,
|
|
);
|
|
}
|
|
|
|
const payload =
|
|
await response.json();
|
|
|
|
const rawPoints =
|
|
Array.isArray(
|
|
payload.points,
|
|
)
|
|
? payload.points
|
|
: [];
|
|
|
|
const points =
|
|
rawPoints
|
|
.map(normalizePoint)
|
|
.filter(Boolean);
|
|
|
|
console.log(
|
|
"[activity route] punti completi ricevuti",
|
|
{
|
|
id:
|
|
activityId,
|
|
|
|
expected:
|
|
payload
|
|
.expected_point_count ??
|
|
null,
|
|
|
|
received:
|
|
points.length,
|
|
|
|
format:
|
|
payload.format ??
|
|
item.format ??
|
|
null,
|
|
},
|
|
);
|
|
|
|
return points;
|
|
}
|
|
|
|
|
|
function parseTcxPoints(text) {
|
|
const xml = new DOMParser().parseFromString(text, "application/xml");
|
|
const parseError = xml.getElementsByTagName("parsererror")[0];
|
|
if (parseError) {
|
|
console.warn("[parseTcxPoints] XML parser error", parseError.textContent);
|
|
return [];
|
|
}
|
|
|
|
return Array.from(xml.getElementsByTagName("Trackpoint"))
|
|
.map(trackpoint => {
|
|
const lat = Number(trackpoint.getElementsByTagName("LatitudeDegrees")[0]?.textContent);
|
|
const lng = Number(trackpoint.getElementsByTagName("LongitudeDegrees")[0]?.textContent);
|
|
return Number.isFinite(lat) && Number.isFinite(lng) ? { lat, lng } : null;
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function parseGpxPoints(text) {
|
|
const xml = new DOMParser().parseFromString(text, "application/xml");
|
|
const parseError = xml.getElementsByTagName("parsererror")[0];
|
|
if (parseError) {
|
|
console.warn("[parseGpxPoints] XML parser error", parseError.textContent);
|
|
return [];
|
|
}
|
|
|
|
return Array.from(xml.getElementsByTagName("trkpt"))
|
|
.map(point => {
|
|
const lat = Number(point.getAttribute("lat"));
|
|
const lng = Number(point.getAttribute("lon"));
|
|
return Number.isFinite(lat) && Number.isFinite(lng) ? { lat, lng } : null;
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function getRoutePoints(
|
|
item,
|
|
options = {},
|
|
) {
|
|
if (!item) {
|
|
return [];
|
|
}
|
|
|
|
const full =
|
|
options.full === true;
|
|
|
|
const baseCacheKey =
|
|
item.id ||
|
|
item.path ||
|
|
item.name;
|
|
|
|
const cacheKey =
|
|
baseCacheKey
|
|
? `${full ? "full" : "preview"}:${baseCacheKey}`
|
|
: null;
|
|
|
|
if (
|
|
cacheKey &&
|
|
routeCache.has(
|
|
cacheKey,
|
|
)
|
|
) {
|
|
const cachedPoints =
|
|
routeCache.get(
|
|
cacheKey,
|
|
);
|
|
|
|
console.log(
|
|
"[activity route] punti dalla cache",
|
|
{
|
|
mode:
|
|
full
|
|
? "full"
|
|
: "preview",
|
|
|
|
loaded:
|
|
Array.isArray(
|
|
cachedPoints,
|
|
)
|
|
? cachedPoints.length
|
|
: 0,
|
|
|
|
cacheKey,
|
|
},
|
|
);
|
|
|
|
return Array.isArray(
|
|
cachedPoints,
|
|
)
|
|
? cachedPoints
|
|
: [];
|
|
}
|
|
|
|
const format =
|
|
String(
|
|
item.format ||
|
|
item.file_extension ||
|
|
item.mime_type ||
|
|
"",
|
|
)
|
|
.toLowerCase()
|
|
.trim();
|
|
|
|
const lowerPath =
|
|
String(
|
|
item.path ||
|
|
item.file_name ||
|
|
item.name ||
|
|
"",
|
|
)
|
|
.toLowerCase()
|
|
.trim();
|
|
|
|
const isFit =
|
|
format.includes("fit") ||
|
|
lowerPath.endsWith(
|
|
".fit",
|
|
);
|
|
|
|
const isTcx =
|
|
format.includes("tcx") ||
|
|
lowerPath.endsWith(
|
|
".tcx",
|
|
);
|
|
|
|
const isGpx =
|
|
format.includes("gpx") ||
|
|
lowerPath.endsWith(
|
|
".gpx",
|
|
);
|
|
|
|
let points =
|
|
full
|
|
? extractFullDirectPoints(
|
|
item,
|
|
)
|
|
: extractPreviewPoints(
|
|
item,
|
|
);
|
|
|
|
/*
|
|
* In modalità full, per i FIT non usa
|
|
* track_preview: richiede tutti i punti
|
|
* all'endpoint protetto.
|
|
*/
|
|
if (
|
|
full &&
|
|
points.length < 2 &&
|
|
isFit
|
|
) {
|
|
points =
|
|
await fetchFullActivityPoints(
|
|
item,
|
|
);
|
|
}
|
|
|
|
/*
|
|
* GPX e TCX possono essere letti
|
|
* direttamente dal file originale.
|
|
*/
|
|
if (
|
|
full &&
|
|
points.length < 2 &&
|
|
item.path &&
|
|
(
|
|
isTcx ||
|
|
isGpx
|
|
)
|
|
) {
|
|
const text =
|
|
await fetchActivityText(
|
|
item.path,
|
|
);
|
|
|
|
points =
|
|
isTcx
|
|
? parseTcxPoints(
|
|
text,
|
|
)
|
|
: parseGpxPoints(
|
|
text,
|
|
);
|
|
}
|
|
|
|
/*
|
|
* Solo le preview vengono ridotte.
|
|
* La traccia full rimane completa.
|
|
*/
|
|
if (!full) {
|
|
points =
|
|
simplifyPoints(
|
|
points,
|
|
350,
|
|
);
|
|
}
|
|
|
|
if (!Array.isArray(points)) {
|
|
points = [];
|
|
}
|
|
|
|
if (cacheKey) {
|
|
routeCache.set(
|
|
cacheKey,
|
|
points,
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
"[activity route] punti caricati",
|
|
{
|
|
id:
|
|
item.id,
|
|
|
|
mode:
|
|
full
|
|
? "full"
|
|
: "preview",
|
|
|
|
format:
|
|
item.format ||
|
|
item.file_extension ||
|
|
null,
|
|
|
|
expected:
|
|
Number(
|
|
item.point_count,
|
|
) || null,
|
|
|
|
loaded:
|
|
points.length,
|
|
|
|
cacheKey,
|
|
},
|
|
);
|
|
|
|
return points;
|
|
}
|
|
|
|
function createRouteSvg(points) {
|
|
const width = 240;
|
|
const height = 160;
|
|
const padding = 16;
|
|
const lats = points.map(point => point.lat);
|
|
const lngs = points.map(point => point.lng);
|
|
const minLat = Math.min(...lats);
|
|
const maxLat = Math.max(...lats);
|
|
const minLng = Math.min(...lngs);
|
|
const maxLng = Math.max(...lngs);
|
|
const latRange = maxLat - minLat || 0.000001;
|
|
const lngRange = maxLng - minLng || 0.000001;
|
|
const usableWidth = width - padding * 2;
|
|
const usableHeight = height - padding * 2;
|
|
|
|
const svgPoints = points.map(point => {
|
|
const x = padding + ((point.lng - minLng) / lngRange) * usableWidth;
|
|
const y = padding + ((maxLat - point.lat) / latRange) * usableHeight;
|
|
return { x, y, text: `${x.toFixed(1)},${y.toFixed(1)}` };
|
|
});
|
|
|
|
const pointsAttribute = svgPoints.map(point => point.text).join(" ");
|
|
const first = svgPoints[0];
|
|
const last = svgPoints.at(-1);
|
|
|
|
return `
|
|
<svg viewBox="0 0 ${width} ${height}" class="activity-route-svg" aria-hidden="true">
|
|
<rect x="0" y="0" width="${width}" height="${height}" rx="16" class="activity-route-bg"></rect>
|
|
<polyline points="${pointsAttribute}" class="activity-route-line-shadow"></polyline>
|
|
<polyline points="${pointsAttribute}" class="activity-route-line"></polyline>
|
|
<circle cx="${first.x.toFixed(1)}" cy="${first.y.toFixed(1)}" r="4.2" class="activity-route-start"></circle>
|
|
<circle cx="${last.x.toFixed(1)}" cy="${last.y.toFixed(1)}" r="4.2" class="activity-route-end"></circle>
|
|
</svg>`;
|
|
}
|
|
|
|
async function renderRouteThumb(container, item) {
|
|
const escapeHtml = window.GalleryUtils?.escapeHtml || (value => String(value ?? ""));
|
|
|
|
try {
|
|
const points = await getRoutePoints(item);
|
|
if (points.length < 2) {
|
|
container.classList.remove("loading");
|
|
container.classList.add("empty");
|
|
container.innerHTML = `<div class="activity-route-placeholder"><span>${escapeHtml(item?.sport || item?.format || "Attività")}</span></div>`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = createRouteSvg(points);
|
|
container.classList.remove("loading");
|
|
container.classList.add("ready");
|
|
} catch (error) {
|
|
console.warn("[activity thumb] errore:", item?.name, error);
|
|
container.classList.remove("loading");
|
|
container.classList.add("empty");
|
|
container.innerHTML = `<div class="activity-route-placeholder"><span>${escapeHtml(item?.sport || item?.format || "Attività")}</span></div>`;
|
|
}
|
|
}
|
|
|
|
window.ActivityThumbs =
|
|
Object.freeze({
|
|
renderRouteThumb,
|
|
getRoutePoints,
|
|
fetchFullActivityPoints,
|
|
parseTcxPoints,
|
|
parseGpxPoints,
|
|
createRouteSvg,
|
|
});
|
|
|
|
window.renderActivityRouteThumb = renderRouteThumb;
|
|
window.fetchFullActivityPoints = fetchFullActivityPoints;
|
|
window.getActivityRoutePoints = getRoutePoints;
|
|
window.parseTcxPoints = parseTcxPoints;
|
|
window.parseGpxPoints = parseGpxPoints;
|
|
window.createRouteSvg = createRouteSvg;
|
|
})();
|