/** * Chart Management Module * Handles Chart.js initialization and updates */ class ChartManager { constructor(calculator) { this.calculator = calculator; this.charts = {}; } initializeCharts() { // Check if Chart.js is available if (typeof Chart === 'undefined') { console.error('Chart.js library not loaded. Charts will not be available.'); this.showChartError('Chart.js library failed to load. Please refresh the page.'); return; } try { // ROI Progression Chart (replaces Instance Growth Chart) const roiCanvas = document.getElementById('instanceGrowthChart'); if (!roiCanvas) { console.error('ROI progression chart canvas not found'); return; } const roiCtx = roiCanvas.getContext('2d'); this.charts.roiProgression = new Chart(roiCtx, { type: 'line', data: { labels: [], datasets: [] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'top' }, title: { display: true, text: 'ROI Progression Over Time' } }, scales: { y: { title: { display: true, text: 'ROI (%)' }, grid: { color: function(context) { return context.tick.value === 0 ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.1)'; } } }, x: { title: { display: true, text: 'Month' } } } } }); // Net Position Chart (replaces simple Revenue Chart) const netPositionCanvas = document.getElementById('revenueChart'); if (!netPositionCanvas) { console.error('Net position chart canvas not found'); return; } const netPositionCtx = netPositionCanvas.getContext('2d'); this.charts.netPosition = new Chart(netPositionCtx, { type: 'line', data: { labels: [], datasets: [] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'top' }, title: { display: true, text: 'Net Financial Position (Break-Even Analysis)' } }, scales: { y: { title: { display: true, text: 'Net Position (CHF)' }, grid: { color: function(context) { return context.tick.value === 0 ? 'rgba(0,0,0,0.8)' : 'rgba(0,0,0,0.1)'; } } }, x: { title: { display: true, text: 'Month' } } } } }); // CSP Revenue Breakdown Chart const cspRevenueCanvas = document.getElementById('cspRevenueChart'); if (!cspRevenueCanvas) { console.error('CSP revenue breakdown chart canvas not found'); return; } const cspRevenueCtx = cspRevenueCanvas.getContext('2d'); this.charts.cspRevenue = new Chart(cspRevenueCtx, { type: 'line', data: { labels: [], datasets: [] }, options: { responsive: true, maintainAspectRatio: false, layout: { padding: { left: 10, right: 200, // Add space for side legend top: 10, bottom: 10 } }, plugins: { legend: { position: 'right', align: 'start', labels: { boxWidth: 12, padding: 15, font: { size: 11 }, usePointStyle: true, generateLabels: function(chart) { const original = Chart.defaults.plugins.legend.labels.generateLabels; const labels = original.call(this, chart); // Group labels by scenario for better organization return labels.map(label => { // Shorten label text for better fit if (label.text.includes('Service Revenue')) { label.text = label.text.replace(' - Service Revenue', ' - Service'); } if (label.text.includes('Core Service Revenue')) { label.text = label.text.replace(' - Core Service Revenue', ' - Core'); } if (label.text.includes('CSP Total')) { label.text = label.text.replace(' - CSP Total', ' - Total'); } if (label.text.includes('Servala Revenue')) { label.text = label.text.replace(' - Servala Revenue', ' - Servala'); } return label; }); } } }, title: { display: true, text: 'CSP Revenue Growth - Direct Investment Only', font: { size: 14, weight: 'bold' } } }, scales: { y: { beginAtZero: true, title: { display: true, text: 'Revenue Amount' }, stacked: false, grid: { color: 'rgba(0,0,0,0.1)' } }, x: { title: { display: true, text: 'Month' }, grid: { color: 'rgba(0,0,0,0.05)' } } }, interaction: { mode: 'index', intersect: false }, elements: { point: { radius: 3, hoverRadius: 6 }, line: { tension: 0.1 } } } }); // Performance Comparison Chart (for cashFlowChart canvas) const performanceCanvas = document.getElementById('cashFlowChart'); if (!performanceCanvas) { console.error('Performance comparison chart canvas not found'); return; } const performanceCtx = performanceCanvas.getContext('2d'); this.charts.performance = new Chart(performanceCtx, { type: 'bar', data: { labels: [], datasets: [] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'top' }, title: { display: true, text: 'Model Performance Comparison' } }, scales: { y: { beginAtZero: true, title: { display: true, text: 'ROI (%)' } }, x: { title: { display: true, text: 'Growth Scenario' } } } } }); } catch (error) { console.error('Error initializing charts:', error); this.showChartError('Failed to initialize charts. Please refresh the page.'); } } showChartError(message) { // Show error message in place of charts const chartContainers = ['instanceGrowthChart', 'revenueChart', 'cashFlowChart', 'cspRevenueChart']; chartContainers.forEach(containerId => { const container = document.getElementById(containerId); if (container) { container.style.display = 'flex'; container.style.justifyContent = 'center'; container.style.alignItems = 'center'; container.style.minHeight = '300px'; container.style.backgroundColor = '#f8f9fa'; container.style.border = '1px solid #dee2e6'; container.style.borderRadius = '4px'; container.innerHTML = `

${message}
`; } }); } updateCharts() { try { const scenarios = Object.keys(this.calculator.results); if (scenarios.length === 0 || !this.charts.roiProgression) return; const colors = { conservative: '#28a745', moderate: '#ffc107', aggressive: '#dc3545' }; const modelColors = { direct: { border: '', background: '80' }, loan: { border: '', background: '40' } }; // Get month labels const maxMonths = Math.max(...scenarios.map(s => this.calculator.monthlyData[s].length)); const monthLabels = Array.from({ length: maxMonths }, (_, i) => `M${i + 1}`); // Update ROI Progression Chart with both models this.charts.roiProgression.data.labels = monthLabels; this.charts.roiProgression.data.datasets = scenarios.filter(s => this.calculator.results[s]).map(scenario => { const scenarioBase = scenario.replace('_direct', '').replace('_loan', ''); const model = scenario.includes('_loan') ? 'loan' : 'direct'; const isDirect = model === 'direct'; const scenarioName = this.calculator.scenarios[scenarioBase]?.name || scenarioBase; return { label: `${scenarioName} (${model.charAt(0).toUpperCase() + model.slice(1)})`, data: this.calculator.monthlyData[scenario].map(d => d.roiPercent), borderColor: colors[scenarioBase], backgroundColor: colors[scenarioBase] + (isDirect ? '30' : '15'), borderDash: isDirect ? [] : [5, 5], borderWidth: isDirect ? 3 : 2, tension: 0.4, pointBackgroundColor: this.calculator.monthlyData[scenario].map(d => d.roiPercent >= 0 ? colors[scenarioBase] : '#dc3545' ) }; }); this.charts.roiProgression.update(); // Update Net Position Chart (Break-Even Analysis) with both models this.charts.netPosition.data.labels = monthLabels; this.charts.netPosition.data.datasets = scenarios.filter(s => this.calculator.results[s]).map(scenario => { const scenarioBase = scenario.replace('_direct', '').replace('_loan', ''); const model = scenario.includes('_loan') ? 'loan' : 'direct'; const isDirect = model === 'direct'; const scenarioName = this.calculator.scenarios[scenarioBase]?.name || scenarioBase; return { label: `${scenarioName} (${model.charAt(0).toUpperCase() + model.slice(1)}) Net Position`, data: this.calculator.monthlyData[scenario].map(d => d.netPosition), borderColor: colors[scenarioBase], backgroundColor: colors[scenarioBase] + (isDirect ? '30' : '15'), borderDash: isDirect ? [] : [5, 5], borderWidth: isDirect ? 3 : 2, tension: 0.4, fill: { target: 'origin', above: colors[scenarioBase] + (isDirect ? '20' : '10'), below: '#dc354510' } }; }); this.charts.netPosition.update(); // Update Model Comparison Chart - Direct comparison of both models const inputs = this.calculator.getInputValues(); // Get unique scenario names (without model suffix) const baseScenarios = ['conservative', 'moderate', 'aggressive'].filter(s => this.calculator.scenarios[s].enabled ); const comparisonLabels = baseScenarios.map(s => this.calculator.scenarios[s].name); // Get net profit data for both models // Update CSP Revenue Breakdown Chart (Direct Investment Only) this.charts.cspRevenue.data.labels = monthLabels; this.charts.cspRevenue.data.datasets = []; // Filter to only direct investment scenarios const directScenarios = scenarios.filter(s => this.calculator.results[s] && s.includes('_direct') ); // Define revenue types and their styling const revenueTypes = [ { key: 'serviceRevenue', label: 'Service Revenue', borderWidth: 2, borderDash: [], opacity: '40' }, { key: 'coreRevenue', label: 'Core Service Revenue', borderWidth: 2, borderDash: [3, 3], opacity: '60' }, { key: 'cspRevenue', label: 'CSP Total', borderWidth: 3, borderDash: [], opacity: 'FF' }, { key: 'servalaRevenue', label: 'Servala Revenue', borderWidth: 1, borderDash: [5, 5], opacity: '80', color: '#6c757d' } ]; // Add datasets organized by revenue type for better legend grouping directScenarios.forEach(scenario => { const scenarioBase = scenario.replace('_direct', ''); const scenarioName = this.calculator.scenarios[scenarioBase]?.name || scenarioBase; const monthlyData = this.calculator.monthlyData[scenario]; const scenarioColor = colors[scenarioBase] || '#007bff'; revenueTypes.forEach(type => { // Skip Servala revenue if it's zero (no revenue sharing) if (type.key === 'servalaRevenue') { const hasServalaRevenue = monthlyData.some(d => (d.servalaRevenue || 0) > 0); if (!hasServalaRevenue) return; } // Skip core revenue if it's zero if (type.key === 'coreRevenue') { const hasCoreRevenue = monthlyData.some(d => (d.coreRevenue || 0) > 0); if (!hasCoreRevenue) return; } const dataValues = monthlyData.map(d => { if (type.key === 'serviceRevenue') return d.serviceRevenue || d.monthlyRevenue || 0; return d[type.key] || 0; }); this.charts.cspRevenue.data.datasets.push({ label: `${scenarioName} - ${type.label}`, data: dataValues, borderColor: type.color || scenarioColor, backgroundColor: (type.color || scenarioColor) + type.opacity, borderWidth: type.borderWidth, borderDash: type.borderDash, fill: false, tension: 0.1, pointBackgroundColor: type.color || scenarioColor, pointBorderColor: '#fff', pointBorderWidth: 1 }); }); }); this.charts.cspRevenue.update(); // Update Performance Comparison Chart (ROI comparison for both models) this.charts.performance.data.labels = comparisonLabels; // Get ROI data for both models const directROIData = baseScenarios.map(scenario => { const result = this.calculator.results[scenario + '_direct']; return result ? result.roi : 0; }); const loanROIData = baseScenarios.map(scenario => { const result = this.calculator.results[scenario + '_loan']; return result ? result.roi : 0; }); this.charts.performance.data.datasets = [ { label: 'Direct Investment ROI', data: directROIData, backgroundColor: baseScenarios.map(scenario => colors[scenario] + '80'), borderColor: baseScenarios.map(scenario => colors[scenario]), borderWidth: 2 }, { label: `Loan Model ROI (${(inputs.loanInterestRate * 100).toFixed(1)}% fixed)`, data: loanROIData, backgroundColor: '#ffc10780', borderColor: '#e0a800', borderWidth: 2 } ]; this.charts.performance.update(); } catch (error) { console.error('Error updating charts:', error); } } // Helper method to calculate loan payment calculateLoanPayment(principal, annualRate, years) { try { const monthlyRate = annualRate / 12; const numberOfPayments = years * 12; if (monthlyRate === 0) { return principal / numberOfPayments; } const monthlyPayment = principal * (monthlyRate * Math.pow(1 + monthlyRate, numberOfPayments)) / (Math.pow(1 + monthlyRate, numberOfPayments) - 1); return monthlyPayment; } catch (error) { console.error('Error calculating loan payment:', error); return 0; } } }