initial: rent vs buy calculator
This commit is contained in:
121
calc.js
Normal file
121
calc.js
Normal 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
282
index.html
Normal 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
|
||||
<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
|
||||
<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
421
style.css
Normal 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
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