/** * Dashboard Chart Animator * Continuously animates SVG bar charts, line graph, and pie chart. * Uses requestAnimationFrame, pauses off-screen via IntersectionObserver. */ (function () { 'use strict'; /* ── Configuration ──────────────────────────────────── */ var SPEED = 0.0025; // phase increment per ms (~4-6 s visible cycle) var BAR_H = 120; // max bar height in SVG units var BAR_MIN = 0.12; // minimum bar height ratio (12 %) var LINE_PTS = 8; // number of points on the line graph /* ── Colour sets (matches theme tokens) ─────────────── */ 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 palette() { return isDark() ? DARK : LIGHT; } /** Stacked sine waves => smooth organic value in 0..1 range */ function wave(t, offset) { return Math.max(0, Math.min(1, 0.55 + Math.sin(t * 1.0 + offset) * 0.30 + Math.sin(t * 2.3 + offset * 1.4) * 0.25 + Math.sin(t * 0.7 + offset * 0.6) * 0.20 )); } /* ── Per-chart state ────────────────────────────────── */ 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, raf: null, themeN: 0 }; } /* ── Update routines ────────────────────────────────── */ function updateBars(bars, vals, st, pct) { var i, v, h; for (i = 0; i < bars.length; i++) { v = wave(st.phase, i * 1.1); v = Math.max(BAR_MIN, v); h = v * BAR_H; bars[i].setAttribute('height', h); bars[i].setAttribute('y', BAR_H - h); } for (i = 0; i < Math.min(bars.length, vals.length); i++) { v = wave(st.phase, i * 1.1); v = Math.max(BAR_MIN, v); vals[i].textContent = pct ? Math.round(v * 100) + '%' : Math.round(v * 5000); } } function updateLine(st) { if (!st.linePath) return; var d = 'M', i, x, y, v; for (i = 0; i < LINE_PTS; i++) { x = (i / (LINE_PTS - 1)) * 200; v = wave(st.phase * 0.8, i * 0.9); y = 25 + (1 - v) * 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'); } function updatePie(st) { if (!st.pieSegs.length) return; var base = (st.phase * 40) % 360; for (var i = 0; i < st.pieSegs.length; i++) { var w = Math.sin(st.phase * 0.6 + i * 1.5) * 4; st.pieSegs[i].setAttribute('transform', 'rotate(' + (base + i * 90 + w) + ')'); } } function applyTheme(st) { var c = palette(); var q = st.svg.querySelectorAll.bind(st.svg); var all, j; all = q('.ct'); for (j = 0; j < all.length; j++) all[j].setAttribute('fill', c.text); all = q('.cl'); for (j = 0; j < all.length; j++) all[j].setAttribute('fill', c.muted); all = q('.cv'); for (j = 0; j < all.length; j++) all[j].setAttribute('fill', c.text); all = q('.grid-line'); for (j = 0; j < all.length; j++) all[j].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); } /* ── Animation loop ─────────────────────────────────── */ function tick(st) { if (!st.paused) { st.phase += SPEED * 16; // ~16 ms per frame at 60 fps updateBars(st.bars1, st.vals1, st, true); updateBars(st.bars2, st.vals2, st, false); updateLine(st); updatePie(st); // Re-apply theme colours every ~30 frames (~0.5 s) if (++st.themeN > 30) { st.themeN = 0; applyTheme(st); } } st.raf = requestAnimationFrame(function () { tick(st); }); } /* ── Hover pause / resume ───────────────────────────── */ function attachHover(st) { var el = st.svg.closest('[data-dashboard-container]') || st.svg.parentElement; if (!el) return; el.addEventListener('mouseenter', function () { st.paused = true; }); el.addEventListener('mouseleave', function () { st.paused = false; }); el.addEventListener('touchstart', function () { st.paused = true; }, { passive: true }); el.addEventListener('touchend', function () { st.paused = false; }, { passive: true }); } /* ── IntersectionObserver (pause off-screen) ────────── */ 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); } /* ── Bootstrap ──────────────────────────────────────── */ function boot() { var svgs = document.querySelectorAll('.dashboard-chart'); if (!svgs.length) return; var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; for (var i = 0; i < svgs.length; i++) { if (svgs[i]._dbAnim) continue; var st = makeState(svgs[i]); svgs[i]._dbAnim = st; applyTheme(st); if (!reduced) { tick(st); attachHover(st); observe(st); } } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })();