| 1 |
(function () { |
| 2 |
'use strict'; |
| 3 |
|
| 4 |
var CHART_CDN = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js'; |
| 5 |
|
| 6 |
function calcLoan(amount, annualRate, years) { |
| 7 |
if (!amount || !annualRate || !years) return { monthly: 0, totalPayment: 0, totalInterest: 0 }; |
| 8 |
var r = annualRate / 1200; |
| 9 |
var n = years * 12; |
| 10 |
var monthly = amount * (r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1); |
| 11 |
var totalPayment = monthly * n; |
| 12 |
var totalInterest = totalPayment - amount; |
| 13 |
return { |
| 14 |
monthly: Math.round(monthly * 100) / 100, |
| 15 |
totalPayment: Math.round(totalPayment * 100) / 100, |
| 16 |
totalInterest: Math.round(totalInterest * 100) / 100 |
| 17 |
}; |
| 18 |
} |
| 19 |
|
| 20 |
function fmtMoney(val, currency, pos) { |
| 21 |
var s = Math.round(val).toLocaleString('en-US'); |
| 22 |
return pos === 'after' ? s + currency : currency + s; |
| 23 |
} |
| 24 |
|
| 25 |
function buildAmort(amount, annualRate, years, maxRows) { |
| 26 |
var r = annualRate / 1200; |
| 27 |
var n = years * 12; |
| 28 |
var monthly = amount * (r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1); |
| 29 |
var balance = amount; |
| 30 |
var rows = []; |
| 31 |
for (var i = 1; i <= n && i <= maxRows; i++) { |
| 32 |
var interest = balance * r; |
| 33 |
var principal = monthly - interest; |
| 34 |
balance = Math.max(0, balance - principal); |
| 35 |
rows.push({ month: i, payment: monthly, principal: principal, interest: interest, balance: balance }); |
| 36 |
} |
| 37 |
return rows; |
| 38 |
} |
| 39 |
|
| 40 |
document.querySelectorAll('.bkbg-lc-app').forEach(function (app) { |
| 41 |
var opts = {}; |
| 42 |
try { opts = JSON.parse(app.dataset.opts || '{}'); } catch (e) {} |
| 43 |
|
| 44 |
var amt = opts.loanAmountDefault || 300000; |
| 45 |
var rate = opts.interestDefault || 5.5; |
| 46 |
var yrs = opts.termDefault || 25; |
| 47 |
var cur = opts.currency || '$'; |
| 48 |
var pos = opts.currencyPos || 'before'; |
| 49 |
|
| 50 |
// Find sliders and bind them |
| 51 |
var sliderGroups = app.querySelectorAll('.bkbg-lc-slider-group'); |
| 52 |
var amountGroup, interestGroup, termGroup; |
| 53 |
|
| 54 |
sliderGroups.forEach(function (g) { |
| 55 |
var type = g.dataset.slider; |
| 56 |
if (type === 'amount') amountGroup = g; |
| 57 |
if (type === 'interest') interestGroup = g; |
| 58 |
if (type === 'term') termGroup = g; |
| 59 |
}); |
| 60 |
|
| 61 |
function initSlider(group, min, max, step, value, format) { |
| 62 |
if (!group) return; |
| 63 |
var input = group.querySelector('input[type="range"]'); |
| 64 |
var valEl = group.querySelector('.bkbg-lc-slider-val'); |
| 65 |
var minEl = group.querySelector('.bkbg-lc-range-min'); |
| 66 |
var maxEl = group.querySelector('.bkbg-lc-range-max'); |
| 67 |
if (input) { |
| 68 |
input.min = min; input.max = max; input.step = step; input.value = value; |
| 69 |
input.style.accentColor = opts.accentColor || '#6c3fb5'; |
| 70 |
} |
| 71 |
if (valEl) valEl.textContent = format(value); |
| 72 |
if (minEl) minEl.textContent = format(min); |
| 73 |
if (maxEl) maxEl.textContent = format(max); |
| 74 |
return input; |
| 75 |
} |
| 76 |
|
| 77 |
var amtInput = initSlider(amountGroup, opts.loanAmountMin || 10000, opts.loanAmountMax || 2000000, opts.loanAmountStep || 5000, amt, function (v) { return fmtMoney(v, cur, pos); }); |
| 78 |
var rateInput = initSlider(interestGroup, opts.interestMin || 0.5, opts.interestMax || 20, opts.interestStep || 0.1, rate, function (v) { return v + '%'; }); |
| 79 |
var yrsInput = initSlider(termGroup, opts.termMin || 1, opts.termMax || 30, opts.termStep || 1, yrs, function (v) { return v + ' yr'; }); |
| 80 |
|
| 81 |
// Result elements |
| 82 |
var monthlyEl = app.querySelector('.bkbg-lc-monthly'); |
| 83 |
var principalEl = app.querySelector('[data-result="principal"] .bkbg-lc-res-val'); |
| 84 |
var interestEl = app.querySelector('[data-result="interest"] .bkbg-lc-res-val'); |
| 85 |
var totalEl = app.querySelector('[data-result="total"] .bkbg-lc-res-val'); |
| 86 |
var amortWrap = app.querySelector('.bkbg-lc-amort-wrap'); |
| 87 |
|
| 88 |
var chartInstance = null; |
| 89 |
|
| 90 |
function updateAll() { |
| 91 |
var a = parseFloat(amtInput ? amtInput.value : amt); |
| 92 |
var r = parseFloat(rateInput ? rateInput.value : rate); |
| 93 |
var y = parseFloat(yrsInput ? yrsInput.value : yrs); |
| 94 |
|
| 95 |
// Update displayed values |
| 96 |
if (amountGroup) { |
| 97 |
var v = amountGroup.querySelector('.bkbg-lc-slider-val'); |
| 98 |
if (v) v.textContent = fmtMoney(a, cur, pos); |
| 99 |
} |
| 100 |
if (interestGroup) { |
| 101 |
var v2 = interestGroup.querySelector('.bkbg-lc-slider-val'); |
| 102 |
if (v2) v2.textContent = r + '%'; |
| 103 |
} |
| 104 |
if (termGroup) { |
| 105 |
var v3 = termGroup.querySelector('.bkbg-lc-slider-val'); |
| 106 |
if (v3) v3.textContent = y + ' yr'; |
| 107 |
} |
| 108 |
|
| 109 |
var result = calcLoan(a, r, y); |
| 110 |
|
| 111 |
if (monthlyEl) monthlyEl.textContent = fmtMoney(result.monthly, cur, pos); |
| 112 |
if (principalEl) principalEl.textContent = fmtMoney(a, cur, pos); |
| 113 |
if (interestEl) interestEl.textContent = fmtMoney(result.totalInterest, cur, pos); |
| 114 |
if (totalEl) totalEl.textContent = fmtMoney(result.totalPayment, cur, pos); |
| 115 |
|
| 116 |
// Update chart |
| 117 |
if (chartInstance) { |
| 118 |
chartInstance.data.datasets[0].data = [a, result.totalInterest]; |
| 119 |
chartInstance.update(); |
| 120 |
} |
| 121 |
|
| 122 |
// Amortization table |
| 123 |
if (opts.showAmortization && amortWrap) { |
| 124 |
var rows = buildAmort(a, r, y, opts.amortizationRows || 12); |
| 125 |
var table = '<table class="bkbg-lc-amort-table"><thead><tr><th>#</th><th>Payment</th><th>Principal</th><th>Interest</th><th>Balance</th></tr></thead><tbody>'; |
| 126 |
rows.forEach(function (row) { |
| 127 |
table += '<tr><td>' + row.month + '</td><td>' + fmtMoney(row.payment, cur, pos) + '</td><td>' + fmtMoney(row.principal, cur, pos) + '</td><td>' + fmtMoney(row.interest, cur, pos) + '</td><td>' + fmtMoney(row.balance, cur, pos) + '</td></tr>'; |
| 128 |
}); |
| 129 |
table += '</tbody></table>'; |
| 130 |
amortWrap.innerHTML = table; |
| 131 |
} |
| 132 |
} |
| 133 |
|
| 134 |
function bindSlider(input) { |
| 135 |
if (!input) return; |
| 136 |
input.addEventListener('input', updateAll); |
| 137 |
} |
| 138 |
bindSlider(amtInput); |
| 139 |
bindSlider(rateInput); |
| 140 |
bindSlider(yrsInput); |
| 141 |
|
| 142 |
// Initial render |
| 143 |
updateAll(); |
| 144 |
|
| 145 |
// Load Chart.js and render donut |
| 146 |
if (opts.showChart) { |
| 147 |
function renderChart() { |
| 148 |
var canvas = app.querySelector('.bkbg-lc-chart'); |
| 149 |
if (!canvas || typeof Chart === 'undefined') return; |
| 150 |
var result = calcLoan( |
| 151 |
parseFloat(amtInput ? amtInput.value : amt), |
| 152 |
parseFloat(rateInput ? rateInput.value : rate), |
| 153 |
parseFloat(yrsInput ? yrsInput.value : yrs) |
| 154 |
); |
| 155 |
chartInstance = new Chart(canvas, { |
| 156 |
type: 'doughnut', |
| 157 |
data: { |
| 158 |
labels: ['Principal', 'Interest'], |
| 159 |
datasets: [{ |
| 160 |
data: [parseFloat(amtInput ? amtInput.value : amt), result.totalInterest], |
| 161 |
backgroundColor: [opts.principalColor || '#6c3fb5', opts.interestColor || '#f59e0b'], |
| 162 |
borderWidth: 0 |
| 163 |
}] |
| 164 |
}, |
| 165 |
options: { |
| 166 |
cutout: '70%', |
| 167 |
plugins: { legend: { position: 'bottom', labels: { padding: 16 } } } |
| 168 |
} |
| 169 |
}); |
| 170 |
} |
| 171 |
|
| 172 |
if (typeof Chart !== 'undefined') { |
| 173 |
renderChart(); |
| 174 |
} else { |
| 175 |
if (!document.getElementById('bkbg-chartjs-cdn')) { |
| 176 |
var s = document.createElement('script'); |
| 177 |
s.id = 'bkbg-chartjs-cdn'; |
| 178 |
s.src = CHART_CDN; |
| 179 |
s.onload = renderChart; |
| 180 |
document.head.appendChild(s); |
| 181 |
} else { |
| 182 |
var wait = setInterval(function () { |
| 183 |
if (typeof Chart !== 'undefined') { clearInterval(wait); renderChart(); } |
| 184 |
}, 100); |
| 185 |
} |
| 186 |
} |
| 187 |
} |
| 188 |
}); |
| 189 |
})(); |
| 190 |
|