Refactor dashboard SVG charts and animations

- Removed the old SVG file for the dashboard chart and replaced it with a new implementation directly in the PHP file.
- Updated the SVG structure to improve accessibility and styling, including the use of CSS classes for dynamic theming.
- Enhanced the bar charts, line graph, and pie chart with new gradients and animations.
- Adjusted the enqueue script for the dashboard animator to include versioning based on file modification time.
This commit is contained in:
Matt Batchelder
2026-02-21 02:08:54 -05:00
parent 38d585e071
commit a33a6d62d2
5 changed files with 271 additions and 634 deletions

View File

@@ -1,311 +1,160 @@
/**
* Dashboard Chart Animator
* Continuously animates bar charts, line graphs, and pie charts
* Performance-optimized with IntersectionObserver and requestAnimationFrame
* Continuously animates SVG bar charts, line graph, and pie chart.
* Uses requestAnimationFrame, pauses off-screen via IntersectionObserver.
*/
(function () {
'use strict';
class DashboardAnimator {
constructor(svgElement) {
this.svg = svgElement;
this.animationId = null;
this.isPaused = false;
this.isReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this.startTime = Date.now();
// Chart dimensions
this.barChartHeight = 120;
this.chartUpdateSpeed = 1500; // Faster: 1.5s cycle for constant movement
// Cache DOM elements - try multiple selector methods for robustness
this.barsGroup1 = this.svg.querySelectorAll('#bars-group-1 rect.bar');
this.barsGroup2 = this.svg.querySelectorAll('#bars-group-2 rect.bar');
this.valuesGroup1 = this.svg.querySelectorAll('#values-group-1 text.chart-value');
this.valuesGroup2 = this.svg.querySelectorAll('#values-group-2 text.chart-value');
this.linePath = this.svg.querySelector('#line-path');
this.lineFill = this.svg.querySelector('#line-fill');
this.pieSegments = this.svg.querySelectorAll('g.pie-segment');
// Debug: Check if elements were found
const hasElements = this.barsGroup1.length > 0 || this.linePath || this.pieSegments.length > 0;
if (!hasElements) {
console.warn('[DashboardAnimator] No chart elements found. Debugging info:');
console.warn(' SVG element:', this.svg);
console.warn(' bars-group-1:', this.barsGroup1.length);
console.warn(' line-path:', !!this.linePath);
console.warn(' pie-segment:', this.pieSegments.length);
// Try direct getElementById as fallback
console.warn(' Testing getElementById on document:', document.getElementById('bars-group-1'));
} else {
console.log('[DashboardAnimator] ✓ Found elements - Bars:', this.barsGroup1.length, 'Line:', !!this.linePath, 'Pie:', this.pieSegments.length);
}
// Noise generators for smooth, realistic data
this.noisePhase1 = Math.random() * Math.PI * 2;
this.noisePhase2 = Math.random() * Math.PI * 2;
this.noisePhase3 = Math.random() * Math.PI * 2;
this.noisePhase4 = Math.random() * Math.PI * 2;
this.noisePhase5 = Math.random() * Math.PI * 2;
this.init();
}
init() {
if (!this.isReducedMotion && (this.barsGroup1.length || this.linePath || this.pieSegments.length)) {
console.log('[DashboardAnimator] ✓ Starting animation loop');
// Visual indicator that animator started
this.svg.style.cssText = 'border: 2px solid green; border-radius: 4px;';
setTimeout(() => {
this.svg.style.border = '';
}, 500);
this.animate();
this.attachHoverListeners();
} else if (this.isReducedMotion) {
console.log('[DashboardAnimator] Motion preference: reduced');
} else {
console.log('[DashboardAnimator] ✗ No animatable elements found');
this.svg.style.cssText = 'border: 2px solid red; border-radius: 4px;';
}
}
/**
* Perlin-like noise for smooth, natural-looking data
* Uses sine waves at different frequencies
*/
generateNoise(phase, frequency = 0.5) {
return Math.sin(phase * frequency) * 0.5 + 0.5; // Normalize to 0-1
}
/**
* Generate smooth, continuous data values
* Uses combination of sine waves for organic motion
*/
generateValue(basePhase, index, max = 100) {
const time = (Date.now() - this.startTime) / this.chartUpdateSpeed;
const phase = basePhase + time * 0.3 + index * 0.4;
// Multi-frequency sine wave for natural variation
const noise1 = Math.sin(phase * 0.7) * 0.4;
const noise2 = Math.sin(phase * 1.3) * 0.3;
const noise3 = Math.sin(phase * 0.3) * 0.2;
const base = 0.5 + noise1 + noise2 + noise3;
// Ensure value stays in range
return Math.max(10, Math.min(max, base * max * 0.9));
}
/**
* Update bar chart with smooth animation
*/
updateBars(barsElements, valuesElements, maxValue = 100, isPercent = true) {
if (!barsElements || barsElements.length === 0) return;
barsElements.forEach((bar, index) => {
const value = this.generateValue(this.noisePhase1, index, maxValue);
const heightPercent = (value / maxValue) * 100;
const height = (heightPercent / 100) * this.barChartHeight;
// For SVG bars to grow upward, adjust y position inversely
const y = this.barChartHeight - height;
bar.setAttribute('height', height);
bar.setAttribute('y', y);
if (valuesElements && valuesElements[index]) {
valuesElements[index].textContent = isPercent ?
Math.round(value) + '%' :
Math.round(value);
valuesElements[index].setAttribute('data-value', value.toFixed(1));
}
});
}
/**
* Update line graph with smooth curve
*/
updateLineGraph() {
if (!this.linePath || !this.lineFill) return;
const time = (Date.now() - this.startTime) / this.chartUpdateSpeed;
const points = [];
// Generate 3 points for quadratic bezier curve
for (let i = 0; i < 3; i++) {
const x = i * 100;
const basePhase = this.noisePhase3 + time * 0.2 + i * 0.5;
const noise = Math.sin(basePhase) * 0.3 + Math.sin(basePhase * 0.5) * 0.2;
const y = 80 + noise * 60 - i * 15;
points.push({ x, y });
}
// Build quadratic bezier path
const pathData = `M${points[0].x},${points[0].y} Q${points[1].x},${points[1].y} ${points[2].x},${points[2].y}`;
this.linePath.setAttribute('d', pathData);
this.lineFill.setAttribute('d', `${pathData} L200,160 Q100,140 0,160 Z`);
// Animate stroke-dasharray for drawing effect
const strokeLength = 200;
const offset = ((time * 50) % strokeLength);
this.linePath.setAttribute('stroke-dashoffset', offset);
}
/**
* Update pie chart with smooth segment rotation
*/
updatePieChart() {
if (!this.pieSegments || this.pieSegments.length === 0) return;
const time = (Date.now() - this.startTime) / (this.chartUpdateSpeed * 0.5);
const baseRotation = (time * 15) % 360; // Slow rotation
this.pieSegments.forEach((segment, index) => {
// Add wobble effect to each segment
const wobble = Math.sin(time * 0.5 + index * Math.PI / 2) * 3;
segment.setAttribute('transform', `rotate(${baseRotation + index * 90 + wobble})`);
});
}
/**
* Main animation loop using requestAnimationFrame
*/
animate = () => {
if (!this.isPaused) {
try {
this.updateBars(this.barsGroup1, this.valuesGroup1, 100, true);
this.updateBars(this.barsGroup2, this.valuesGroup2, 5000, false);
this.updateLineGraph();
this.updatePieChart();
} catch (err) {
console.error('[DashboardAnimator] Animation error:', err);
}
}
this.animationId = requestAnimationFrame(this.animate);
}
/**
* Attach hover event listeners to pause/resume animation
*/
attachHoverListeners() {
const container = this.svg.closest('[data-dashboard-container]') || this.svg.parentElement;
if (container) {
container.addEventListener('mouseenter', () => this.pause());
container.addEventListener('mouseleave', () => this.resume());
// Touch support: pause on touch
container.addEventListener('touchstart', () => this.pause());
container.addEventListener('touchend', () => this.resume());
}
}
/**
* Pause animation (on hover)
*/
pause() {
this.isPaused = true;
}
/**
* Resume animation (after hover)
*/
resume() {
this.isPaused = false;
}
/**
* Stop animation and clean up
*/
destroy() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
}
/* ── 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
/**
* Initialize dashboard animation when element enters viewport
*/
function initDashboardAnimation() {
const dashboardCharts = document.querySelectorAll('.dashboard-chart');
console.log('[initDashboardAnimation] Found', dashboardCharts.length, 'dashboard charts');
if (!dashboardCharts.length) return;
dashboardCharts.forEach((chart, idx) => {
// Check if element is likely in viewport
const rect = chart.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
console.log('[initDashboardAnimation] Chart', idx, 'visible:', isVisible, 'rect:', rect);
if (isVisible || !chart._dashboardAnimator) {
// Start animation immediately if visible or if not set up yet
if (!chart._dashboardAnimator) {
console.log('[initDashboardAnimation] Creating DashboardAnimator for chart', idx);
try {
chart._dashboardAnimator = new DashboardAnimator(chart);
console.log('[initDashboardAnimation] ✓ DashboardAnimator created successfully');
} catch (err) {
console.error('[initDashboardAnimation] ✗ Failed to create DashboardAnimator:', err);
}
}
}
});
// Also set up IntersectionObserver for when elements come into view
if ('IntersectionObserver' in window) {
const observerOptions = {
root: null,
rootMargin: '100px',
threshold: 0.05
/* ── 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
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
console.log('[IntersectionObserver]', entry.isIntersecting ? 'Intersecting' : 'Not intersecting');
if (entry.isIntersecting) {
if (!entry.target._dashboardAnimator) {
console.log('[IntersectionObserver] Creating new DashboardAnimator');
try {
entry.target._dashboardAnimator = new DashboardAnimator(entry.target);
} catch (err) {
console.error('[IntersectionObserver] Failed:', err);
}
} else {
entry.target._dashboardAnimator.resume();
}
} else {
if (entry.target._dashboardAnimator) {
entry.target._dashboardAnimator.pause();
}
}
});
}, observerOptions);
dashboardCharts.forEach(chart => observer.observe(chart));
}
}
// Auto-initialize when DOM is ready
console.log('[Dashboard Animator] Script loaded, readyState:', document.readyState);
/* ── 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 tryInitialize() {
console.log('[Dashboard Animator] tryInitialize called');
const charts = document.querySelectorAll('.dashboard-chart');
console.log('[Dashboard Animator] Found', charts.length, 'dashboard charts on page');
if (charts.length > 0) {
console.log('[Dashboard Animator] Found charts, initializing now...');
initDashboardAnimation();
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 {
console.log('[Dashboard Animator] No charts found yet, retrying in 100ms...');
setTimeout(tryInitialize, 100);
boot();
}
}
if (document.readyState === 'loading') {
console.log('[Dashboard Animator] DOM still loading, waiting for DOMContentLoaded');
document.addEventListener('DOMContentLoaded', tryInitialize);
} else {
console.log('[Dashboard Animator] DOM already loaded, initializing immediately');
tryInitialize();
}
})();