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