195 lines
6.6 KiB
JavaScript
195 lines
6.6 KiB
JavaScript
|
|
// 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();
|