initial: rent vs buy calculator
This commit is contained in:
247
ui.js
Normal file
247
ui.js
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* ui.js — DOM wiring, event listeners, chart rendering
|
||||
*/
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
const fmt_eur = (n) =>
|
||||
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(n);
|
||||
|
||||
const get_params = () => ({
|
||||
years: +document.getElementById('years').value,
|
||||
invest_return: +document.getElementById('invest_return').value,
|
||||
tax_rate: +document.getElementById('tax_rate').value,
|
||||
home_price: +document.getElementById('home_price').value,
|
||||
down_pct: +document.getElementById('down_pct').value,
|
||||
interest_rate: +document.getElementById('interest_rate').value,
|
||||
loan_years: +document.getElementById('loan_years').value,
|
||||
closing_buy_pct: +document.getElementById('closing_buy_pct').value,
|
||||
closing_sell_pct:+document.getElementById('closing_sell_pct').value,
|
||||
appreciation: +document.getElementById('appreciation').value,
|
||||
prop_tax_rate: +document.getElementById('prop_tax_rate').value,
|
||||
maint_rate: +document.getElementById('maint_rate').value,
|
||||
insurance: +document.getElementById('insurance').value,
|
||||
monthly_rent: +document.getElementById('monthly_rent').value,
|
||||
rent_increase: +document.getElementById('rent_increase').value,
|
||||
renters_ins: +document.getElementById('renters_ins').value,
|
||||
});
|
||||
|
||||
// ===== Charts =====
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
color: '#8b949e',
|
||||
borderColor: 'rgba(255,255,255,0.08)',
|
||||
};
|
||||
|
||||
const make_chart = (canvas_id, label_buy, label_rent) => {
|
||||
const ctx = document.getElementById(canvas_id).getContext('2d');
|
||||
return new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: label_buy,
|
||||
data: [],
|
||||
borderColor: '#f59e0b',
|
||||
backgroundColor: 'rgba(245,158,11,0.06)',
|
||||
borderWidth: 2.5,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
fill: false,
|
||||
tension: 0.35,
|
||||
},
|
||||
{
|
||||
label: label_rent,
|
||||
data: [],
|
||||
borderColor: '#2dd4bf',
|
||||
backgroundColor: 'rgba(45,212,191,0.06)',
|
||||
borderWidth: 2.5,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
fill: false,
|
||||
tension: 0.35,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
backgroundColor: 'rgba(13,17,23,0.9)',
|
||||
borderColor: 'rgba(255,255,255,0.12)',
|
||||
borderWidth: 1,
|
||||
titleColor: '#8b949e',
|
||||
bodyColor: '#e6edf3',
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: (ctx) => ` ${ctx.dataset.label}: ${fmt_eur(ctx.parsed.y)}`,
|
||||
},
|
||||
},
|
||||
annotation: { annotations: {} },
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||
ticks: { color: '#6e7681', font: { size: 11 } },
|
||||
},
|
||||
y: {
|
||||
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||
ticks: {
|
||||
color: '#6e7681',
|
||||
font: { size: 11 },
|
||||
callback: (v) => fmt_eur(v),
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: { mode: 'nearest', axis: 'x', intersect: false },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const chart_nw = make_chart('chart_nw', 'Kaufen', 'Mieten (ETF)');
|
||||
const chart_cost = make_chart('chart_cost', 'Kaufen', 'Mieten');
|
||||
|
||||
const update_chart = (chart, labels, data_buy, data_rent, breakeven_month, years) => {
|
||||
chart.data.labels = labels;
|
||||
chart.data.datasets[0].data = data_buy;
|
||||
chart.data.datasets[1].data = data_rent;
|
||||
|
||||
// breakeven annotation
|
||||
if (breakeven_month !== null && breakeven_month <= years * 12) {
|
||||
const be_year = breakeven_month / 12;
|
||||
chart.options.plugins.annotation.annotations = {
|
||||
breakeven: {
|
||||
type: 'line',
|
||||
xMin: be_year - 1, // labels are "Jahr 1" ... "Jahr N", zero-indexed
|
||||
xMax: be_year - 1,
|
||||
borderColor: 'rgba(255,255,255,0.25)',
|
||||
borderWidth: 1,
|
||||
borderDash: [5, 4],
|
||||
label: {
|
||||
display: true,
|
||||
content: `Break-Even Jahr ${be_year.toFixed(1)}`,
|
||||
color: '#8b949e',
|
||||
backgroundColor: 'rgba(13,17,23,0.85)',
|
||||
font: { size: 11 },
|
||||
position: 'start',
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
chart.options.plugins.annotation.annotations = {};
|
||||
}
|
||||
|
||||
chart.update('none');
|
||||
};
|
||||
|
||||
// ===== Table =====
|
||||
|
||||
const build_table = (result) => {
|
||||
const tbody = document.getElementById('table_body');
|
||||
tbody.innerHTML = '';
|
||||
for (let i = 0; i < result.labels.length; i++) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${result.labels[i]}</td>
|
||||
<td>${fmt_eur(result.buyer_nw_arr[i])}</td>
|
||||
<td>${fmt_eur(result.renter_nw_arr[i])}</td>
|
||||
<td>${fmt_eur(result.cum_buy_arr[i])}</td>
|
||||
<td>${fmt_eur(result.cum_rent_arr[i])}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
};
|
||||
|
||||
// ===== Result cards =====
|
||||
|
||||
const update_results = (result) => {
|
||||
document.getElementById('res_buyer_nw').textContent = fmt_eur(result.final_buyer_nw);
|
||||
document.getElementById('res_renter_nw').textContent = fmt_eur(result.final_renter_nw);
|
||||
document.getElementById('res_buy_cost').textContent = `Gesamtausgaben: ${fmt_eur(result.cum_buy_arr.at(-1))}`;
|
||||
document.getElementById('res_rent_cost').textContent = `Gesamtausgaben: ${fmt_eur(result.cum_rent_arr.at(-1))}`;
|
||||
|
||||
// breakeven
|
||||
const be_el = document.getElementById('res_breakeven');
|
||||
if (result.breakeven_month !== null) {
|
||||
const be_yr = (result.breakeven_month / 12).toFixed(1);
|
||||
be_el.textContent = `Jahr ${be_yr}`;
|
||||
} else {
|
||||
be_el.textContent = 'Kein Break-Even';
|
||||
}
|
||||
|
||||
// winner badge
|
||||
const badge_el = document.getElementById('res_winner_badge');
|
||||
if (result.winner === 'buy') {
|
||||
badge_el.innerHTML = '<span class="winner-badge winner-buy">Kaufen gewinnt</span>';
|
||||
} else {
|
||||
badge_el.innerHTML = '<span class="winner-badge winner-rent">Mieten gewinnt</span>';
|
||||
}
|
||||
|
||||
// monthly payment note
|
||||
document.getElementById('res_monthly_payment').textContent =
|
||||
`Monatliche Rate: ${fmt_eur(result.monthly_payment)} · Nettovermögen nach Kapitalertragsteuer`;
|
||||
};
|
||||
|
||||
// ===== Main recalc =====
|
||||
|
||||
const recalc = () => {
|
||||
const p = get_params();
|
||||
const result = run_simulation(p);
|
||||
|
||||
update_results(result);
|
||||
update_chart(chart_nw, result.labels, result.buyer_nw_arr, result.renter_nw_arr, result.breakeven_month, p.years);
|
||||
update_chart(chart_cost, result.labels, result.cum_buy_arr, result.cum_rent_arr, null, p.years);
|
||||
build_table(result);
|
||||
};
|
||||
|
||||
// ===== Slider ↔ number sync =====
|
||||
|
||||
const wire_slider = (base_id) => {
|
||||
const slider = document.getElementById(`${base_id}_slider`);
|
||||
const num = document.getElementById(base_id);
|
||||
if (!slider || !num) return;
|
||||
|
||||
slider.addEventListener('input', () => {
|
||||
num.value = slider.value;
|
||||
recalc();
|
||||
});
|
||||
num.addEventListener('input', () => {
|
||||
slider.value = num.value;
|
||||
recalc();
|
||||
});
|
||||
};
|
||||
|
||||
const SLIDER_IDS = [
|
||||
'years', 'invest_return', 'tax_rate',
|
||||
'down_pct', 'interest_rate', 'loan_years',
|
||||
'closing_buy_pct', 'closing_sell_pct', 'appreciation',
|
||||
'prop_tax_rate', 'maint_rate',
|
||||
'rent_increase',
|
||||
];
|
||||
|
||||
SLIDER_IDS.forEach(wire_slider);
|
||||
|
||||
// wire plain number inputs (no slider)
|
||||
['home_price', 'insurance', 'monthly_rent', 'renters_ins'].forEach((id) => {
|
||||
document.getElementById(id).addEventListener('input', recalc);
|
||||
});
|
||||
|
||||
// ===== Table toggle =====
|
||||
|
||||
const toggle_btn = document.getElementById('table_toggle');
|
||||
const table_wrap = document.getElementById('table_wrap');
|
||||
|
||||
toggle_btn.addEventListener('click', () => {
|
||||
const expanded = toggle_btn.getAttribute('aria-expanded') === 'true';
|
||||
toggle_btn.setAttribute('aria-expanded', String(!expanded));
|
||||
toggle_btn.querySelector('.toggle-icon').style.transform = expanded ? '' : 'rotate(90deg)';
|
||||
table_wrap.hidden = expanded;
|
||||
});
|
||||
|
||||
// ===== Init =====
|
||||
recalc();
|
||||
Reference in New Issue
Block a user