2026-02-21 01:45:51 -05:00
|
|
|
/**
|
|
|
|
|
* Dashboard Chart Animator
|
2026-02-21 02:08:54 -05:00
|
|
|
* Continuously animates SVG bar charts, line graph, and pie chart.
|
|
|
|
|
* Uses requestAnimationFrame, pauses off-screen via IntersectionObserver.
|
2026-02-21 01:45:51 -05:00
|
|
|
*/
|
2026-02-21 02:08:54 -05:00
|
|
|
(function () {
|
|
|
|
|
'use strict';
|
2026-02-21 01:45:51 -05:00
|
|
|
|
2026-02-21 02:08:54 -05:00
|
|
|
/* ── 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';
|
2026-02-21 01:45:51 -05:00
|
|
|
}
|
2026-02-21 02:08:54 -05:00
|
|
|
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
|
|
|
|
|
));
|
2026-02-21 01:45:51 -05:00
|
|
|
}
|
2026-02-21 02:08:54 -05:00
|
|
|
|
|
|
|
|
/* ── 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
|
|
|
|
|
};
|
2026-02-21 01:45:51 -05:00
|
|
|
}
|
2026-02-21 02:08:54 -05:00
|
|
|
|
|
|
|
|
/* ── 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);
|
2026-02-21 01:45:51 -05:00
|
|
|
}
|
2026-02-21 02:08:54 -05:00
|
|
|
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);
|
2026-02-21 01:45:51 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-21 02:08:54 -05:00
|
|
|
|
|
|
|
|
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);
|
2026-02-21 01:45:51 -05:00
|
|
|
}
|
2026-02-21 02:08:54 -05:00
|
|
|
st.linePath.setAttribute('d', d);
|
|
|
|
|
if (st.lineFill) st.lineFill.setAttribute('d', d + ' L200,145 L0,145 Z');
|
2026-02-21 01:45:51 -05:00
|
|
|
}
|
2026-02-21 02:08:54 -05:00
|
|
|
|
|
|
|
|
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) + ')');
|
|
|
|
|
}
|
2026-02-21 01:45:51 -05:00
|
|
|
}
|
2026-02-21 02:08:54 -05:00
|
|
|
|
|
|
|
|
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);
|
2026-02-21 01:45:51 -05:00
|
|
|
}
|
2026-02-21 02:08:54 -05:00
|
|
|
|
|
|
|
|
/* ── 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); }
|
2026-02-21 01:45:51 -05:00
|
|
|
}
|
2026-02-21 02:08:54 -05:00
|
|
|
st.raf = requestAnimationFrame(function () { tick(st); });
|
2026-02-21 01:45:51 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-21 02:08:54 -05:00
|
|
|
/* ── 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);
|
2026-02-21 01:55:20 -05:00
|
|
|
}
|
2026-02-21 01:45:51 -05:00
|
|
|
|
2026-02-21 02:08:54 -05:00
|
|
|
/* ── Bootstrap ──────────────────────────────────────── */
|
|
|
|
|
function boot() {
|
|
|
|
|
var svgs = document.querySelectorAll('.dashboard-chart');
|
|
|
|
|
if (!svgs.length) return;
|
|
|
|
|
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
2026-02-21 01:55:20 -05:00
|
|
|
|
2026-02-21 02:08:54 -05:00
|
|
|
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); }
|
|
|
|
|
}
|
2026-02-21 01:55:20 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-21 02:08:54 -05:00
|
|
|
if (document.readyState === 'loading') {
|
|
|
|
|
document.addEventListener('DOMContentLoaded', boot);
|
|
|
|
|
} else {
|
|
|
|
|
boot();
|
|
|
|
|
}
|
|
|
|
|
})();
|