// public/js/galleryRenderer.js
// Virtualizzazione della timeline: foto e attività.
(() => {
"use strict";
const OVERSCAN_ROWS = 4;
let currentSections = [];
let framePending = false;
function createPhotoTile(item, sectionItems, itemIndex, globalIndex) {
const tile = document.createElement("div");
tile.className = "thumb";
tile.id = `photo_${item.id}`;
if (item.deleted_at) tile.classList.add("soft-deleted");
const gridThumb = window.toAbsoluteUrl(
item.thub1 || item.thub2 || item.path,
item.name,
"thumbs",
item.cartella
);
const modalPreview = window.toAbsoluteUrl(
item.thub2 || item.thub1 || item.path,
item.name,
"thumbs",
item.cartella
);
const original = window.toAbsoluteUrl(
item.path,
item.name,
"original",
item.cartella
);
const image = document.createElement("img");
image.src = gridThumb;
image.alt = item.name || "";
image.loading = globalIndex < 20 ? "eager" : "lazy";
image.decoding = "async";
if (globalIndex < 8) image.fetchPriority = "high";
tile.appendChild(image);
// Nome completo visibile solo quando sono abilitate le soft delete.
if (localStorage.getItem("showDeleted") === "1" && item.name) {
const filename = document.createElement("div");
filename.className = "thumb-filename";
filename.textContent = item.name;
filename.title = item.name;
tile.appendChild(filename);
}
if (item.mime_type?.startsWith("video/")) {
const play = document.createElement("div");
play.className = "play-icon";
play.textContent = "▶";
tile.appendChild(play);
}
window.GallerySelection?.bindTile(
tile, "photos", item, () => {
window.closeBottomSheet?.();
if (typeof window.openModalFromList === "function") {
window.openModalFromList(sectionItems, itemIndex);
} else {
window.openModal?.(original, modalPreview, item);
}
}
);
return tile;
}
function createActivityTile(item, sectionItems, itemIndex) {
const tile = document.createElement("div");
tile.className = "activity-tile";
tile.id = `activity_${String(item.id || "").replaceAll("/", "_")}`;
if (item.deleted_at) tile.classList.add("soft-deleted");
const routeBox = document.createElement("div");
routeBox.className = "activity-route-thumb loading";
routeBox.innerHTML =
'
Caricamento percorso...
';
tile.appendChild(routeBox);
window.ActivityThumbs?.renderRouteThumb(routeBox, item);
const label = document.createElement("div");
label.className = "activity-label";
label.textContent =
item.display_name ||
item.route_name ||
item.title ||
item.name ||
"(senza nome)";
tile.appendChild(label);
// Nome file completo, estensione compresa, quando le soft delete sono visibili.
if (localStorage.getItem("showDeleted") === "1" && item.name) {
const filename = document.createElement("div");
filename.className = "thumb-filename";
filename.textContent = item.name;
filename.title = item.name;
tile.appendChild(filename);
}
const metaText = window.GalleryUtils?.formatActivityMeta(item) || "";
if (metaText) {
const meta = document.createElement("div");
meta.className = "activity-meta";
meta.textContent = metaText;
tile.appendChild(meta);
}
window.GallerySelection?.bindTile(
tile, "activities", item, async () => {
window.closeBottomSheet?.();
if (typeof window.openActivityModal !== "function") {
console.error(
"[galleryRenderer] openActivityModal non disponibile"
);
return;
}
await window.openActivityModal(item, sectionItems, itemIndex);
}
);
return tile;
}
function getLayout(container) {
const activities = window.state?.mode === "activities";
const width = Math.max(1, container.clientWidth - 8);
const baseMinTile = activities
? (window.innerWidth <= 420
? 110
: window.innerWidth >= 700
? 140
: 120)
: (window.innerWidth <= 420 ? 96 : 100);
const minTile = window.GalleryZoom
? window.GalleryZoom.getMinTile(activities, baseMinTile)
: baseMinTile;
const gap = activities ? 6 : 4;
const columns = Math.max(
1,
Math.floor((width + gap) / (minTile + gap))
);
const tileWidth = (width - gap * (columns - 1)) / columns;
return {
activities,
columns,
gap,
rowHeight: tileWidth + gap,
width
};
}
function updateSection(entry, viewportHeight) {
const { container, section, windowGrid } = entry;
const count = section.items.length;
const layout = getLayout(container);
const totalRows = Math.ceil(count / layout.columns);
const totalHeight = totalRows
? totalRows * layout.rowHeight - layout.gap
: 0;
container.style.height = `${Math.max(0, totalHeight)}px`;
const rect = container.getBoundingClientRect();
let firstRow = Math.floor(
-rect.top / layout.rowHeight - OVERSCAN_ROWS
);
let lastRow = Math.ceil(
(viewportHeight - rect.top) / layout.rowHeight + OVERSCAN_ROWS
);
firstRow = Math.max(0, Math.min(totalRows, firstRow));
lastRow = Math.max(firstRow, Math.min(totalRows, lastRow));
const signature = [
layout.activities ? "activities" : "photos",
layout.columns,
layout.rowHeight.toFixed(2),
layout.width.toFixed(2),
firstRow,
lastRow
].join(":");
if (entry.signature === signature) return;
entry.signature = signature;
const firstIndex = firstRow * layout.columns;
const lastIndex = Math.min(count, lastRow * layout.columns);
windowGrid.style.top = `${firstRow * layout.rowHeight}px`;
windowGrid.style.gridTemplateColumns =
`repeat(${layout.columns}, minmax(0, 1fr))`;
windowGrid.style.gap = `${layout.gap}px`;
const wanted = new Set();
for (let i = firstIndex; i < lastIndex; i++) {
wanted.add(i);
}
// Elimina dalla cache locale solo gli elementi fuori dalla finestra.
for (const [index, node] of entry.rendered) {
if (!wanted.has(index)) {
node.remove();
entry.rendered.delete(index);
}
}
const fragment = document.createDocumentFragment();
for (let i = firstIndex; i < lastIndex; i++) {
let tile = entry.rendered.get(i);
if (!tile) {
const item = section.items[i];
tile = layout.activities
? createActivityTile(item, section.items, i)
: createPhotoTile(
item,
section.items,
i,
section.globalStart + i
);
entry.rendered.set(i, tile);
}
fragment.appendChild(tile);
}
windowGrid.replaceChildren(fragment);
}
function updateVisibleSections() {
framePending = false;
const viewportHeight = window.innerHeight;
for (const entry of currentSections) {
updateSection(entry, viewportHeight);
}
}
function refreshLayout() {
// Invalida le geometrie memorizzate: la galleria potrebbe
// essere stata nascosta mentre la mappa era aperta.
for (const entry of currentSections) {
entry.signature = null;
}
// Attende che il browser abbia ripristinato il layout visibile.
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
updateVisibleSections();
});
});
}
function scheduleUpdate() {
if (framePending) return;
framePending = true;
window.requestAnimationFrame(updateVisibleSections);
}
function renderGallery(sections, append = false) {
const gallery = document.getElementById("gallery");
if (!gallery) return;
const incoming = Array.isArray(sections) ? sections : [];
const activities = window.state?.mode === "activities";
function createEntry(section, globalStart, fragment) {
const items = section.items || [];
const heading = document.createElement("h2");
heading.className = "gallery-section-title";
heading.textContent = section.label;
fragment.appendChild(heading);
const container = document.createElement("div");
container.className = "gallery-section gallery-virtualized";
const windowGrid = document.createElement("div");
windowGrid.className = "gallery-virtual-window";
container.appendChild(windowGrid);
fragment.appendChild(container);
return {
section: {
label: section.label,
items,
globalStart
},
heading,
container,
windowGrid,
rendered: new Map(),
signature: null
};
}
// Riutilizza il DOM esistente quando arriva una pagina successiva,
// sia per le foto sia per le attività, se le sezioni restano in ordine.
if (append && currentSections.length > 0) {
const isPrefix =
currentSections.length <= incoming.length &&
currentSections.every(
(entry, index) =>
entry.section.label === incoming[index].label
);
if (isPrefix) {
const fragment = document.createDocumentFragment();
let globalPhotoIndex = 0;
for (let i = 0; i < incoming.length; i++) {
const section = incoming[i];
const newItems = section.items || [];
let entry = currentSections[i];
if (entry) {
// Mantiene lo stesso array: le miniature già esistenti
// conservano i riferimenti usati per aprire il visualizzatore.
const oldItems = entry.section.items;
const oldLength = oldItems.length;
const common = Math.min(oldLength, newItems.length);
// Aggiorna gli elementi esistenti e copia davvero quelli nuovi.
for (let j = 0; j < common; j++) {
oldItems[j] = newItems[j];
}
for (let j = oldLength; j < newItems.length; j++) {
oldItems.push(newItems[j]);
}
if (newItems.length < oldLength) {
oldItems.length = newItems.length;
}
// Forza l'aggiornamento della finestra virtualizzata
// quando cambia il numero di elementi della sezione.
entry.signature = null;
entry.section.label = section.label;
entry.heading.textContent = section.label;
entry.section.globalStart = globalPhotoIndex;
globalPhotoIndex += oldItems.length;
} else {
entry = createEntry(section, globalPhotoIndex, fragment);
currentSections.push(entry);
globalPhotoIndex += entry.section.items.length;
}
}
// Aggiunge solo le nuove sezioni, senza sostituire quelle vecchie.
if (fragment.childNodes.length) {
gallery.appendChild(fragment);
}
updateVisibleSections();
return;
}
}
// Rendering completo per prima pagina, filtri e modalità attività.
const fragment = document.createDocumentFragment();
const entries = [];
let globalPhotoIndex = 0;
for (const section of incoming) {
const entry = createEntry(section, globalPhotoIndex, fragment);
entries.push(entry);
if (!activities) {
globalPhotoIndex += entry.section.items.length;
}
}
currentSections = entries;
gallery.replaceChildren(fragment);
updateVisibleSections();
}
window.addEventListener("scroll", scheduleUpdate, { passive: true });
window.addEventListener("resize", scheduleUpdate, { passive: true });
window.GalleryRenderer = Object.freeze({ renderGallery, refreshLayout });
window.renderGallery = renderGallery;
})();