/** * visibility-manager.js — Advanced visibility management with scroll prediction * * Uses IntersectionObserver for accurate page visibility detection * Implements scroll prediction for smooth pre-loading * Manages buffer zones intelligently */ class VisibilityManager { constructor(container, pageElements, totalPages) { this.container = container; this.pageElements = pageElements; this.totalPages = totalPages; // Visibility state this.visiblePages = new Set(); this.bufferPages = new Set(); this.observer = null; this.bufferSize = 2; // Minimum pages to buffer this.scrollHistory = []; this.maxScrollHistory = 10; // Scroll prediction this.scrollVelocity = 0; this.scrollDirection = 0; // 1 for down, -1 for up, 0 for stationary this.lastScrollTime = 0; this.lastScrollPosition = 0; // Performance tracking this.visibilityChanges = 0; this.lastVisibilityTime = 0; // Setup observers this.setupIntersectionObserver(); this.setupScrollMonitoring(); } setupIntersectionObserver() { this.observer = new IntersectionObserver( (entries) => this.handleIntersectionEntries(entries), { root: this.container, rootMargin: '100% 0px 100% 0px', // Large margin for extensive buffering threshold: [0, 0.25, 0.5, 0.75, 1.0] // Multiple thresholds for smooth transitions } ); // Observe all page elements this.pageElements.forEach(element => { this.observer.observe(element); }); } setupScrollMonitoring() { // Track scroll events for velocity calculation this.container.addEventListener('scroll', (event) => { this.trackScrollEvent(event); }, { passive: true }); // Periodic scroll prediction setInterval(() => { this.updateScrollPrediction(); }, 100); } trackScrollEvent(event) { const currentTime = performance.now(); const currentPosition = this.container.scrollTop; // Calculate velocity if we have previous data if (this.lastScrollPosition !== 0 && this.lastScrollTime !== 0) { const timeDelta = currentTime - this.lastScrollTime; if (timeDelta > 0) { this.scrollVelocity = (currentPosition - this.lastScrollPosition) / timeDelta; this.scrollDirection = this.scrollVelocity > 0 ? 1 : (this.scrollVelocity < 0 ? -1 : 0); } } // Store scroll history this.scrollHistory.push({ position: currentPosition, time: currentTime, velocity: this.scrollVelocity }); if (this.scrollHistory.length > this.maxScrollHistory) { this.scrollHistory.shift(); } this.lastScrollPosition = currentPosition; this.lastScrollTime = currentTime; } updateScrollPrediction() { if (this.scrollHistory.length < 3) return; // Calculate average velocity from recent history const recentHistory = this.scrollHistory.slice(-5); // Last 5 entries if (recentHistory.length < 2) return; const totalDelta = recentHistory[recentHistory.length - 1].position - recentHistory[0].position; const totalTime = recentHistory[recentHistory.length - 1].time - recentHistory[0].time; if (totalTime > 0) { this.scrollVelocity = totalDelta / totalTime; this.scrollDirection = this.scrollVelocity > 5 ? 1 : (this.scrollVelocity < -5 ? -1 : 0); } } handleIntersectionEntries(entries) { const newVisiblePages = new Set(); const newBufferPages = new Set(); // Process intersection entries entries.forEach(entry => { const pageNum = parseInt(entry.target.dataset.page); if (isNaN(pageNum)) return; if (entry.isIntersecting) { // Page is visible or in buffer zone if (entry.intersectionRatio >= 0.5) { newVisiblePages.add(pageNum); } else { newBufferPages.add(pageNum); } } }); // Add intelligent buffering based on scroll prediction this.addPredictiveBuffering(newVisiblePages, newBufferPages); // Update state if changed if (!this.setsEqual(this.visiblePages, newVisiblePages) || !this.setsEqual(this.bufferPages, newBufferPages)) { this.visiblePages = newVisiblePages; this.bufferPages = newBufferPages; this.visibilityChanges++; // Notify about visibility changes this.notifyVisibilityChange(); } } addPredictiveBuffering(visiblePages, bufferPages) { if (visiblePages.size === 0) return; const visibleArray = Array.from(visiblePages).sort((a, b) => a - b); const firstVisible = visibleArray[0]; const lastVisible = visibleArray[visibleArray.length - 1]; // Add adjacent pages (minimum buffer) for (let i = 1; i <= this.bufferSize; i++) { if (firstVisible - i >= 1) bufferPages.add(firstVisible - i); if (lastVisible + i <= this.totalPages) bufferPages.add(lastVisible + i); } // Add scroll prediction-based buffering if (Math.abs(this.scrollVelocity) > 10) { // Significant scrolling const predictionDistance = Math.min(5, Math.floor(Math.abs(this.scrollVelocity) / 2)); if (this.scrollDirection === 1) { // Scrolling down // Add more pages below for (let i = 1; i <= predictionDistance; i++) { const nextPage = lastVisible + this.bufferSize + i; if (nextPage <= this.totalPages) { bufferPages.add(nextPage); } } } else if (this.scrollDirection === -1) { // Scrolling up // Add more pages above for (let i = 1; i <= predictionDistance; i++) { const prevPage = firstVisible - this.bufferSize - i; if (prevPage >= 1) { bufferPages.add(prevPage); } } } } } setsEqual(a, b) { if (a.size !== b.size) return false; return Array.from(a).every(item => b.has(item)); } notifyVisibilityChange() { // Throttle visibility notifications to prevent excessive rendering const now = performance.now(); if (now - this.lastVisibilityTime < 50) return; // Max 20 updates per second this.lastVisibilityTime = now; // Notify the render system if (window.renderSystem) { window.renderSystem.setVisibility( Array.from(this.visiblePages), Array.from(this.bufferPages) ); } } getScrollPrediction() { return { velocity: this.scrollVelocity, direction: this.scrollDirection, confidence: Math.min(1.0, Math.abs(this.scrollVelocity) / 20) }; } cleanup() { if (this.observer) { this.observer.disconnect(); } this.visiblePages.clear(); this.bufferPages.clear(); this.scrollHistory = []; } getVisibilityStats() { return { visiblePages: Array.from(this.visiblePages).sort((a, b) => a - b), bufferPages: Array.from(this.bufferPages).sort((a, b) => a - b), visibilityChanges: this.visibilityChanges, scrollVelocity: this.scrollVelocity, scrollDirection: this.scrollDirection }; } } // Export for ES6 modules export { VisibilityManager };