Refactor dashboard animator for improved performance and readability
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user