// Graph-based Trend Visualization with Offline Charts // Comprehensive charting system using Chart.js for environmental data visualization import { Chart, ChartConfiguration, ChartData, ChartOptions } from 'chart.js'; interface ChartDataPoint { x: number | string | Date; y: number; label?: string; metadata?: Record; } interface TrendAnalysis { slope: number; correlation: number; trend: 'increasing' | 'decreasing' | 'stable'; confidence: number; forecast?: ChartDataPoint[]; } interface ChartTheme { primary: string; secondary: string; accent: string; background: string; text: string; grid: string; } class ChartVisualizer { private readonly indianTheme: ChartTheme = { primary: '#FF6B35', // Saffron secondary: '#2E8B57', // Sea Green accent: '#4169E1', // Royal Blue background: '#FFFEF7', // Cream text: '#2F4F4F', // Dark Slate Gray grid: '#E6E6FA' // Lavender }; // Time Series Chart for Environmental Parameters createTimeSeriesChart( canvasId: string, data: ChartDataPoint[], options: { title: string; yAxisLabel: string; parameter: string; unit: string; showTrend?: boolean; thresholds?: { value: number; label: string; color: string }[]; } ): Chart { try { if (!data || data.length === 0) { throw new Error('Chart data is required and cannot be empty'); } this.validateChartData(data); const canvas = document.getElementById(canvasId) as HTMLCanvasElement; if (!canvas) { throw new Error(`Canvas element with ID '${canvasId}' not found`); } // Sort data by time const sortedData = [...data].sort((a, b) => { const timeA = new Date(a.x).getTime(); const timeB = new Date(b.x).getTime(); return timeA - timeB; }); const datasets: any[] = [{ label: `${options.parameter} (${options.unit})`, data: sortedData.map(point => ({ x: point.x, y: point.y })), borderColor: this.indianTheme.primary, backgroundColor: this.indianTheme.primary + '20', tension: 0.4, fill: true, pointBackgroundColor: this.indianTheme.primary, pointBorderColor: '#fff', pointBorderWidth: 2, pointRadius: 4 }]; // Add trend line if requested if (options.showTrend && sortedData.length > 2) { const trendData = this.calculateTrendLine(sortedData); datasets.push({ label: 'Trend', data: trendData, borderColor: this.indianTheme.accent, backgroundColor: 'transparent', borderDash: [5, 5], pointRadius: 0, tension: 0 }); } // Add threshold lines if (options.thresholds) { options.thresholds.forEach((threshold, index) => { datasets.push({ label: threshold.label, data: sortedData.map(point => ({ x: point.x, y: threshold.value })), borderColor: threshold.color, backgroundColor: 'transparent', borderDash: [10, 5], pointRadius: 0, tension: 0 }); }); } const config: ChartConfiguration = { type: 'line', data: { datasets }, options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: true, text: options.title, font: { size: 16, weight: 'bold' }, color: this.indianTheme.text }, legend: { display: true, position: 'top', labels: { color: this.indianTheme.text, usePointStyle: true } }, tooltip: { mode: 'index', intersect: false, backgroundColor: this.indianTheme.background, titleColor: this.indianTheme.text, bodyColor: this.indianTheme.text, borderColor: this.indianTheme.primary, borderWidth: 1 } }, scales: { x: { type: 'time', display: true, title: { display: true, text: 'Time', color: this.indianTheme.text }, grid: { color: this.indianTheme.grid }, ticks: { color: this.indianTheme.text } }, y: { display: true, title: { display: true, text: options.yAxisLabel, color: this.indianTheme.text }, grid: { color: this.indianTheme.grid }, ticks: { color: this.indianTheme.text } } }, interaction: { mode: 'nearest', axis: 'x', intersect: false } } }; return new Chart(canvas, config); } catch (error) { throw new Error(`Failed to create time series chart: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Bar Chart for Comparative Analysis createBarChart( canvasId: string, data: { label: string; value: number; color?: string }[], options: { title: string; yAxisLabel: string; horizontal?: boolean; } ): Chart { try { if (!data || data.length === 0) { throw new Error('Chart data is required and cannot be empty'); } const canvas = document.getElementById(canvasId) as HTMLCanvasElement; if (!canvas) { throw new Error(`Canvas element with ID '${canvasId}' not found`); } const colors = data.map((item, index) => item.color || this.getColorByIndex(index) ); const config: ChartConfiguration = { type: options.horizontal ? 'bar' : 'bar', data: { labels: data.map(item => item.label), datasets: [{ label: options.yAxisLabel, data: data.map(item => item.value), backgroundColor: colors.map(color => color + '80'), borderColor: colors, borderWidth: 2 }] }, options: { indexAxis: options.horizontal ? 'y' : 'x', responsive: true, maintainAspectRatio: false, plugins: { title: { display: true, text: options.title, font: { size: 16, weight: 'bold' }, color: this.indianTheme.text }, legend: { display: false }, tooltip: { backgroundColor: this.indianTheme.background, titleColor: this.indianTheme.text, bodyColor: this.indianTheme.text, borderColor: this.indianTheme.primary, borderWidth: 1 } }, scales: { x: { grid: { color: this.indianTheme.grid }, ticks: { color: this.indianTheme.text } }, y: { grid: { color: this.indianTheme.grid }, ticks: { color: this.indianTheme.text }, title: { display: true, text: options.yAxisLabel, color: this.indianTheme.text } } } } }; return new Chart(canvas, config); } catch (error) { throw new Error(`Failed to create bar chart: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Scatter Plot for Correlation Analysis createScatterPlot( canvasId: string, data: { x: number; y: number; label?: string }[], options: { title: string; xAxisLabel: string; yAxisLabel: string; showTrendLine?: boolean; } ): Chart { try { if (!data || data.length === 0) { throw new Error('Chart data is required and cannot be empty'); } data.forEach((point, index) => { if (typeof point.x !== 'number' || typeof point.y !== 'number') { throw new Error(`Invalid data point at index ${index}: x and y must be numbers`); } if (isNaN(point.x) || isNaN(point.y)) { throw new Error(`Invalid data point at index ${index}: x and y cannot be NaN`); } }); const canvas = document.getElementById(canvasId) as HTMLCanvasElement; if (!canvas) { throw new Error(`Canvas element with ID '${canvasId}' not found`); } const datasets: any[] = [{ label: 'Data Points', data: data, backgroundColor: this.indianTheme.primary + '80', borderColor: this.indianTheme.primary, pointRadius: 6, pointHoverRadius: 8 }]; // Add trend line if requested if (options.showTrendLine && data.length > 2) { const correlation = this.calculateCorrelation(data); const trendLine = this.calculateLinearRegression(data); datasets.push({ label: `Trend Line (r = ${correlation.toFixed(3)})`, data: trendLine, type: 'line', backgroundColor: 'transparent', borderColor: this.indianTheme.accent, borderDash: [5, 5], pointRadius: 0, tension: 0 }); } const config: ChartConfiguration = { type: 'scatter', data: { datasets }, options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: true, text: options.title, font: { size: 16, weight: 'bold' }, color: this.indianTheme.text }, legend: { display: true, position: 'top', labels: { color: this.indianTheme.text } }, tooltip: { backgroundColor: this.indianTheme.background, titleColor: this.indianTheme.text, bodyColor: this.indianTheme.text, borderColor: this.indianTheme.primary, borderWidth: 1, callbacks: { label: function(context: any) { const point = context.parsed; return `(${point.x.toFixed(2)}, ${point.y.toFixed(2)})`; } } } }, scales: { x: { type: 'linear', position: 'bottom', title: { display: true, text: options.xAxisLabel, color: this.indianTheme.text }, grid: { color: this.indianTheme.grid }, ticks: { color: this.indianTheme.text } }, y: { title: { display: true, text: options.yAxisLabel, color: this.indianTheme.text }, grid: { color: this.indianTheme.grid }, ticks: { color: this.indianTheme.text } } } } }; return new Chart(canvas, config); } catch (error) { throw new Error(`Failed to create scatter plot: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Pie Chart for Distribution Analysis createPieChart( canvasId: string, data: { label: string; value: number; color?: string }[], options: { title: string; showPercentages?: boolean; } ): Chart { try { if (!data || data.length === 0) { throw new Error('Chart data is required and cannot be empty'); } data.forEach((item, index) => { if (typeof item.value !== 'number' || item.value < 0) { throw new Error(`Invalid value at index ${index}: must be a non-negative number`); } }); const canvas = document.getElementById(canvasId) as HTMLCanvasElement; if (!canvas) { throw new Error(`Canvas element with ID '${canvasId}' not found`); } const total = data.reduce((sum, item) => sum + item.value, 0); if (total === 0) { throw new Error('Total of all values cannot be zero'); } const colors = data.map((item, index) => item.color || this.getColorByIndex(index) ); const config: ChartConfiguration = { type: 'pie', data: { labels: data.map(item => item.label), datasets: [{ data: data.map(item => item.value), backgroundColor: colors.map(color => color + '80'), borderColor: colors, borderWidth: 2 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: true, text: options.title, font: { size: 16, weight: 'bold' }, color: this.indianTheme.text }, legend: { display: true, position: 'right', labels: { color: this.indianTheme.text, usePointStyle: true } }, tooltip: { backgroundColor: this.indianTheme.background, titleColor: this.indianTheme.text, bodyColor: this.indianTheme.text, borderColor: this.indianTheme.primary, borderWidth: 1, callbacks: { label: function(context: any) { const value = context.parsed; const percentage = ((value / total) * 100).toFixed(1); return options.showPercentages ? `${context.label}: ${value} (${percentage}%)` : `${context.label}: ${value}`; } } } } } }; return new Chart(canvas, config); } catch (error) { throw new Error(`Failed to create pie chart: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Trend Analysis analyzeTrend(data: ChartDataPoint[]): TrendAnalysis { try { if (!data || data.length < 3) { throw new Error('At least 3 data points are required for trend analysis'); } this.validateChartData(data); // Convert to numeric format for analysis const numericData = data.map((point, index) => ({ x: index, y: point.y })); const correlation = this.calculateCorrelation(numericData); const regression = this.calculateLinearRegression(numericData); const slope = this.calculateSlope(numericData); let trend: 'increasing' | 'decreasing' | 'stable'; if (Math.abs(slope) < 0.01) { trend = 'stable'; } else if (slope > 0) { trend = 'increasing'; } else { trend = 'decreasing'; } // Calculate confidence based on correlation strength const confidence = Math.min(95, Math.abs(correlation) * 100); // Generate forecast for next 3 points const forecast: ChartDataPoint[] = []; const lastIndex = numericData.length - 1; for (let i = 1; i <= 3; i++) { const futureX = lastIndex + i; const futureY = slope * futureX + this.getYIntercept(numericData); forecast.push({ x: futureX, y: Math.max(0, futureY), // Ensure non-negative forecast label: `Forecast ${i}` }); } return { slope, correlation, trend, confidence, forecast }; } catch (error) { throw new Error(`Failed to analyze trend: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Export chart as image exportChart(chart: Chart, filename: string = 'chart'): void { try { if (!chart || !chart.canvas) { throw new Error('Invalid chart object'); } const canvas = chart.canvas; const url = canvas.toDataURL('image/png'); const link = document.createElement('a'); link.download = `${filename}.png`; link.href = url; document.body.appendChild(link); link.click(); document.body.removeChild(link); } catch (error) { throw new Error(`Failed to export chart: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Private helper methods private validateChartData(data: ChartDataPoint[]): void { data.forEach((point, index) => { if (typeof point.y !== 'number') { throw new Error(`Invalid y value at index ${index}: must be a number`); } if (isNaN(point.y) || !isFinite(point.y)) { throw new Error(`Invalid y value at index ${index}: must be a finite number`); } }); } private calculateTrendLine(data: ChartDataPoint[]): { x: string | number | Date; y: number }[] { const numericData = data.map((point, index) => ({ x: index, y: point.y })); const regression = this.calculateLinearRegression(numericData); return [ { x: data[0].x, y: regression[0].y }, { x: data[data.length - 1].x, y: regression[regression.length - 1].y } ]; } private calculateCorrelation(data: { x: number; y: number }[]): number { const n = data.length; const sumX = data.reduce((sum, point) => sum + point.x, 0); const sumY = data.reduce((sum, point) => sum + point.y, 0); const sumXY = data.reduce((sum, point) => sum + point.x * point.y, 0); const sumX2 = data.reduce((sum, point) => sum + point.x * point.x, 0); const sumY2 = data.reduce((sum, point) => sum + point.y * point.y, 0); const numerator = n * sumXY - sumX * sumY; const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY)); return denominator === 0 ? 0 : numerator / denominator; } private calculateLinearRegression(data: { x: number; y: number }[]): { x: number; y: number }[] { const slope = this.calculateSlope(data); const yIntercept = this.getYIntercept(data); return data.map(point => ({ x: point.x, y: slope * point.x + yIntercept })); } private calculateSlope(data: { x: number; y: number }[]): number { const n = data.length; const sumX = data.reduce((sum, point) => sum + point.x, 0); const sumY = data.reduce((sum, point) => sum + point.y, 0); const sumXY = data.reduce((sum, point) => sum + point.x * point.y, 0); const sumX2 = data.reduce((sum, point) => sum + point.x * point.x, 0); const denominator = n * sumX2 - sumX * sumX; return denominator === 0 ? 0 : (n * sumXY - sumX * sumY) / denominator; } private getYIntercept(data: { x: number; y: number }[]): number { const n = data.length; const sumX = data.reduce((sum, point) => sum + point.x, 0); const sumY = data.reduce((sum, point) => sum + point.y, 0); const slope = this.calculateSlope(data); return (sumY - slope * sumX) / n; } private getColorByIndex(index: number): string { const colors = [ this.indianTheme.primary, this.indianTheme.secondary, this.indianTheme.accent, '#FF4500', // Orange Red '#32CD32', // Lime Green '#6A5ACD', // Slate Blue '#FF69B4', // Hot Pink '#20B2AA', // Light Sea Green '#FFD700', // Gold '#DC143C' // Crimson ]; return colors[index % colors.length]; } } export const chartVisualizer = new ChartVisualizer(); export { ChartDataPoint, TrendAnalysis, ChartTheme };