<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Investment Projection Calculator</title>
<style>
:root {
--primary: #3c556e;
--primary-light: #5a7a96;
--primary-dark: #2a3b4d;
--accent: #4a6a85;
--accent-light: #6b8aa5;
--bg: #f4f6f8;
--card: #ffffff;
--text: #1e2a36;
--muted: #6b7c8d;
--border: #d8e0e8;
--slider-track: #d8e0e8;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
padding: 24px;
min-height: 100vh;
}
.container {
max-width: 1100px;
margin: 0 auto;
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 32px;
align-items: start;
}
@media (max-width: 900px) {
.container {
grid-template-columns: 1fr;
}
}
/* Chart Card */
.chart-card {
background: var(--card);
border-radius: 16px;
padding: 28px 24px 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 4px 12px rgba(0,0,0,0.04);
position: relative;
}
.chart-wrapper {
position: relative;
height: 320px;
width: 100%;
}
#projectionChart {
width: 100% !important;
height: 100% !important;
}
.chart-labels {
position: absolute;
right: 12px;
top: 20px;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
font-weight: 500;
pointer-events: none;
z-index: 10;
}
.label-higher {
color: #5a7a96;
}
.label-target {
color: #3c556e;
}
.label-lower {
color: #2a3b4d;
}
.chart-footer {
text-align: center;
margin-top: 12px;
font-size: 12px;
color: var(--muted);
}
.chart-footer .axis-label {
font-weight: 500;
color: var(--text);
margin-bottom: 4px;
}
/* Controls */
.controls {
display: flex;
flex-direction: column;
gap: 16px;
}
.control-card {
background: var(--card);
border-radius: 16px;
padding: 18px 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 4px 12px rgba(0,0,0,0.04);
}
.control-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.control-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.unit-badge {
background: #f1f5f9;
color: var(--muted);
font-size: 11px;
font-weight: 500;
padding: 2px 9px;
border-radius: 999px;
}
.value-display {
font-size: 20px;
font-weight: 600;
color: var(--text);
margin-bottom: 12px;
letter-spacing: -0.02em;
}
.slider-container {
position: relative;
padding: 0 2px;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--slider-track);
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--primary);
border: 3px solid white;
box-shadow: 0 1px 4px rgba(60,85,110,0.4);
cursor: pointer;
transition: transform 0.15s ease;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.12);
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--primary);
border: 3px solid white;
box-shadow: 0 1px 4px rgba(60,85,110,0.4);
cursor: pointer;
}
.slider-labels {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: 11px;
color: var(--muted);
}
.hint {
font-size: 12px;
color: var(--muted);
margin-top: 8px;
line-height: 1.4;
}
.summary-row {
display: flex;
justify-content: space-between;
font-size: 13px;
color: var(--muted);
margin-top: 6px;
}
.summary-row strong {
color: var(--text);
}
.summary-row.target-row {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--border);
align-items: baseline;
}
.summary-row.target-row span {
font-size: 14px;
font-weight: 500;
color: var(--text);
}
.summary-row.target-row strong {
font-size: 22px;
font-weight: 700;
color: #3c556e;
letter-spacing: -0.02em;
}
/* Dropdown styles */
select.plan-select {
width: 100%;
padding: 10px 12px;
font-size: 14px;
font-weight: 500;
color: var(--text);
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M2.5 4.5L6 8l3.5-3.5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
transition: border-color 0.15s ease;
}
select.plan-select:hover,
select.plan-select:focus {
border-color: var(--primary);
outline: none;
}
.plans-btn {
display: block;
width: 100%;
padding: 12px 16px;
font-size: 14px;
font-weight: 600;
color: white;
background: var(--primary);
border: none;
border-radius: 10px;
cursor: pointer;
text-align: center;
text-decoration: none;
transition: background 0.15s ease, transform 0.1s ease;
}
.plans-btn:hover {
background: var(--primary-dark);
}
.plans-btn:active {
transform: scale(0.98);
}
@media (max-width: 600px) {
body { padding: 16px; }
.chart-wrapper { height: 260px; }
.value-display { font-size: 18px; }
}
</style>
</head>
<body>
<div class="container">
<!-- Chart -->
<div class="chart-card">
<div class="chart-wrapper">
<canvas id="projectionChart"></canvas>
<div class="chart-labels" id="endLabels">
<div class="label-higher">Higher Range<br><span id="higherVal">—</span></div>
<div class="label-target">Target<br><span id="targetVal">—</span></div>
<div class="label-lower">Lower Range<br><span id="lowerVal">—</span></div>
</div>
</div>
<div class="chart-footer">
<div class="axis-label">Time</div>
<div>Expected scenarios are based on historical standard deviation. Actual results may vary.</div>
</div>
</div>
<!-- Controls -->
<div class="controls">
<div class="control-card">
<div class="control-header">
<span class="control-title">Investment Plan</span>
</div>
<select id="planSelect" class="plan-select">
<option value="Retire Safely">Retire Safely</option>
<option value="Buy a big ticket item (new house, car, education)">Buy a big ticket item (new house, car, education)</option>
<option value="Treat myself (Holiday, new clothes etc)">Treat myself (Holiday, new clothes etc)</option>
</select>
</div>
<div class="control-card">
<div class="control-header">
<span class="control-title">Initial Investment</span>
<span class="unit-badge">ZAR</span>
</div>
<div class="value-display" id="initialDisplay">329,000</div>
<div class="slider-container">
<input type="range" id="initialSlider" min="0" max="1000000" step="1000" value="329000" />
<div class="slider-labels">
<span>0</span>
<span>1,000,000</span>
</div>
</div>
</div>
<div class="control-card">
<div class="control-header">
<span class="control-title">Monthly Contribution</span>
<span class="unit-badge">ZAR</span>
</div>
<div class="value-display" id="contribDisplay">0</div>
<div class="slider-container">
<input type="range" id="contribSlider" min="0" max="30000" step="50" value="0" />
<div class="slider-labels">
<span>0</span>
<span>30,000</span>
</div>
</div>
</div>
<div class="control-card">
<div class="control-header">
<span class="control-title">Duration</span>
<span class="unit-badge">years</span>
</div>
<div class="value-display" id="durationDisplay">3</div>
<div class="slider-container">
<input type="range" id="durationSlider" min="1" max="30" step="1" value="3" />
<div class="slider-labels">
<span>1</span>
<span>30</span>
</div>
</div>
</div>
<div class="control-card">
<div class="control-header">
<span class="control-title">Expected Annual Return</span>
<span class="unit-badge">%</span>
</div>
<div class="value-display" id="returnDisplay">15.0</div>
<div class="slider-container">
<input type="range" id="returnSlider" min="0" max="30" step="0.5" value="15" />
<div class="slider-labels">
<span>0%</span>
<span>30%</span>
</div>
</div>
<p class="hint">Target value is calculated from initial + contributions + this return. Higher/Lower ranges use historical volatility (~±15–20% annualised).</p>
</div>
<div class="control-card" style="background:#f8fafc; border:1px solid #e2e8f0;">
<div class="summary-row">
<span>Total contributions</span>
<strong id="totalContrib">R0</strong>
</div>
<div class="summary-row target-row">
<span>Projected target value</span>
<strong id="targetSummary">—</strong>
</div>
</div>
<div class="control-card">
<a href="https://www.digiwealth.co.za/plans#compare" class="plans-btn" target="_blank" rel="noopener noreferrer">
Our Investment plans
</a>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
function fmt(n) {
return Math.round(n).toLocaleString('en-US');
}
/**
* Future value of initial lump sum + monthly contributions.
* Monthly rate = annualRate / 12
* FV = P0*(1+r)^n + PMT * [((1+r)^n - 1) / r]
* where n = years * 12, r = monthly rate
*/
function futureValue(initial, monthlyPmt, years, annualRate) {
const months = Math.round(years * 12);
const r = annualRate / 12;
if (r === 0) {
return initial + monthlyPmt * months;
}
const growth = Math.pow(1 + r, months);
const fvLump = initial * growth;
const fvAnnuity = monthlyPmt * (growth - 1) / r;
return fvLump + fvAnnuity;
}
/**
* Build yearly projection points including monthly contributions.
* Volatility model calibrated so that at ~3 years the relative spread
* roughly matches the original screenshot (higher ≈ 1.62×, lower ≈ 0.56×).
*/
function project(initial, monthlyPmt, years, annualRate) {
const t = Math.max(1, years);
const points = [];
// Base volatility calibrated to original screenshot
const baseLogSpread = Math.log(1.617); // ≈ 0.480 at t=3
const sigmaPerSqrtYear = baseLogSpread / Math.sqrt(3);
for (let y = 0; y <= t; y++) {
const expected = futureValue(initial, monthlyPmt, y, annualRate);
const spreadFactor = y === 0 ? 0 : sigmaPerSqrtYear * Math.sqrt(y);
const higher = expected * Math.exp(spreadFactor);
const lower = expected * Math.exp(-spreadFactor * 1.15);
points.push({
year: y,
expected: expected,
higher: higher,
lower: Math.max(0, lower)
});
}
return points;
}
const ctx = document.getElementById('projectionChart').getContext('2d');
let chart = null;
function buildChart(points) {
const labels = points.map(p => p.year === 0 ? '' : p.year);
const expected = points.map(p => p.expected);
const higher = points.map(p => p.higher);
const lower = points.map(p => p.lower);
if (chart) chart.destroy();
chart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Higher',
data: higher,
borderColor: 'transparent',
backgroundColor: 'rgba(90, 122, 150, 0.35)',
fill: '+1',
pointRadius: 0,
tension: 0.35,
borderWidth: 0
},
{
label: 'Expected',
data: expected,
borderColor: '#3c556e',
backgroundColor: 'rgba(60, 85, 110, 0.45)',
fill: '+1',
pointRadius: points.map((_, i) => (i === 0 || i === points.length - 1) ? 5 : 3),
pointBackgroundColor: '#3c556e',
pointBorderColor: '#fff',
pointBorderWidth: 2,
tension: 0.35,
borderWidth: 2.5
},
{
label: 'Lower',
data: lower,
borderColor: 'transparent',
backgroundColor: 'rgba(42, 59, 77, 0.30)',
fill: false,
pointRadius: points.map((_, i) => (i === 0 || i === points.length - 1) ? 5 : 3),
pointBackgroundColor: '#2a3b4d',
pointBorderColor: '#fff',
pointBorderWidth: 2,
tension: 0.35,
borderWidth: 0
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
title: (items) => items[0].label ? `Year ${items[0].label}` : 'Start',
label: (ctx) => ctx.dataset.label + ': R' + fmt(ctx.raw)
}
}
},
scales: {
x: {
display: true,
grid: { display: false },
ticks: { display: false }
},
y: {
display: false,
beginAtZero: false
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
}
});
}
function update() {
const initial = +document.getElementById('initialSlider').value;
const monthly = +document.getElementById('contribSlider').value;
const years = +document.getElementById('durationSlider').value;
const annualRate = +document.getElementById('returnSlider').value / 100;
document.getElementById('initialDisplay').textContent = fmt(initial);
document.getElementById('contribDisplay').textContent = fmt(monthly);
document.getElementById('durationDisplay').textContent = years;
document.getElementById('returnDisplay').textContent = (annualRate * 100).toFixed(1);
const totalContrib = monthly * years * 12;
document.getElementById('totalContrib').textContent = 'R' + fmt(totalContrib);
const points = project(initial, monthly, years, annualRate);
const last = points[points.length - 1];
document.getElementById('higherVal').textContent = 'R' + fmt(last.higher);
document.getElementById('targetVal').textContent = 'R' + fmt(last.expected);
document.getElementById('lowerVal').textContent = 'R' + fmt(last.lower);
document.getElementById('targetSummary').textContent = 'R' + fmt(last.expected);
buildChart(points);
}
['initialSlider', 'contribSlider', 'durationSlider', 'returnSlider'].forEach(id => {
document.getElementById(id).addEventListener('input', update);
});
// Initial render
update();
</script>
</body>
</html>