Legg til tre nye kalkulatorer: sparing, bolig og arv
Nye interaktive kalkulatorer som utforsker hvordan tilsynelatende små forskjeller vokser eksponentielt over tid: - sparing: rentes rente med ulik avkastning (månedlig compounding) - bolig: belåningseffekt forsterker egenkapitalgap ~6.7× - arv: arvet formue vokser uavhengig av innsats, gapet lukkes aldri Landingsside oppdatert med nye kort. Dokumentasjon oppdatert. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
768acc6577
commit
3ffb404250
13 changed files with 2194 additions and 37 deletions
195
public/sparing/app.js
Normal file
195
public/sparing/app.js
Normal file
|
|
@ -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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue