/** * GoingsOn - VirtualScroller * High-performance virtual scrolling for large lists. * Only renders visible DOM nodes + overscan buffer. */ class VirtualScroller { /** * Create a virtual scroller instance. * @param {Object} config - Configuration options * @param {HTMLElement} config.container - Scrollable container element * @param {Function} config.renderItem - (item, index) => HTML string * @param {Function} config.getItems - () => array of items * @param {Object} config.rowHeight - { estimated: number, measure?: boolean } * @param {number} config.overscan - Number of buffer rows above/below viewport (default: 5) * @param {Function} config.onRender - Optional callback after render (visibleItems, startIndex) */ constructor(config) { this.container = config.container; this.renderItem = config.renderItem; this.getItems = config.getItems; this.estimatedRowHeight = config.rowHeight?.estimated || 52; this.shouldMeasure = config.rowHeight?.measure !== false; this.overscan = config.overscan ?? 5; this.onRender = config.onRender || null; // State this.items = []; this.heightCache = new Map(); // itemId -> measured height this.scrollTop = 0; this.viewportHeight = 0; this.startIndex = 0; this.endIndex = 0; this.isDestroyed = false; // Create DOM structure this._createStructure(); // Bind event handlers this._handleScroll = this._handleScroll.bind(this); this._handleResize = this._handleResize.bind(this); // Attach listeners this.container.addEventListener('scroll', this._handleScroll, { passive: true }); this.resizeObserver = new ResizeObserver(this._handleResize); this.resizeObserver.observe(this.container); // Initial render this.refresh(); } /** * Create the internal DOM structure for virtual scrolling. * @private */ _createStructure() { // Wrapper for content this.wrapper = document.createElement('div'); this.wrapper.className = 'virtual-scroller-wrapper'; // Top spacer this.topSpacer = document.createElement('div'); this.topSpacer.className = 'virtual-scroller-spacer'; // Content area where visible items are rendered this.content = document.createElement('div'); this.content.className = 'virtual-scroller-content'; // Bottom spacer this.bottomSpacer = document.createElement('div'); this.bottomSpacer.className = 'virtual-scroller-spacer'; this.wrapper.appendChild(this.topSpacer); this.wrapper.appendChild(this.content); this.wrapper.appendChild(this.bottomSpacer); this.container.innerHTML = ''; this.container.appendChild(this.wrapper); } /** * Get the height for an item (measured or estimated). * @private * @param {*} item - The item * @param {number} index - Item index * @returns {number} Height in pixels */ _getItemHeight(item, index) { const id = item.id ?? index; if (this.heightCache.has(id)) { return this.heightCache.get(id); } return this.estimatedRowHeight; } /** * Calculate total content height. * @private * @returns {number} Total height in pixels */ _getTotalHeight() { let total = 0; for (let i = 0; i < this.items.length; i++) { total += this._getItemHeight(this.items[i], i); } return total; } /** * Calculate which items should be visible. * @private * @returns {{ startIndex: number, endIndex: number, topOffset: number }} */ _calculateVisibleRange() { if (this.items.length === 0) { return { startIndex: 0, endIndex: 0, topOffset: 0 }; } const scrollTop = this.container.scrollTop; const viewportHeight = this.container.clientHeight; let accumulatedHeight = 0; let startIndex = 0; let topOffset = 0; // Find start index for (let i = 0; i < this.items.length; i++) { const height = this._getItemHeight(this.items[i], i); if (accumulatedHeight + height > scrollTop) { startIndex = i; topOffset = accumulatedHeight; break; } accumulatedHeight += height; if (i === this.items.length - 1) { startIndex = this.items.length; topOffset = accumulatedHeight; } } // Apply overscan to start startIndex = Math.max(0, startIndex - this.overscan); // Recalculate topOffset for adjusted start topOffset = 0; for (let i = 0; i < startIndex; i++) { topOffset += this._getItemHeight(this.items[i], i); } // Find end index let endIndex = startIndex; let renderedHeight = 0; const targetHeight = viewportHeight + (this.overscan * 2 * this.estimatedRowHeight); for (let i = startIndex; i < this.items.length && renderedHeight < targetHeight; i++) { renderedHeight += this._getItemHeight(this.items[i], i); endIndex = i + 1; } // Apply overscan to end endIndex = Math.min(this.items.length, endIndex + this.overscan); return { startIndex, endIndex, topOffset }; } /** * Measure rendered items and update height cache. * @private * @returns {boolean} true if any cached height changed (caller should recompute spacers) */ _measureRenderedItems() { if (!this.shouldMeasure) return false; const rows = this.content.children; let changed = false; for (let i = 0; i < rows.length; i++) { const row = rows[i]; const itemIndex = this.startIndex + i; if (itemIndex >= this.items.length) break; const item = this.items[itemIndex]; const id = item.id ?? itemIndex; const height = row.offsetHeight; if (height > 0) { const prev = this.heightCache.get(id); if (prev !== height) { this.heightCache.set(id, height); changed = true; } } } return changed; } /** * Render visible items. * @private * @param {boolean} forceRender - Skip the range-unchanged short-circuit (use after data refresh) */ _render(forceRender = false) { if (this.isDestroyed) return; this.items = this.getItems() || []; if (this.items.length === 0) { this.topSpacer.style.height = '0px'; this.bottomSpacer.style.height = '0px'; this.content.innerHTML = '
No items to display
'; this.startIndex = 0; this.endIndex = 0; return; } const { startIndex, endIndex, topOffset } = this._calculateVisibleRange(); // Short-circuit: if the visible range hasn't changed since last render, // skip the innerHTML thrash. This is the hot path on touch scroll — // scroll events fire at 60Hz+ but most don't cross row boundaries. if (!forceRender && startIndex === this.startIndex && endIndex === this.endIndex && this._hasRendered) { return; } this.startIndex = startIndex; this.endIndex = endIndex; // Render visible items const html = []; for (let i = startIndex; i < endIndex; i++) { html.push(this.renderItem(this.items[i], i)); } this.content.innerHTML = html.join(''); this._hasRendered = true; // Set spacers from the values we just computed — no need to recompute. this.topSpacer.style.height = `${topOffset}px`; let bottomHeight = 0; for (let i = endIndex; i < this.items.length; i++) { bottomHeight += this._getItemHeight(this.items[i], i); } this.bottomSpacer.style.height = `${bottomHeight}px`; // Measure after layout. Only re-update spacers if measurement // actually changed cached heights for items above the viewport // (those shift topOffset and need correction). requestAnimationFrame(() => { if (this.isDestroyed) return; const changed = this._measureRenderedItems(); if (changed) this._updateSpacers(); }); // Callback if (this.onRender) { const visibleItems = this.items.slice(startIndex, endIndex); this.onRender(visibleItems, startIndex); } } /** * Recompute and apply spacer heights. Called after measurement if a row's * height changed from its previous cached/estimated value. * @private */ _updateSpacers() { let topHeight = 0; for (let i = 0; i < this.startIndex; i++) { topHeight += this._getItemHeight(this.items[i], i); } this.topSpacer.style.height = `${topHeight}px`; let bottomHeight = 0; for (let i = this.endIndex; i < this.items.length; i++) { bottomHeight += this._getItemHeight(this.items[i], i); } this.bottomSpacer.style.height = `${bottomHeight}px`; } /** * Handle scroll events. * @private */ _handleScroll() { if (this.isDestroyed) return; // Use requestAnimationFrame for smooth scrolling if (this._scrollRaf) { cancelAnimationFrame(this._scrollRaf); } this._scrollRaf = requestAnimationFrame(() => { this._render(); }); } /** * Handle container resize. * @private */ _handleResize(entries) { if (this.isDestroyed) return; const entry = entries[0]; if (entry) { this.viewportHeight = entry.contentRect.height; this._render(); } } /** * Refresh the scroller (call after data changes). */ refresh() { if (this.isDestroyed) return; this._render(true); } /** * Scroll to a specific item index. * @param {number} index - Item index to scroll to * @param {string} align - 'start', 'center', or 'end' (default: 'start') */ scrollToIndex(index, align = 'start') { if (this.isDestroyed) return; if (index < 0 || index >= this.items.length) return; let targetScrollTop = 0; for (let i = 0; i < index; i++) { targetScrollTop += this._getItemHeight(this.items[i], i); } const itemHeight = this._getItemHeight(this.items[index], index); const viewportHeight = this.container.clientHeight; switch (align) { case 'center': targetScrollTop -= (viewportHeight - itemHeight) / 2; break; case 'end': targetScrollTop -= viewportHeight - itemHeight; break; // 'start' - no adjustment needed } targetScrollTop = Math.max(0, targetScrollTop); this.container.scrollTop = targetScrollTop; } /** * Get the currently visible items. * @returns {Array} Array of visible items */ getVisibleItems() { return this.items.slice(this.startIndex, this.endIndex); } /** * Get the index of an item by its ID. * @param {string} id - Item ID * @returns {number} Index or -1 if not found */ getIndexById(id) { return this.items.findIndex(item => item.id === id); } /** * Clear the height cache (useful after style changes). */ clearHeightCache() { this.heightCache.clear(); this._hasRendered = false; this.refresh(); } /** * Destroy the scroller and clean up. */ destroy() { this.isDestroyed = true; if (this._scrollRaf) { cancelAnimationFrame(this._scrollRaf); } this.container.removeEventListener('scroll', this._handleScroll); if (this.resizeObserver) { this.resizeObserver.disconnect(); } this.heightCache.clear(); this.items = []; } } // ============ Populate GoingsOn Namespace ============ if (window.GoingsOn) { GoingsOn.VirtualScroller = VirtualScroller; }