158 lines
5.5 KiB
JavaScript
158 lines
5.5 KiB
JavaScript
/**
|
|
* 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 LINE_W = 340; // line graph width in SVG units
|
|
var PIE_R = 55; // pie chart radius
|
|
|
|
var DARK = { text: '#E0E0E0', muted: '#9E9E9E', border: '#333', center: '#1A1A1A' };
|
|
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)) * LINE_W;
|
|
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 + ' L' + LINE_W + ',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 = PIE_R;
|
|
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();
|
|
}
|
|
})();
|