225 lines
6.8 KiB
JavaScript
225 lines
6.8 KiB
JavaScript
|
|
/**
|
||
|
|
* Dashboard Chart Animator
|
||
|
|
* Continuously animates bar charts, line graphs, and pie charts
|
||
|
|
* Performance-optimized with IntersectionObserver and requestAnimationFrame
|
||
|
|
*/
|
||
|
|
|
||
|
|
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 = 2000; // Base cycle speed in ms (controls smoothness)
|
||
|
|
|
||
|
|
// Cache DOM elements
|
||
|
|
this.barsGroup1 = svg.querySelectorAll('#bars-group-1 .bar');
|
||
|
|
this.barsGroup2 = svg.querySelectorAll('#bars-group-2 .bar');
|
||
|
|
this.valuesGroup1 = svg.querySelectorAll('#values-group-1 .chart-value');
|
||
|
|
this.valuesGroup2 = svg.querySelectorAll('#values-group-2 .chart-value');
|
||
|
|
this.linePath = svg.getElementById('line-path');
|
||
|
|
this.lineFill = svg.getElementById('line-fill');
|
||
|
|
this.pieSegments = svg.querySelectorAll('.pie-segment');
|
||
|
|
|
||
|
|
// 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.animate();
|
||
|
|
this.attachHoverListeners();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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) {
|
||
|
|
barsElements.forEach((bar, index) => {
|
||
|
|
const value = this.generateValue(this.noisePhase1, index, maxValue);
|
||
|
|
const heightPercent = (value / maxValue) * 100;
|
||
|
|
const height = (heightPercent / 100) * this.barChartHeight;
|
||
|
|
|
||
|
|
bar.setAttribute('height', height);
|
||
|
|
|
||
|
|
if (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() {
|
||
|
|
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() {
|
||
|
|
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) {
|
||
|
|
this.updateBars(this.barsGroup1, this.valuesGroup1, 100, true);
|
||
|
|
this.updateBars(this.barsGroup2, this.valuesGroup2, 5000, false);
|
||
|
|
this.updateLineGraph();
|
||
|
|
this.updatePieChart();
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Initialize dashboard animation when element enters viewport
|
||
|
|
*/
|
||
|
|
function initDashboardAnimation() {
|
||
|
|
const dashboardCharts = document.querySelectorAll('.dashboard-chart');
|
||
|
|
|
||
|
|
if (!dashboardCharts.length) return;
|
||
|
|
|
||
|
|
const observerOptions = {
|
||
|
|
root: null,
|
||
|
|
rootMargin: '50px',
|
||
|
|
threshold: 0.1
|
||
|
|
};
|
||
|
|
|
||
|
|
const observer = new IntersectionObserver((entries) => {
|
||
|
|
entries.forEach(entry => {
|
||
|
|
if (entry.isIntersecting) {
|
||
|
|
if (!entry.target._dashboardAnimator) {
|
||
|
|
entry.target._dashboardAnimator = new DashboardAnimator(entry.target);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Pause animation when out of view for performance
|
||
|
|
if (entry.target._dashboardAnimator) {
|
||
|
|
entry.target._dashboardAnimator.pause();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}, observerOptions);
|
||
|
|
|
||
|
|
dashboardCharts.forEach(chart => observer.observe(chart));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Auto-initialize when DOM is ready
|
||
|
|
if (document.readyState === 'loading') {
|
||
|
|
document.addEventListener('DOMContentLoaded', initDashboardAnimation);
|
||
|
|
} else {
|
||
|
|
initDashboardAnimation();
|
||
|
|
}
|