Files
rentbuy/ui.js

368 lines
13 KiB
JavaScript

/**
* 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,
start_capital: +document.getElementById('start_capital').value,
finance_nebenkosten: document.getElementById('finance_nebenkosten').checked,
});
// ===== 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, // labels are "Jahr 0" ... "Jahr N", zero-indexed
xMax: be_year,
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');
};
// ===== Tooltip =====
const tooltip_el = (() => {
const el = document.createElement('div');
el.id = 'tbl_tooltip';
el.style.cssText = `
position:fixed; z-index:1000; pointer-events:none; display:none;
background:rgba(13,17,23,0.96); border:1px solid rgba(255,255,255,0.12);
border-radius:10px; padding:0.7rem 1rem; font-size:0.8rem; line-height:1.8;
color:#e6edf3; white-space:nowrap; box-shadow:0 8px 32px rgba(0,0,0,0.5);
`;
document.body.appendChild(el);
return el;
})();
const show_tooltip = (e, html) => {
tooltip_el.innerHTML = html;
tooltip_el.style.display = 'block';
move_tooltip(e);
};
const move_tooltip = (e) => {
const pad = 14;
const tw = tooltip_el.offsetWidth;
const th = tooltip_el.offsetHeight;
let x = e.clientX + pad;
let y = e.clientY + pad;
if (x + tw > window.innerWidth - pad) x = e.clientX - tw - pad;
if (y + th > window.innerHeight - pad) y = e.clientY - th - pad;
tooltip_el.style.left = x + 'px';
tooltip_el.style.top = y + 'px';
};
const hide_tooltip = () => { tooltip_el.style.display = 'none'; };
const tip_row = (label, value, color) =>
`<span style="color:${color || '#8b949e'}">${label}</span> <b>${fmt_eur(value)}</b>`;
// ===== Table =====
const build_table = (result) => {
const tbody = document.getElementById('table_body');
tbody.innerHTML = '';
for (let i = 0; i < result.labels.length; i++) {
const d = result.detail_arr[i];
const tr = document.createElement('tr');
const td_buy_nw = document.createElement('td');
td_buy_nw.textContent = fmt_eur(result.buyer_nw_arr[i]);
td_buy_nw.classList.add('has-tip');
td_buy_nw.addEventListener('mouseenter', (e) => show_tooltip(e, [
tip_row('Immobilienwert', d.house_value, '#f59e0b'),
tip_row('Verkaufskosten', -d.selling_costs, '#ef4444'),
tip_row('ETF-Portfolio', d.buyer_portfolio, '#f59e0b'),
tip_row('Restschuld', -d.mortgage_balance, '#ef4444'),
].join('<br>')));
const td_rent_nw = document.createElement('td');
td_rent_nw.textContent = fmt_eur(result.renter_nw_arr[i]);
td_rent_nw.classList.add('has-tip');
td_rent_nw.addEventListener('mouseenter', (e) => show_tooltip(e, [
tip_row('ETF-Portfolio (nach Steuer)', result.renter_nw_arr[i], '#2dd4bf'),
].join('<br>')));
const td_buy_cost = document.createElement('td');
td_buy_cost.textContent = fmt_eur(result.cum_buy_arr[i]);
td_buy_cost.classList.add('has-tip');
td_buy_cost.addEventListener('mouseenter', (e) => {
const rows = i === 0
? [
`<span style="color:#8b949e;font-size:0.72rem;text-transform:uppercase;letter-spacing:.05em">Kauftag</span>`,
tip_row('Eigenkapital', d.finance_nebenkosten
? result.cum_buy_arr[0]
: Math.min(result.cum_buy_arr[0] - d.kaufnebenkosten, d.start_capital - d.kaufnebenkosten), '#f59e0b'),
tip_row('Kaufnebenkosten', d.kaufnebenkosten, d.finance_nebenkosten ? '#8b949e' : '#f59e0b'),
d.finance_nebenkosten ? `<span style="color:#6e7681;font-size:0.75rem">↳ mitfinanziert</span>` : null,
].filter(Boolean)
: [
`<span style="color:#8b949e;font-size:0.72rem;text-transform:uppercase;letter-spacing:.05em">dieses Jahr</span>`,
tip_row('Zinsen', d.yr_interest, '#f59e0b'),
tip_row('Tilgung', d.yr_principal, '#f59e0b'),
tip_row('Nebenkosten', d.yr_upkeep, '#f59e0b'),
];
show_tooltip(e, rows.join('<br>'));
});
const td_rent_cost = document.createElement('td');
td_rent_cost.textContent = fmt_eur(result.cum_rent_arr[i]);
td_rent_cost.classList.add('has-tip');
td_rent_cost.addEventListener('mouseenter', (e) => show_tooltip(e, [
`<span style="color:#8b949e;font-size:0.72rem;text-transform:uppercase;letter-spacing:.05em">dieses Jahr</span>`,
tip_row('Kaltmiete', d.yr_rent, '#2dd4bf'),
tip_row('Versicherung', d.yr_renters_ins, '#2dd4bf'),
].join('<br>')));
tr.appendChild(document.createElement('td')).textContent = result.labels[i];
tr.appendChild(td_buy_nw);
tr.appendChild(td_rent_nw);
tr.appendChild(td_buy_cost);
tr.appendChild(td_rent_cost);
tbody.appendChild(tr);
}
tbody.addEventListener('mousemove', move_tooltip);
tbody.addEventListener('mouseleave', hide_tooltip);
};
// ===== 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 = () => {
clamp_down_pct();
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);
};
// ===== Down payment cap =====
const clamp_down_pct = () => {
const home_price = +document.getElementById('home_price').value;
const start_capital = +document.getElementById('start_capital').value;
const closing_pct = +document.getElementById('closing_buy_pct').value;
const financed = document.getElementById('finance_nebenkosten').checked;
const closing_costs = home_price * (closing_pct / 100);
const available = financed ? start_capital : Math.max(0, start_capital - closing_costs);
const max_pct = home_price > 0 ? Math.min(100, (available / home_price) * 100) : 100;
const slider = document.getElementById('down_pct_slider');
const num = document.getElementById('down_pct');
const at_cap = +num.value >= max_pct - 0.05;
if (+num.value > max_pct) {
num.value = Math.round(max_pct * 10) / 10;
slider.value = num.value;
}
document.getElementById('down_pct_cap').classList.toggle('visible', at_cap);
};
// ===== 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)
document.getElementById('finance_nebenkosten').addEventListener('change', () => { clamp_down_pct(); recalc(); });
['home_price', 'start_capital'].forEach((id) => {
document.getElementById(id).addEventListener('input', () => { clamp_down_pct(); recalc(); });
});
['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();