├── README.md ├── styles.css ├── index.html └── script.js /README.md: -------------------------------------------------------------------------------- 1 | # fuego -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | :root{ 2 | --bg:#ffffff; 3 | --card:#ffffff; 4 | --ink:#111111; 5 | --muted:#555555; 6 | --accent:#000000; 7 | --grid-gap:14px; 8 | --radius:0; 9 | --border:1px solid #000; 10 | } 11 | 12 | *{box-sizing:border-box} 13 | html,body{height:100%} 14 | body{ 15 | margin:0; 16 | font:14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; 17 | background:var(--bg); 18 | color:var(--ink); 19 | } 20 | 21 | .container{max-width:72ch; margin:24px auto; padding:0 16px} 22 | .subtle{color:var(--muted)} 23 | .small{font-size:12px} 24 | h1{font-size:22px; margin:8px 0; border-bottom:2px solid #000; padding-bottom:6px} 25 | h2{font-size:14px; margin:0 0 8px 0; text-transform:uppercase; letter-spacing:.5px} 26 | 27 | .card{ 28 | background:var(--card); 29 | border:var(--border); 30 | border-radius:var(--radius); 31 | padding:16px; 32 | } 33 | 34 | .grid{display:grid; gap:var(--grid-gap)} 35 | .grid.two{grid-template-columns:repeat(2,minmax(0,1fr))} 36 | @media (max-width:900px){.grid.two{grid-template-columns:1fr}} 37 | 38 | .field{display:flex; flex-direction:column; gap:6px} 39 | label{font-weight:700} 40 | input{ 41 | background:#fff; 42 | color:var(--ink); 43 | border:var(--border); 44 | border-radius:0; 45 | padding:8px 10px; 46 | outline:none; 47 | } 48 | input:focus{outline:2px solid #000; outline-offset:1px} 49 | 50 | .divider{height:0; border-top:1px dotted #000; margin:4px 0 8px 0; grid-column:1/-1} 51 | 52 | .actions{display:flex; gap:10px; align-items:center; margin-top:4px} 53 | button{ 54 | appearance:none; 55 | border-radius:0; 56 | border:var(--border); 57 | background:#fff; 58 | color:#000; 59 | padding:8px 12px; 60 | cursor:pointer; 61 | font-weight:700; 62 | } 63 | button:hover{background:#000; color:#fff} 64 | .ghost{background:transparent} 65 | 66 | .chart{position:relative; height:320px} 67 | .tooltip{ 68 | position:absolute; 69 | pointer-events:none; 70 | background:#fff; 71 | color:#000; 72 | border:1px solid #000; 73 | border-radius:0; 74 | padding:4px 6px; 75 | font-size:12px; 76 | transform:translate(-50%,-110%); 77 | white-space:nowrap; 78 | } 79 | .axis text{fill:#000; font-size:11px} 80 | .axis path,.axis line{stroke:#000} 81 | .line{fill:none; stroke:#000; stroke-width:1.25} 82 | .area{fill:none} 83 | .point{fill:#000; stroke:#000; stroke-width:1} 84 | 85 | #summary p{margin:.25rem 0} 86 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | FIRE Simulator 7 | 8 | 9 | 10 |
11 |

FIRE Simulator

12 |

Minimal, interactive savings and withdrawal modeling

13 |
14 | 15 |
16 |
17 |
18 |
19 | 20 | 21 |
22 |
23 | 24 | 25 |
26 |
27 | 28 | 29 |
30 |
31 | 32 | 33 |
34 | 35 |
36 | 37 | 38 |
39 |
40 | 41 | 42 |
43 |
44 | 45 | 46 |
47 |
48 | 49 | 50 |
51 |
52 |
53 | 54 |
55 |
56 |

Savings Growth

57 | 58 |
59 |
60 |

Withdrawal Decay

61 | 62 |
63 |
64 | 65 | 78 |
79 | 80 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | // FIRE Simulator - minimal, dependency-free 2 | 3 | function $(sel) { return document.querySelector(sel); } 4 | function fmtMoney(v) { 5 | return new Intl.NumberFormat(undefined,{style:'currency',currency:'USD',maximumFractionDigits:0}).format(v); 6 | } 7 | 8 | // Simulation 9 | function simulateSavings(startBalance, monthlyContribution, annualReturn, years) { 10 | const months = Math.max(0, Math.floor(years*12)); 11 | const r = annualReturn/100/12; 12 | let bal = startBalance; 13 | const yearly = [{ year: 0, balance: bal, contributed: 0 }]; 14 | let contributed = 0; 15 | for (let m=1;m<=months;m++) { 16 | bal = bal * (1 + r) + monthlyContribution; 17 | contributed += monthlyContribution; 18 | if (m % 12 === 0) { 19 | const y = m/12; 20 | yearly.push({ year: y, balance: bal, contributed }); 21 | } 22 | } 23 | return yearly; 24 | } 25 | 26 | function simulateWithdrawal(startBalance, annualWithdrawal, annualReturn, years) { 27 | const months = Math.max(0, Math.floor(years*12)); 28 | const r = annualReturn/100/12; 29 | const w = annualWithdrawal/12; 30 | let bal = startBalance; 31 | const yearly = [{ year: 0, balance: bal }]; 32 | for (let m=1;m<=months;m++) { 33 | bal = bal * (1 + r) - w; 34 | if (bal <= 0) { 35 | bal = 0; 36 | if (m % 12 === 0) yearly.push({ year: m/12, balance: bal }); 37 | break; 38 | } 39 | if (m % 12 === 0) yearly.push({ year: m/12, balance: bal }); 40 | } 41 | return yearly; 42 | } 43 | 44 | // Chart rendering (SVG) 45 | function renderLineChart(el, series, opts={}) { 46 | const width = opts.width || el.clientWidth || 500; 47 | const height = opts.height || el.clientHeight || 320; 48 | const margin = {top:18,right:18,bottom:28,left:48, ...(opts.margin||{})}; 49 | const w = Math.max(100, width - margin.left - margin.right); 50 | const h = Math.max(60, height - margin.top - margin.bottom); 51 | 52 | el.innerHTML = ''; 53 | const svg = document.createElementNS('http://www.w3.org/2000/svg','svg'); 54 | svg.setAttribute('width', width); 55 | svg.setAttribute('height', height); 56 | el.appendChild(svg); 57 | 58 | const g = document.createElementNS('http://www.w3.org/2000/svg','g'); 59 | g.setAttribute('transform', `translate(${margin.left},${margin.top})`); 60 | svg.appendChild(g); 61 | 62 | // scales 63 | const xs = series.map(d=>d.year); 64 | const ys = series.map(d=>d.balance); 65 | const xMin = Math.min(...xs), xMax = Math.max(...xs); 66 | const yMin = 0; // always baseline at zero 67 | const yMax = Math.max(1, Math.max(...ys) * 1.08); 68 | 69 | const xScale = x => w * (x - xMin) / (xMax - xMin || 1); 70 | const yScale = y => h - h * (y - yMin) / (yMax - yMin || 1); 71 | 72 | // axes 73 | const axisG = document.createElementNS('http://www.w3.org/2000/svg','g'); 74 | axisG.setAttribute('class','axis'); 75 | g.appendChild(axisG); 76 | 77 | // x axis ticks (years) 78 | const tickCount = Math.min(10, xs.length); 79 | for (let i=0;i { 120 | const X = xScale(d.year), Y = yScale(d.balance); 121 | areaD += (i ? ' L ' : 'M ') + X + ' ' + Y; 122 | }); 123 | areaD += ` L ${xScale(xMax)} ${yScale(0)} L ${xScale(xMin)} ${yScale(0)} Z`; 124 | areaPath.setAttribute('d', areaD); 125 | g.appendChild(areaPath); 126 | 127 | // line path 128 | const path = document.createElementNS('http://www.w3.org/2000/svg','path'); 129 | path.setAttribute('class','line'); 130 | let d = ''; 131 | series.forEach((p,i)=>{ 132 | const X = xScale(p.year), Y = yScale(p.balance); 133 | d += (i ? ' L ' : 'M ') + X + ' ' + Y; 134 | }); 135 | path.setAttribute('d', d); 136 | g.appendChild(path); 137 | 138 | // points 139 | const pointsG = document.createElementNS('http://www.w3.org/2000/svg','g'); 140 | g.appendChild(pointsG); 141 | series.forEach((p)=>{ 142 | const c = document.createElementNS('http://www.w3.org/2000/svg','circle'); 143 | c.setAttribute('class','point'); 144 | c.setAttribute('cx', xScale(p.year)); 145 | c.setAttribute('cy', yScale(p.balance)); 146 | c.setAttribute('r', 3); 147 | pointsG.appendChild(c); 148 | }); 149 | 150 | // tooltip interactivity 151 | const tooltip = document.createElement('div'); 152 | tooltip.className = 'tooltip'; 153 | tooltip.style.display = 'none'; 154 | el.appendChild(tooltip); 155 | 156 | const overlay = document.createElementNS('http://www.w3.org/2000/svg','rect'); 157 | overlay.setAttribute('x',0); overlay.setAttribute('y',0); 158 | overlay.setAttribute('width', w); overlay.setAttribute('height', h); 159 | overlay.setAttribute('fill','transparent'); 160 | g.appendChild(overlay); 161 | 162 | const bisect = (mx) => { 163 | const targetX = mx; 164 | let idx = 0; let best = Infinity; 165 | series.forEach((p,i)=>{ 166 | const dx = Math.abs(xScale(p.year) - targetX); 167 | if (dx < best) { best = dx; idx = i; } 168 | }); 169 | return idx; 170 | }; 171 | 172 | overlay.addEventListener('mousemove', (e) => { 173 | const pt = svg.createSVGPoint(); 174 | pt.x = e.clientX; pt.y = e.clientY; 175 | const ctm = svg.getScreenCTM(); 176 | if (!ctm) return; 177 | const inv = ctm.inverse(); 178 | const loc = pt.matrixTransform(inv); 179 | const mx = Math.max(0, Math.min(w, loc.x - margin.left)); 180 | const idx = bisect(mx); 181 | const p = series[idx]; 182 | const X = margin.left + xScale(p.year); 183 | const Y = margin.top + yScale(p.balance); 184 | 185 | tooltip.style.display = 'block'; 186 | tooltip.style.left = X + 'px'; 187 | tooltip.style.top = Y + 'px'; 188 | tooltip.innerHTML = `Year ${p.year}
${fmtMoney(p.balance)}`; 189 | }); 190 | overlay.addEventListener('mouseleave', ()=>{ 191 | tooltip.style.display = 'none'; 192 | }); 193 | } 194 | 195 | function readInputs(){ 196 | const val = id => parseFloat($(id).value || '0'); 197 | const startBalance = val('#startBalance'); 198 | const monthlyContribution = val('#monthlyContribution'); 199 | const annualReturnSave = val('#annualReturnSave'); 200 | const yearsSaving = Math.max(0, Math.floor(val('#yearsSaving'))); 201 | const annualWithdrawal = val('#annualWithdrawal'); 202 | const annualReturnRetire = val('#annualReturnRetire'); 203 | const yearsWithdrawing = Math.max(0, Math.floor(val('#yearsWithdrawing'))); 204 | return {startBalance, monthlyContribution, annualReturnSave, yearsSaving, annualWithdrawal, annualReturnRetire, yearsWithdrawing}; 205 | } 206 | 207 | function update(){ 208 | const i = readInputs(); 209 | const saved = simulateSavings(i.startBalance, i.monthlyContribution, i.annualReturnSave, i.yearsSaving); 210 | const retireStart = saved[saved.length-1].balance; 211 | const withdrawn = simulateWithdrawal(retireStart, i.annualWithdrawal, i.annualReturnRetire, i.yearsWithdrawing); 212 | 213 | renderLineChart($('#chartSavings'), saved, {}); 214 | renderLineChart($('#chartWithdrawal'), withdrawn, {}); 215 | 216 | // summary 217 | $('#summary').hidden = false; 218 | $('#sumRetireBal').textContent = fmtMoney(retireStart); 219 | $('#sumContrib').textContent = fmtMoney(saved[saved.length-1].contributed ?? 0); 220 | const yearsLast = withdrawn[withdrawn.length-1].year; 221 | $('#sumYearsLast').textContent = yearsLast.toFixed(0) + ' yrs'; 222 | $('#sumFinalBal').textContent = fmtMoney(withdrawn[withdrawn.length-1].balance); 223 | } 224 | 225 | // Wire up 226 | window.addEventListener('DOMContentLoaded', () => { 227 | $('#controls').addEventListener('submit', (e)=>{e.preventDefault(); update();}); 228 | $('#resetBtn').addEventListener('click', ()=>{ 229 | // reset to defaults 230 | $('#startBalance').value = 50000; 231 | $('#monthlyContribution').value = 1500; 232 | $('#annualReturnSave').value = 6.0; 233 | $('#yearsSaving').value = 15; 234 | $('#annualWithdrawal').value = 40000; 235 | $('#annualReturnRetire').value = 5.0; 236 | $('#yearsWithdrawing').value = 35; 237 | update(); 238 | }); 239 | // auto-run once 240 | update(); 241 | // live update on input changes (debounced) 242 | let t; document.querySelectorAll('#controls input').forEach(inp=>{ 243 | inp.addEventListener('input', ()=>{ clearTimeout(t); t=setTimeout(update, 250); }); 244 | }); 245 | }); 246 | 247 | --------------------------------------------------------------------------------