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

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