// modules/canvas-animations/DataVisualization.js import { CanvasManager } from './CanvasManager.js'; import { Logger } from '../../core/logger.js'; /** * Data Visualization Canvas Components - Animated charts and graphs */ export const DataVisualization = { /** * Initialize data visualization canvas */ init(canvas, config) { const manager = new CanvasManager(canvas); const vizType = config.type || 'bar'; // Get data from config or data attributes const data = this.parseData(canvas, config); if (!data || data.length === 0) { Logger.warn('[DataVisualization] No data provided for canvas'); return; } switch (vizType) { case 'bar': this.createBarChart(manager, data, config); break; case 'line': this.createLineChart(manager, data, config); break; case 'pie': this.createPieChart(manager, data, config); break; case 'progress': this.createProgressChart(manager, data, config); break; default: this.createBarChart(manager, data, config); } Logger.info('[DataVisualization] Initialized', vizType, 'chart with', data.length, 'data points'); }, /** * Parse data from canvas element or config */ parseData(canvas, config) { let data = config.data; // Try to get data from data-canvas-data attribute if (!data && canvas.dataset.canvasData) { try { data = JSON.parse(canvas.dataset.canvasData); } catch (e) { Logger.warn('[DataVisualization] Failed to parse canvas data', e); } } // Try to get data from script tag if (!data) { const scriptTag = canvas.nextElementSibling; if (scriptTag && scriptTag.tagName === 'SCRIPT' && scriptTag.type === 'application/json') { try { data = JSON.parse(scriptTag.textContent); } catch (e) { Logger.warn('[DataVisualization] Failed to parse script data', e); } } } // Ensure data is always an array if (!data) { Logger.warn('[DataVisualization] No data found'); return []; } // If data is not an array, wrap it or convert it if (!Array.isArray(data)) { // If it's a single number (for progress charts) if (typeof data === 'number') { data = [{ value: data, label: 'Progress' }]; } // If it's an object with values, convert to array else if (typeof data === 'object') { data = Object.entries(data).map(([key, value]) => ({ label: key, value: typeof value === 'object' ? value.value || value : value })); } // Fallback: empty array else { Logger.warn('[DataVisualization] Data format not recognized:', data); return []; } } return data; }, /** * Create animated bar chart */ createBarChart(manager, data, config) { const { ctx } = manager; const { width, height } = manager.getSize(); const padding = config.padding || 40; const chartWidth = width - padding * 2; const chartHeight = height - padding * 2; const barWidth = chartWidth / data.length * 0.8; const barSpacing = chartWidth / data.length * 0.2; const maxValue = Math.max(...data.map(d => d.value)); let animationProgress = 0; const animationDuration = config.animationDuration || 2000; let startTime = null; const animate = (timestamp) => { if (!startTime) startTime = timestamp; const elapsed = timestamp - startTime; animationProgress = Math.min(elapsed / animationDuration, 1); // Easing function (easeOutCubic) const progress = 1 - Math.pow(1 - animationProgress, 3); manager.clear(); this.renderBarChart(manager, data, config, progress, { padding, chartWidth, chartHeight, barWidth, barSpacing, maxValue }); if (animationProgress < 1) { requestAnimationFrame(animate); } }; requestAnimationFrame(animate); }, /** * Render bar chart frame */ renderBarChart(manager, data, config, progress, layout) { const { ctx } = manager; const { padding, chartHeight, barWidth, barSpacing, maxValue } = layout; // Draw bars data.forEach((item, index) => { const x = padding + index * (barWidth + barSpacing); const barHeight = (item.value / maxValue) * chartHeight * progress; const y = padding + chartHeight - barHeight; // Bar ctx.fillStyle = item.color || config.barColor || 'rgba(100, 150, 255, 0.8)'; ctx.fillRect(x, y, barWidth, barHeight); // Label if (config.showLabels !== false) { ctx.fillStyle = config.textColor || '#333'; ctx.font = config.font || '12px Arial'; ctx.textAlign = 'center'; ctx.fillText( item.label, x + barWidth / 2, padding + chartHeight + 20 ); // Value if (config.showValues !== false) { ctx.fillText( Math.round(item.value * progress), x + barWidth / 2, y - 5 ); } } }); // Draw axes if enabled if (config.showAxes !== false) { ctx.strokeStyle = config.axisColor || '#ccc'; ctx.lineWidth = 1; // Y-axis ctx.beginPath(); ctx.moveTo(padding, padding); ctx.lineTo(padding, padding + chartHeight); ctx.stroke(); // X-axis ctx.beginPath(); ctx.moveTo(padding, padding + chartHeight); ctx.lineTo(padding + layout.chartWidth, padding + chartHeight); ctx.stroke(); } }, /** * Create animated line chart */ createLineChart(manager, data, config) { const { ctx } = manager; const { width, height } = manager.getSize(); const padding = config.padding || 40; const chartWidth = width - padding * 2; const chartHeight = height - padding * 2; const maxValue = Math.max(...data.map(d => d.value)); const minValue = Math.min(...data.map(d => d.value)); const valueRange = maxValue - minValue; let animationProgress = 0; const animationDuration = config.animationDuration || 2000; let startTime = null; const animate = (timestamp) => { if (!startTime) startTime = timestamp; const elapsed = timestamp - startTime; animationProgress = Math.min(elapsed / animationDuration, 1); manager.clear(); this.renderLineChart(manager, data, config, animationProgress, { padding, chartWidth, chartHeight, maxValue, minValue, valueRange }); if (animationProgress < 1) { requestAnimationFrame(animate); } }; requestAnimationFrame(animate); }, /** * Render line chart frame */ renderLineChart(manager, data, config, progress, layout) { const { ctx } = manager; const { padding, chartWidth, chartHeight, minValue, valueRange } = layout; const pointsToShow = Math.floor(data.length * progress); ctx.strokeStyle = config.lineColor || 'rgba(100, 150, 255, 0.8)'; ctx.lineWidth = config.lineWidth || 3; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; // Draw line ctx.beginPath(); for (let i = 0; i <= pointsToShow && i < data.length; i++) { const x = padding + (i / (data.length - 1)) * chartWidth; const y = padding + chartHeight - ((data[i].value - minValue) / valueRange) * chartHeight; if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } } ctx.stroke(); // Draw points if (config.showPoints !== false) { ctx.fillStyle = config.pointColor || config.lineColor || 'rgba(100, 150, 255, 0.8)'; for (let i = 0; i <= pointsToShow && i < data.length; i++) { const x = padding + (i / (data.length - 1)) * chartWidth; const y = padding + chartHeight - ((data[i].value - minValue) / valueRange) * chartHeight; ctx.beginPath(); ctx.arc(x, y, config.pointSize || 4, 0, Math.PI * 2); ctx.fill(); // Labels if (config.showLabels !== false && data[i].label) { ctx.fillStyle = config.textColor || '#333'; ctx.font = config.font || '12px Arial'; ctx.textAlign = 'center'; ctx.fillText(data[i].label, x, padding + chartHeight + 20); } } } }, /** * Create animated pie chart */ createPieChart(manager, data, config) { const { ctx } = manager; const { width, height } = manager.getSize(); const centerX = width / 2; const centerY = height / 2; const radius = Math.min(width, height) / 2 * 0.8; const total = data.reduce((sum, item) => sum + item.value, 0); let animationProgress = 0; const animationDuration = config.animationDuration || 2000; let startTime = null; const animate = (timestamp) => { if (!startTime) startTime = timestamp; const elapsed = timestamp - startTime; animationProgress = Math.min(elapsed / animationDuration, 1); manager.clear(); this.renderPieChart(manager, data, config, animationProgress, { centerX, centerY, radius, total }); if (animationProgress < 1) { requestAnimationFrame(animate); } }; requestAnimationFrame(animate); }, /** * Render pie chart frame */ renderPieChart(manager, data, config, progress, layout) { const { ctx } = manager; const { centerX, centerY, radius, total } = layout; let currentAngle = -Math.PI / 2; // Start at top const maxAngle = currentAngle + (Math.PI * 2 * progress); for (let index = 0; index < data.length; index++) { if (currentAngle >= maxAngle) break; const item = data[index]; const sliceAngle = (item.value / total) * Math.PI * 2; const endAngle = Math.min(currentAngle + sliceAngle, maxAngle); if (endAngle > currentAngle) { // Draw slice ctx.fillStyle = item.color || this.getDefaultColor(index); ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.arc(centerX, centerY, radius, currentAngle, endAngle); ctx.closePath(); ctx.fill(); // Draw labels if enabled if (config.showLabels !== false && endAngle === currentAngle + sliceAngle) { const labelAngle = currentAngle + sliceAngle / 2; const labelX = centerX + Math.cos(labelAngle) * radius * 0.7; const labelY = centerY + Math.sin(labelAngle) * radius * 0.7; ctx.fillStyle = config.textColor || '#333'; ctx.font = config.font || '12px Arial'; ctx.textAlign = 'center'; ctx.fillText(item.label, labelX, labelY); } } currentAngle += sliceAngle; } }, /** * Create progress visualization */ createProgressChart(manager, data, config) { const { ctx } = manager; const { width, height } = manager.getSize(); let animationProgress = 0; const animationDuration = config.animationDuration || 1500; let startTime = null; const animate = (timestamp) => { if (!startTime) startTime = timestamp; const elapsed = timestamp - startTime; animationProgress = Math.min(elapsed / animationDuration, 1); manager.clear(); this.renderProgressChart(manager, data, config, animationProgress); if (animationProgress < 1) { requestAnimationFrame(animate); } }; requestAnimationFrame(animate); }, /** * Render progress chart */ renderProgressChart(manager, data, config, progress) { const { ctx } = manager; const { width, height } = manager.getSize(); const centerX = width / 2; const centerY = height / 2; const radius = Math.min(width, height) / 2 * 0.8; const lineWidth = config.lineWidth || 20; // Get progress value from data array const value = data[0]?.value || 0; const maxValue = config.maxValue || 100; const progressValue = (value / maxValue) * progress; // Background circle ctx.strokeStyle = config.backgroundColor || 'rgba(200, 200, 200, 0.3)'; ctx.lineWidth = lineWidth; ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); ctx.stroke(); // Progress arc ctx.strokeStyle = config.color || 'rgba(100, 150, 255, 0.8)'; ctx.lineCap = 'round'; ctx.beginPath(); ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + (progressValue / maxValue) * Math.PI * 2); ctx.stroke(); // Center text if (config.showText !== false) { ctx.fillStyle = config.textColor || '#333'; ctx.font = config.font || 'bold 24px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(Math.round(progressValue) + '%', centerX, centerY); } }, /** * Get default color for data point */ getDefaultColor(index) { const colors = [ 'rgba(100, 150, 255, 0.8)', 'rgba(255, 100, 150, 0.8)', 'rgba(150, 255, 100, 0.8)', 'rgba(255, 200, 100, 0.8)', 'rgba(200, 100, 255, 0.8)', 'rgba(100, 255, 200, 0.8)' ]; return colors[index % colors.length]; } };