537 lines
14 KiB
JavaScript
537 lines
14 KiB
JavaScript
// public/js/infoPanel.js
|
||
// ===============================
|
||
// PANNELLO INFO - foto + attività + mappa
|
||
// ===============================
|
||
|
||
const infoPanel = document.getElementById("infoPanel");
|
||
let infoMapInstance = null;
|
||
|
||
// -------------------------------
|
||
// Helpers UI / stato
|
||
// -------------------------------
|
||
function isPanelOpen() {
|
||
return (
|
||
infoPanel.classList.contains("open") ||
|
||
infoPanel.getAttribute("aria-hidden") === "false" ||
|
||
infoPanel.getAttribute("data-open") === "1" ||
|
||
infoPanel.style.display === "block"
|
||
);
|
||
}
|
||
|
||
function markButtonActive(active) {
|
||
const photoBtn = document.getElementById("modalInfoBtn");
|
||
const activityBtn = document.getElementById("activityInfoBtn");
|
||
|
||
if (photoBtn) {
|
||
photoBtn.classList.toggle("active", !!active);
|
||
}
|
||
|
||
if (activityBtn) {
|
||
activityBtn.classList.toggle("active", !!active);
|
||
}
|
||
}
|
||
|
||
function escapeInfoHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function fmtInt(value) {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) ? String(Math.round(n)) : "-";
|
||
}
|
||
|
||
function fmtKm(meters) {
|
||
const n = Number(meters);
|
||
return Number.isFinite(n) ? `${(n / 1000).toFixed(1)} km` : "-";
|
||
}
|
||
|
||
function fmtMeters(value) {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) ? `${Math.round(n)} m` : "-";
|
||
}
|
||
|
||
function fmtSpeedMps(value) {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) ? `${(n * 3.6).toFixed(1)} km/h` : "-";
|
||
}
|
||
|
||
function fmtPace(secPerKm) {
|
||
const n = Number(secPerKm);
|
||
|
||
if (!Number.isFinite(n) || n <= 0) {
|
||
return "-";
|
||
}
|
||
|
||
const min = Math.floor(n / 60);
|
||
const sec = Math.round(n % 60);
|
||
|
||
return `${min}:${String(sec).padStart(2, "0")} /km`;
|
||
}
|
||
|
||
function fmtDuration(ms) {
|
||
const n = Number(ms);
|
||
|
||
if (!Number.isFinite(n) || n < 0) {
|
||
return "-";
|
||
}
|
||
|
||
const totalSec = Math.round(n / 1000);
|
||
const h = Math.floor(totalSec / 3600);
|
||
const m = Math.floor((totalSec % 3600) / 60);
|
||
const s = totalSec % 60;
|
||
|
||
if (h > 0) {
|
||
return `${h}h ${m}m ${s}s`;
|
||
}
|
||
|
||
return `${m}m ${s}s`;
|
||
}
|
||
|
||
function fmtDateFromMillis(ms) {
|
||
const n = Number(ms);
|
||
|
||
if (!Number.isFinite(n)) {
|
||
return "-";
|
||
}
|
||
|
||
try {
|
||
return new Date(n).toLocaleString("it-IT");
|
||
} catch {
|
||
return "-";
|
||
}
|
||
}
|
||
|
||
function normalizeGpsFromItem(item) {
|
||
if (!item) {
|
||
return { lat: "-", lng: "-" };
|
||
}
|
||
|
||
if (item.gps && item.gps.lat && item.gps.lng) {
|
||
return item.gps;
|
||
}
|
||
|
||
const lat =
|
||
item.center_latitude ??
|
||
item.latitude ??
|
||
item.lat ??
|
||
item.min_latitude;
|
||
|
||
const lng =
|
||
item.center_longitude ??
|
||
item.longitude ??
|
||
item.lng ??
|
||
item.min_longitude;
|
||
|
||
if (
|
||
Number.isFinite(Number(lat)) &&
|
||
Number.isFinite(Number(lng))
|
||
) {
|
||
return {
|
||
lat: Number(lat),
|
||
lng: Number(lng)
|
||
};
|
||
}
|
||
|
||
return { lat: "-", lng: "-" };
|
||
}
|
||
|
||
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 (
|
||
window.state?.mode === "activities" ||
|
||
format.includes("fit") ||
|
||
format.includes("gpx") ||
|
||
format.includes("tcx") ||
|
||
item.distance_meters != null ||
|
||
item.duration_millis != null ||
|
||
item.track_preview != null
|
||
);
|
||
}
|
||
|
||
// -------------------------------
|
||
// RENDER FOTO
|
||
// -------------------------------
|
||
function renderInfoPhoto(photo) {
|
||
const gps = photo.gps || { lat: "-", lng: "-", alt: "-" };
|
||
const loc = photo.location || {};
|
||
const folder = photo.cartella || "-";
|
||
|
||
infoPanel.innerHTML = `
|
||
<button class="info-close-btn" type="button" aria-label="Chiudi informazioni">×</button>
|
||
<h3>Informazioni</h3>
|
||
|
||
<div class="info-row"><b>Nome:</b> ${escapeInfoHtml(photo.name ?? "-")}</div>
|
||
<div class="info-row"><b>Data:</b> ${escapeInfoHtml(photo.taken_at ?? "-")}</div>
|
||
|
||
<div class="info-row"><b>Latitudine:</b> ${escapeInfoHtml(gps.lat ?? "-")}</div>
|
||
<div class="info-row"><b>Longitudine:</b> ${escapeInfoHtml(gps.lng ?? "-")}</div>
|
||
<div class="info-row"><b>Altitudine:</b> ${escapeInfoHtml(gps.alt ?? "-")} m</div>
|
||
|
||
<div class="info-row"><b>Dimensioni:</b> ${escapeInfoHtml(photo.width ?? "-")} × ${escapeInfoHtml(photo.height ?? "-")}</div>
|
||
|
||
<div class="info-row"><b>Peso:</b> ${
|
||
photo.size_bytes
|
||
? `${(Number(photo.size_bytes) / 1024 / 1024).toFixed(2)} MB`
|
||
: "-"
|
||
}</div>
|
||
|
||
<div class="info-row"><b>Tipo:</b> ${escapeInfoHtml(photo.mime_type ?? "-")}</div>
|
||
<div class="info-row"><b>Cartella:</b> ${escapeInfoHtml(folder)}</div>
|
||
|
||
<div class="info-spacer"></div>
|
||
|
||
<h3>Mappa</h3>
|
||
${
|
||
gps.lat !== "-" && gps.lng !== "-"
|
||
? '<div id="infoMap" class="info-map"></div>'
|
||
: '<div class="info-row">Coordinate non disponibili</div>'
|
||
}
|
||
|
||
<div class="info-spacer"></div>
|
||
|
||
<h3>Location</h3>
|
||
${loc.continent ? `<div class="info-row"><b>Continente:</b> ${escapeInfoHtml(loc.continent)}</div>` : ""}
|
||
${loc.country ? `<div class="info-row"><b>Nazione:</b> ${escapeInfoHtml(loc.country)}</div>` : ""}
|
||
${loc.region ? `<div class="info-row"><b>Regione:</b> ${escapeInfoHtml(loc.region)}</div>` : ""}
|
||
${loc.city ? `<div class="info-row"><b>Città:</b> ${escapeInfoHtml(loc.city)}</div>` : ""}
|
||
${loc.address ? `<div class="info-row"><b>Indirizzo:</b> ${escapeInfoHtml(loc.address)}</div>` : ""}
|
||
${loc.postcode ? `<div class="info-row"><b>CAP:</b> ${escapeInfoHtml(loc.postcode)}</div>` : ""}
|
||
${loc.county_code ? `<div class="info-row"><b>Provincia:</b> ${escapeInfoHtml(loc.county_code)}</div>` : ""}
|
||
${loc.timezone ? `<div class="info-row"><b>Timezone:</b> ${escapeInfoHtml(loc.timezone)}</div>` : ""}
|
||
${loc.time ? `<div class="info-row"><b>Offset:</b> ${escapeInfoHtml(loc.time)}</div>` : ""}
|
||
`;
|
||
|
||
renderMap(gps);
|
||
}
|
||
|
||
// -------------------------------
|
||
// RENDER ATTIVITÀ
|
||
// -------------------------------
|
||
function renderInfoActivity(a) {
|
||
const gps = normalizeGpsFromItem(a);
|
||
|
||
const name =
|
||
a.display_name ||
|
||
a.route_name ||
|
||
a.title ||
|
||
a.file_name ||
|
||
a.name ||
|
||
"-";
|
||
|
||
infoPanel.innerHTML = `
|
||
<button class="info-close-btn" type="button" aria-label="Chiudi informazioni">×</button>
|
||
<h3>Attività</h3>
|
||
|
||
<div class="info-row"><b>Nome:</b> ${escapeInfoHtml(name)}</div>
|
||
<div class="info-row"><b>Sport:</b> ${escapeInfoHtml(a.sport ?? "-")}</div>
|
||
<div class="info-row"><b>Tipo attività:</b> ${escapeInfoHtml(a.activity_type ?? "-")}</div>
|
||
<div class="info-row"><b>Formato:</b> ${escapeInfoHtml(a.format || a.file_extension || "-")}</div>
|
||
|
||
<div class="info-spacer"></div>
|
||
|
||
<h3>Prestazione</h3>
|
||
|
||
<div class="info-row"><b>Distanza:</b> ${fmtKm(a.distance_meters)}</div>
|
||
<div class="info-row"><b>Durata:</b> ${fmtDuration(a.duration_millis)}</div>
|
||
<div class="info-row"><b>Dislivello positivo:</b> ${fmtMeters(a.elevation_gain_meters)}</div>
|
||
<div class="info-row"><b>Dislivello negativo:</b> ${fmtMeters(a.elevation_loss_meters)}</div>
|
||
<div class="info-row"><b>Quota minima:</b> ${fmtMeters(a.min_elevation_meters)}</div>
|
||
<div class="info-row"><b>Quota massima:</b> ${fmtMeters(a.max_elevation_meters)}</div>
|
||
|
||
<div class="info-row"><b>Velocità media:</b> ${fmtSpeedMps(a.average_speed_mps)}</div>
|
||
<div class="info-row"><b>Velocità massima:</b> ${fmtSpeedMps(a.max_speed_mps)}</div>
|
||
<div class="info-row"><b>Passo medio:</b> ${fmtPace(a.average_pace_sec_per_km)}</div>
|
||
|
||
<div class="info-spacer"></div>
|
||
|
||
<h3>Frequenza, cadenza, potenza</h3>
|
||
|
||
<div class="info-row"><b>FC media:</b> ${fmtInt(a.average_heart_rate)} bpm</div>
|
||
<div class="info-row"><b>FC massima:</b> ${fmtInt(a.max_heart_rate)} bpm</div>
|
||
<div class="info-row"><b>Cadenza media:</b> ${fmtInt(a.average_cadence)}</div>
|
||
<div class="info-row"><b>Cadenza massima:</b> ${fmtInt(a.max_cadence)}</div>
|
||
<div class="info-row"><b>Calorie:</b> ${fmtInt(a.calories)} kcal</div>
|
||
<div class="info-row"><b>Potenza media:</b> ${fmtInt(a.power_avg)} W</div>
|
||
<div class="info-row"><b>Potenza massima:</b> ${fmtInt(a.power_max)} W</div>
|
||
|
||
<div class="info-spacer"></div>
|
||
|
||
<h3>Tempo</h3>
|
||
|
||
<div class="info-row"><b>Inizio:</b> ${fmtDateFromMillis(a.start_time_millis)}</div>
|
||
<div class="info-row"><b>Fine:</b> ${fmtDateFromMillis(a.end_time_millis)}</div>
|
||
<div class="info-row"><b>Ultima modifica file:</b> ${fmtDateFromMillis(a.last_modified_millis)}</div>
|
||
|
||
<div class="info-spacer"></div>
|
||
|
||
<h3>Mappa</h3>
|
||
${
|
||
gps.lat !== "-" && gps.lng !== "-"
|
||
? '<div id="infoMap" class="info-map"></div>'
|
||
: '<div class="info-row">Coordinate non disponibili</div>'
|
||
}
|
||
|
||
<div class="info-spacer"></div>
|
||
|
||
<h3>File e sorgente</h3>
|
||
|
||
<div class="info-row"><b>File:</b> ${escapeInfoHtml(a.file_name ?? "-")}</div>
|
||
<div class="info-row"><b>Cartella:</b> ${escapeInfoHtml(a.cartella ?? "-")}</div>
|
||
<div class="info-row"><b>Percorso:</b> ${escapeInfoHtml(a.path ?? "-")}</div>
|
||
|
||
<div class="info-row"><b>Dimensione:</b> ${
|
||
a.size_bytes
|
||
? `${(Number(a.size_bytes) / 1024 / 1024).toFixed(2)} MB`
|
||
: "-"
|
||
}</div>
|
||
|
||
<div class="info-row"><b>Dispositivo:</b> ${
|
||
escapeInfoHtml(
|
||
[a.device_manufacturer, a.device_model]
|
||
.filter(Boolean)
|
||
.join(" ") || "-"
|
||
)
|
||
}</div>
|
||
|
||
<div class="info-row"><b>App sorgente:</b> ${escapeInfoHtml(a.source_app ?? "-")}</div>
|
||
|
||
<div class="info-spacer"></div>
|
||
|
||
<h3>Traccia</h3>
|
||
|
||
<div class="info-row"><b>Punti:</b> ${fmtInt(a.point_count)}</div>
|
||
<div class="info-row"><b>Segmenti:</b> ${fmtInt(a.segment_count)}</div>
|
||
<div class="info-row"><b>Lap:</b> ${fmtInt(a.lap_count)}</div>
|
||
<div class="info-row"><b>Centro:</b> ${
|
||
gps.lat !== "-" && gps.lng !== "-"
|
||
? `${gps.lat}, ${gps.lng}`
|
||
: "-"
|
||
}</div>
|
||
`;
|
||
|
||
renderMap(gps);
|
||
}
|
||
|
||
// -------------------------------
|
||
// MAPPA INFO PANEL
|
||
// -------------------------------
|
||
function renderMap(gps) {
|
||
try {
|
||
infoMapInstance?.remove();
|
||
} catch {}
|
||
|
||
infoMapInstance = null;
|
||
|
||
if (gps.lat === "-" || gps.lng === "-") return;
|
||
|
||
setTimeout(() => {
|
||
try {
|
||
const lat = Number(gps.lat);
|
||
const lng = Number(gps.lng);
|
||
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return;
|
||
|
||
infoMapInstance = L.map("infoMap", {
|
||
zoomControl: false,
|
||
attributionControl: false
|
||
}).setView([lat, lng], 13);
|
||
|
||
L.tileLayer(
|
||
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||
{
|
||
maxZoom: 19
|
||
}
|
||
).addTo(infoMapInstance);
|
||
|
||
L.marker([lat, lng]).addTo(infoMapInstance);
|
||
|
||
setTimeout(() => {
|
||
infoMapInstance?.invalidateSize?.();
|
||
}, 100);
|
||
} catch (err) {
|
||
console.warn("Errore creazione mappa info:", err);
|
||
}
|
||
}, 80);
|
||
}
|
||
|
||
// -------------------------------
|
||
// RENDER DISPATCHER
|
||
// -------------------------------
|
||
function renderInfo(item) {
|
||
if (!item) return;
|
||
|
||
if (isActivityItem(item)) {
|
||
renderInfoActivity(item);
|
||
} else {
|
||
renderInfoPhoto(item);
|
||
}
|
||
}
|
||
|
||
// -------------------------------
|
||
// API pubbliche
|
||
// -------------------------------
|
||
window.openInfoPanel = function(item) {
|
||
renderInfo(item || window.currentPhoto || window.currentActivity);
|
||
|
||
infoPanel.classList.add("open");
|
||
infoPanel.setAttribute("aria-hidden", "false");
|
||
infoPanel.setAttribute("data-open", "1");
|
||
|
||
markButtonActive(true);
|
||
};
|
||
|
||
window.closeInfoPanel = function() {
|
||
infoPanel.classList.remove("open");
|
||
infoPanel.setAttribute("aria-hidden", "true");
|
||
infoPanel.setAttribute("data-open", "0");
|
||
|
||
markButtonActive(false);
|
||
|
||
try {
|
||
infoMapInstance?.remove();
|
||
} catch {}
|
||
|
||
infoMapInstance = null;
|
||
};
|
||
|
||
window.toggleInfoPanel = function(item) {
|
||
if (isPanelOpen()) {
|
||
window.closeInfoPanel();
|
||
} else {
|
||
window.openInfoPanel(item || window.currentPhoto || window.currentActivity);
|
||
}
|
||
};
|
||
|
||
// -------------------------------
|
||
// Chiudi pannello cliccando fuori
|
||
// -------------------------------
|
||
/*document.addEventListener("click", event => {
|
||
if (!isPanelOpen()) return;
|
||
|
||
const insidePanel = infoPanel.contains(event.target);
|
||
|
||
const isPhotoInfoButton =
|
||
event.target.id === "modalInfoBtn" ||
|
||
event.target.closest?.("#modalInfoBtn");
|
||
|
||
const isActivityInfoButton =
|
||
event.target.id === "activityInfoBtn" ||
|
||
event.target.closest?.("#activityInfoBtn");
|
||
|
||
if (!insidePanel && !isPhotoInfoButton && !isActivityInfoButton) {
|
||
window.closeInfoPanel();
|
||
}
|
||
});
|
||
*/
|
||
document.addEventListener("click", event => {
|
||
if (!isPanelOpen()) return;
|
||
|
||
const insidePanel = infoPanel.contains(event.target);
|
||
|
||
const isPhotoInfoButton =
|
||
event.target.closest?.("#modalInfoBtn");
|
||
|
||
const isActivityInfoButton =
|
||
event.target.closest?.("#activityInfoBtn");
|
||
|
||
const insideMedia =
|
||
event.target.closest?.("#modalMediaContainer");
|
||
|
||
if (insidePanel || isPhotoInfoButton || isActivityInfoButton) {
|
||
return;
|
||
}
|
||
|
||
if (insideMedia) {
|
||
window.closeInfoPanel();
|
||
return;
|
||
}
|
||
|
||
window.closeInfoPanel();
|
||
});
|
||
|
||
// Chiudi info panel cliccando sul contenuto media del modal
|
||
document.getElementById("modalMediaContainer")
|
||
?.addEventListener("click", event => {
|
||
|
||
if (!isPanelOpen()) return;
|
||
|
||
const isVideo = event.target.closest("video");
|
||
const isImage = event.target.closest("img");
|
||
|
||
if (isVideo || isImage) {
|
||
window.closeInfoPanel();
|
||
}
|
||
});
|
||
// -------------------------------
|
||
// Auto-refresh su cambio media nel modal foto
|
||
// -------------------------------
|
||
(() => {
|
||
const mediaContainer = document.getElementById("modalMediaContainer");
|
||
if (!mediaContainer) return;
|
||
|
||
const refreshIfOpen = () => {
|
||
if (!isPanelOpen()) return;
|
||
|
||
const item = window.currentPhoto || window.currentActivity;
|
||
|
||
if (item) {
|
||
renderInfo(item);
|
||
}
|
||
};
|
||
|
||
const mo = new MutationObserver(() => {
|
||
setTimeout(refreshIfOpen, 0);
|
||
});
|
||
|
||
mo.observe(mediaContainer, {
|
||
childList: true
|
||
});
|
||
|
||
document.getElementById("modalPrev")?.addEventListener("click", () =>
|
||
setTimeout(refreshIfOpen, 0)
|
||
);
|
||
|
||
document.getElementById("modalNext")?.addEventListener("click", () =>
|
||
setTimeout(refreshIfOpen, 0)
|
||
);
|
||
|
||
document.addEventListener("keydown", event => {
|
||
if (
|
||
event.key === "ArrowLeft" ||
|
||
event.key === "ArrowRight"
|
||
) {
|
||
setTimeout(refreshIfOpen, 0);
|
||
}
|
||
});
|
||
})();
|
||
|
||
document
|
||
.getElementById("modalMediaContainer")
|
||
?.addEventListener("click", () => {
|
||
if (isPanelOpen()) {
|
||
window.closeInfoPanel();
|
||
}
|
||
}, true);
|
||
|
||
document.addEventListener("click", event => {
|
||
const btn = event.target.closest(".info-close-btn");
|
||
|
||
if (btn) {
|
||
window.closeInfoPanel?.();
|
||
}
|
||
});
|