├── 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 |
14 |
15 |
16 |
53 |
54 |
55 |
56 |
Savings Growth
57 |
58 |
59 |
60 |
Withdrawal Decay
61 |
62 |
63 |
64 |
65 |
66 | Summary
67 |
68 |
69 |
At Retirement: —
70 |
Total Contributed: —
71 |
72 |
73 |
Years Funds Last: —
74 |
Final Balance: —
75 |
76 |
77 |
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 |
--------------------------------------------------------------------------------