2025-07-22 08:50:48 +02:00
|
|
|
/**
|
|
|
|
|
* 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 {
|
2025-07-22 09:16:40 +02:00
|
|
|
// ROI Progression Chart (replaces Instance Growth Chart)
|
|
|
|
|
const roiCanvas = document.getElementById('instanceGrowthChart');
|
|
|
|
|
if (!roiCanvas) {
|
|
|
|
|
console.error('ROI progression chart canvas not found');
|
2025-07-22 08:50:48 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2025-07-22 09:16:40 +02:00
|
|
|
const roiCtx = roiCanvas.getContext('2d');
|
|
|
|
|
this.charts.roiProgression = new Chart(roiCtx, {
|
2025-07-22 08:50:48 +02:00
|
|
|
type: 'line',
|
|
|
|
|
data: { labels: [], datasets: [] },
|
|
|
|
|
options: {
|
|
|
|
|
responsive: true,
|
|
|
|
|
maintainAspectRatio: false,
|
|
|
|
|
plugins: {
|
2025-07-22 09:16:40 +02:00
|
|
|
legend: { position: 'top' },
|
|
|
|
|
title: { display: true, text: 'ROI Progression Over Time' }
|
2025-07-22 08:50:48 +02:00
|
|
|
},
|
|
|
|
|
scales: {
|
|
|
|
|
y: {
|
2025-07-22 09:16:40 +02:00
|
|
|
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)';
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-07-22 08:50:48 +02:00
|
|
|
},
|
|
|
|
|
x: {
|
|
|
|
|
title: { display: true, text: 'Month' }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-22 09:16:40 +02:00
|
|
|
// Net Position Chart (replaces simple Revenue Chart)
|
|
|
|
|
const netPositionCanvas = document.getElementById('revenueChart');
|
|
|
|
|
if (!netPositionCanvas) {
|
|
|
|
|
console.error('Net position chart canvas not found');
|
2025-07-22 08:50:48 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2025-07-22 09:16:40 +02:00
|
|
|
const netPositionCtx = netPositionCanvas.getContext('2d');
|
|
|
|
|
this.charts.netPosition = new Chart(netPositionCtx, {
|
2025-07-22 08:50:48 +02:00
|
|
|
type: 'line',
|
|
|
|
|
data: { labels: [], datasets: [] },
|
|
|
|
|
options: {
|
|
|
|
|
responsive: true,
|
|
|
|
|
maintainAspectRatio: false,
|
|
|
|
|
plugins: {
|
2025-07-22 09:16:40 +02:00
|
|
|
legend: { position: 'top' },
|
|
|
|
|
title: { display: true, text: 'Net Financial Position (Break-Even Analysis)' }
|
2025-07-22 08:50:48 +02:00
|
|
|
},
|
|
|
|
|
scales: {
|
|
|
|
|
y: {
|
2025-07-22 09:16:40 +02:00
|
|
|
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)';
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-07-22 08:50:48 +02:00
|
|
|
},
|
|
|
|
|
x: {
|
|
|
|
|
title: { display: true, text: 'Month' }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-22 09:16:40 +02:00
|
|
|
// Model Comparison Chart (replaces generic Cash Flow Chart)
|
2025-07-22 17:30:37 +02:00
|
|
|
const modelComparisonCanvas = document.getElementById('modelComparisonChart');
|
2025-07-22 09:16:40 +02:00
|
|
|
if (!modelComparisonCanvas) {
|
|
|
|
|
console.error('Model comparison chart canvas not found');
|
2025-07-22 08:50:48 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2025-07-22 09:16:40 +02:00
|
|
|
const modelComparisonCtx = modelComparisonCanvas.getContext('2d');
|
|
|
|
|
this.charts.modelComparison = new Chart(modelComparisonCtx, {
|
2025-07-22 08:50:48 +02:00
|
|
|
type: 'bar',
|
|
|
|
|
data: { labels: [], datasets: [] },
|
|
|
|
|
options: {
|
|
|
|
|
responsive: true,
|
|
|
|
|
maintainAspectRatio: false,
|
|
|
|
|
plugins: {
|
2025-07-22 09:16:40 +02:00
|
|
|
legend: { position: 'top' },
|
2025-07-22 17:30:37 +02:00
|
|
|
title: { display: true, text: 'Investment Model Comparison' }
|
2025-07-22 08:50:48 +02:00
|
|
|
},
|
|
|
|
|
scales: {
|
|
|
|
|
y: {
|
2025-07-22 09:16:40 +02:00
|
|
|
beginAtZero: true,
|
|
|
|
|
title: { display: true, text: 'Total Return (CHF)' }
|
2025-07-22 08:50:48 +02:00
|
|
|
},
|
|
|
|
|
x: {
|
2025-07-22 09:16:40 +02:00
|
|
|
title: { display: true, text: 'Growth Scenario' }
|
2025-07-22 08:50:48 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-07-22 17:30:37 +02:00
|
|
|
|
|
|
|
|
// 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' }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-07-22 08:50:48 +02:00
|
|
|
} 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
|
2025-07-22 17:30:37 +02:00
|
|
|
const chartContainers = ['instanceGrowthChart', 'revenueChart', 'cashFlowChart', 'modelComparisonChart'];
|
2025-07-22 08:50:48 +02:00
|
|
|
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);
|
2025-07-22 09:16:40 +02:00
|
|
|
if (scenarios.length === 0 || !this.charts.roiProgression) return;
|
2025-07-22 08:50:48 +02:00
|
|
|
|
|
|
|
|
const colors = {
|
|
|
|
|
conservative: '#28a745',
|
2025-07-22 09:16:40 +02:00
|
|
|
moderate: '#ffc107',
|
2025-07-22 08:50:48 +02:00
|
|
|
aggressive: '#dc3545'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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}`);
|
|
|
|
|
|
2025-07-22 09:16:40 +02:00
|
|
|
// Update ROI Progression Chart
|
|
|
|
|
this.charts.roiProgression.data.labels = monthLabels;
|
2025-07-22 17:30:37 +02:00
|
|
|
this.charts.roiProgression.data.datasets = scenarios.filter(s => this.calculator.results[s]).map(scenario => ({
|
2025-07-22 09:16:40 +02:00
|
|
|
label: `${this.calculator.scenarios[scenario].name} (${this.calculator.results[scenario].investmentModel})`,
|
|
|
|
|
data: this.calculator.monthlyData[scenario].map(d => d.roiPercent),
|
2025-07-22 08:50:48 +02:00
|
|
|
borderColor: colors[scenario],
|
|
|
|
|
backgroundColor: colors[scenario] + '20',
|
2025-07-22 09:16:40 +02:00
|
|
|
tension: 0.4,
|
|
|
|
|
pointBackgroundColor: this.calculator.monthlyData[scenario].map(d =>
|
|
|
|
|
d.roiPercent >= 0 ? colors[scenario] : '#dc3545'
|
|
|
|
|
)
|
2025-07-22 08:50:48 +02:00
|
|
|
}));
|
2025-07-22 09:16:40 +02:00
|
|
|
this.charts.roiProgression.update();
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-22 09:16:40 +02:00
|
|
|
// Update Net Position Chart (Break-Even Analysis)
|
|
|
|
|
this.charts.netPosition.data.labels = monthLabels;
|
2025-07-22 17:30:37 +02:00
|
|
|
this.charts.netPosition.data.datasets = scenarios.filter(s => this.calculator.results[s]).map(scenario => ({
|
2025-07-22 09:16:40 +02:00
|
|
|
label: `${this.calculator.scenarios[scenario].name} Net Position`,
|
|
|
|
|
data: this.calculator.monthlyData[scenario].map(d => d.netPosition),
|
2025-07-22 08:50:48 +02:00
|
|
|
borderColor: colors[scenario],
|
|
|
|
|
backgroundColor: colors[scenario] + '20',
|
2025-07-22 09:16:40 +02:00
|
|
|
tension: 0.4,
|
|
|
|
|
fill: {
|
|
|
|
|
target: 'origin',
|
|
|
|
|
above: colors[scenario] + '10',
|
|
|
|
|
below: '#dc354510'
|
|
|
|
|
}
|
2025-07-22 08:50:48 +02:00
|
|
|
}));
|
2025-07-22 09:16:40 +02:00
|
|
|
this.charts.netPosition.update();
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-22 17:30:37 +02:00
|
|
|
// Update Model Comparison Chart - Side-by-side comparison of both models
|
|
|
|
|
const inputs = this.calculator.getInputValues();
|
2025-07-22 09:16:40 +02:00
|
|
|
|
2025-07-22 17:30:37 +02:00
|
|
|
// Calculate loan model net profit (fixed return regardless of scenario)
|
|
|
|
|
const loanMonthlyPayment = this.calculateLoanPayment(inputs.investmentAmount, inputs.loanInterestRate, inputs.timeframe);
|
|
|
|
|
const loanTotalPayments = loanMonthlyPayment * (inputs.timeframe * 12);
|
|
|
|
|
const loanNetProfit = loanTotalPayments - inputs.investmentAmount;
|
2025-07-22 09:16:40 +02:00
|
|
|
|
2025-07-22 17:30:37 +02:00
|
|
|
// Prepare scenario-based comparison
|
|
|
|
|
const enabledScenarios = scenarios.filter(s => this.calculator.scenarios[s].enabled);
|
|
|
|
|
const comparisonLabels = enabledScenarios.map(s => this.calculator.scenarios[s].name);
|
|
|
|
|
|
|
|
|
|
// Loan model data (same profit for all scenarios since it's fixed)
|
|
|
|
|
const loanModelData = enabledScenarios.map(() => loanNetProfit);
|
|
|
|
|
|
|
|
|
|
// Direct investment data (varies by scenario performance)
|
|
|
|
|
const directInvestmentData = enabledScenarios.map(scenario => {
|
|
|
|
|
const scenarioResult = this.calculator.results[scenario];
|
|
|
|
|
if (!scenarioResult) return 0;
|
|
|
|
|
return scenarioResult.cspRevenue - inputs.investmentAmount;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Performance bonus data (shows the additional revenue from performance bonuses)
|
|
|
|
|
const performanceBonusData = enabledScenarios.map(scenario => {
|
|
|
|
|
const monthlyData = this.calculator.monthlyData[scenario] || [];
|
|
|
|
|
const totalPerformanceBonus = monthlyData.reduce((sum, month) => {
|
|
|
|
|
// Calculate the bonus revenue (difference from standard share)
|
|
|
|
|
const standardRevenue = month.monthlyRevenue * inputs.servalaShare;
|
|
|
|
|
const actualServalaRevenue = month.servalaRevenue;
|
|
|
|
|
const bonusAmount = Math.max(0, standardRevenue - actualServalaRevenue); // CSP gets this as bonus
|
|
|
|
|
return sum + bonusAmount;
|
|
|
|
|
}, 0);
|
|
|
|
|
return totalPerformanceBonus;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.charts.modelComparison.data.labels = comparisonLabels;
|
|
|
|
|
this.charts.modelComparison.data.datasets = [
|
|
|
|
|
{
|
|
|
|
|
label: `Loan Model (${(inputs.loanInterestRate * 100).toFixed(1)}% fixed return)`,
|
|
|
|
|
data: loanModelData,
|
|
|
|
|
backgroundColor: '#ffc107',
|
|
|
|
|
borderColor: '#e0a800',
|
2025-07-22 09:16:40 +02:00
|
|
|
borderWidth: 2
|
2025-07-22 17:30:37 +02:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Direct Investment (base return)',
|
|
|
|
|
data: directInvestmentData,
|
|
|
|
|
backgroundColor: enabledScenarios.map(scenario => colors[scenario] + '80'),
|
|
|
|
|
borderColor: enabledScenarios.map(scenario => colors[scenario]),
|
|
|
|
|
borderWidth: 2
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Performance Bonus Impact',
|
|
|
|
|
data: performanceBonusData,
|
|
|
|
|
backgroundColor: enabledScenarios.map(scenario => colors[scenario] + '40'),
|
|
|
|
|
borderColor: enabledScenarios.map(scenario => colors[scenario]),
|
|
|
|
|
borderWidth: 2,
|
|
|
|
|
borderDash: [5, 5]
|
|
|
|
|
}
|
|
|
|
|
];
|
2025-07-22 09:16:40 +02:00
|
|
|
|
|
|
|
|
this.charts.modelComparison.update();
|
2025-07-22 17:30:37 +02:00
|
|
|
|
|
|
|
|
// Update Performance Comparison Chart (ROI comparison for both models)
|
|
|
|
|
this.charts.performance.data.labels = comparisonLabels;
|
|
|
|
|
|
|
|
|
|
// Calculate ROI for loan model (same for all scenarios)
|
|
|
|
|
const loanROI = (loanNetProfit / inputs.investmentAmount) * 100;
|
|
|
|
|
const loanROIData = enabledScenarios.map(() => loanROI);
|
|
|
|
|
|
|
|
|
|
// Get ROI for direct investment (varies by scenario)
|
|
|
|
|
const directROIData = enabledScenarios.map(scenario => {
|
|
|
|
|
const result = this.calculator.results[scenario];
|
|
|
|
|
return result ? result.roi : 0;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.charts.performance.data.datasets = [
|
|
|
|
|
{
|
|
|
|
|
label: `Loan Model ROI (${(inputs.loanInterestRate * 100).toFixed(1)}% fixed)`,
|
|
|
|
|
data: loanROIData,
|
|
|
|
|
backgroundColor: '#ffc107',
|
|
|
|
|
borderColor: '#e0a800',
|
|
|
|
|
borderWidth: 2
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Direct Investment ROI',
|
|
|
|
|
data: directROIData,
|
|
|
|
|
backgroundColor: enabledScenarios.map(scenario => colors[scenario] + '80'),
|
|
|
|
|
borderColor: enabledScenarios.map(scenario => colors[scenario]),
|
|
|
|
|
borderWidth: 2
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
this.charts.performance.update();
|
2025-07-22 08:50:48 +02:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error updating charts:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-07-22 17:30:37 +02:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-07-22 08:50:48 +02:00
|
|
|
}
|