0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Most retirement calculators draw one smooth line, as if the market returned exactly 7% every year. This one rolls 5,000 possible futures for a mixed stock/bond portfolio in numpy — geometric Brownian motion, one-year steps, correlated assets, rebalanced annually — and sets the whole fan of outcomes beside the number that actually matters: the share of those futures that funds every year of a 30-year retirement. It opens on a plan clearing around 85%, so drag annual spending from $40k to $50k and watch that fall about ten points — a 25% raise in spending takes the futures that go broke from roughly 700 to roughly 1,200. Then hit reset and push stocks vs bonds to 100% instead: the median ending balance leaps from about $2.3M to nearly $4M while the success rate barely twitches, because ruin is decided by the order the returns arrive in, not by their average.
Source — the code you see is the code that just ran 1138 lines 48.4 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<link rel="icon" href="data:,">
<title>Monte Carlo Retirement Simulator</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ---------- base ---------- */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
line-height: 1.5;
overflow-x: hidden;
-webkit-text-size-adjust: 100%;
font-size: 15px;
}
.wrap { max-width: 1180px; margin: 0 auto; padding: 18px 14px 32px; }
h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.sr {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
/* ---------- header ---------- */
.head {
display: flex; flex-wrap: wrap; gap: 12px 16px;
align-items: flex-start; justify-content: space-between;
padding-bottom: 14px; margin-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.head h1 { font-size: 1.32rem; }
.lede { margin: 5px 0 0; color: var(--muted); max-width: 62ch; font-size: 0.95rem; }
.status {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--font-mono); font-size: 0.74rem; letter-spacing: 0.02em;
padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px;
background: var(--surface); color: var(--muted); white-space: nowrap;
flex-shrink: 0;
}
.status .dot {
width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none;
}
.status[data-state="load"] .dot { background: var(--accent); animation: pulse 1.1s ease-in-out infinite; }
.status[data-state="ok"] .dot { background: var(--run); }
.status[data-state="ok"] { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.status[data-state="warn"] .dot { background: var(--accent); }
.status[data-state="warn"] { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
/* ---------- layout ---------- */
.grid { display: grid; gap: 14px; grid-template-columns: 320px minmax(0, 1fr); align-items: start; }
.col { display: grid; gap: 14px; min-width: 0; }
@media (max-width: 900px) {
.grid { grid-template-columns: minmax(0, 1fr); }
.controls { order: 2; }
.col { order: 1; }
}
.panel {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 14px;
min-width: 0;
}
.panel > h2, .chart-head h2 {
font-family: var(--font-mono); font-size: 0.7rem; text-transform: uppercase;
letter-spacing: 0.09em; color: var(--muted); font-weight: 600; margin-bottom: 12px;
}
/* ---------- controls ---------- */
.presets { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
button {
font: inherit; color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 7px;
padding: 7px 11px; min-height: 34px; cursor: pointer;
font-family: var(--font-mono); font-size: 0.75rem;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
button:hover { border-color: var(--accent); color: var(--accent); }
button[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.ctl { margin-bottom: 15px; }
.ctl:last-child { margin-bottom: 0; }
.ctl-top { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 4px; }
.ctl-top label { font-size: 0.88rem; }
.ctl-val {
font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: 0.88rem; font-weight: 600; color: var(--accent); white-space: nowrap;
}
.ctl-hint { font-family: var(--font-mono); font-size: 0.68rem; color: var(--muted); margin-top: 2px; }
input[type=range] {
-webkit-appearance: none; appearance: none;
width: 100%; height: 24px; background: transparent; margin: 0; display: block; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 4px; background: var(--line); border-radius: 2px; }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 16px; height: 16px; margin-top: -6px; border-radius: 50%;
background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]::-moz-range-track { height: 4px; background: var(--line); border-radius: 2px; }
input[type=range]::-moz-range-thumb {
width: 16px; height: 16px; border-radius: 50%;
background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]:focus-visible { outline: none; }
input[type=range]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--accent); }
input[type=range]:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--accent); }
.mix { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 0.68rem; color: var(--muted); }
/* ---------- dial ---------- */
.verdict { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
.dial { position: relative; width: 148px; height: 148px; flex: none; }
.dial svg { width: 100%; height: 100%; display: block; transform: rotate(-90deg); }
.dial .arc-bg { stroke: var(--line); }
.dial .arc-fg { stroke: var(--accent); transition: stroke-dashoffset 0.45s cubic-bezier(.4,0,.2,1), stroke 0.25s ease; }
.dial[data-tone="good"] .arc-fg { stroke: var(--run); }
.dial[data-tone="bad"] .arc-fg { stroke: var(--danger); }
.dial-txt {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 1px; pointer-events: none;
}
.dial-num {
font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: 2.15rem; font-weight: 600; line-height: 1; letter-spacing: -0.03em;
}
.dial[data-tone="good"] .dial-num { color: var(--run); }
.dial[data-tone="bad"] .dial-num { color: var(--danger); }
.dial[data-tone="mid"] .dial-num { color: var(--accent); }
.dial-sub { font-family: var(--font-mono); font-size: 0.62rem; color: var(--muted); letter-spacing: 0.06em; }
.verdict-txt { min-width: 0; flex: 1 1 210px; }
.verdict-txt h3 { font-size: 1rem; margin-bottom: 4px; }
.verdict-txt p { margin: 0; color: var(--muted); font-size: 0.87rem; }
.verdict-txt .err { font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted); margin-top: 7px; display: block; }
/* ---------- stats ---------- */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.stat { background: var(--surface); padding: 11px 12px; min-width: 0; }
.stat dt { font-family: var(--font-mono); font-size: 0.63rem; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); margin-bottom: 3px; }
.stat dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 1.18rem; font-weight: 600; letter-spacing: -0.02em; }
.stat dd small { font-size: 0.62rem; font-weight: 400; color: var(--muted); display: block; letter-spacing: 0.03em; margin-top: 2px; }
/* ---------- chart ---------- */
.chart-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; }
.seg button { border: 0; border-radius: 0; padding: 5px 10px; min-height: 30px; font-size: 0.7rem; }
.seg button + button { border-left: 1px solid var(--line); }
.seg button:focus-visible { outline-offset: -2px; }
.canvas-box { position: relative; width: 100%; }
canvas { display: block; width: 100%; height: 260px; border-radius: 6px; touch-action: pan-y; }
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 9px 0 0; font-family: var(--font-mono); font-size: 0.68rem; color: var(--muted); }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.swatch { width: 12px; height: 9px; border-radius: 2px; display: inline-block; }
.readout {
margin-top: 9px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 7px;
background: var(--bg); font-family: var(--font-mono); font-size: 0.72rem;
font-variant-numeric: tabular-nums; color: var(--ink);
display: flex; flex-wrap: wrap; gap: 3px 14px; min-height: 34px; align-items: center;
}
.readout b { font-weight: 600; color: var(--accent); }
.readout .k { color: var(--muted); }
.note { margin: 10px 0 0; font-family: var(--font-mono); font-size: 0.66rem; color: var(--muted); line-height: 1.6; }
.banner {
display: none; gap: 9px; align-items: flex-start;
border: 1px solid var(--accent); background: var(--accent-soft); color: var(--ink);
border-radius: var(--radius); padding: 10px 12px; margin-bottom: 14px; font-size: 0.85rem;
}
.banner.show { display: flex; }
.banner strong { font-family: var(--font-mono); font-size: 0.72rem; color: var(--accent); text-transform: uppercase; letter-spacing: 0.06em; }
.banner p { margin: 2px 0 0; color: var(--muted); }
/* ---------- footer ---------- */
.foot {
margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--line);
font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted);
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
}
@media (max-width: 420px) {
.wrap { padding: 14px 11px 26px; }
.dial { width: 124px; height: 124px; }
.dial-num { font-size: 1.8rem; }
.head h1 { font-size: 1.15rem; }
}
</style>
</head>
<body>
<div class="wrap">
<header class="head">
<div>
<h1>Monte Carlo Retirement Simulator</h1>
<p class="lede">Roll 5,000 possible market futures for your savings, then count how many of them still have money left at the end.</p>
</div>
<div class="status" id="status" data-state="load">
<span class="dot" aria-hidden="true"></span>
<span id="statusText">booting python…</span>
</div>
</header>
<div class="banner" id="banner" role="status">
<div>
<strong id="bannerTitle">note</strong>
<p id="bannerBody"></p>
</div>
</div>
<main class="grid">
<section class="panel controls" aria-labelledby="ctlH">
<h2 id="ctlH">Your plan</h2>
<div class="presets" role="group" aria-label="Example scenarios">
<button type="button" data-preset="steady" aria-pressed="true">steady saver</button>
<button type="button" data-preset="late" aria-pressed="false">late starter</button>
<button type="button" data-preset="coast" aria-pressed="false">coasting</button>
</div>
<div class="ctl">
<div class="ctl-top">
<label for="start">Savings today</label>
<span class="ctl-val" id="v-start">$120,000</span>
</div>
<input type="range" id="start" min="0" max="2000000" step="5000" value="120000">
</div>
<div class="ctl">
<div class="ctl-top">
<label for="monthly">Monthly contribution</label>
<span class="ctl-val" id="v-monthly">$1,500</span>
</div>
<input type="range" id="monthly" min="0" max="10000" step="50" value="1500">
<div class="ctl-hint" id="h-monthly">$18,000 / year</div>
</div>
<div class="ctl">
<div class="ctl-top">
<label for="years">Years until retirement</label>
<span class="ctl-val" id="v-years">25 yr</span>
</div>
<input type="range" id="years" min="1" max="45" step="1" value="25">
<div class="ctl-hint" id="h-years">then a 30-year retirement</div>
</div>
<div class="ctl">
<div class="ctl-top">
<label for="stock">Stocks vs bonds</label>
<span class="ctl-val" id="v-stock">70 / 30</span>
</div>
<input type="range" id="stock" min="0" max="100" step="5" value="70">
<div class="mix" aria-hidden="true"><span>all bonds</span><span>all stocks</span></div>
</div>
<div class="ctl">
<div class="ctl-top">
<label for="wd">Annual spending in retirement</label>
<span class="ctl-val" id="v-wd">$40,000</span>
</div>
<input type="range" id="wd" min="0" max="300000" step="1000" value="40000">
<div class="ctl-hint" id="h-wd">$3,333 / month</div>
</div>
<div class="ctl" style="display:flex;gap:6px;flex-wrap:wrap;padding-top:2px">
<button type="button" id="resample" title="Draw a fresh set of 5,000 random futures">resample</button>
<button type="button" id="reset">reset</button>
</div>
</section>
<div class="col">
<section class="panel" aria-labelledby="verdictH">
<h2 id="verdictH">Verdict</h2>
<div class="verdict">
<div class="dial" id="dial" data-tone="mid">
<svg viewBox="0 0 120 120" aria-hidden="true" focusable="false">
<circle class="arc-bg" cx="60" cy="60" r="52" fill="none" stroke-width="11"></circle>
<circle class="arc-fg" id="arc" cx="60" cy="60" r="52" fill="none" stroke-width="11"
stroke-linecap="round" stroke-dasharray="326.73" stroke-dashoffset="326.73"></circle>
</svg>
<div class="dial-txt">
<span class="dial-num" id="pct">—</span>
<span class="dial-sub">SUCCESS</span>
</div>
</div>
<div class="verdict-txt">
<h3 id="verdictTitle">Simulating…</h3>
<p id="verdictBody">Preparing 5,000 market futures.</p>
<span class="err mono" id="verdictErr"></span>
</div>
</div>
<p class="sr" id="live" role="status" aria-live="polite"></p>
</section>
<dl class="stats">
<div class="stat"><dt>Median at retirement</dt><dd id="s-ret">—</dd></div>
<div class="stat"><dt>Median at the end</dt><dd id="s-end">—</dd></div>
<div class="stat"><dt>Unlucky 5% end with</dt><dd id="s-p5">—</dd></div>
<div class="stat"><dt>When money runs out</dt><dd id="s-fail">—</dd></div>
</dl>
<section class="panel" aria-labelledby="chartH">
<div class="chart-head">
<h2 id="chartH" style="margin:0">Balance over time · 5,000 paths</h2>
<div class="seg" role="group" aria-label="Vertical scale">
<button type="button" id="sc-lin" aria-pressed="true">linear</button>
<button type="button" id="sc-log" aria-pressed="false">log</button>
</div>
</div>
<div class="canvas-box">
<canvas id="chart" tabindex="0" role="img"
aria-label="Fan chart of simulated portfolio balance. Focus it and use left and right arrow keys to read each year."></canvas>
</div>
<p class="legend" aria-hidden="true">
<span><i class="swatch" id="sw95"></i>5th–95th</span>
<span><i class="swatch" id="sw75"></i>25th–75th</span>
<span><i class="swatch" id="sw50" style="height:3px"></i>median</span>
<span><i class="swatch" id="swRet" style="height:3px"></i>retirement</span>
</p>
<div class="readout mono" id="readout"></div>
<p class="note" id="assumptions"></p>
<table class="sr" id="srTable"><caption>Simulated balance percentiles every five years</caption></table>
</section>
</div>
</main>
<footer class="foot">
<span>demo · sloprun.dev</span>
<span id="perf">—</span>
</footer>
</div>
<script type="text/x-python" id="pysrc">
import json
import numpy as np
# Long-run REAL (inflation-adjusted) assumptions, annual.
MU_S, SIG_S = 0.070, 0.180 # broad equities
MU_B, SIG_B = 0.020, 0.060 # investment-grade bonds
RHO = 0.15 # stock/bond correlation
def run_sim(cfg_json):
c = json.loads(cfg_json)
n = int(c["paths"])
acc = int(c["years"])
ret = int(c["retire_years"])
T = acc + ret
w = float(c["stock_pct"]) / 100.0
start = float(c["start"])
add = float(c["monthly"]) * 12.0
draw = float(c["withdrawal"])
rng = np.random.default_rng(int(c["seed"]))
z1 = rng.standard_normal((T, n))
z2 = RHO * z1 + np.sqrt(1.0 - RHO * RHO) * rng.standard_normal((T, n))
# Geometric Brownian motion, one-year steps -> simple annual returns.
rs = np.exp((MU_S - 0.5 * SIG_S ** 2) + SIG_S * z1) - 1.0
rb = np.exp((MU_B - 0.5 * SIG_B ** 2) + SIG_B * z2) - 1.0
rp = w * rs + (1.0 - w) * rb # annually rebalanced portfolio
paths = np.empty((T + 1, n), dtype=np.float64)
bal = np.full(n, start, dtype=np.float64)
paths[0] = bal
# A path fails in the year a withdrawal cannot be funded — the year the
# balance would go negative. Scoring "ended at zero" instead would call a
# plan that never spends a penny a total failure, which it plainly is not.
ruin_at = np.zeros(n, dtype=np.int32) # 0 = still solvent
for t in range(T):
bal = bal * (1.0 + rp[t])
bal += add if t < acc else -draw
short = bal < 0.0
if short.any():
np.copyto(ruin_at, t + 1, where=short & (ruin_at == 0))
np.maximum(bal, 0.0, out=bal) # ruin is absorbing
paths[t + 1] = bal
q = np.percentile(paths, [5, 25, 50, 75, 95], axis=1)
failed = ruin_at > 0
n_fail = int(np.count_nonzero(failed))
success = float(n - n_fail) / n
ruin = float(np.median(ruin_at[failed])) if n_fail else None
return json.dumps({
"years": acc, "retire_years": ret, "paths": n,
"bands": [[round(v, 2) for v in row] for row in q.tolist()],
"success": success,
"at_retirement": float(q[2][acc]),
"final_median": float(q[2][-1]),
"final_p5": float(q[0][-1]),
"ruin_year": ruin,
"engine": "pyodide + numpy " + np.__version__,
})
</script>
<script>
(function () {
"use strict";
/* ------------------------------------------------------------------ *
* Constants — kept identical in the Python and JavaScript engines.
* ------------------------------------------------------------------ */
var MU_S = 0.070, SIG_S = 0.180;
var MU_B = 0.020, SIG_B = 0.060;
var RHO = 0.15;
var PATHS = 5000;
var RETIRE_YEARS = 30;
var DEFAULTS = { start: 120000, monthly: 1500, years: 25, stock: 70, wd: 40000 };
var PRESETS = {
steady: { start: 120000, monthly: 1500, years: 25, stock: 70, wd: 40000 },
late: { start: 40000, monthly: 2500, years: 15, stock: 85, wd: 45000 },
coast: { start: 600000, monthly: 0, years: 12, stock: 55, wd: 45000 }
};
var state = {
start: DEFAULTS.start, monthly: DEFAULTS.monthly, years: DEFAULTS.years,
stock: DEFAULTS.stock, wd: DEFAULTS.wd, seed: 20260813, scale: "linear"
};
var $ = function (id) { return document.getElementById(id); };
var engine = "js"; // "js" until numpy is up
var jsLabel = "javascript preview"; // becomes plain "javascript" if pyodide never arrives
var pyRun = null;
var pyodide = null;
var lastRes = null;
var scrub = -1; // hovered/keyboard-selected year, -1 = none
/* ------------------------------------------------------------------ *
* Formatting
* ------------------------------------------------------------------ */
function money(v) {
if (!isFinite(v)) return "—";
var s = v < 0 ? "-" : "";
v = Math.abs(v);
if (v >= 1e9) return s + "$" + (v / 1e9).toFixed(v >= 1e10 ? 1 : 2) + "B";
if (v >= 1e6) return s + "$" + (v / 1e6).toFixed(v >= 1e7 ? 1 : 2) + "M";
if (v >= 1e4) return s + "$" + Math.round(v / 1e3) + "k";
if (v >= 1e3) return s + "$" + (v / 1e3).toFixed(1) + "k";
return s + "$" + Math.round(v);
}
function full(v) {
return "$" + Math.round(v).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
/* ------------------------------------------------------------------ *
* JavaScript engine — the instant preview, and the standalone fallback.
* Same model as run_sim() in the Python block above.
* ------------------------------------------------------------------ */
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function normals(rand, n) {
var out = new Float64Array(n);
for (var i = 0; i < n; i += 2) {
var u1 = rand() || 1e-12, u2 = rand();
var r = Math.sqrt(-2 * Math.log(u1)), th = 2 * Math.PI * u2;
out[i] = r * Math.cos(th);
if (i + 1 < n) out[i + 1] = r * Math.sin(th);
}
return out;
}
function quantile(sorted, p) {
var n = sorted.length;
var h = (n - 1) * p;
var lo = Math.floor(h), hi = Math.ceil(h);
return sorted[lo] + (sorted[hi] - sorted[lo]) * (h - lo);
}
function simulateJS(cfg) {
var n = cfg.paths, acc = cfg.years, T = acc + cfg.retire_years;
var w = cfg.stock_pct / 100, add = cfg.monthly * 12, draw = cfg.withdrawal;
var rand = mulberry32(cfg.seed >>> 0);
var driftS = MU_S - 0.5 * SIG_S * SIG_S, driftB = MU_B - 0.5 * SIG_B * SIG_B;
var rhoC = Math.sqrt(1 - RHO * RHO);
var paths = new Float64Array((T + 1) * n);
var bal = new Float64Array(n);
// Year each path first failed to fund a withdrawal; 0 = still solvent.
// Mirrors ruin_at in run_sim() — see the note there for why "ended at zero"
// is the wrong test.
var ruinAt = new Int32Array(n);
var i, t;
for (i = 0; i < n; i++) { bal[i] = cfg.start; paths[i] = cfg.start; }
for (t = 0; t < T; t++) {
var z1 = normals(rand, n), z3 = normals(rand, n);
var off = (t + 1) * n, saving = t < acc;
for (i = 0; i < n; i++) {
var a = z1[i], b = RHO * a + rhoC * z3[i];
var rp = w * (Math.exp(driftS + SIG_S * a) - 1) +
(1 - w) * (Math.exp(driftB + SIG_B * b) - 1);
var v = bal[i] * (1 + rp) + (saving ? add : -draw);
if (v < 0) { v = 0; if (ruinAt[i] === 0) ruinAt[i] = t + 1; }
bal[i] = v; paths[off + i] = v;
}
}
var ps = [0.05, 0.25, 0.5, 0.75, 0.95];
var bands = [[], [], [], [], []];
var col = new Float64Array(n);
for (t = 0; t <= T; t++) {
col.set(paths.subarray(t * n, t * n + n));
col.sort();
for (var k = 0; k < 5; k++) bands[k].push(quantile(col, ps[k]));
}
var ok = 0, ruinSum = [];
for (i = 0; i < n; i++) {
if (ruinAt[i] === 0) ok++; else ruinSum.push(ruinAt[i]);
}
var ruin = null;
if (ruinSum.length) {
ruinSum.sort(function (x, y) { return x - y; });
ruin = quantile(Float64Array.from(ruinSum), 0.5);
}
return {
years: acc, retire_years: cfg.retire_years, paths: n, bands: bands,
success: ok / n,
at_retirement: bands[2][acc],
final_median: bands[2][T],
final_p5: bands[0][T],
ruin_year: ruin,
engine: jsLabel
};
}
/* ------------------------------------------------------------------ *
* Run orchestration
* ------------------------------------------------------------------ */
function config() {
return {
paths: PATHS, years: state.years, retire_years: RETIRE_YEARS,
stock_pct: state.stock, start: state.start, monthly: state.monthly,
withdrawal: state.wd, seed: state.seed
};
}
var pending = 0;
function schedule() {
clearTimeout(pending);
pending = setTimeout(run, 90);
}
function run() {
var cfg = config(), t0 = performance.now(), res;
try {
if (engine === "py") {
res = JSON.parse(pyRun(JSON.stringify(cfg)));
} else {
res = simulateJS(cfg);
}
} catch (err) {
if (engine === "py") { // never leave the user stranded
engine = "js"; jsLabel = "javascript";
setStatus("warn", "python failed · javascript engine");
banner("python engine stopped",
"numpy raised an error mid-run (" + (err && err.message ? err.message : err) +
"), so the identical model has been handed back to JavaScript. Everything below is live.");
if (window.console && console.info) console.info("[monte-carlo] numpy failed mid-run; using the JS engine");
res = simulateJS(cfg);
} else {
// Final safety net: this fires on the very first synchronous run(),
// before boot() is even reached, so it must not throw the page away.
setStatus("warn", "simulation failed");
$("verdictTitle").textContent = "The simulation could not run.";
$("verdictBody").textContent =
"Nothing was computed, so the numbers below are not showing a result. " +
"Reloading the page usually clears it.";
$("verdictErr").textContent = String(err && err.message ? err.message : err);
return;
}
}
var ms = performance.now() - t0;
lastRes = res;
$("perf").textContent = res.engine + " · " + PATHS.toLocaleString() + " paths · " +
(ms < 1 ? "<1" : Math.round(ms)) + " ms";
render(res);
}
/* ------------------------------------------------------------------ *
* Rendering
* ------------------------------------------------------------------ */
var C = 2 * Math.PI * 52;
function render(res) {
var p = res.success;
var tone = p >= 0.85 ? "good" : (p >= 0.6 ? "mid" : "bad");
var dial = $("dial");
dial.setAttribute("data-tone", tone);
$("arc").style.strokeDashoffset = (C * (1 - p)).toFixed(2);
$("pct").textContent = (p * 100).toFixed(1) + "%";
var endYear = res.years + res.retire_years;
var se = Math.sqrt(p * (1 - p) / res.paths) * 100;
var titles = {
good: "This plan holds up.",
mid: "Workable, but it can break.",
bad: "This plan runs out of money."
};
$("verdictTitle").textContent = titles[tone];
$("verdictBody").textContent =
Math.round(p * res.paths).toLocaleString() + " of " + res.paths.toLocaleString() +
" simulated futures never ran out over " + res.retire_years +
" years of spending " + full(state.wd) + " a year.";
$("verdictErr").textContent = "± " + se.toFixed(2) + " pt sampling error · seed " + state.seed;
$("live").textContent = "Success probability " + (p * 100).toFixed(1) + " percent.";
$("s-ret").innerHTML = money(res.at_retirement) + "<small>year " + res.years + "</small>";
$("s-end").innerHTML = money(res.final_median) + "<small>year " + endYear + "</small>";
$("s-p5").innerHTML = money(res.final_p5) + "<small>5th percentile</small>";
if (res.ruin_year == null) {
$("s-fail").innerHTML = "never<small>in any path</small>";
} else {
$("s-fail").innerHTML = "year " + Math.round(res.ruin_year) +
"<small>median of the " + Math.round((1 - p) * res.paths).toLocaleString() + " that fail</small>";
}
$("assumptions").textContent =
"Real (inflation-adjusted) dollars. Stocks " + (MU_S * 100).toFixed(1) + "% ± " +
(SIG_S * 100).toFixed(0) + "%, bonds " + (MU_B * 100).toFixed(1) + "% ± " +
(SIG_B * 100).toFixed(0) + "%, correlation " + RHO.toFixed(2) +
". Geometric Brownian motion, one-year steps, rebalanced annually. " +
"Contributions until year " + res.years + ", then withdrawals. No taxes, fees or state pension.";
// The years slider can shorten the timeline under a scrub position that was
// set while the chart was longer; without this the readout indexes past the
// end of every band and prints five em-dashes.
if (scrub > endYear) scrub = endYear;
buildTable(res);
draw();
updateReadout();
}
function buildTable(res) {
var rows = ["<caption>Simulated balance percentiles every five years</caption>",
"<tr><th scope=\"col\">Year</th><th scope=\"col\">5th</th><th scope=\"col\">25th</th>" +
"<th scope=\"col\">Median</th><th scope=\"col\">75th</th><th scope=\"col\">95th</th></tr>"];
var T = res.years + res.retire_years;
for (var t = 0; t <= T; t += 5) {
rows.push("<tr><th scope=\"row\">" + t + "</th>" +
[0, 1, 2, 3, 4].map(function (k) { return "<td>" + money(res.bands[k][t]) + "</td>"; }).join("") +
"</tr>");
}
$("srTable").innerHTML = rows.join("");
}
function readoutYear() {
var T = lastRes.years + lastRes.retire_years;
// scrub is clamped everywhere it is written, but a stale index here would
// silently print five em-dashes, so pin it to the band length one last time.
return scrub >= 0 ? Math.max(0, Math.min(T, scrub)) : lastRes.years;
}
function updateReadout() {
if (!lastRes) return;
var t = readoutYear();
var b = lastRes.bands, tag = scrub >= 0 ? "year" : "at retirement · year";
$("readout").innerHTML =
"<b>" + tag + " " + t + "</b>" +
"<span><span class=\"k\">p05</span> " + money(b[0][t]) + "</span>" +
"<span><span class=\"k\">p25</span> " + money(b[1][t]) + "</span>" +
"<span><span class=\"k\">p50</span> " + money(b[2][t]) + "</span>" +
"<span><span class=\"k\">p75</span> " + money(b[3][t]) + "</span>" +
"<span><span class=\"k\">p95</span> " + money(b[4][t]) + "</span>";
}
// The readout itself is NOT a live region: it repaints on every pointermove,
// which would make a screen reader chatter through a mouse gesture nobody
// asked it to narrate. Keyboard scrubbing announces deliberately instead.
function announceScrub() {
if (!lastRes) return;
var t = readoutYear(), b = lastRes.bands;
$("live").textContent = "Year " + t + ". Median " + money(b[2][t]) +
", 5th percentile " + money(b[0][t]) + ", 95th percentile " + money(b[4][t]) + ".";
}
/* ------------------------------------------------------------------ *
* Canvas fan chart
* ------------------------------------------------------------------ */
var cv = $("chart"), ctx = cv.getContext("2d");
var plot = null; // geometry cached for hit-testing
function css(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function rgba(hex, a) {
var h = hex.replace("#", "");
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
var n = parseInt(h, 16);
return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a + ")";
}
function niceTicks(max, count) {
var raw = max / count, mag = Math.pow(10, Math.floor(Math.log10(raw || 1)));
var norm = raw / mag, step;
step = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10;
step *= mag;
var out = [];
for (var v = 0; v <= max + step * 0.5; v += step) out.push(v);
return out;
}
var LOG_FLOOR = 1000;
function axisMoney(v) {
if (v === 0) return "$0";
if (v >= 1e9) { var g = v / 1e9; return "$" + (g >= 10 ? Math.round(g) : +g.toFixed(1)) + "B"; }
if (v >= 1e6) { var m = v / 1e6; return "$" + (m >= 10 ? Math.round(m) : +m.toFixed(1)) + "M"; }
if (v >= 1e3) { var k = v / 1e3; return "$" + (k >= 10 ? Math.round(k) : +k.toFixed(1)) + "k"; }
return "$" + Math.round(v);
}
function draw() {
var box = cv.parentNode.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
var w = Math.max(240, Math.floor(box.width));
var h = cv.clientHeight || 260;
cv.width = Math.floor(w * dpr);
cv.height = Math.floor(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
if (!lastRes) return;
var res = lastRes, b = res.bands, T = res.years + res.retire_years;
var inkC = css("--ink"), mutedC = css("--muted"), lineC = css("--line");
var accentC = css("--accent");
var padL = w < 420 ? 44 : 54, padR = 10, padT = 10, padB = 26;
var pw = w - padL - padR, ph = h - padT - padB;
var log = state.scale === "log";
// A lognormal 95th percentile runs away over 55 years and flattens everything
// else against the floor. In linear mode frame the readable middle and let the
// outer band clip, with the true 95th annotated so nothing is hidden.
var peak = Math.max(b[4][T], b[4][res.years], LOG_FLOOR * 10);
var top = log ? peak
: Math.max(b[3][T], b[4][res.years], b[2][T] * 1.6, LOG_FLOOR * 10);
var ticks;
if (log) {
// Frame the data, not the next round decade: rounding $16M up to $100M
// would push the whole fan into the bottom two thirds of the panel.
top = peak * 1.06;
var e0 = Math.floor(Math.log10(LOG_FLOOR)), e1 = Math.floor(Math.log10(top));
ticks = [];
for (var e = e0; e <= e1; e++) ticks.push(Math.pow(10, e));
} else {
ticks = niceTicks(top, w < 420 ? 3 : 4);
top = ticks[ticks.length - 1];
}
var lo = log ? Math.log10(LOG_FLOOR) : 0, hi = log ? Math.log10(top) : top;
function X(t) { return padL + (t / T) * pw; }
function Y(v) {
var u = log ? Math.log10(Math.max(v, LOG_FLOOR)) : v;
return padT + ph - ((u - lo) / (hi - lo)) * ph;
}
plot = { X: X, Y: Y, padL: padL, pw: pw, T: T, padT: padT, ph: ph };
// grid + y labels
ctx.font = "500 10px " + css("--font-mono");
ctx.textAlign = "right"; ctx.textBaseline = "middle";
ctx.lineWidth = 1;
for (var i = 0; i < ticks.length; i++) {
var y = Math.round(Y(ticks[i])) + 0.5;
ctx.strokeStyle = lineC; ctx.beginPath();
ctx.moveTo(padL, y); ctx.lineTo(padL + pw, y); ctx.stroke();
ctx.fillStyle = mutedC;
ctx.fillText(axisMoney(ticks[i]), padL - 7, y);
}
// bands (clipped to the frame — the 95th may exceed it in linear mode)
ctx.save();
ctx.beginPath(); ctx.rect(padL, padT, pw, ph); ctx.clip();
function band(loArr, hiArr, alpha) {
ctx.beginPath();
for (var t = 0; t <= T; t++) { var x = X(t), y = Y(hiArr[t]); t ? ctx.lineTo(x, y) : ctx.moveTo(x, y); }
for (var s = T; s >= 0; s--) ctx.lineTo(X(s), Y(loArr[s]));
ctx.closePath();
ctx.fillStyle = rgba(accentC, alpha);
ctx.fill();
}
band(b[0], b[4], 0.16);
band(b[1], b[3], 0.28);
// median
ctx.beginPath();
for (var t2 = 0; t2 <= T; t2++) { var x2 = X(t2), y2 = Y(b[2][t2]); t2 ? ctx.lineTo(x2, y2) : ctx.moveTo(x2, y2); }
ctx.strokeStyle = accentC; ctx.lineWidth = 2; ctx.lineJoin = "round"; ctx.stroke();
ctx.restore();
// honest note when the top band runs off the frame
if (!log && peak > top * 1.02) {
ctx.textAlign = "right"; ctx.textBaseline = "top"; ctx.fillStyle = mutedC;
ctx.fillText("95th peaks at " + axisMoney(peak), padL + pw - 3, padT + 14);
}
// retirement marker
var xr = Math.round(X(res.years)) + 0.5;
ctx.save();
ctx.setLineDash([3, 3]); ctx.lineWidth = 1;
ctx.strokeStyle = inkC;
ctx.beginPath(); ctx.moveTo(xr, padT); ctx.lineTo(xr, padT + ph); ctx.stroke();
ctx.restore();
ctx.textAlign = xr > padL + pw - 60 ? "right" : "left";
ctx.textBaseline = "top";
ctx.fillStyle = mutedC;
ctx.fillText("retire", xr + (ctx.textAlign === "left" ? 4 : -4), padT + 1);
// x axis
ctx.strokeStyle = lineC; ctx.beginPath();
ctx.moveTo(padL, padT + ph + 0.5); ctx.lineTo(padL + pw, padT + ph + 0.5); ctx.stroke();
ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.fillStyle = mutedC;
var stepX = T <= 20 ? 5 : T <= 45 ? 10 : 15;
var xEnd = X(T);
// The final "yT" label always gets drawn, so drop any regular tick that would
// land on top of it (T = 31 at 360px puts y30 and y31 eight pixels apart).
for (var t3 = 0; t3 <= T && xEnd - X(t3) > 26; t3 += stepX) {
ctx.fillText("y" + t3, X(t3), padT + ph + 6);
}
ctx.fillText("y" + T, xEnd, padT + ph + 6);
// scrub
if (scrub >= 0 && scrub <= T) {
var xs = Math.round(X(scrub)) + 0.5;
ctx.strokeStyle = inkC; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(xs, padT); ctx.lineTo(xs, padT + ph); ctx.stroke();
for (var k = 0; k < 5; k++) {
ctx.beginPath();
ctx.arc(xs, Y(b[k][scrub]), k === 2 ? 3.5 : 2.5, 0, 2 * Math.PI);
ctx.fillStyle = k === 2 ? accentC : rgba(accentC, 0.65);
ctx.fill();
}
}
if (log) {
ctx.textAlign = "left"; ctx.textBaseline = "bottom"; ctx.fillStyle = mutedC;
ctx.fillText("log · empty paths rest on the floor", padL + 3, padT + ph - 3);
}
}
/* ------------------------------------------------------------------ *
* Interaction
* ------------------------------------------------------------------ */
function yearAt(clientX) {
if (!plot) return -1;
var r = cv.getBoundingClientRect();
var t = Math.round(((clientX - r.left - plot.padL) / plot.pw) * plot.T);
return Math.max(0, Math.min(plot.T, t));
}
cv.addEventListener("pointermove", function (e) {
var t = yearAt(e.clientX);
if (t !== scrub) { scrub = t; draw(); updateReadout(); }
});
cv.addEventListener("pointerleave", function () {
if (scrub !== -1) { scrub = -1; draw(); updateReadout(); }
});
var SCRUB_KEYS = ["ArrowRight", "ArrowLeft", "PageUp", "PageDown", "Home", "End", "Escape"];
cv.addEventListener("keydown", function (e) {
if (!lastRes) return;
var k = e.key;
// Bail before touching scrub: seeding it for a key we do not handle moved the
// cursor invisibly, and the next repaint would jump to a year nobody chose.
if (SCRUB_KEYS.indexOf(k) < 0) return;
var T = lastRes.years + lastRes.retire_years;
var at = scrub < 0 ? lastRes.years : scrub;
if (k === "ArrowRight") at = Math.min(T, at + 1);
else if (k === "ArrowLeft") at = Math.max(0, at - 1);
else if (k === "PageUp") at = Math.min(T, at + 5);
else if (k === "PageDown") at = Math.max(0, at - 5);
else if (k === "Home") at = 0;
else if (k === "End") at = T;
else if (k === "Escape") at = -1;
scrub = at;
e.preventDefault();
draw(); updateReadout();
if (scrub >= 0) announceScrub();
});
cv.addEventListener("focus", function () {
if (scrub < 0 && lastRes) { scrub = lastRes.years; draw(); updateReadout(); announceScrub(); }
});
cv.addEventListener("blur", function () { scrub = -1; draw(); updateReadout(); });
function labels() {
$("v-start").textContent = full(state.start);
$("v-monthly").textContent = full(state.monthly);
$("h-monthly").textContent = full(state.monthly * 12) + " / year";
$("v-years").textContent = state.years + " yr";
$("h-years").textContent = "then a " + RETIRE_YEARS + "-year retirement · ends at year " + (state.years + RETIRE_YEARS);
$("v-stock").textContent = state.stock + " / " + (100 - state.stock);
$("v-wd").textContent = full(state.wd);
$("h-wd").textContent = full(Math.round(state.wd / 12)) + " / month";
$("start").setAttribute("aria-valuetext", full(state.start));
$("monthly").setAttribute("aria-valuetext", full(state.monthly) + " per month");
$("years").setAttribute("aria-valuetext", state.years + " years");
$("stock").setAttribute("aria-valuetext", state.stock + " percent stocks, " + (100 - state.stock) + " percent bonds");
$("wd").setAttribute("aria-valuetext", full(state.wd) + " per year");
}
var MAP = { start: "start", monthly: "monthly", years: "years", stock: "stock", wd: "wd" };
Object.keys(MAP).forEach(function (id) {
$(id).addEventListener("input", function () {
state[MAP[id]] = Number(this.value);
labels(); markPreset(); schedule();
});
});
function apply(p) {
state.start = p.start; state.monthly = p.monthly; state.years = p.years;
state.stock = p.stock; state.wd = p.wd;
$("start").value = p.start; $("monthly").value = p.monthly; $("years").value = p.years;
$("stock").value = p.stock; $("wd").value = p.wd;
labels(); markPreset(); schedule();
}
function markPreset() {
document.querySelectorAll("[data-preset]").forEach(function (btn) {
var p = PRESETS[btn.dataset.preset];
var on = p.start === state.start && p.monthly === state.monthly && p.years === state.years &&
p.stock === state.stock && p.wd === state.wd;
btn.setAttribute("aria-pressed", on ? "true" : "false");
});
}
document.querySelectorAll("[data-preset]").forEach(function (btn) {
btn.addEventListener("click", function () { apply(PRESETS[btn.dataset.preset]); });
});
$("reset").addEventListener("click", function () { state.seed = 20260813; apply(DEFAULTS); });
$("resample").addEventListener("click", function () {
state.seed = (Math.random() * 1e9) | 0; schedule();
});
$("sc-lin").addEventListener("click", function () { setScale("linear"); });
$("sc-log").addEventListener("click", function () { setScale("log"); });
function setScale(s) {
state.scale = s;
$("sc-lin").setAttribute("aria-pressed", s === "linear" ? "true" : "false");
$("sc-log").setAttribute("aria-pressed", s === "log" ? "true" : "false");
draw();
}
/* ------------------------------------------------------------------ *
* Theme + resize
* ------------------------------------------------------------------ */
function repaintTheme() { paintLegend(); draw(); }
// Canvas colours are sampled from the tokens at draw time, so any theme flip —
// postMessage, or the platform setting the attribute straight on <html> — has
// to force a repaint or the chart keeps the old palette.
var themeObs = window.MutationObserver ? new MutationObserver(repaintTheme) : null;
if (themeObs) {
themeObs.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
}
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
if (!themeObs) repaintTheme();
}
});
var mq = window.matchMedia("(prefers-color-scheme: dark)");
(mq.addEventListener ? mq.addEventListener.bind(mq, "change") : mq.addListener.bind(mq))(repaintTheme);
function paintLegend() {
var a = css("--accent");
$("sw95").style.background = rgba(a, 0.16);
$("sw75").style.background = rgba(a, 0.28);
$("sw50").style.background = a;
// the marker is a dashed --ink rule, so the swatch has to be one too
var ink = css("--ink");
$("swRet").style.background =
"repeating-linear-gradient(90deg," + ink + " 0 3px, transparent 3px 6px)";
}
var ro = window.ResizeObserver ? new ResizeObserver(function () { draw(); }) : null;
if (ro) ro.observe(cv.parentNode); else window.addEventListener("resize", draw);
/* ------------------------------------------------------------------ *
* Pyodide boot — real loading state, graceful standalone failure
* ------------------------------------------------------------------ */
function setStatus(st, txt) {
$("status").setAttribute("data-state", st);
$("statusText").textContent = txt;
}
function banner(title, body) {
$("bannerTitle").textContent = title;
$("bannerBody").textContent = body;
$("banner").classList.add("show");
}
function loadScript(src, ms) {
return new Promise(function (resolve, reject) {
var s = document.createElement("script"), settled = false;
function end(ok, err) {
if (settled) return;
settled = true;
clearTimeout(timer);
s.onload = s.onerror = null;
if (!ok && s.parentNode) s.parentNode.removeChild(s); // no late arrivals
if (ok) { resolve(); } else { reject(err); }
}
var timer = setTimeout(function () {
end(false, new Error("timed out after " + Math.round(ms / 1000) + "s"));
}, ms);
s.onload = function () { end(true); };
s.onerror = function () { end(false, new Error("cannot load " + src)); };
s.src = src;
document.head.appendChild(s);
});
}
/**
* Hand the page back to the JavaScript engine and say so plainly. The model is
* the same either way, so this is a note, never an error.
*/
function fallback(title, body, logLine) {
engine = "js";
jsLabel = "javascript";
setStatus("warn", "javascript engine");
$("perf").textContent = $("perf").textContent.replace("javascript preview", "javascript");
banner(title, body);
if (window.console && console.info) console.info("[monte-carlo] " + logLine);
}
/**
* Pyodide is WebAssembly. A page served under a CSP without 'wasm-unsafe-eval'
* cannot compile ANY wasm — and in that case loadPyodide() neither resolves nor
* rejects, it just stops, which would leave the header pinned on "starting
* python…" forever. Probe with an eight-byte empty module (legal wasm, compiled
* synchronously) so a blocked sandbox is answered instantly and honestly.
*/
function wasmUsable() {
try {
return typeof WebAssembly === "object" &&
new WebAssembly.Module(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]))
instanceof WebAssembly.Module;
} catch (err) {
return false;
}
}
/** Nothing in the boot chain is allowed to hang without an answer. */
function deadline(promise, ms, what) {
return new Promise(function (resolve, reject) {
var t = setTimeout(function () {
reject(new Error(what + " did not finish within " + Math.round(ms / 1000) + "s"));
}, ms);
promise.then(
function (v) { clearTimeout(t); resolve(v); },
function (e) { clearTimeout(t); reject(e); }
);
});
}
function boot() {
if (!wasmUsable()) {
fallback("running in javascript",
"Python and numpy reach a browser as WebAssembly, and this page is sandboxed by a policy " +
"that will not compile it. The identical model — same drift, same volatility, the same " +
"5,000 paths — is running in JavaScript instead, so everything below is live.",
"WebAssembly is blocked here; staying on the JS engine");
return;
}
setStatus("load", "loading pyodide…");
loadScript("/vendor/pyodide/pyodide.js", 30000)
.then(function () {
if (typeof loadPyodide !== "function") throw new Error("pyodide.js did not define loadPyodide");
setStatus("load", "starting python…");
return deadline(loadPyodide({ indexURL: "/vendor/pyodide/" }), 45000, "the python runtime");
})
.then(function (py) {
pyodide = py;
setStatus("load", "loading numpy…");
return deadline(py.loadPackage("numpy"), 30000, "numpy");
})
.then(function () {
pyodide.runPython($("pysrc").textContent);
pyRun = pyodide.globals.get("run_sim");
engine = "py";
setStatus("ok", "numpy ready");
run();
})
.catch(function (err) {
fallback("pyodide unavailable",
"This demo normally runs its 5,000 paths in Python with numpy, loaded from the sloprun " +
"bundle at /vendor/pyodide/. That bundle did not come up here (" + err.message +
"), so the identical model is running in JavaScript instead. Everything below is live.",
"falling back to the JS engine: " + err.message);
});
}
/* ------------------------------------------------------------------ *
* Go — instant first result, then upgrade to numpy
* ------------------------------------------------------------------ */
labels();
paintLegend();
run(); // synchronous JS pass: a full chart before Pyodide is even requested
boot();
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
1 totaldfefewewfwe