/** * 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 - 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'); }; // ===== 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) => `${label} ${fmt_eur(value)}`; // ===== 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('ETF-Portfolio', d.buyer_portfolio, '#f59e0b'), tip_row('Restschuld', -d.mortgage_balance, '#ef4444'), ].join('
'))); 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('
'))); 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 ? [ `Kauftag`, 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 ? `↳ mitfinanziert` : null, ].filter(Boolean) : [ `dieses Jahr`, 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('
')); }); 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, [ `dieses Jahr`, tip_row('Kaltmiete', d.yr_rent, '#2dd4bf'), tip_row('Versicherung', d.yr_renters_ins, '#2dd4bf'), ].join('
'))); 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 = 'Kaufen gewinnt'; } else { badge_el.innerHTML = 'Mieten gewinnt'; } // 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'); if (+num.value > max_pct) { num.value = Math.round(max_pct * 10) / 10; slider.value = num.value; } }; // ===== 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();