/** * Dashboard Chart Animator * Gently animates SVG bar charts, line graph, and pie chart. * Pauses off-screen via IntersectionObserver for performance. */ (function () { 'use strict'; var SPEED = 0.002; // phase increment per frame var BAR_H = 120; // max bar height (SVG units) var BAR_MIN = 0.15; // min bar ratio var LINE_PTS = 8; var DARK = { text: '#E0E0E0', muted: '#9E9E9E', border: '#333', center: '#222' }; var LIGHT = { text: '#333333', muted: '#666666', border: '#E0E0E0', center: '#fff' }; function isDark() { return document.documentElement.getAttribute('data-theme') === 'dark'; } function pal() { return isDark() ? DARK : LIGHT; } function wave(t, off) { return Math.max(0, Math.min(1, 0.55 + Math.sin(t + off) * 0.25 + Math.sin(t * 1.8 + off * 1.3) * 0.15 )); } function makeState(svg) { return { svg: svg, bars1: svg.querySelectorAll('#bars-group-1 .bar'), bars2: svg.querySelectorAll('#bars-group-2 .bar'), vals1: svg.querySelectorAll('#values-group-1 text'), vals2: svg.querySelectorAll('#values-group-2 text'), linePath: svg.querySelector('#line-path'), lineFill: svg.querySelector('#line-fill'), pieSegs: svg.querySelectorAll('.pie-segment'), phase: Math.random() * Math.PI * 2, paused: false, themeN: 0 }; } function updateBars(bars, vals, st, pct) { for (var i = 0; i < bars.length; i++) { var v = Math.max(BAR_MIN, wave(st.phase, i * 1.1)); var h = v * BAR_H; bars[i].setAttribute('height', h); bars[i].setAttribute('y', BAR_H - h); } for (var j = 0; j < Math.min(bars.length, vals.length); j++) { var val = Math.max(BAR_MIN, wave(st.phase, j * 1.1)); vals[j].textContent = pct ? Math.round(val * 100) + '%' : Math.round(val * 5000); } } function updateLine(st) { if (!st.linePath) return; var d = 'M'; for (var i = 0; i < LINE_PTS; i++) { var x = (i / (LINE_PTS - 1)) * 200; var y = 25 + (1 - wave(st.phase * 0.8, i * 0.9)) * 110; d += (i ? ' L' : '') + x.toFixed(1) + ',' + y.toFixed(1); } st.linePath.setAttribute('d', d); if (st.lineFill) st.lineFill.setAttribute('d', d + ' L200,145 L0,145 Z'); } /* Pie: each segment gently shifts its slice size, no spinning */ function updatePie(st) { if (!st.pieSegs.length) return; var n = st.pieSegs.length; // Generate proportional weights that shift over time var weights = [], total = 0; for (var i = 0; i < n; i++) { var w = 0.5 + wave(st.phase * 0.4, i * 2.0) * 0.5; weights.push(w); total += w; } // Convert to cumulative angles var angle = 0; for (var j = 0; j < n; j++) { var sweep = (weights[j] / total) * 360; var startA = angle * Math.PI / 180; var endA = (angle + sweep) * Math.PI / 180; // Large-arc flag needed when sweep > 180 var large = sweep > 180 ? 1 : 0; var r = 45; var x1 = Math.sin(startA) * r, y1 = -Math.cos(startA) * r; var x2 = Math.sin(endA) * r, y2 = -Math.cos(endA) * r; var path = st.pieSegs[j].querySelector('path'); if (path) { path.setAttribute('d', 'M0,0 L' + x1.toFixed(2) + ',' + y1.toFixed(2) + ' A' + r + ',' + r + ' 0 ' + large + ',1 ' + x2.toFixed(2) + ',' + y2.toFixed(2) + ' Z' ); } // Remove any rotation transform — segments are positioned by path geometry st.pieSegs[j].removeAttribute('transform'); angle += sweep; } } function applyTheme(st) { var c = pal(), q = st.svg.querySelectorAll.bind(st.svg), all, k; all = q('.ct'); for (k = 0; k < all.length; k++) all[k].setAttribute('fill', c.text); all = q('.cl'); for (k = 0; k < all.length; k++) all[k].setAttribute('fill', c.muted); all = q('.cv'); for (k = 0; k < all.length; k++) all[k].setAttribute('fill', c.text); all = q('.grid-line'); for (k = 0; k < all.length; k++) all[k].setAttribute('stroke', c.border); var cc = st.svg.querySelector('#pie-center'); if (cc) { cc.setAttribute('fill', c.center); cc.setAttribute('stroke', c.border); } var pt = st.svg.querySelector('#pie-center-text'); if (pt) pt.setAttribute('fill', c.text); } function tick(st) { if (!st.paused) { st.phase += SPEED * 16; updateBars(st.bars1, st.vals1, st, true); updateBars(st.bars2, st.vals2, st, false); updateLine(st); updatePie(st); if (++st.themeN > 30) { st.themeN = 0; applyTheme(st); } } requestAnimationFrame(function () { tick(st); }); } function observe(st) { if (!('IntersectionObserver' in window)) return; new IntersectionObserver(function (entries) { for (var i = 0; i < entries.length; i++) st.paused = !entries[i].isIntersecting; }, { rootMargin: '200px', threshold: 0.05 }).observe(st.svg); } function boot() { var svgs = document.querySelectorAll('.dashboard-chart'); if (!svgs.length) return; if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; for (var i = 0; i < svgs.length; i++) { if (svgs[i]._dbAnim) continue; var st = makeState(svgs[i]); svgs[i]._dbAnim = st; applyTheme(st); tick(st); observe(st); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })();