website/hub/services/static/js/roi-calculator/chart-manager.js

454 lines
No EOL
20 KiB
JavaScript

/**
* 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 = `<div class="text-muted text-center"><i class="bi bi-exclamation-triangle"></i><br>${message}</div>`;
}
});
}
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;
}
}
}