diff --git a/CLAUDE.md b/CLAUDE.md index bb13538..fdbf38b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,33 +4,41 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Interactive web application that visualizes how percentage-based salary increases amplify absolute salary differences over time. Norwegian-language UI ("Lønnsforskjellen vokser" = "The salary gap grows"). +Collection of interactive calculators visualizing how seemingly small differences grow exponentially over time. Norwegian-language UI. Hosted at `forskjeller.naiv.no`. ## Architecture -Hosted at `forskjeller.naiv.no`. Static site in `public/` — no build system, no package manager. Serve `public/` as the site root. Each topic gets its own subdirectory (e.g. `public/lonn/`). +Static site in `public/` — no build system, no package manager. Serve `public/` as the site root. Each topic gets its own subdirectory with `index.html`, `style.css`, `app.js`. -- `public/lonn/index.html` — Page structure: header, 4 range-slider controls, 4 stat cards, 4 chart canvases, explainer section -- `public/lonn/style.css` — CSS custom properties in `:root`, CSS Grid layouts, responsive breakpoints at 560px/480px/360px -- `public/lonn/app.js` — All logic: - - `fmtKr(n)` / `fmtShort(n)` — Norwegian locale number formatting (nb-NO) - - `getData()` — Computes compound growth arrays from slider values - - `baseOpts()` — Shared Chart.js config factory for all 4 charts - - `update()` — Reads sliders, recalculates, updates DOM and charts (called on every `input` event) +### Shared pattern (all calculators) + +Each calculator directory contains three files following the same pattern: +- `index.html` — Header, range-slider controls, stat cards, chart canvases, explainer section +- `style.css` — Identical copy across all calculators. CSS custom properties in `:root`, CSS Grid layouts, responsive breakpoints at 560px/480px/360px +- `app.js` — Self-contained logic with `fmtKr()`, `fmtShort()`, `getData()`, `baseOpts()`, `update()`. Charts update with `'none'` animation mode. + +### Calculators + +- **`public/lonn/`** — Salary gap: percentage raises amplify absolute differences. Model: `gap = (B₀ − A₀) × (1 + r)ⁿ` +- **`public/sparing/`** — Compound interest: same savings, different returns. Model: `FV = P × ((1+r)^n - 1) / r` (monthly compounding). Stacked bar chart for deposits vs interest. +- **`public/bolig/`** — Housing leverage: same price growth, different starting prices. 85% LTV amplifies equity gap ~6.67×. Model: `Equity = Price × (1+g)^n - Loan` +- **`public/arv/`** — Inheritance gap: lump sum + equal savings, gap never closes. Model: `Gap = Arv × (1+r)^n`. Has percentage y-axis chart. **External dependencies** (loaded via CDN, no install needed): Chart.js 4.4.1, Google Fonts (Fraunces + DM Sans). ### Key Design Decisions -- All 4 charts share `baseOpts()` — modify this for consistent chart styling changes -- Charts update with `'none'` animation mode for responsive slider interaction -- Mathematical model: gap = `(B₀ − A₀) × (1 + r)ⁿ` +- Color palette: Blue (#1a4a8a) = A, Red (#c0392b) = B, Purple (#4a3a8a) = gap +- All charts share `baseOpts()` — modify this for consistent chart styling changes +- Label pattern: "I dag" at year 0, show every 5th year, always show last year +- DOM updates use `textContent` or DOM methods (not innerHTML) for security ## Development ```bash python3 -m http.server 8000 -d public -# open http://localhost:8000/lonn/ +# open http://localhost:8000/ for landing page +# open http://localhost:8000/{lonn,sparing,bolig,arv}/ for individual calculators ``` ## Language diff --git a/README.md b/README.md index fa23498..54632ce 100644 --- a/README.md +++ b/README.md @@ -2,37 +2,33 @@ Interaktive visualiseringer av forskjeller og ulikhet i Norge. -## Lønn +## Kalkulatorer -Kalkulator som viser hvordan prosentvise lønnstillegg forsterker lønnsforskjeller over tid. +### Lønn — Lønnsforskjellen vokser +Prosentvise lønnstillegg *ser* rettferdige ut — alle får den samme prosenten. Men matematikken er asymmetrisk: jo høyere startlønn, desto flere kroner i absolutt tillegg. Kroneforskjellen forsvinner aldri — den multipliseres eksponentielt. -## Hva er dette? +### Sparing — Rentes rente forsterker +Likt månedlig sparebeløp, ulik avkastning. Noen få prosentpoeng i forskjell skaper enorme gap over tid — takket være rentes rente-effekten. Viser formue, gap, og fordeling mellom innskudd og renter. -Prosentvise lønnstillegg *ser* rettferdige ut — alle får den samme prosenten. Men matematikken er asymmetrisk: jo høyere startlønn, desto flere kroner i absolutt tillegg hvert år. Denne kalkulatoren visualiserer effekten over en hel karriere. +### Bolig — Boligforskjellen forsterkes +To boliger med ulik pris, samme prisvekst. Belåning (85 % lån) forsterker egenkapitalgapet ~6,7 ganger. Viser boligverdi, verdidifferanse og egenkapitalvekst med leverage-effekt. -**Formelen:** -``` -Kroneforskjell etter n år = (B₀ − A₀) × (1 + vekst)ⁿ -``` - -Den opprinnelige forskjellen forsvinner aldri — den multipliseres eksponentielt. - -## Funksjoner - -- 4 interaktive slidere: startlønn A, startlønn B, årlig vekst (%), antall år -- 4 diagrammer: årslønn, absolutt gap, månedlig gap, akkumulert total -- Statistikkort med nøkkeltall -- Responsivt design for mobil og desktop +### Arv — Arvens forsprang +Person A starter fra null, person B arver en sum. Begge sparer likt — men gapet lukkes aldri. Arvet formue vokser eksponentielt uavhengig av innsats. ## Kjøre lokalt -Åpne `public/lonn/index.html` direkte i en nettleser, eller start en lokal server: +Start en lokal server: ```bash python3 -m http.server 8000 -d public ``` -Deretter åpne `http://localhost:8000/lonn/` i nettleseren. +Åpne `http://localhost:8000/` for landingssiden, eller gå direkte til en kalkulator: +- `http://localhost:8000/lonn/` +- `http://localhost:8000/sparing/` +- `http://localhost:8000/bolig/` +- `http://localhost:8000/arv/` ## Avhengigheter @@ -45,10 +41,23 @@ Ingen byggsteg eller pakkebehandler nødvendig. ``` public/ -└── lonn/ - ├── index.html # Lønnsforskjell-kalkulator - ├── style.css # Styling og responsivt design - └── app.js # Beregningslogikk, diagrammer og interaktivitet +├── index.html # Landingsside med oversikt over alle kalkulatorer +├── lonn/ +│ ├── index.html # Lønnsforskjell-kalkulator +│ ├── style.css +│ └── app.js +├── sparing/ +│ ├── index.html # Rentes rente-kalkulator +│ ├── style.css +│ └── app.js +├── bolig/ +│ ├── index.html # Bolig-kalkulator med leverage +│ ├── style.css +│ └── app.js +└── arv/ + ├── index.html # Arv-kalkulator + ├── style.css + └── app.js ``` ## Lisens diff --git a/public/arv/app.js b/public/arv/app.js new file mode 100644 index 0000000..38e3d83 --- /dev/null +++ b/public/arv/app.js @@ -0,0 +1,195 @@ +// Formateringshjelpere — norsk locale +function fmtKr(n) { + return Math.round(n).toLocaleString('nb-NO') + '\u202fkr'; +} + +function fmtShort(n) { + if (n >= 1e6) return (n / 1e6).toFixed(1).replace('.', ',') + '\u202fM'; + if (n >= 1e3) return Math.round(n / 1e3) + '\u202fk'; + return Math.round(n); +} + +// Fremtidig verdi av annuitet med månedlig compounding +function futureValue(P, annualRate, years) { + const n = years * 12; + if (annualRate === 0) return P * n; + const r = annualRate / 12; + return P * (Math.pow(1 + r, n) - 1) / r; +} + +function getData() { + const inheritance = +document.getElementById('inheritance').value; + const P = +document.getElementById('monthly').value; + const rate = +document.getElementById('rate').value / 100; + const Y = +document.getElementById('yrs').value; + + const labels = [], wealthA = [], wealthB = [], gaps = [], arvPct = []; + + for (let y = 0; y <= Y; y++) { + labels.push(y === 0 ? 'I dag' : (y % 5 === 0 || Y <= 10 || y === Y) ? 'År ' + y : ''); + + // A = ren annuitet (starter fra null) + const a = futureValue(P, rate, y); + + // B = arv med rentes rente + samme annuitet + const arvGrown = inheritance * Math.pow(1 + rate, y); + const b = arvGrown + futureValue(P, rate, y); + + // Gap = Arv × (1+r)^n — vokser alltid + const gap = arvGrown; + + wealthA.push(a); + wealthB.push(b); + gaps.push(gap); + + // Arvens andel av Bs formue (prosent) + arvPct.push(b > 0 ? (arvGrown / b) * 100 : 0); + } + + return { inheritance, P, rate, Y, labels, wealthA, wealthB, gaps, arvPct }; +} + +const GRID_COLOR = 'rgba(0,0,0,0.06)'; +const TICK_COLOR = '#8a857e'; + +function baseOpts() { + return { + responsive: true, maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { label: c => ' ' + fmtKr(c.parsed.y) }, + backgroundColor: '#1a1714', + titleColor: '#f5f2eb', + bodyColor: 'rgba(245,242,235,0.7)', + padding: 10, + cornerRadius: 4, + titleFont: { family: 'DM Sans', size: 12 }, + bodyFont: { family: 'DM Sans', size: 12 } + } + }, + scales: { + x: { + ticks: { color: TICK_COLOR, font: { size: 11, family: 'DM Sans' }, autoSkip: false, maxRotation: 0 }, + grid: { display: false }, + border: { color: 'rgba(0,0,0,0.1)' } + }, + y: { + ticks: { color: TICK_COLOR, font: { size: 11, family: 'DM Sans' }, callback: v => fmtShort(v) }, + grid: { color: GRID_COLOR }, + border: { dash: [3, 3], color: 'transparent' } + } + } + }; +} + +// Tilpasset y-akse for prosentdiagram +function pctOpts() { + const opts = baseOpts(); + opts.scales.y.min = 0; + opts.scales.y.max = 100; + opts.scales.y.ticks = { + color: TICK_COLOR, + font: { size: 11, family: 'DM Sans' }, + callback: v => v + ' %' + }; + opts.plugins.tooltip.callbacks = { + label: c => ' ' + c.parsed.y.toFixed(1) + ' %' + }; + return opts; +} + +// Diagram 1: Formue over tid +const chart1 = new Chart(document.getElementById('chart1'), { + type: 'line', + data: { labels: [], datasets: [ + { data: [], borderColor: '#1a4a8a', backgroundColor: 'rgba(26,74,138,0.07)', fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2 }, + { data: [], borderColor: '#c0392b', backgroundColor: 'rgba(192,57,43,0.07)', fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2 } + ]}, + options: baseOpts() +}); + +// Diagram 2: Absolutt forskjell (gap) +const chart2 = new Chart(document.getElementById('chart2'), { + type: 'line', + data: { labels: [], datasets: [ + { data: [], borderColor: '#4a3a8a', backgroundColor: 'rgba(74,58,138,0.1)', fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2 } + ]}, + options: baseOpts() +}); + +// Diagram 3: Arvens andel av Bs formue (prosent — tilpasset y-akse) +const chart3 = new Chart(document.getElementById('chart3'), { + type: 'line', + data: { labels: [], datasets: [ + { data: [], borderColor: '#b5620a', backgroundColor: 'rgba(181,98,10,0.1)', fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2 } + ]}, + options: pctOpts() +}); + +// SIKKERHETSNOTAT: Alle verdier er beregnet fra numeriske slider-verdier. +// Ingen bruker-tekstinput brukes i DOM-oppdateringer. +function update() { + const { inheritance, P, rate, Y, labels, wealthA, wealthB, gaps, arvPct } = getData(); + + // Slider-visning + document.querySelectorAll('.yr-lbl').forEach(el => el.textContent = Y); + document.getElementById('inheritance-out').textContent = Math.round(inheritance).toLocaleString('nb-NO') + '\u202fkr'; + document.getElementById('monthly-out').textContent = Math.round(P).toLocaleString('nb-NO') + '\u202fkr'; + document.getElementById('rate-out').textContent = (rate * 100).toLocaleString('nb-NO', { minimumFractionDigits: 1 }) + '\u202f%'; + document.getElementById('yrs-out').textContent = Y + '\u202får'; + + // Statistikkort + document.getElementById('st-valA').textContent = fmtKr(wealthA[Y]); + document.getElementById('st-valB').textContent = fmtKr(wealthB[Y]); + document.getElementById('st-subB').textContent = 'herav ' + fmtKr(gaps[Y]) + ' fra arv'; + document.getElementById('st-gap').textContent = fmtKr(gaps[Y]); + const multiplier = (gaps[Y] / inheritance).toFixed(1).replace('.', ','); + document.getElementById('st-gap-sub').textContent = multiplier + '× opprinnelig arv'; + document.getElementById('st-grown').textContent = fmtKr(gaps[Y]); + document.getElementById('st-grown-sub').textContent = 'fra ' + fmtKr(inheritance); + + // Banner + document.getElementById('banner-gap').textContent = fmtKr(gaps[Y]); + + // Innsikt — bruker DOM-metoder for sikkerhet + const insightEl = document.getElementById('insight'); + insightEl.textContent = ''; + const parts = [ + { text: 'A sparte like mye som B i ' + Y + ' år. Men arven på ' }, + { text: fmtKr(inheritance), bold: true }, + { text: ' har vokst til ' }, + { text: fmtKr(gaps[Y]), bold: true }, + { text: ' — og gapet kan aldri lukkes.' } + ]; + parts.forEach(p => { + if (p.bold) { + const strong = document.createElement('strong'); + strong.textContent = p.text; + insightEl.appendChild(strong); + } else { + insightEl.appendChild(document.createTextNode(p.text)); + } + }); + + // Diagram 1: Formue + chart1.data.labels = labels; + chart1.data.datasets[0].data = wealthA; + chart1.data.datasets[1].data = wealthB; + chart1.update('none'); + + // Diagram 2: Absolutt gap + chart2.data.labels = labels; + chart2.data.datasets[0].data = gaps; + chart2.update('none'); + + // Diagram 3: Arvens prosentandel + chart3.data.labels = labels; + chart3.data.datasets[0].data = arvPct; + chart3.update('none'); +} + +['inheritance', 'monthly', 'rate', 'yrs'].forEach(id => + document.getElementById(id).addEventListener('input', update) +); +update(); diff --git a/public/arv/index.html b/public/arv/index.html new file mode 100644 index 0000000..ffd609f --- /dev/null +++ b/public/arv/index.html @@ -0,0 +1,149 @@ + + + + + +Arvens forsprang — interaktiv kalkulator + + + + + + + +
+

Lik innsats. Ulikt utgangspunkt.

+

Arvens
forsprang

+

Person A starter fra null. Person B arver en sum. Begge sparer likt — men gapet lukkes aldri. Se hvordan arv forsterkes over tid.

+
Ulik startlinje
+
+ +
+ + En arv på 500 000 kr vokser til over på 30 år — uten at B løfter en finger. +
+ +
+ +
+

Sjekk selv

+
+
+ +
+ + 500 000 kr +
+
+
+ +
+ + 3 000 kr +
+
+
+ +
+ + 7,0 % +
+
+
+ +
+ + 30 år +
+
+
+
+ +
+
+
Formue A etter 30 år
+
+
startet fra null
+
+
+
Formue B etter 30 år
+
+
+
+
+
B har mer (= arv med renter)
+
+
+
+
+
Arven har vokst til
+
+
+
+
+ + +
+
+
+
Formue over tid
+
A sparer fra null, B starter med arv — begge sparer likt
+
+
+ Person A + Person B +
+
+
+
+ + +
+
+
+
Absolutt forskjell
+
Gapet = arven med rentes rente — vokser alltid
+
+
+ Gap (arv × (1+r)ⁿ) +
+
+
+
+ +
+ + +
+
+
+
Arvens andel av Bs formue
+
Andelen synker over tid, men det absolutte beløpet vokser alltid
+
+
+ Arv-andel (%) +
+
+
+
+ + +
+

Arvet formue vokser av seg selv

+

Når B arver en sum, vokser den med rentes rente — helt uavhengig av innsats. A kan spare like mye, like lenge, med samme avkastning — men vil aldri ta igjen forskjellen.

+
+ Gap = Arv × (1 + r)ⁿ
+ Gapet vokser eksponentielt, uavhengig av spareinnsats +
+

Over tid utgjør arven en stadig mindre andel av Bs totale formue — fordi egne sparepenger også vokser. Men det absolutte gapet øker alltid. Lik innsats gir aldri lik formue når startlinjen er ulik.

+
+ +
+ + + + + + diff --git a/public/arv/style.css b/public/arv/style.css new file mode 100644 index 0000000..8db04e6 --- /dev/null +++ b/public/arv/style.css @@ -0,0 +1,370 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #f5f2eb; + --bg2: #ede9e0; + --bg3: #e4dfd3; + --ink: #1a1714; + --ink2: #5a5650; + --ink3: #8a857e; + --accent: #c0392b; + --accent2: #2c6e49; + --blue: #1a4a8a; + --amber: #b5620a; + --purple: #4a3a8a; + --border: rgba(26,23,20,0.12); + --radius: 4px; +} + +html { scroll-behavior: smooth; } + +body { + font-family: 'DM Sans', sans-serif; + background: var(--bg); + color: var(--ink); + font-size: 16px; + line-height: 1.6; + min-height: 100vh; +} + +/* HEADER */ +header { + background: var(--ink); + color: var(--bg); + padding: 3.5rem 2rem 3rem; + text-align: center; + position: relative; + overflow: hidden; +} +header::before { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + -45deg, + transparent, + transparent 40px, + rgba(255,255,255,0.02) 40px, + rgba(255,255,255,0.02) 41px + ); +} +header .eyebrow { + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: rgba(245,242,235,0.5); + margin-bottom: 1rem; + font-weight: 400; +} +header h1 { + font-family: 'Fraunces', serif; + font-size: clamp(2.2rem, 6vw, 4rem); + font-weight: 600; + line-height: 1.1; + margin-bottom: 1rem; + position: relative; +} +header h1 em { + font-style: italic; + color: #e8b4a0; +} +header p.lead { + font-size: clamp(0.95rem, 2vw, 1.1rem); + color: rgba(245,242,235,0.65); + max-width: 560px; + margin: 0 auto; + font-weight: 300; + line-height: 1.7; +} + +/* MAIN LAYOUT */ +main { + max-width: 900px; + margin: 0 auto; + padding: 2.5rem 1.25rem 4rem; +} + +/* CONTROLS CARD */ +.controls-card { + background: var(--ink); + color: var(--bg); + border-radius: 8px; + padding: 2rem; + margin-bottom: 2.5rem; +} +.controls-card h2 { + font-family: 'Fraunces', serif; + font-size: 1rem; + font-weight: 300; + font-style: italic; + color: rgba(245,242,235,0.5); + margin-bottom: 1.5rem; + letter-spacing: 0.03em; +} +.controls-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem 2rem; +} +@media (max-width: 560px) { + .controls-grid { grid-template-columns: 1fr; gap: 1rem; } +} + +.control-group label { + display: block; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(245,242,235,0.45); + margin-bottom: 0.5rem; + font-weight: 400; +} +.control-group .val-row { + display: flex; + align-items: center; + gap: 10px; +} +.control-group input[type=range] { + flex: 1; + -webkit-appearance: none; + height: 3px; + background: rgba(245,242,235,0.15); + border-radius: 2px; + outline: none; + cursor: pointer; +} +.control-group input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; height: 16px; + border-radius: 50%; + background: var(--bg); + cursor: pointer; + transition: transform 0.1s; +} +.control-group input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.2); } +.control-group input[type=range]::-moz-range-thumb { + width: 16px; height: 16px; + border-radius: 50%; + background: var(--bg); + cursor: pointer; + border: none; +} +.control-group .val-display { + font-size: 1rem; + font-weight: 500; + color: var(--bg); + min-width: 80px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* STAT STRIP */ +.stat-strip { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-bottom: 2.5rem; +} +@media (max-width: 600px) { + .stat-strip { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 360px) { + .stat-strip { grid-template-columns: 1fr; } +} +.stat { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 1rem 1.1rem; +} +.stat-label { + font-size: 11px; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--ink3); + margin-bottom: 0.35rem; +} +.stat-value { + font-size: 1.4rem; + font-weight: 500; + color: var(--ink); + font-variant-numeric: tabular-nums; + line-height: 1.2; +} +.stat-sub { + font-size: 12px; + color: var(--ink3); + margin-top: 0.2rem; +} + +/* CHART SECTIONS */ +.chart-section { + background: #fff; + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1.25rem; +} +.chart-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 1rem; + gap: 1rem; + flex-wrap: wrap; +} +.chart-title { + font-family: 'Fraunces', serif; + font-size: 1.1rem; + font-weight: 600; + color: var(--ink); +} +.chart-desc { + font-size: 13px; + color: var(--ink3); + margin-top: 2px; +} +.legend { + display: flex; + gap: 14px; + font-size: 12px; + color: var(--ink2); + flex-wrap: wrap; + align-items: center; +} +.legend span { display: flex; align-items: center; gap: 5px; } +.swatch { + width: 10px; height: 10px; + border-radius: 2px; + flex-shrink: 0; + display: inline-block; +} +.swatch.dashed { + background: linear-gradient(90deg, var(--sw-color) 50%, transparent 50%); + background-size: 6px 2px; + background-repeat: repeat-x; + background-position: center; + background-color: transparent; + height: 2px; + width: 18px; + border-radius: 0; +} +.chart-wrap { + position: relative; + width: 100%; +} + +/* INSIGHT BOX */ +.insight { + background: var(--bg2); + border-left: 3px solid var(--accent); + border-radius: 0 6px 6px 0; + padding: 1rem 1.25rem; + margin-bottom: 1.25rem; + font-size: 0.95rem; + color: var(--ink2); + line-height: 1.65; +} +.insight strong { color: var(--ink); font-weight: 500; } + +/* EXPLAINER */ +.explainer { + background: var(--bg3); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.75rem 2rem; + margin-top: 2rem; +} +.explainer h3 { + font-family: 'Fraunces', serif; + font-size: 1.2rem; + font-weight: 600; + margin-bottom: 1rem; +} +.explainer p { + font-size: 0.95rem; + color: var(--ink2); + margin-bottom: 0.75rem; + line-height: 1.7; +} +.explainer p:last-child { margin-bottom: 0; } +.explainer strong { color: var(--ink); font-weight: 500; } + +/* FORMULA */ +.formula-box { + background: var(--ink); + color: var(--bg); + border-radius: 6px; + padding: 1rem 1.25rem; + font-family: 'DM Sans', monospace; + font-size: 0.9rem; + margin: 1rem 0; + letter-spacing: 0.02em; + line-height: 1.8; +} +.formula-box span { color: #e8b4a0; } + +/* STAMP */ +.stamp { + display: inline-block; + position: relative; + margin-top: 2rem; + padding: 0.55rem 1.1rem; + border: 3px solid #e8b4a0; + border-radius: 4px; + font-family: 'Fraunces', serif; + font-size: clamp(0.75rem, 2.5vw, 0.95rem); + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #e8b4a0; + opacity: 0.72; + transform: rotate(-4deg); + box-shadow: inset 0 0 0 2px rgba(232,180,160,0.18); + user-select: none; +} +.stamp::before { + content: ''; + position: absolute; + inset: 3px; + border: 1px solid rgba(232,180,160,0.3); + border-radius: 2px; + pointer-events: none; +} + +/* SMALL DIFF BANNER */ +.small-diff-banner { + background: var(--accent); + color: #fff; + padding: 1rem 2rem; + display: flex; + align-items: flex-start; + gap: 0.75rem; + font-size: clamp(0.88rem, 2vw, 1rem); + line-height: 1.6; +} +.small-diff-banner strong { font-weight: 500; } +.sd-icon { font-size: 1.1rem; flex-shrink: 0; margin-top: 2px; } + +/* FOOTER */ +footer { + text-align: center; + padding: 2rem 1rem; + font-size: 12px; + color: var(--ink3); + border-top: 1px solid var(--border); + margin-top: 3rem; +} +footer a { color: var(--ink3); } + +/* RESPONSIVE CHART HEIGHTS */ +.h-240 { height: 240px; } +.h-200 { height: 200px; } +@media (max-width: 480px) { + .h-240 { height: 200px; } + .h-200 { height: 170px; } + .chart-section { padding: 1rem; } + main { padding: 1.5rem 1rem 3rem; } + .controls-card { padding: 1.25rem; } + .explainer { padding: 1.25rem; } +} diff --git a/public/bolig/app.js b/public/bolig/app.js new file mode 100644 index 0000000..65eb9bc --- /dev/null +++ b/public/bolig/app.js @@ -0,0 +1,171 @@ +// Formateringshjelpere — norsk locale +function fmtKr(n) { + return Math.round(n).toLocaleString('nb-NO') + '\u202fkr'; +} + +function fmtShort(n) { + if (n >= 1e6) return (n / 1e6).toFixed(1).replace('.', ',') + '\u202fM'; + if (n >= 1e3) return Math.round(n / 1e3) + '\u202fk'; + return Math.round(n); +} + +function getData() { + const pA = +document.getElementById('priceA').value; + const pB = +document.getElementById('priceB').value; + const g = +document.getElementById('growth').value / 100; + const Y = +document.getElementById('yrs').value; + + // Lån = 85 % av kjøpspris (forenklet avdragsfritt) + const loanA = pA * 0.85; + const loanB = pB * 0.85; + + const labels = [], valA = [], valB = [], valGaps = []; + const eqA = [], eqB = [], eqGaps = []; + + for (let y = 0; y <= Y; y++) { + labels.push(y === 0 ? 'I dag' : (y % 5 === 0 || Y <= 10 || y === Y) ? 'År ' + y : ''); + const a = pA * Math.pow(1 + g, y); + const b = pB * Math.pow(1 + g, y); + valA.push(a); + valB.push(b); + valGaps.push(b - a); + eqA.push(a - loanA); + eqB.push(b - loanB); + eqGaps.push((b - loanB) - (a - loanA)); + } + + return { pA, pB, g, Y, loanA, loanB, labels, valA, valB, valGaps, eqA, eqB, eqGaps }; +} + +const GRID_COLOR = 'rgba(0,0,0,0.06)'; +const TICK_COLOR = '#8a857e'; + +function baseOpts() { + return { + responsive: true, maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { label: c => ' ' + fmtKr(c.parsed.y) }, + backgroundColor: '#1a1714', + titleColor: '#f5f2eb', + bodyColor: 'rgba(245,242,235,0.7)', + padding: 10, + cornerRadius: 4, + titleFont: { family: 'DM Sans', size: 12 }, + bodyFont: { family: 'DM Sans', size: 12 } + } + }, + scales: { + x: { + ticks: { color: TICK_COLOR, font: { size: 11, family: 'DM Sans' }, autoSkip: false, maxRotation: 0 }, + grid: { display: false }, + border: { color: 'rgba(0,0,0,0.1)' } + }, + y: { + ticks: { color: TICK_COLOR, font: { size: 11, family: 'DM Sans' }, callback: v => fmtShort(v) }, + grid: { color: GRID_COLOR }, + border: { dash: [3, 3], color: 'transparent' } + } + } + }; +} + +// Diagram 1: Boligverdi over tid +const chart1 = new Chart(document.getElementById('chart1'), { + type: 'line', + data: { labels: [], datasets: [ + { data: [], borderColor: '#1a4a8a', backgroundColor: 'rgba(26,74,138,0.07)', fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2 }, + { data: [], borderColor: '#c0392b', backgroundColor: 'rgba(192,57,43,0.07)', fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2 } + ]}, + options: baseOpts() +}); + +// Diagram 2: Verdidifferanse +const chart2 = new Chart(document.getElementById('chart2'), { + type: 'line', + data: { labels: [], datasets: [ + { data: [], borderColor: '#4a3a8a', backgroundColor: 'rgba(74,58,138,0.1)', fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2 } + ]}, + options: baseOpts() +}); + +// Diagram 3: Egenkapitalvekst (A, B, gap) +const chart3 = new Chart(document.getElementById('chart3'), { + type: 'line', + data: { labels: [], datasets: [ + { data: [], borderColor: '#1a4a8a', backgroundColor: 'rgba(26,74,138,0.07)', fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2 }, + { data: [], borderColor: '#c0392b', backgroundColor: 'rgba(192,57,43,0.07)', fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2 }, + { data: [], borderColor: '#4a3a8a', backgroundColor: 'rgba(74,58,138,0.1)', fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2, borderDash: [6, 4] } + ]}, + options: baseOpts() +}); + +// SIKKERHETSNOTAT: Alle verdier er beregnet fra numeriske slider-verdier. +// Ingen bruker-tekstinput brukes i DOM-oppdateringer. +function update() { + const { pA, pB, g, Y, loanA, loanB, labels, valA, valB, valGaps, eqA, eqB, eqGaps } = getData(); + + // Slider-visning + document.querySelectorAll('.yr-lbl').forEach(el => el.textContent = Y); + document.getElementById('priceA-out').textContent = Math.round(pA).toLocaleString('nb-NO') + '\u202fkr'; + document.getElementById('priceB-out').textContent = Math.round(pB).toLocaleString('nb-NO') + '\u202fkr'; + document.getElementById('growth-out').textContent = (g * 100).toLocaleString('nb-NO', { minimumFractionDigits: 1 }) + '\u202f%'; + document.getElementById('yrs-out').textContent = Y + '\u202får'; + + // Statistikkort + document.getElementById('st-valA').textContent = fmtKr(valA[Y]); + document.getElementById('st-subA').textContent = 'EK: ' + fmtKr(eqA[Y]); + document.getElementById('st-valB').textContent = fmtKr(valB[Y]); + document.getElementById('st-subB').textContent = 'EK: ' + fmtKr(eqB[Y]); + document.getElementById('st-valdiff').textContent = fmtKr(valGaps[Y]); + document.getElementById('st-valdiff-sub').textContent = 'var ' + fmtKr(pB - pA) + ' ved start'; + document.getElementById('st-eqdiff').textContent = fmtKr(eqGaps[Y]); + const startEqGap = (pB * 0.15) - (pA * 0.15); + document.getElementById('st-eqdiff-sub').textContent = 'var ' + fmtKr(startEqGap) + ' ved start'; + + // Banner + document.getElementById('banner-gap').textContent = fmtKr(eqGaps[Y]); + + // Innsikt — bruker DOM-metoder for sikkerhet + const insightEl = document.getElementById('insight'); + insightEl.textContent = ''; + const growthPct = (g * 100).toLocaleString('nb-NO', { minimumFractionDigits: 1 }); + const parts = [ + { text: 'Begge boligene steg ' + growthPct + ' % i året. Men B-eieren har ' }, + { text: fmtKr(eqGaps[Y]), bold: true }, + { text: ' mer i egenkapital — fordi gjeldsgraden forsterker forskjellen.' } + ]; + parts.forEach(p => { + if (p.bold) { + const strong = document.createElement('strong'); + strong.textContent = p.text; + insightEl.appendChild(strong); + } else { + insightEl.appendChild(document.createTextNode(p.text)); + } + }); + + // Diagram 1: Boligverdi + chart1.data.labels = labels; + chart1.data.datasets[0].data = valA; + chart1.data.datasets[1].data = valB; + chart1.update('none'); + + // Diagram 2: Verdidifferanse + chart2.data.labels = labels; + chart2.data.datasets[0].data = valGaps; + chart2.update('none'); + + // Diagram 3: Egenkapital + chart3.data.labels = labels; + chart3.data.datasets[0].data = eqA; + chart3.data.datasets[1].data = eqB; + chart3.data.datasets[2].data = eqGaps; + chart3.update('none'); +} + +['priceA', 'priceB', 'growth', 'yrs'].forEach(id => + document.getElementById(id).addEventListener('input', update) +); +update(); diff --git a/public/bolig/index.html b/public/bolig/index.html new file mode 100644 index 0000000..6e0fee5 --- /dev/null +++ b/public/bolig/index.html @@ -0,0 +1,151 @@ + + + + + +Boligforskjellen vokser — interaktiv kalkulator + + + + + + + +
+

Samme prisvekst. Ikke det samme resultatet.

+

Boligforskjellen
forsterkes

+

To boliger med ulik pris stiger like mye i prosent — men belåningen forsterker gapet i egenkapital dramatisk.

+
Belåningseffekten
+
+ +
+ + Med 1 million i prisforskjell og 4 % årlig vekst: egenkapitalgapet vokser til over på 15 år. +
+ +
+ +
+

Sjekk selv

+
+
+ +
+ + 3 000 000 kr +
+
+
+ +
+ + 4 000 000 kr +
+
+
+ +
+ + 4,0 % +
+
+
+ +
+ + 15 år +
+
+
+
+ +
+
+
Bolig A verdi etter 15 år
+
+
+
+
+
Bolig B verdi etter 15 år
+
+
+
+
+
Verdidifferanse
+
+
+
+
+
Egenkapital-differanse
+
+
+
+
+ + +
+
+
+
Boligverdi over tid
+
Begge boliger stiger med samme prosentsats
+
+
+ Bolig A + Bolig B +
+
+
+
+ + +
+
+
+
Absolutt verdidifferanse
+
Kronedifferansen mellom boligene — vokser hvert år
+
+
+ Verdidifferanse +
+
+
+
+ +
+ + +
+
+
+
Egenkapitalvekst
+
Belåningen forsterker forskjellen — egenkapitalgapet vokser raskere enn verdigapet
+
+
+ EK Bolig A + EK Bolig B + EK-gap +
+
+
+
+ + +
+

Belåningseffekten

+

Med 15 % egenkapital finansierer du boligen med 85 % gjeld. Det betyr at hele prisveksten tilfaller bare 15 % av kapitalen — en forsterkning på omtrent 6,7 ganger.

+
+ Egenkapital = Pris × (1 + vekst)ⁿ − Lån
+ Lån = 85 % av kjøpspris (forenklet avdragsfritt) +
+

Verdidifferansen mellom boligene vokser eksponentielt — men egenkapitaldifferansen vokser enda raskere fordi begge eierne har like stor gjeldsandel. Jo dyrere bolig, desto mer egenkapital skapes av samme prosentvise prisvekst.

+
+ +
+ + + + + + diff --git a/public/bolig/style.css b/public/bolig/style.css new file mode 100644 index 0000000..8db04e6 --- /dev/null +++ b/public/bolig/style.css @@ -0,0 +1,370 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #f5f2eb; + --bg2: #ede9e0; + --bg3: #e4dfd3; + --ink: #1a1714; + --ink2: #5a5650; + --ink3: #8a857e; + --accent: #c0392b; + --accent2: #2c6e49; + --blue: #1a4a8a; + --amber: #b5620a; + --purple: #4a3a8a; + --border: rgba(26,23,20,0.12); + --radius: 4px; +} + +html { scroll-behavior: smooth; } + +body { + font-family: 'DM Sans', sans-serif; + background: var(--bg); + color: var(--ink); + font-size: 16px; + line-height: 1.6; + min-height: 100vh; +} + +/* HEADER */ +header { + background: var(--ink); + color: var(--bg); + padding: 3.5rem 2rem 3rem; + text-align: center; + position: relative; + overflow: hidden; +} +header::before { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + -45deg, + transparent, + transparent 40px, + rgba(255,255,255,0.02) 40px, + rgba(255,255,255,0.02) 41px + ); +} +header .eyebrow { + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: rgba(245,242,235,0.5); + margin-bottom: 1rem; + font-weight: 400; +} +header h1 { + font-family: 'Fraunces', serif; + font-size: clamp(2.2rem, 6vw, 4rem); + font-weight: 600; + line-height: 1.1; + margin-bottom: 1rem; + position: relative; +} +header h1 em { + font-style: italic; + color: #e8b4a0; +} +header p.lead { + font-size: clamp(0.95rem, 2vw, 1.1rem); + color: rgba(245,242,235,0.65); + max-width: 560px; + margin: 0 auto; + font-weight: 300; + line-height: 1.7; +} + +/* MAIN LAYOUT */ +main { + max-width: 900px; + margin: 0 auto; + padding: 2.5rem 1.25rem 4rem; +} + +/* CONTROLS CARD */ +.controls-card { + background: var(--ink); + color: var(--bg); + border-radius: 8px; + padding: 2rem; + margin-bottom: 2.5rem; +} +.controls-card h2 { + font-family: 'Fraunces', serif; + font-size: 1rem; + font-weight: 300; + font-style: italic; + color: rgba(245,242,235,0.5); + margin-bottom: 1.5rem; + letter-spacing: 0.03em; +} +.controls-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem 2rem; +} +@media (max-width: 560px) { + .controls-grid { grid-template-columns: 1fr; gap: 1rem; } +} + +.control-group label { + display: block; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(245,242,235,0.45); + margin-bottom: 0.5rem; + font-weight: 400; +} +.control-group .val-row { + display: flex; + align-items: center; + gap: 10px; +} +.control-group input[type=range] { + flex: 1; + -webkit-appearance: none; + height: 3px; + background: rgba(245,242,235,0.15); + border-radius: 2px; + outline: none; + cursor: pointer; +} +.control-group input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; height: 16px; + border-radius: 50%; + background: var(--bg); + cursor: pointer; + transition: transform 0.1s; +} +.control-group input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.2); } +.control-group input[type=range]::-moz-range-thumb { + width: 16px; height: 16px; + border-radius: 50%; + background: var(--bg); + cursor: pointer; + border: none; +} +.control-group .val-display { + font-size: 1rem; + font-weight: 500; + color: var(--bg); + min-width: 80px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* STAT STRIP */ +.stat-strip { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-bottom: 2.5rem; +} +@media (max-width: 600px) { + .stat-strip { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 360px) { + .stat-strip { grid-template-columns: 1fr; } +} +.stat { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 1rem 1.1rem; +} +.stat-label { + font-size: 11px; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--ink3); + margin-bottom: 0.35rem; +} +.stat-value { + font-size: 1.4rem; + font-weight: 500; + color: var(--ink); + font-variant-numeric: tabular-nums; + line-height: 1.2; +} +.stat-sub { + font-size: 12px; + color: var(--ink3); + margin-top: 0.2rem; +} + +/* CHART SECTIONS */ +.chart-section { + background: #fff; + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1.25rem; +} +.chart-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 1rem; + gap: 1rem; + flex-wrap: wrap; +} +.chart-title { + font-family: 'Fraunces', serif; + font-size: 1.1rem; + font-weight: 600; + color: var(--ink); +} +.chart-desc { + font-size: 13px; + color: var(--ink3); + margin-top: 2px; +} +.legend { + display: flex; + gap: 14px; + font-size: 12px; + color: var(--ink2); + flex-wrap: wrap; + align-items: center; +} +.legend span { display: flex; align-items: center; gap: 5px; } +.swatch { + width: 10px; height: 10px; + border-radius: 2px; + flex-shrink: 0; + display: inline-block; +} +.swatch.dashed { + background: linear-gradient(90deg, var(--sw-color) 50%, transparent 50%); + background-size: 6px 2px; + background-repeat: repeat-x; + background-position: center; + background-color: transparent; + height: 2px; + width: 18px; + border-radius: 0; +} +.chart-wrap { + position: relative; + width: 100%; +} + +/* INSIGHT BOX */ +.insight { + background: var(--bg2); + border-left: 3px solid var(--accent); + border-radius: 0 6px 6px 0; + padding: 1rem 1.25rem; + margin-bottom: 1.25rem; + font-size: 0.95rem; + color: var(--ink2); + line-height: 1.65; +} +.insight strong { color: var(--ink); font-weight: 500; } + +/* EXPLAINER */ +.explainer { + background: var(--bg3); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.75rem 2rem; + margin-top: 2rem; +} +.explainer h3 { + font-family: 'Fraunces', serif; + font-size: 1.2rem; + font-weight: 600; + margin-bottom: 1rem; +} +.explainer p { + font-size: 0.95rem; + color: var(--ink2); + margin-bottom: 0.75rem; + line-height: 1.7; +} +.explainer p:last-child { margin-bottom: 0; } +.explainer strong { color: var(--ink); font-weight: 500; } + +/* FORMULA */ +.formula-box { + background: var(--ink); + color: var(--bg); + border-radius: 6px; + padding: 1rem 1.25rem; + font-family: 'DM Sans', monospace; + font-size: 0.9rem; + margin: 1rem 0; + letter-spacing: 0.02em; + line-height: 1.8; +} +.formula-box span { color: #e8b4a0; } + +/* STAMP */ +.stamp { + display: inline-block; + position: relative; + margin-top: 2rem; + padding: 0.55rem 1.1rem; + border: 3px solid #e8b4a0; + border-radius: 4px; + font-family: 'Fraunces', serif; + font-size: clamp(0.75rem, 2.5vw, 0.95rem); + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #e8b4a0; + opacity: 0.72; + transform: rotate(-4deg); + box-shadow: inset 0 0 0 2px rgba(232,180,160,0.18); + user-select: none; +} +.stamp::before { + content: ''; + position: absolute; + inset: 3px; + border: 1px solid rgba(232,180,160,0.3); + border-radius: 2px; + pointer-events: none; +} + +/* SMALL DIFF BANNER */ +.small-diff-banner { + background: var(--accent); + color: #fff; + padding: 1rem 2rem; + display: flex; + align-items: flex-start; + gap: 0.75rem; + font-size: clamp(0.88rem, 2vw, 1rem); + line-height: 1.6; +} +.small-diff-banner strong { font-weight: 500; } +.sd-icon { font-size: 1.1rem; flex-shrink: 0; margin-top: 2px; } + +/* FOOTER */ +footer { + text-align: center; + padding: 2rem 1rem; + font-size: 12px; + color: var(--ink3); + border-top: 1px solid var(--border); + margin-top: 3rem; +} +footer a { color: var(--ink3); } + +/* RESPONSIVE CHART HEIGHTS */ +.h-240 { height: 240px; } +.h-200 { height: 200px; } +@media (max-width: 480px) { + .h-240 { height: 200px; } + .h-200 { height: 170px; } + .chart-section { padding: 1rem; } + main { padding: 1.5rem 1rem 3rem; } + .controls-card { padding: 1.25rem; } + .explainer { padding: 1.25rem; } +} diff --git a/public/index.html b/public/index.html index cc6318a..eb35b5f 100644 --- a/public/index.html +++ b/public/index.html @@ -145,6 +145,24 @@ Se kalkulatoren → + +

Rentes rente forsterker

+

Likt sparebeløp, ulik avkastning. Noen få prosentpoeng i forskjell skaper enorme gap over tid — takket være rentes rente-effekten.

+ Se kalkulatoren → +
+ + +

Boligforskjellen forsterkes

+

To boliger med ulik pris stiger like mye i prosent — men belåningen forsterker gapet i egenkapital dramatisk.

+ Se kalkulatoren → +
+ + +

Arvens forsprang

+

Person A starter fra null, person B arver en sum. Begge sparer likt — men gapet lukkes aldri. Arvet formue vokser eksponentielt uavhengig av innsats.

+ Se kalkulatoren → +
+ diff --git a/public/lonn/index.html b/public/lonn/index.html index c646e09..4b5d4e8 100644 --- a/public/lonn/index.html +++ b/public/lonn/index.html @@ -161,7 +161,7 @@ diff --git a/public/sparing/app.js b/public/sparing/app.js new file mode 100644 index 0000000..f13f9ea --- /dev/null +++ b/public/sparing/app.js @@ -0,0 +1,195 @@ +// Formateringshjelpere — norsk locale +function fmtKr(n) { + return Math.round(n).toLocaleString('nb-NO') + '\u202fkr'; +} + +function fmtShort(n) { + if (n >= 1e6) return (n / 1e6).toFixed(1).replace('.', ',') + '\u202fM'; + if (n >= 1e3) return Math.round(n / 1e3) + '\u202fk'; + return Math.round(n); +} + +// Fremtidig verdi av annuitet med månedlig compounding +// FV = P × ((1+r)^n - 1) / r der r = årsrate/12, n = år×12 +// Spesialtilfelle: r=0 → FV = P × n +function futureValue(P, annualRate, years) { + const n = years * 12; + if (annualRate === 0) return P * n; + const r = annualRate / 12; + return P * (Math.pow(1 + r, n) - 1) / r; +} + +function getData() { + const P = +document.getElementById('monthly').value; + const rA = +document.getElementById('rateA').value / 100; + const rB = +document.getElementById('rateB').value / 100; + const Y = +document.getElementById('yrs').value; + + const labels = [], valA = [], valB = [], gaps = []; + const deposits = [], interestA = [], interestB = []; + + for (let y = 0; y <= Y; y++) { + labels.push(y === 0 ? 'I dag' : (y % 5 === 0 || Y <= 10 || y === Y) ? 'År ' + y : ''); + const a = futureValue(P, rA, y); + const b = futureValue(P, rB, y); + const dep = P * 12 * y; + valA.push(a); + valB.push(b); + gaps.push(b - a); + deposits.push(dep); + interestA.push(a - dep); + interestB.push(b - dep); + } + + return { P, rA, rB, Y, labels, valA, valB, gaps, deposits, interestA, interestB }; +} + +const GRID_COLOR = 'rgba(0,0,0,0.06)'; +const TICK_COLOR = '#8a857e'; + +function baseOpts() { + return { + responsive: true, maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { label: c => ' ' + fmtKr(c.parsed.y) }, + backgroundColor: '#1a1714', + titleColor: '#f5f2eb', + bodyColor: 'rgba(245,242,235,0.7)', + padding: 10, + cornerRadius: 4, + titleFont: { family: 'DM Sans', size: 12 }, + bodyFont: { family: 'DM Sans', size: 12 } + } + }, + scales: { + x: { + ticks: { color: TICK_COLOR, font: { size: 11, family: 'DM Sans' }, autoSkip: false, maxRotation: 0 }, + grid: { display: false }, + border: { color: 'rgba(0,0,0,0.1)' } + }, + y: { + ticks: { color: TICK_COLOR, font: { size: 11, family: 'DM Sans' }, callback: v => fmtShort(v) }, + grid: { color: GRID_COLOR }, + border: { dash: [3, 3], color: 'transparent' } + } + } + }; +} + +// Diagram 1: Formue over tid (to linjer) +const chart1 = new Chart(document.getElementById('chart1'), { + type: 'line', + data: { labels: [], datasets: [ + { data: [], borderColor: '#1a4a8a', backgroundColor: 'rgba(26,74,138,0.07)', fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2 }, + { data: [], borderColor: '#c0392b', backgroundColor: 'rgba(192,57,43,0.07)', fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2 } + ]}, + options: baseOpts() +}); + +// Diagram 2: Forskjell i formue (gap) +const chart2 = new Chart(document.getElementById('chart2'), { + type: 'line', + data: { labels: [], datasets: [ + { data: [], borderColor: '#4a3a8a', backgroundColor: 'rgba(74,58,138,0.1)', fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2 } + ]}, + options: baseOpts() +}); + +// Diagram 3: Stacked bar — innskudd + renter for A og B +const chart3Opts = baseOpts(); +chart3Opts.scales.x.stacked = true; +chart3Opts.scales.y.stacked = true; +const chart3 = new Chart(document.getElementById('chart3'), { + type: 'bar', + data: { labels: [], datasets: [ + { label: 'Innskudd', data: [], backgroundColor: '#1a4a8a', stack: 'A' }, + { label: 'Renter A', data: [], backgroundColor: 'rgba(26,74,138,0.35)', stack: 'A' }, + { label: 'Innskudd', data: [], backgroundColor: '#c0392b', stack: 'B' }, + { label: 'Renter B', data: [], backgroundColor: 'rgba(192,57,43,0.35)', stack: 'B' } + ]}, + options: chart3Opts +}); + +// SIKKERHETSNOTAT: Alle verdier i innerHTML-kall er utelukkende beregnet fra +// numeriske slider-verdier (range inputs). Ingen bruker-tekstinput brukes. +function update() { + const { P, rA, rB, Y, labels, valA, valB, gaps, deposits, interestA, interestB } = getData(); + + // Oppdater slider-visning + document.querySelectorAll('.yr-lbl').forEach(el => el.textContent = Y); + document.getElementById('monthly-out').textContent = Math.round(P).toLocaleString('nb-NO') + '\u202fkr'; + document.getElementById('rateA-out').textContent = (rA * 100).toLocaleString('nb-NO', { minimumFractionDigits: 1 }) + '\u202f%'; + document.getElementById('rateB-out').textContent = (rB * 100).toLocaleString('nb-NO', { minimumFractionDigits: 1 }) + '\u202f%'; + document.getElementById('yrs-out').textContent = Y + '\u202får'; + + const totalPaid = P * 12 * Y; + + // Statistikkort + document.getElementById('st-valA').textContent = fmtKr(valA[Y]); + document.getElementById('st-subA').textContent = 'herav ' + fmtKr(interestA[Y]) + ' i renter'; + document.getElementById('st-valB').textContent = fmtKr(valB[Y]); + document.getElementById('st-subB').textContent = 'herav ' + fmtKr(interestB[Y]) + ' i renter'; + document.getElementById('st-diff').textContent = fmtKr(gaps[Y]); + const ppDiff = Math.abs(rB * 100 - rA * 100); + document.getElementById('st-diff-sub').textContent = 'pga. ' + ppDiff.toLocaleString('nb-NO', { minimumFractionDigits: 1 }) + ' pp forskjell'; + document.getElementById('st-paid').textContent = fmtKr(totalPaid); + + // Banner + document.getElementById('banner-gap').textContent = fmtKr(gaps[Y]); + + // Innsikt + const insightEl = document.getElementById('insight'); + insightEl.textContent = ''; + const parts = [ + { text: 'Dere betalte inn like mye: ' }, + { text: fmtKr(totalPaid), bold: true }, + { text: '. Men B har ' }, + { text: fmtKr(gaps[Y]), bold: true }, + { text: ' mer — kun pga. ' + ppDiff.toLocaleString('nb-NO', { minimumFractionDigits: 1 }) + ' prosentpoeng høyere avkastning.' } + ]; + parts.forEach(p => { + if (p.bold) { + const strong = document.createElement('strong'); + strong.textContent = p.text; + insightEl.appendChild(strong); + } else { + insightEl.appendChild(document.createTextNode(p.text)); + } + }); + + // Diagram 1: Formue over tid + chart1.data.labels = labels; + chart1.data.datasets[0].data = valA; + chart1.data.datasets[1].data = valB; + chart1.update('none'); + + // Diagram 2: Gap + chart2.data.labels = labels; + chart2.data.datasets[0].data = gaps; + chart2.update('none'); + + // Diagram 3: Stacked bar — sample hvert 5. år for lesbarhet + const barLabels = [], barDep = [], barIntA = [], barDepB = [], barIntB = []; + for (let y = 0; y <= Y; y++) { + if (y === 0 || y % 5 === 0 || y === Y) { + barLabels.push(y === 0 ? 'I dag' : 'År ' + y); + barDep.push(deposits[y]); + barIntA.push(interestA[y]); + barDepB.push(deposits[y]); + barIntB.push(interestB[y]); + } + } + chart3.data.labels = barLabels; + chart3.data.datasets[0].data = barDep; + chart3.data.datasets[1].data = barIntA; + chart3.data.datasets[2].data = barDepB; + chart3.data.datasets[3].data = barIntB; + chart3.update('none'); +} + +['monthly', 'rateA', 'rateB', 'yrs'].forEach(id => + document.getElementById(id).addEventListener('input', update) +); +update(); diff --git a/public/sparing/index.html b/public/sparing/index.html new file mode 100644 index 0000000..275e25a --- /dev/null +++ b/public/sparing/index.html @@ -0,0 +1,151 @@ + + + + + +Rentes rente — interaktiv kalkulator + + + + + + + +
+

Samme sparebeløp. Ikke det samme resultatet.

+

Rentes rente
forsterker

+

Noen få prosentpoeng i avkastning ser ubetydelig ut — men over tid skaper de enorme forskjeller. Se hva rentes rente gjør med sparepengene dine.

+
Tid i markedet
+
+ +
+ + Med 3 000 kr/mnd i 25 år: bare 3 prosentpoeng mer i avkastning gir over ekstra. +
+ +
+ +
+

Sjekk selv

+
+
+ +
+ + 3 000 kr +
+
+
+ +
+ + 4,0 % +
+
+
+ +
+ + 7,0 % +
+
+
+ +
+ + 25 år +
+
+
+
+ +
+
+
Sparekonto A etter 25 år
+
+
+
+
+
Sparekonto B etter 25 år
+
+
+
+
+
B har mer enn A
+
+
+
+
+
Totalt innbetalt
+
+
likt for begge
+
+
+ + +
+
+
+
Formue over tid
+
Samme sparebeløp, ulik avkastning
+
+
+ Konto A + Konto B +
+
+
+
+ + +
+
+
+
Forskjell i formue
+
Hvor mye mer B har enn A — vokser eksponentielt
+
+
+ Gap +
+
+
+
+ +
+ + +
+
+
+
Avkastning vs. innbetalt
+
Viser hvor mye som er innskudd og hvor mye som er renter
+
+
+ Innskudd + Renter A + Renter B +
+
+
+
+ + +
+

Rentes rente-effekten

+

Rentes rente betyr at avkastningen din også gir avkastning. Over tid blir det renter du tjener på rentene som utgjør mesteparten av formuen — ikke de månedlige innskuddene dine.

+
+ Sluttverdi = P × ((1 + r)ⁿ − 1) / r
+ der P = månedlig beløp, r = månedsrente, n = antall måneder +
+

Selv små forskjeller i avkastning — noen få prosentpoeng — gir dramatisk ulike resultater over lang tid. Det er derfor valg av sparestrategi tidlig i livet har så stor betydning.

+
+ +
+ + + + + + diff --git a/public/sparing/style.css b/public/sparing/style.css new file mode 100644 index 0000000..8db04e6 --- /dev/null +++ b/public/sparing/style.css @@ -0,0 +1,370 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #f5f2eb; + --bg2: #ede9e0; + --bg3: #e4dfd3; + --ink: #1a1714; + --ink2: #5a5650; + --ink3: #8a857e; + --accent: #c0392b; + --accent2: #2c6e49; + --blue: #1a4a8a; + --amber: #b5620a; + --purple: #4a3a8a; + --border: rgba(26,23,20,0.12); + --radius: 4px; +} + +html { scroll-behavior: smooth; } + +body { + font-family: 'DM Sans', sans-serif; + background: var(--bg); + color: var(--ink); + font-size: 16px; + line-height: 1.6; + min-height: 100vh; +} + +/* HEADER */ +header { + background: var(--ink); + color: var(--bg); + padding: 3.5rem 2rem 3rem; + text-align: center; + position: relative; + overflow: hidden; +} +header::before { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + -45deg, + transparent, + transparent 40px, + rgba(255,255,255,0.02) 40px, + rgba(255,255,255,0.02) 41px + ); +} +header .eyebrow { + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: rgba(245,242,235,0.5); + margin-bottom: 1rem; + font-weight: 400; +} +header h1 { + font-family: 'Fraunces', serif; + font-size: clamp(2.2rem, 6vw, 4rem); + font-weight: 600; + line-height: 1.1; + margin-bottom: 1rem; + position: relative; +} +header h1 em { + font-style: italic; + color: #e8b4a0; +} +header p.lead { + font-size: clamp(0.95rem, 2vw, 1.1rem); + color: rgba(245,242,235,0.65); + max-width: 560px; + margin: 0 auto; + font-weight: 300; + line-height: 1.7; +} + +/* MAIN LAYOUT */ +main { + max-width: 900px; + margin: 0 auto; + padding: 2.5rem 1.25rem 4rem; +} + +/* CONTROLS CARD */ +.controls-card { + background: var(--ink); + color: var(--bg); + border-radius: 8px; + padding: 2rem; + margin-bottom: 2.5rem; +} +.controls-card h2 { + font-family: 'Fraunces', serif; + font-size: 1rem; + font-weight: 300; + font-style: italic; + color: rgba(245,242,235,0.5); + margin-bottom: 1.5rem; + letter-spacing: 0.03em; +} +.controls-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem 2rem; +} +@media (max-width: 560px) { + .controls-grid { grid-template-columns: 1fr; gap: 1rem; } +} + +.control-group label { + display: block; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(245,242,235,0.45); + margin-bottom: 0.5rem; + font-weight: 400; +} +.control-group .val-row { + display: flex; + align-items: center; + gap: 10px; +} +.control-group input[type=range] { + flex: 1; + -webkit-appearance: none; + height: 3px; + background: rgba(245,242,235,0.15); + border-radius: 2px; + outline: none; + cursor: pointer; +} +.control-group input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; height: 16px; + border-radius: 50%; + background: var(--bg); + cursor: pointer; + transition: transform 0.1s; +} +.control-group input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.2); } +.control-group input[type=range]::-moz-range-thumb { + width: 16px; height: 16px; + border-radius: 50%; + background: var(--bg); + cursor: pointer; + border: none; +} +.control-group .val-display { + font-size: 1rem; + font-weight: 500; + color: var(--bg); + min-width: 80px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* STAT STRIP */ +.stat-strip { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-bottom: 2.5rem; +} +@media (max-width: 600px) { + .stat-strip { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 360px) { + .stat-strip { grid-template-columns: 1fr; } +} +.stat { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 1rem 1.1rem; +} +.stat-label { + font-size: 11px; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--ink3); + margin-bottom: 0.35rem; +} +.stat-value { + font-size: 1.4rem; + font-weight: 500; + color: var(--ink); + font-variant-numeric: tabular-nums; + line-height: 1.2; +} +.stat-sub { + font-size: 12px; + color: var(--ink3); + margin-top: 0.2rem; +} + +/* CHART SECTIONS */ +.chart-section { + background: #fff; + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1.25rem; +} +.chart-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 1rem; + gap: 1rem; + flex-wrap: wrap; +} +.chart-title { + font-family: 'Fraunces', serif; + font-size: 1.1rem; + font-weight: 600; + color: var(--ink); +} +.chart-desc { + font-size: 13px; + color: var(--ink3); + margin-top: 2px; +} +.legend { + display: flex; + gap: 14px; + font-size: 12px; + color: var(--ink2); + flex-wrap: wrap; + align-items: center; +} +.legend span { display: flex; align-items: center; gap: 5px; } +.swatch { + width: 10px; height: 10px; + border-radius: 2px; + flex-shrink: 0; + display: inline-block; +} +.swatch.dashed { + background: linear-gradient(90deg, var(--sw-color) 50%, transparent 50%); + background-size: 6px 2px; + background-repeat: repeat-x; + background-position: center; + background-color: transparent; + height: 2px; + width: 18px; + border-radius: 0; +} +.chart-wrap { + position: relative; + width: 100%; +} + +/* INSIGHT BOX */ +.insight { + background: var(--bg2); + border-left: 3px solid var(--accent); + border-radius: 0 6px 6px 0; + padding: 1rem 1.25rem; + margin-bottom: 1.25rem; + font-size: 0.95rem; + color: var(--ink2); + line-height: 1.65; +} +.insight strong { color: var(--ink); font-weight: 500; } + +/* EXPLAINER */ +.explainer { + background: var(--bg3); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.75rem 2rem; + margin-top: 2rem; +} +.explainer h3 { + font-family: 'Fraunces', serif; + font-size: 1.2rem; + font-weight: 600; + margin-bottom: 1rem; +} +.explainer p { + font-size: 0.95rem; + color: var(--ink2); + margin-bottom: 0.75rem; + line-height: 1.7; +} +.explainer p:last-child { margin-bottom: 0; } +.explainer strong { color: var(--ink); font-weight: 500; } + +/* FORMULA */ +.formula-box { + background: var(--ink); + color: var(--bg); + border-radius: 6px; + padding: 1rem 1.25rem; + font-family: 'DM Sans', monospace; + font-size: 0.9rem; + margin: 1rem 0; + letter-spacing: 0.02em; + line-height: 1.8; +} +.formula-box span { color: #e8b4a0; } + +/* STAMP */ +.stamp { + display: inline-block; + position: relative; + margin-top: 2rem; + padding: 0.55rem 1.1rem; + border: 3px solid #e8b4a0; + border-radius: 4px; + font-family: 'Fraunces', serif; + font-size: clamp(0.75rem, 2.5vw, 0.95rem); + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #e8b4a0; + opacity: 0.72; + transform: rotate(-4deg); + box-shadow: inset 0 0 0 2px rgba(232,180,160,0.18); + user-select: none; +} +.stamp::before { + content: ''; + position: absolute; + inset: 3px; + border: 1px solid rgba(232,180,160,0.3); + border-radius: 2px; + pointer-events: none; +} + +/* SMALL DIFF BANNER */ +.small-diff-banner { + background: var(--accent); + color: #fff; + padding: 1rem 2rem; + display: flex; + align-items: flex-start; + gap: 0.75rem; + font-size: clamp(0.88rem, 2vw, 1rem); + line-height: 1.6; +} +.small-diff-banner strong { font-weight: 500; } +.sd-icon { font-size: 1.1rem; flex-shrink: 0; margin-top: 2px; } + +/* FOOTER */ +footer { + text-align: center; + padding: 2rem 1rem; + font-size: 12px; + color: var(--ink3); + border-top: 1px solid var(--border); + margin-top: 3rem; +} +footer a { color: var(--ink3); } + +/* RESPONSIVE CHART HEIGHTS */ +.h-240 { height: 240px; } +.h-200 { height: 200px; } +@media (max-width: 480px) { + .h-240 { height: 200px; } + .h-200 { height: 170px; } + .chart-section { padding: 1rem; } + main { padding: 1.5rem 1rem 3rem; } + .controls-card { padding: 1.25rem; } + .explainer { padding: 1.25rem; } +}