From e1d9b1a402cdc03a21804b0145919021fbbf8b6b Mon Sep 17 00:00:00 2001 From: Matt Batchelder Date: Sat, 21 Feb 2026 02:13:52 -0500 Subject: [PATCH] Refactor dashboard animator for improved performance and readability --- theme/assets/js/dashboard-animator.js | 145 +++++++++++++------------- 1 file changed, 70 insertions(+), 75 deletions(-) diff --git a/theme/assets/js/dashboard-animator.js b/theme/assets/js/dashboard-animator.js index e6b574f..4ac994a 100644 --- a/theme/assets/js/dashboard-animator.js +++ b/theme/assets/js/dashboard-animator.js @@ -1,135 +1,131 @@ /** * Dashboard Chart Animator - * Continuously animates SVG bar charts, line graph, and pie chart. - * Uses requestAnimationFrame, pauses off-screen via IntersectionObserver. + * Gently animates SVG bar charts, line graph, and pie chart. + * Pauses off-screen via IntersectionObserver for performance. */ (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 + 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; - /* ── 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; } + function isDark() { return document.documentElement.getAttribute('data-theme') === 'dark'; } + function pal() { return isDark() ? DARK : LIGHT; } - /** Stacked sine waves => smooth organic value in 0..1 range */ - function wave(t, offset) { + function wave(t, off) { 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 + Math.sin(t + off) * 0.25 + + Math.sin(t * 1.8 + off * 1.3) * 0.15 )); } - /* ── 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'), + 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 + phase: Math.random() * Math.PI * 2, + paused: false, + 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; + 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 (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); + 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', 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; + 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 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) + ')'); + 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 = 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 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); } - /* ── Animation loop ─────────────────────────────────── */ function tick(st) { if (!st.paused) { - st.phase += SPEED * 16; // ~16 ms per frame at 60 fps + st.phase += SPEED * 16; 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); }); + 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) { @@ -137,18 +133,17 @@ }, { 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; - + 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); - if (!reduced) { tick(st); attachHover(st); observe(st); } + tick(st); + observe(st); } }