/** * 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 = '