initial: rent vs buy calculator

This commit is contained in:
Johannes
2026-03-20 01:51:03 +01:00
commit 9b9fd6a76a
4 changed files with 1071 additions and 0 deletions

121
calc.js Normal file
View File

@@ -0,0 +1,121 @@
/**
* calc.js — pure calculation engine, no DOM
*/
/**
* Run the full rent vs buy simulation month by month.
* Returns arrays (one value per year) plus summary stats.
*
* @param {Object} p - input parameters
* @returns {Object} result
*/
function run_simulation(p) {
const months = p.years * 12;
// --- mortgage setup ---
const loan = p.home_price * (1 - p.down_pct / 100);
const r_m = p.interest_rate / 100 / 12;
const n = p.loan_years * 12;
let monthly_payment;
if (r_m === 0) {
monthly_payment = loan / n;
} else {
monthly_payment = loan * (r_m * Math.pow(1 + r_m, n)) / (Math.pow(1 + r_m, n) - 1);
}
// upfront costs for buyer
const down_payment = p.home_price * (p.down_pct / 100);
const upfront_buy = down_payment + p.home_price * (p.closing_buy_pct / 100);
// --- state ---
let balance = loan;
let home_value = p.home_price;
let portfolio = upfront_buy; // renter invests this lump sum
let monthly_rent = p.monthly_rent;
let cum_buy_cost = upfront_buy; // running cash spent on buying
let cum_rent_cost = 0;
// per-year arrays (index 0 = end of year 1)
const buyer_nw_arr = [];
const renter_nw_arr = [];
const cum_buy_arr = [];
const cum_rent_arr = [];
const labels = [];
let breakeven_month = null;
const invest_r_m = p.invest_return / 100 / 12;
const app_r_m = p.appreciation / 100 / 12;
const rent_inc_m = p.rent_increase / 100 / 12;
for (let m = 1; m <= months; m++) {
// --- buy side ---
const interest_m = balance * r_m;
const principal_m = monthly_payment - interest_m;
balance = Math.max(0, balance - principal_m);
home_value *= (1 + app_r_m);
const prop_tax_m = home_value * (p.prop_tax_rate / 100) / 12;
const maint_m = home_value * (p.maint_rate / 100) / 12;
const insurance_m = p.insurance / 12;
// German context: no mortgage interest tax deduction for private buyers
const total_buy_m = monthly_payment + prop_tax_m + maint_m + insurance_m;
cum_buy_cost += total_buy_m;
const selling_costs = home_value * (p.closing_sell_pct / 100);
const buyer_nw = home_value - selling_costs - balance;
// --- rent side ---
const renters_ins_m = p.renters_ins / 12;
const total_rent_m = monthly_rent + renters_ins_m;
cum_rent_cost += total_rent_m;
// renter invests the difference if buying is more expensive
const monthly_delta = Math.max(0, total_buy_m - total_rent_m);
portfolio = portfolio * (1 + invest_r_m) + monthly_delta;
// apply capital gains tax on portfolio gains when we "cash out" at end
// (we track gross portfolio, deduct tax in summary)
const renter_nw = portfolio;
// breakeven: when buyer net worth overtakes renter
if (breakeven_month === null && buyer_nw >= renter_nw) {
breakeven_month = m;
}
// rent increases monthly
monthly_rent *= (1 + rent_inc_m);
// record yearly snapshots
if (m % 12 === 0) {
const yr = m / 12;
labels.push(`Jahr ${yr}`);
buyer_nw_arr.push(Math.round(buyer_nw));
// apply Abgeltungssteuer on portfolio gains at cashout
const portfolio_gains = portfolio - upfront_buy;
const tax_on_gains = Math.max(0, portfolio_gains) * (p.tax_rate / 100);
renter_nw_arr.push(Math.round(portfolio - tax_on_gains));
cum_buy_arr.push(Math.round(cum_buy_cost));
cum_rent_arr.push(Math.round(cum_rent_cost));
}
}
// final net worth at end of horizon
const final_buyer_nw = buyer_nw_arr[buyer_nw_arr.length - 1];
const final_renter_nw = renter_nw_arr[renter_nw_arr.length - 1];
return {
labels,
buyer_nw_arr,
renter_nw_arr,
cum_buy_arr,
cum_rent_arr,
monthly_payment: Math.round(monthly_payment),
final_buyer_nw,
final_renter_nw,
breakeven_month,
winner: final_buyer_nw >= final_renter_nw ? 'buy' : 'rent',
};
}

282
index.html Normal file
View File

@@ -0,0 +1,282 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kaufen oder Mieten?</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header>
<div class="header-inner">
<h1>Kaufen oder Mieten?</h1>
<p class="subtitle">Kein Bauchgefühl. Nur Zahlen.</p>
</div>
</header>
<main>
<!-- Shared inputs -->
<section class="inputs-shared card">
<div class="input-group">
<label for="years">Zeithorizont</label>
<div class="slider-row">
<input type="range" id="years_slider" min="2" max="40" step="1" value="15" />
<div class="num-unit">
<input type="number" id="years" min="2" max="40" value="15" />
<span class="unit">Jahre</span>
</div>
</div>
</div>
<div class="input-group">
<label for="invest_return">ETF-Rendite (p.a.)</label>
<div class="slider-row">
<input type="range" id="invest_return_slider" min="1" max="15" step="0.1" value="6" />
<div class="num-unit">
<input type="number" id="invest_return" min="1" max="15" step="0.1" value="6" />
<span class="unit">%</span>
</div>
</div>
</div>
<div class="input-group">
<label for="tax_rate">Abgeltungssteuer (inkl. Soli)</label>
<div class="slider-row">
<input type="range" id="tax_rate_slider" min="0" max="50" step="0.1" value="26.375" />
<div class="num-unit">
<input type="number" id="tax_rate" min="0" max="50" step="0.1" value="26.375" />
<span class="unit">%</span>
</div>
</div>
</div>
</section>
<!-- Two-column inputs -->
<div class="inputs-columns">
<!-- Buy inputs -->
<section class="card inputs-buy">
<h2 class="col-header buy-header">Kaufen</h2>
<div class="input-group">
<label for="home_price">Kaufpreis</label>
<div class="num-unit euro-input">
<span class="unit prefix"></span>
<input type="number" id="home_price" min="50000" max="5000000" step="1000" value="400000" />
</div>
</div>
<div class="input-group">
<label for="down_pct">Eigenkapital</label>
<div class="slider-row">
<input type="range" id="down_pct_slider" min="5" max="80" step="1" value="25" />
<div class="num-unit">
<input type="number" id="down_pct" min="5" max="80" step="1" value="25" />
<span class="unit">%</span>
</div>
</div>
</div>
<div class="input-group">
<label for="interest_rate">Zinssatz (p.a.)</label>
<div class="slider-row">
<input type="range" id="interest_rate_slider" min="0.5" max="12" step="0.05" value="3.8" />
<div class="num-unit">
<input type="number" id="interest_rate" min="0.5" max="12" step="0.05" value="3.8" />
<span class="unit">%</span>
</div>
</div>
</div>
<div class="input-group">
<label for="loan_years">Tilgungsdauer</label>
<div class="slider-row">
<input type="range" id="loan_years_slider" min="5" max="40" step="1" value="20" />
<div class="num-unit">
<input type="number" id="loan_years" min="5" max="40" step="1" value="20" />
<span class="unit">Jahre</span>
</div>
</div>
</div>
<div class="input-group">
<label for="closing_buy_pct">Kaufnebenkosten <span class="hint">(Steuer + Notar + Makler)</span></label>
<div class="slider-row">
<input type="range" id="closing_buy_pct_slider" min="3" max="20" step="0.1" value="10" />
<div class="num-unit">
<input type="number" id="closing_buy_pct" min="3" max="20" step="0.1" value="10" />
<span class="unit">%</span>
</div>
</div>
</div>
<div class="input-group">
<label for="closing_sell_pct">Verkaufsnebenkosten <span class="hint">(Makler)</span></label>
<div class="slider-row">
<input type="range" id="closing_sell_pct_slider" min="0" max="10" step="0.1" value="3.57" />
<div class="num-unit">
<input type="number" id="closing_sell_pct" min="0" max="10" step="0.1" value="3.57" />
<span class="unit">%</span>
</div>
</div>
</div>
<div class="input-group">
<label for="appreciation">Wertsteigerung (p.a.)</label>
<div class="slider-row">
<input type="range" id="appreciation_slider" min="-2" max="10" step="0.1" value="2.5" />
<div class="num-unit">
<input type="number" id="appreciation" min="-2" max="10" step="0.1" value="2.5" />
<span class="unit">%</span>
</div>
</div>
</div>
<div class="input-group">
<label for="prop_tax_rate">Grundsteuer (p.a.)</label>
<div class="slider-row">
<input type="range" id="prop_tax_rate_slider" min="0.1" max="2" step="0.05" value="0.35" />
<div class="num-unit">
<input type="number" id="prop_tax_rate" min="0.1" max="2" step="0.05" value="0.35" />
<span class="unit">%</span>
</div>
</div>
</div>
<div class="input-group">
<label for="maint_rate">Instandhaltung (p.a.)</label>
<div class="slider-row">
<input type="range" id="maint_rate_slider" min="0.1" max="3" step="0.1" value="1" />
<div class="num-unit">
<input type="number" id="maint_rate" min="0.1" max="3" step="0.1" value="1" />
<span class="unit">%</span>
</div>
</div>
</div>
<div class="input-group">
<label for="insurance">Gebäudeversicherung (p.a.)</label>
<div class="num-unit euro-input">
<span class="unit prefix"></span>
<input type="number" id="insurance" min="0" max="10000" step="100" value="800" />
</div>
</div>
</section>
<!-- Rent inputs -->
<section class="card inputs-rent">
<h2 class="col-header rent-header">Mieten</h2>
<div class="input-group">
<label for="monthly_rent">Kaltmiete (monatlich)</label>
<div class="num-unit euro-input">
<span class="unit prefix"></span>
<input type="number" id="monthly_rent" min="200" max="20000" step="50" value="1400" />
</div>
</div>
<div class="input-group">
<label for="rent_increase">Mieterhöhung (p.a.)</label>
<div class="slider-row">
<input type="range" id="rent_increase_slider" min="0" max="10" step="0.1" value="2.5" />
<div class="num-unit">
<input type="number" id="rent_increase" min="0" max="10" step="0.1" value="2.5" />
<span class="unit">%</span>
</div>
</div>
</div>
<div class="input-group">
<label for="renters_ins">Haftpflicht / Hausrat (p.a.)</label>
<div class="num-unit euro-input">
<span class="unit prefix"></span>
<input type="number" id="renters_ins" min="0" max="2000" step="50" value="200" />
</div>
</div>
<!-- Spacer info card -->
<div class="info-box">
<p>Der Mieter investiert das nicht ausgegebene Eigenkapital + Nebenkosten in einen ETF. Wenn Kaufen teurer ist, fließt die monatliche Differenz ebenfalls ins Portfolio.</p>
</div>
</section>
</div>
<!-- Results -->
<section class="results">
<div class="result-cards">
<div class="result-card buy-card">
<div class="result-label">Nettovermögen Kauf</div>
<div class="result-value" id="res_buyer_nw"></div>
<div class="result-sub" id="res_buy_cost">Gesamtausgaben: —</div>
</div>
<div class="result-card rent-card">
<div class="result-label">Nettovermögen Miete</div>
<div class="result-value" id="res_renter_nw"></div>
<div class="result-sub" id="res_rent_cost">Gesamtausgaben: —</div>
</div>
<div class="result-card breakeven-card">
<div class="result-label">Break-Even</div>
<div class="result-value" id="res_breakeven"></div>
<div class="result-sub" id="res_winner_badge"></div>
</div>
</div>
<div class="monthly-payment-note" id="res_monthly_payment"></div>
</section>
<!-- Charts -->
<section class="charts card">
<div class="chart-title">Nettovermögen im Zeitverlauf</div>
<div class="chart-legend">
<span class="legend-dot buy-dot"></span> Kaufen &nbsp;&nbsp;
<span class="legend-dot rent-dot"></span> Mieten (ETF)
</div>
<div class="chart-wrap"><canvas id="chart_nw"></canvas></div>
</section>
<section class="charts card">
<div class="chart-title">Kumulierte Ausgaben (Cash out-of-pocket)</div>
<div class="chart-legend">
<span class="legend-dot buy-dot"></span> Kaufen &nbsp;&nbsp;
<span class="legend-dot rent-dot"></span> Mieten
</div>
<div class="chart-wrap"><canvas id="chart_cost"></canvas></div>
</section>
<!-- Yearly table -->
<section class="card table-section">
<button class="table-toggle" id="table_toggle" aria-expanded="false">
<span class="toggle-icon"></span> Jährliche Übersicht anzeigen
</button>
<div class="table-wrap" id="table_wrap" hidden>
<table id="breakdown_table">
<thead>
<tr>
<th>Jahr</th>
<th>Nettovermögen Kauf</th>
<th>Nettovermögen Miete</th>
<th>Ausgaben Kauf</th>
<th>Ausgaben Miete</th>
</tr>
</thead>
<tbody id="table_body"></tbody>
</table>
</div>
</section>
</main>
<footer>
<p>Diese Berechnung dient nur zur Orientierung — kein Finanzberatungsersatz.</p>
</footer>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1/dist/chartjs-plugin-annotation.min.js"></script>
<script src="calc.js"></script>
<script src="ui.js"></script>
</body>
</html>

421
style.css Normal file
View File

@@ -0,0 +1,421 @@
/* ===== CSS custom properties ===== */
:root {
--bg: #0d1117;
--bg-card: rgba(22, 30, 43, 0.85);
--bg-card-2: rgba(30, 40, 58, 0.7);
--border: rgba(255, 255, 255, 0.08);
--text: #e6edf3;
--text-muted: #8b949e;
--text-hint: #6e7681;
--buy: #f59e0b; /* amber */
--buy-glow: rgba(245, 158, 11, 0.18);
--buy-muted: rgba(245, 158, 11, 0.35);
--rent: #2dd4bf; /* teal */
--rent-glow: rgba(45, 212, 191, 0.18);
--rent-muted: rgba(45, 212, 191, 0.35);
--green: #3fb950;
--radius: 14px;
--font: 'Inter', system-ui, sans-serif;
}
/* ===== Reset & base ===== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; scroll-behavior: smooth; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.5;
}
/* ===== Header ===== */
header {
background: linear-gradient(135deg, #161b27 0%, #0d1117 100%);
border-bottom: 1px solid var(--border);
padding: 2.5rem 1.5rem 2rem;
text-align: center;
}
.header-inner { max-width: 900px; margin: 0 auto; }
h1 {
font-size: clamp(1.8rem, 4vw, 2.8rem);
font-weight: 700;
letter-spacing: -0.02em;
background: linear-gradient(90deg, var(--buy) 0%, var(--rent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
margin-top: 0.4rem;
color: var(--text-muted);
font-size: 1.05rem;
}
/* ===== Main layout ===== */
main {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1.25rem 4rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* ===== Card ===== */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
backdrop-filter: blur(12px);
}
/* ===== Shared inputs ===== */
.inputs-shared {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.25rem 2rem;
}
/* ===== Two-column inputs ===== */
.inputs-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 700px) {
.inputs-columns { grid-template-columns: 1fr; }
}
.col-header {
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 1.25rem;
padding-bottom: 0.6rem;
border-bottom: 2px solid;
}
.buy-header { color: var(--buy); border-color: var(--buy); }
.rent-header { color: var(--rent); border-color: var(--rent); }
/* ===== Input group ===== */
.input-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1.1rem;
}
.input-group:last-child { margin-bottom: 0; }
label {
font-size: 0.82rem;
font-weight: 500;
color: var(--text-muted);
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.hint {
font-size: 0.72rem;
color: var(--text-hint);
font-weight: 400;
}
/* ===== Slider row ===== */
.slider-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.slider-row input[type="range"] {
flex: 1;
min-width: 0;
}
/* ===== Number + unit ===== */
.num-unit {
display: flex;
align-items: center;
gap: 0.25rem;
}
.num-unit input[type="number"] {
width: 72px;
padding: 0.3rem 0.5rem;
background: rgba(255,255,255,0.06);
border: 1px solid var(--border);
border-radius: 7px;
color: var(--text);
font-family: var(--font);
font-size: 0.9rem;
text-align: right;
transition: border-color 0.15s;
}
.num-unit input[type="number"]:focus {
outline: none;
border-color: rgba(255,255,255,0.25);
}
.unit {
font-size: 0.82rem;
color: var(--text-muted);
white-space: nowrap;
}
/* ===== Euro prefix input ===== */
.euro-input {
background: rgba(255,255,255,0.06);
border: 1px solid var(--border);
border-radius: 7px;
padding: 0.3rem 0.5rem;
width: fit-content;
display: flex;
align-items: center;
gap: 0.2rem;
}
.euro-input input[type="number"] {
width: 110px;
background: transparent;
border: none;
padding: 0;
}
.euro-input input[type="number"]:focus { outline: none; }
.unit.prefix { color: var(--text-muted); }
/* ===== Range slider styling ===== */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 4px;
background: rgba(255,255,255,0.12);
border-radius: 99px;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text);
border: 2px solid var(--bg);
box-shadow: 0 0 0 1px rgba(255,255,255,0.2);
transition: transform 0.1s;
}
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.2); }
input[type="range"]::-moz-range-thumb {
width: 16px; height: 16px;
border-radius: 50%;
background: var(--text);
border: 2px solid var(--bg);
cursor: pointer;
}
/* ===== Hide number spinners ===== */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; }
input[type="number"] { -moz-appearance: textfield; }
/* ===== Info box ===== */
.info-box {
background: rgba(255,255,255,0.04);
border: 1px dashed var(--border);
border-radius: 10px;
padding: 0.9rem 1rem;
font-size: 0.8rem;
color: var(--text-hint);
line-height: 1.6;
margin-top: 1rem;
}
/* ===== Results ===== */
.results { display: flex; flex-direction: column; gap: 0.75rem; }
.result-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
@media (max-width: 600px) {
.result-cards { grid-template-columns: 1fr; }
}
.result-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
transition: box-shadow 0.2s;
backdrop-filter: blur(12px);
}
.buy-card { border-top: 3px solid var(--buy); }
.rent-card { border-top: 3px solid var(--rent); }
.breakeven-card { border-top: 3px solid var(--text-hint); }
.result-label {
font-size: 0.78rem;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.result-value {
font-size: 1.65rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
transition: color 0.2s;
}
.buy-card .result-value { color: var(--buy); }
.rent-card .result-value { color: var(--rent); }
.result-sub {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.1rem;
}
.winner-badge {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
padding: 0.15rem 0.6rem;
border-radius: 99px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.winner-buy { background: var(--buy-muted); color: var(--buy); }
.winner-rent { background: var(--rent-muted); color: var(--rent); }
.monthly-payment-note {
font-size: 0.83rem;
color: var(--text-hint);
padding-left: 0.25rem;
}
/* ===== Charts ===== */
.chart-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 0.4rem;
}
.chart-legend {
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.35rem;
}
.legend-dot {
display: inline-block;
width: 10px; height: 10px;
border-radius: 50%;
}
.buy-dot { background: var(--buy); }
.rent-dot { background: var(--rent); }
.chart-wrap {
position: relative;
height: 300px;
}
/* ===== Table ===== */
.table-section { overflow: hidden; }
.table-toggle {
background: none;
border: none;
color: var(--text-muted);
font-family: var(--font);
font-size: 0.88rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
transition: color 0.15s;
}
.table-toggle:hover { color: var(--text); }
.toggle-icon {
display: inline-block;
transition: transform 0.2s;
font-size: 0.7rem;
}
.table-toggle[aria-expanded="true"] .toggle-icon { transform: rotate(90deg); }
.table-wrap {
margin-top: 1.25rem;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
th, td {
padding: 0.6rem 1rem;
text-align: right;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
th:first-child, td:first-child { text-align: left; }
th {
color: var(--text-muted);
font-weight: 500;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
tbody tr:hover { background: rgba(255,255,255,0.03); }
tr.winner-row td { font-weight: 600; }
/* ===== Footer ===== */
footer {
text-align: center;
padding: 2rem 1rem;
color: var(--text-hint);
font-size: 0.8rem;
border-top: 1px solid var(--border);
}

247
ui.js Normal file
View 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();