/** * Dashboard Chart Animator * Continuously animates bar charts, line graphs, and pie charts * Performance-optimized with IntersectionObserver and requestAnimationFrame */ class DashboardAnimator { constructor(svgElement) { this.svg = svgElement; this.animationId = null; this.isPaused = false; this.isReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; this.startTime = Date.now(); // Chart dimensions this.barChartHeight = 120; this.chartUpdateSpeed = 1500; // Faster: 1.5s cycle for constant movement // Cache DOM elements - try multiple selector methods for robustness this.barsGroup1 = this.svg.querySelectorAll('#bars-group-1 rect.bar'); this.barsGroup2 = this.svg.querySelectorAll('#bars-group-2 rect.bar'); this.valuesGroup1 = this.svg.querySelectorAll('#values-group-1 text.chart-value'); this.valuesGroup2 = this.svg.querySelectorAll('#values-group-2 text.chart-value'); this.linePath = this.svg.querySelector('#line-path'); this.lineFill = this.svg.querySelector('#line-fill'); this.pieSegments = this.svg.querySelectorAll('g.pie-segment'); // Debug: Check if elements were found const hasElements = this.barsGroup1.length > 0 || this.linePath || this.pieSegments.length > 0; if (!hasElements) { console.warn('[DashboardAnimator] No chart elements found. Debugging info:'); console.warn(' SVG element:', this.svg); console.warn(' bars-group-1:', this.barsGroup1.length); console.warn(' line-path:', !!this.linePath); console.warn(' pie-segment:', this.pieSegments.length); // Try direct getElementById as fallback console.warn(' Testing getElementById on document:', document.getElementById('bars-group-1')); } else { console.log('[DashboardAnimator] ✓ Found elements - Bars:', this.barsGroup1.length, 'Line:', !!this.linePath, 'Pie:', this.pieSegments.length); } // Noise generators for smooth, realistic data this.noisePhase1 = Math.random() * Math.PI * 2; this.noisePhase2 = Math.random() * Math.PI * 2; this.noisePhase3 = Math.random() * Math.PI * 2; this.noisePhase4 = Math.random() * Math.PI * 2; this.noisePhase5 = Math.random() * Math.PI * 2; this.init(); } init() { if (!this.isReducedMotion && (this.barsGroup1.length || this.linePath || this.pieSegments.length)) { console.log('[DashboardAnimator] ✓ Starting animation loop'); // Visual indicator that animator started this.svg.style.cssText = 'border: 2px solid green; border-radius: 4px;'; setTimeout(() => { this.svg.style.border = ''; }, 500); this.animate(); this.attachHoverListeners(); } else if (this.isReducedMotion) { console.log('[DashboardAnimator] Motion preference: reduced'); } else { console.log('[DashboardAnimator] ✗ No animatable elements found'); this.svg.style.cssText = 'border: 2px solid red; border-radius: 4px;'; } } /** * Perlin-like noise for smooth, natural-looking data * Uses sine waves at different frequencies */ generateNoise(phase, frequency = 0.5) { return Math.sin(phase * frequency) * 0.5 + 0.5; // Normalize to 0-1 } /** * Generate smooth, continuous data values * Uses combination of sine waves for organic motion */ generateValue(basePhase, index, max = 100) { const time = (Date.now() - this.startTime) / this.chartUpdateSpeed; const phase = basePhase + time * 0.3 + index * 0.4; // Multi-frequency sine wave for natural variation const noise1 = Math.sin(phase * 0.7) * 0.4; const noise2 = Math.sin(phase * 1.3) * 0.3; const noise3 = Math.sin(phase * 0.3) * 0.2; const base = 0.5 + noise1 + noise2 + noise3; // Ensure value stays in range return Math.max(10, Math.min(max, base * max * 0.9)); } /** * Update bar chart with smooth animation */ updateBars(barsElements, valuesElements, maxValue = 100, isPercent = true) { if (!barsElements || barsElements.length === 0) return; barsElements.forEach((bar, index) => { const value = this.generateValue(this.noisePhase1, index, maxValue); const heightPercent = (value / maxValue) * 100; const height = (heightPercent / 100) * this.barChartHeight; // For SVG bars to grow upward, adjust y position inversely const y = this.barChartHeight - height; bar.setAttribute('height', height); bar.setAttribute('y', y); if (valuesElements && valuesElements[index]) { valuesElements[index].textContent = isPercent ? Math.round(value) + '%' : Math.round(value); valuesElements[index].setAttribute('data-value', value.toFixed(1)); } }); } /** * Update line graph with smooth curve */ updateLineGraph() { if (!this.linePath || !this.lineFill) return; const time = (Date.now() - this.startTime) / this.chartUpdateSpeed; const points = []; // Generate 3 points for quadratic bezier curve for (let i = 0; i < 3; i++) { const x = i * 100; const basePhase = this.noisePhase3 + time * 0.2 + i * 0.5; const noise = Math.sin(basePhase) * 0.3 + Math.sin(basePhase * 0.5) * 0.2; const y = 80 + noise * 60 - i * 15; points.push({ x, y }); } // Build quadratic bezier path const pathData = `M${points[0].x},${points[0].y} Q${points[1].x},${points[1].y} ${points[2].x},${points[2].y}`; this.linePath.setAttribute('d', pathData); this.lineFill.setAttribute('d', `${pathData} L200,160 Q100,140 0,160 Z`); // Animate stroke-dasharray for drawing effect const strokeLength = 200; const offset = ((time * 50) % strokeLength); this.linePath.setAttribute('stroke-dashoffset', offset); } /** * Update pie chart with smooth segment rotation */ updatePieChart() { if (!this.pieSegments || this.pieSegments.length === 0) return; const time = (Date.now() - this.startTime) / (this.chartUpdateSpeed * 0.5); const baseRotation = (time * 15) % 360; // Slow rotation this.pieSegments.forEach((segment, index) => { // Add wobble effect to each segment const wobble = Math.sin(time * 0.5 + index * Math.PI / 2) * 3; segment.setAttribute('transform', `rotate(${baseRotation + index * 90 + wobble})`); }); } /** * Main animation loop using requestAnimationFrame */ animate = () => { if (!this.isPaused) { try { this.updateBars(this.barsGroup1, this.valuesGroup1, 100, true); this.updateBars(this.barsGroup2, this.valuesGroup2, 5000, false); this.updateLineGraph(); this.updatePieChart(); } catch (err) { console.error('[DashboardAnimator] Animation error:', err); } } this.animationId = requestAnimationFrame(this.animate); } /** * Attach hover event listeners to pause/resume animation */ attachHoverListeners() { const container = this.svg.closest('[data-dashboard-container]') || this.svg.parentElement; if (container) { container.addEventListener('mouseenter', () => this.pause()); container.addEventListener('mouseleave', () => this.resume()); // Touch support: pause on touch container.addEventListener('touchstart', () => this.pause()); container.addEventListener('touchend', () => this.resume()); } } /** * Pause animation (on hover) */ pause() { this.isPaused = true; } /** * Resume animation (after hover) */ resume() { this.isPaused = false; } /** * Stop animation and clean up */ destroy() { if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; } } } /** * Initialize dashboard animation when element enters viewport */ function initDashboardAnimation() { const dashboardCharts = document.querySelectorAll('.dashboard-chart'); console.log('[initDashboardAnimation] Found', dashboardCharts.length, 'dashboard charts'); if (!dashboardCharts.length) return; dashboardCharts.forEach((chart, idx) => { // Check if element is likely in viewport const rect = chart.getBoundingClientRect(); const isVisible = rect.top < window.innerHeight && rect.bottom > 0; console.log('[initDashboardAnimation] Chart', idx, 'visible:', isVisible, 'rect:', rect); if (isVisible || !chart._dashboardAnimator) { // Start animation immediately if visible or if not set up yet if (!chart._dashboardAnimator) { console.log('[initDashboardAnimation] Creating DashboardAnimator for chart', idx); try { chart._dashboardAnimator = new DashboardAnimator(chart); console.log('[initDashboardAnimation] ✓ DashboardAnimator created successfully'); } catch (err) { console.error('[initDashboardAnimation] ✗ Failed to create DashboardAnimator:', err); } } } }); // Also set up IntersectionObserver for when elements come into view if ('IntersectionObserver' in window) { const observerOptions = { root: null, rootMargin: '100px', threshold: 0.05 }; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { console.log('[IntersectionObserver]', entry.isIntersecting ? 'Intersecting' : 'Not intersecting'); if (entry.isIntersecting) { if (!entry.target._dashboardAnimator) { console.log('[IntersectionObserver] Creating new DashboardAnimator'); try { entry.target._dashboardAnimator = new DashboardAnimator(entry.target); } catch (err) { console.error('[IntersectionObserver] Failed:', err); } } else { entry.target._dashboardAnimator.resume(); } } else { if (entry.target._dashboardAnimator) { entry.target._dashboardAnimator.pause(); } } }); }, observerOptions); dashboardCharts.forEach(chart => observer.observe(chart)); } } // Auto-initialize when DOM is ready console.log('[Dashboard Animator] Script loaded, readyState:', document.readyState); function tryInitialize() { console.log('[Dashboard Animator] tryInitialize called'); const charts = document.querySelectorAll('.dashboard-chart'); console.log('[Dashboard Animator] Found', charts.length, 'dashboard charts on page'); if (charts.length > 0) { console.log('[Dashboard Animator] Found charts, initializing now...'); initDashboardAnimation(); } else { console.log('[Dashboard Animator] No charts found yet, retrying in 100ms...'); setTimeout(tryInitialize, 100); } } if (document.readyState === 'loading') { console.log('[Dashboard Animator] DOM still loading, waiting for DOMContentLoaded'); document.addEventListener('DOMContentLoaded', tryInitialize); } else { console.log('[Dashboard Animator] DOM already loaded, initializing immediately'); tryInitialize(); }