further refinement of roi calculator

This commit is contained in:
Tobias Brunner 2025-07-22 17:30:37 +02:00
parent 6f6c80480f
commit 493d45bb5d
Signed by: tobru
SSH key fingerprint: SHA256:kOXg1R6c11XW3/Pt9dbLdQvOJGFAy+B2K6v6PtRWBGQ
6 changed files with 962 additions and 565 deletions

View file

@ -101,14 +101,109 @@
.chart-container {
position: relative;
height: 400px;
background: white;
border-radius: 8px;
padding: 1rem;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 1.5rem;
}
/* Enhanced chart sizing for new layout */
.chart-container canvas {
max-height: 400px;
}
/* Full-width chart containers */
.card-body canvas {
width: 100% !important;
}
/* Primary chart gets extra height */
#instanceGrowthChart {
height: 500px !important;
}
/* Secondary charts get good height */
#revenueChart, #cashFlowChart, #modelComparisonChart {
height: 400px !important;
}
/* Enhanced layout styles for new design */
.sticky-top {
z-index: 1020;
}
.card {
transition: box-shadow 0.15s ease-in-out;
}
.card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
/* Compact header controls */
.form-range {
height: 4px;
}
.input-group-sm .form-control,
.form-select-sm,
.btn-sm {
font-size: 0.825rem;
}
/* Clean chart headers */
.card-header {
background: white !important;
border: none !important;
padding-bottom: 0.5rem;
}
.card-body {
padding: 1.5rem;
}
/* Responsive chart heights */
@media (max-width: 768px) {
#instanceGrowthChart {
height: 350px !important;
}
#revenueChart, #cashFlowChart, #modelComparisonChart {
height: 300px !important;
}
.card-body {
padding: 1rem;
}
}
@media (max-width: 576px) {
#instanceGrowthChart {
height: 250px !important;
}
#revenueChart, #cashFlowChart, #modelComparisonChart {
height: 200px !important;
}
}
/* Manual collapse functionality */
.collapse {
display: none;
}
.collapse.show {
display: block;
}
.collapsing {
position: relative;
height: 0;
overflow: hidden;
transition: height 0.35s ease;
}
.export-buttons {
position: sticky;
top: 20px;

View file

@ -84,7 +84,7 @@ class ChartManager {
});
// Model Comparison Chart (replaces generic Cash Flow Chart)
const modelComparisonCanvas = document.getElementById('cashFlowChart');
const modelComparisonCanvas = document.getElementById('modelComparisonChart');
if (!modelComparisonCanvas) {
console.error('Model comparison chart canvas not found');
return;
@ -98,7 +98,7 @@ class ChartManager {
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' },
title: { display: true, text: 'Investment Model Performance Comparison' }
title: { display: true, text: 'Investment Model Comparison' }
},
scales: {
y: {
@ -111,6 +111,35 @@ class ChartManager {
}
}
});
// 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.');
@ -119,7 +148,7 @@ class ChartManager {
showChartError(message) {
// Show error message in place of charts
const chartContainers = ['instanceGrowthChart', 'revenueChart', 'cashFlowChart'];
const chartContainers = ['instanceGrowthChart', 'revenueChart', 'cashFlowChart', 'modelComparisonChart'];
chartContainers.forEach(containerId => {
const container = document.getElementById(containerId);
if (container) {
@ -152,7 +181,7 @@ class ChartManager {
// Update ROI Progression Chart
this.charts.roiProgression.data.labels = monthLabels;
this.charts.roiProgression.data.datasets = scenarios.map(scenario => ({
this.charts.roiProgression.data.datasets = scenarios.filter(s => this.calculator.results[s]).map(scenario => ({
label: `${this.calculator.scenarios[scenario].name} (${this.calculator.results[scenario].investmentModel})`,
data: this.calculator.monthlyData[scenario].map(d => d.roiPercent),
borderColor: colors[scenario],
@ -166,7 +195,7 @@ class ChartManager {
// Update Net Position Chart (Break-Even Analysis)
this.charts.netPosition.data.labels = monthLabels;
this.charts.netPosition.data.datasets = scenarios.map(scenario => ({
this.charts.netPosition.data.datasets = scenarios.filter(s => this.calculator.results[s]).map(scenario => ({
label: `${this.calculator.scenarios[scenario].name} Net Position`,
data: this.calculator.monthlyData[scenario].map(d => d.netPosition),
borderColor: colors[scenario],
@ -180,37 +209,122 @@ class ChartManager {
}));
this.charts.netPosition.update();
// Update Model Comparison Chart
const scenarioLabels = scenarios.map(s => this.calculator.scenarios[s].name);
const currentInvestmentModel = Object.values(this.calculator.results)[0]?.investmentModel || 'direct';
// Update Model Comparison Chart - Side-by-side comparison of both models
const inputs = this.calculator.getInputValues();
// Show comparison with both models for the same scenarios
this.charts.modelComparison.data.labels = scenarioLabels;
this.charts.modelComparison.data.datasets = [{
label: `${currentInvestmentModel === 'loan' ? 'Loan Model' : 'Direct Investment'} - Final Return`,
data: scenarios.map(scenario => this.calculator.results[scenario].cspRevenue),
backgroundColor: scenarios.map(scenario => colors[scenario] + '80'),
borderColor: scenarios.map(scenario => colors[scenario]),
borderWidth: 2
}];
// 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;
// Add performance metrics for direct investment
if (currentInvestmentModel === 'direct') {
this.charts.modelComparison.data.datasets.push({
label: 'Performance Bonus Impact',
data: scenarios.map(scenario =>
this.calculator.results[scenario].avgPerformanceBonus *
this.calculator.results[scenario].cspRevenue
),
backgroundColor: '#17a2b8',
borderColor: '#17a2b8',
// 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',
borderWidth: 2
});
}
},
{
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]
}
];
this.charts.modelComparison.update();
// 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();
} 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;
}
}
}