forskjeller.naiv.no/public/sparing/app.js
Ole-Morten Duesund 2615e96042 Knytt alle bannerverdier til sliderne i lonn, sparing og bolig
Hardkodede tall i bannerene (lønnsforskjell, sparebeløp,
prosentpoeng, prisforskjell, vekst, antall år) oppdateres nå
dynamisk når brukerne endrer sliderne.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:18:28 +01:00

198 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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-monthly').textContent = Math.round(P).toLocaleString('nb-NO') + '\u202fkr/mnd';
document.getElementById('banner-aar').textContent = Y;
document.getElementById('banner-pp').textContent = Math.abs(rB * 100 - rA * 100).toLocaleString('nb-NO', { maximumFractionDigits: 1 });
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();