0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
The whole shape of an outbreak falls out of three numbers — how fast something spreads, how long people stay contagious, and the day the spread slows down — and this page solves the 1927 SIR equations with fourth-order Runge–Kutta on every slider move so you can watch that happen at about a millisecond per redraw. It opens on a 40% slowdown landing on day 40, which more than halves the peak from 26,178 to 11,847 and still leaves 69% of the crowd affected: flattening buys time, it does not cancel anything. Click acted too late to watch that exact same 40% cut, moved three weeks to day 61, leave the peak completely untouched, then pushed under 1 to see the curve give up the moment R drops below 1. Textbook arithmetic on a perfectly mixed crowd — a tool for intuition, never a forecast.
Source — the code you see is the code that just ran 849 lines 41.1 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Outbreak Curve Simulator</title>
<link rel="icon" href="data:,">
<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 ---------- */
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; padding: 0; }
body { -webkit-text-size-adjust: 100%; line-height: 1.5; overflow-x: hidden; }
.wrap { max-width: 1120px; margin: 0 auto; padding: 22px 16px 28px; }
h1, h2, h3 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
p { margin: 0; }
:where(button, input, select, summary, canvas, [tabindex]):focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px;
}
.sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* ---------- header ---------- */
header { margin-bottom: 16px; }
.eyebrow { font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em;
text-transform: uppercase; color: var(--muted); display: flex; align-items: center;
gap: 8px; flex-wrap: wrap; }
.eyebrow .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex: none; }
h1 { font-size: clamp(21px, 4.4vw, 29px); margin: 6px 0 6px; }
.lede { color: var(--muted); font-size: 15px; max-width: 66ch; text-wrap: pretty; }
.warnbar { margin-top: 12px; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap;
border: 1px solid var(--line); border-left: 3px solid var(--accent);
background: var(--accent-soft); border-radius: 4px; padding: 8px 11px; }
.warnbar b { font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em;
text-transform: uppercase; color: var(--accent); }
.warnbar span { font-size: 13px; color: var(--ink); }
/* ---------- layout ---------- */
.grid { display: grid; grid-template-columns: minmax(0, 1.62fr) minmax(0, 1fr);
gap: 14px; align-items: start; }
.col { display: grid; gap: 14px; min-width: 0; }
@media (max-width: 900px) {
.grid { grid-template-columns: minmax(0, 1fr); }
.grid > .col { display: contents; }
#cChart { order: 1; } #cRead { order: 2; } #cCtl { order: 3; } #cMethod { order: 4; }
}
.card { background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 13px; min-width: 0; }
.card > h2 { font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em;
text-transform: uppercase; color: var(--muted); font-weight: 600; display: flex;
justify-content: space-between; align-items: center; gap: 4px 8px; flex-wrap: wrap;
margin-bottom: 10px; }
.card > h2 span:last-child { text-transform: none; letter-spacing: .02em; }
/* ---------- chart ---------- */
canvas { display: block; width: 100%; height: clamp(232px, 39vw, 336px);
touch-action: pan-y; border-radius: 6px; }
.hoverline { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
margin-top: 7px; min-height: 34px; }
.hoverline b { color: var(--ink); font-weight: 600; }
.hoverline .k { color: var(--muted); }
.legend { display: flex; gap: 12px; flex-wrap: wrap; font-family: var(--font-mono);
font-size: 11px; color: var(--muted); margin-top: 4px; }
.legend i { display: inline-block; width: 14px; height: 3px; border-radius: 2px;
vertical-align: middle; margin-right: 5px; background: var(--muted); }
.legend .inf { background: var(--danger); height: 4px; }
.legend .rec { background: var(--run); }
.legend .gh { background: transparent; border-top: 2px dashed var(--accent); border-radius: 0; }
/* ---------- readout ---------- */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); gap: 1px;
background: var(--line); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.tile { background: var(--surface); padding: 9px 10px; min-width: 0; }
.tile .k { font-family: var(--font-mono); font-size: 10px; letter-spacing: .1em;
text-transform: uppercase; color: var(--muted); }
.tile .v { font-family: var(--font-mono); font-size: 19px; font-weight: 600;
line-height: 1.25; margin-top: 2px; overflow-wrap: anywhere; }
.tile .s { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); margin-top: 1px; }
.tile.hero .v { color: var(--danger); font-size: 23px; }
.tile .v.up { color: var(--danger); }
.tile .v.ok { color: var(--run); }
.tile .s.ok { color: var(--run); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px;
font-family: var(--font-mono); font-size: 11px; }
.chip { border: 1px solid var(--line); border-radius: 999px; padding: 2px 9px; color: var(--muted); }
.chip b { font-weight: 600; color: var(--ink); }
.chip.ok { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.chip.ok b { color: var(--run); }
.insight { font-size: 13.5px; color: var(--ink); margin-top: 11px; text-wrap: pretty;
border-left: 2px solid var(--line); padding-left: 10px; }
.insight b { font-weight: 650; }
/* ---------- controls ---------- */
.gh2 { font-family: var(--font-mono); font-size: 10px; letter-spacing: .12em;
text-transform: uppercase; color: var(--muted); border-top: 1px solid var(--line);
padding-top: 11px; margin-top: 13px; }
.ctl { margin-top: 9px; }
.ctl .top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.ctl .name { font-size: 13px; }
.ctl .val { font-family: var(--font-mono); font-size: 12.5px; font-weight: 600;
color: var(--accent); white-space: nowrap; }
.ctl .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); margin-top: 1px; }
input[type=range] { -webkit-appearance: none; appearance: none; display: block;
width: 100%; height: 26px; margin: 0; background: transparent; cursor: pointer; }
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 15px; height: 15px;
margin-top: -5.5px; 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; border-radius: 2px; background: var(--line); }
input[type=range]::-moz-range-thumb { width: 13px; height: 13px; 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: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
.chipset { display: flex; flex-wrap: wrap; gap: 6px; }
button.b { font-family: var(--font-mono); font-size: 11.5px; padding: 6px 10px; cursor: pointer;
border: 1px solid var(--line); border-radius: 7px; background: var(--surface); color: var(--ink);
line-height: 1.2; transition: border-color .12s, background .12s, color .12s; }
button.b:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button.b[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
.hint { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
.viewrow { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-top: 2px; }
/* ---------- method ---------- */
details.method { border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); padding: 11px 13px; }
details.method summary { font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em;
text-transform: uppercase; color: var(--muted); cursor: pointer; font-weight: 600;
padding: 5px 0; }
details.method summary::marker { color: var(--accent); }
.method .body { margin-top: 10px; font-size: 13.5px; color: var(--muted); display: grid; gap: 9px; }
.method .body b { color: var(--ink); font-weight: 600; }
.eq { font-family: var(--font-mono); font-size: 12.5px; color: var(--ink); background: var(--bg);
border: 1px solid var(--line); border-radius: 7px; padding: 9px 11px; overflow-x: auto; white-space: pre; }
footer { margin-top: 22px; padding-top: 12px; border-top: 1px solid var(--line);
font-family: var(--font-mono); font-size: 11px; color: var(--muted);
display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
@media (max-width: 420px) {
.wrap { padding: 18px 12px 24px; }
.tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.tile .v { font-size: 17px; }
.tile.hero .v { font-size: 20px; }
}
@media (prefers-reduced-motion: reduce) {
* { transition-duration: .01ms !important; animation-duration: .01ms !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="eyebrow"><span class="dot" aria-hidden="true"></span>pharma · javascript · SIR model</div>
<h1>Outbreak Curve Simulator</h1>
<p class="lede">Three numbers — how fast something spreads, how long people stay contagious, and
the day the spread slows down — decide the whole shape of an outbreak. Move the sliders and watch
the curve rearrange itself.</p>
<div class="warnbar">
<b>teaching tool, not a forecast</b>
<span>This is textbook arithmetic on a perfectly mixed crowd. Real outbreaks have ages, places,
travel and luck. Use it to build intuition, never to predict anything.</span>
</div>
</header>
<div class="grid">
<div class="col">
<section class="card" id="cChart">
<h2><span>curves</span><span id="chartTag">—</span></h2>
<canvas id="plot" tabindex="0" role="img" aria-label="Outbreak curves"></canvas>
<p class="hoverline" id="hover" aria-live="polite"></p>
<div class="legend" id="legend" aria-hidden="true">
<span id="lgS"><i></i>not yet affected</span>
<span><i class="inf"></i>sick right now</span>
<span id="lgR"><i class="rec"></i>past it</span>
<span id="lgG"><i class="gh"></i>without the slowdown</span>
</div>
</section>
<section class="card" id="cRead">
<h2><span>numbers</span><span id="calcTag">—</span></h2>
<div class="tiles">
<div class="tile hero"><div class="k">peak at once</div><div class="v" id="oPeak">—</div><div class="s" id="oPeakSub">—</div></div>
<div class="tile"><div class="k">affected in total</div><div class="v" id="oTotal">—</div><div class="s" id="oTotalSub">—</div></div>
<div class="tile"><div class="k">R0 at the start</div><div class="v" id="oR0">—</div><div class="s" id="oR0Sub">—</div></div>
<div class="tile"><div class="k">R after slowdown</div><div class="v" id="oRe">—</div><div class="s" id="oReSub">—</div></div>
</div>
<div class="chips" id="chips"></div>
<p class="insight" id="insight">—</p>
</section>
<details class="method" id="cMethod">
<summary>how it works</summary>
<div class="body">
<div>Everyone is in exactly one of three buckets, and people only ever move forward:
<b>not yet affected → sick right now → past it</b>. That is the classic <b>SIR model</b>,
three lines of calculus written in 1927:</div>
<div class="eq">dS/dt = −b · S · I / N
dI/dt = +b · S · I / N − I / D
dR/dt = +I / D</div>
<div><b>b</b> is the spread rate (how many new cases one sick person starts per day when
everyone around them is still healthy) and <b>D</b> is how many days a person stays contagious.
Their product is the famous <b>R0 = b · D</b> — the number of people one case passes it to at
the very beginning. Above 1 the curve grows, below 1 it fades.</div>
<div><b>The slowdown</b> simply multiplies <b>b</b> from the chosen day onward. Nothing else
changes — same crowd, same illness — which is exactly why the effect is so easy to read.</div>
<div><b>Solved</b> with fourth-order Runge–Kutta at a tenth-of-a-day step over 360 days,
recomputed from scratch on every slider move. No libraries, no network, about a hundred lines
of arithmetic.</div>
<div><b>What to notice.</b> The <b>S·I/N</b> term is why the curve eventually falls even with
no slowdown at all: the disease runs out of people to reach. And it is why a late slowdown
does almost nothing — by then the crowd has already done the flattening for you.</div>
</div>
</details>
</div>
<div class="col">
<section class="card" id="cCtl">
<h2><span>controls</span><span id="ctlTag">drag anything</span></h2>
<div class="chipset" id="presets" role="group" aria-label="Example scenarios"></div>
<div class="gh2">the illness</div>
<div class="ctl">
<div class="top"><label class="name" for="sBeta">spread rate</label><span class="val" id="vBeta">—</span></div>
<input type="range" id="sBeta" min="4" max="120" step="1" value="30">
<div class="sub">new cases one sick person starts per day</div>
</div>
<div class="ctl">
<div class="top"><label class="name" for="sD">days contagious</label><span class="val" id="vD">—</span></div>
<input type="range" id="sD" min="1" max="21" step="1" value="9">
<div class="sub">how long someone can pass it on</div>
</div>
<div class="gh2">the crowd</div>
<div class="ctl">
<div class="top"><label class="name" for="sN">people</label><span class="val" id="vN">—</span></div>
<input type="range" id="sN" min="0" max="11" step="1" value="6">
<div class="sub">everyone is mixed together, evenly</div>
</div>
<div class="ctl">
<div class="top"><label class="name" for="sI0">cases on day 0</label><span class="val" id="vI0">—</span></div>
<input type="range" id="sI0" min="1" max="250" step="1" value="5">
<div class="sub" id="subI0">where the whole thing starts</div>
</div>
<div class="gh2">the slowdown</div>
<div class="ctl">
<div class="top"><label class="name" for="sCut">spread drops by</label><span class="val" id="vCut">—</span></div>
<input type="range" id="sCut" min="0" max="90" step="1" value="40">
<div class="sub">people meet less, spread rate falls</div>
</div>
<div class="ctl">
<div class="top"><label class="name" for="sDay">starting on day</label><span class="val" id="vDay">—</span></div>
<input type="range" id="sDay" min="0" max="240" step="1" value="40">
<div class="sub" id="subDay">—</div>
</div>
<div class="gh2">view</div>
<div class="viewrow" role="group" aria-label="Chart view">
<button class="b" id="vAll" type="button" aria-pressed="true">all three</button>
<button class="b" id="vInf" type="button" aria-pressed="false">sick only</button>
<button class="b" id="bReplay" type="button">replay ▸</button>
</div>
<p class="hint" id="hint">Try <b>acted too late</b> — the same slowdown, three weeks later,
and almost the whole benefit disappears.</p>
</section>
</div>
</div>
<footer>
<span>demo · sloprun.dev</span>
<span id="engine">SIR · RK4 · dt 0.1 d</span>
</footer>
</div>
<script>
(function () {
"use strict";
var root = document.documentElement;
var $ = function (id) { return document.getElementById(id); };
/* ── theme-aware colours, read straight from the tokens ─────────────────── */
var COL = {
surface: [255,255,255], bg: [246,246,243], ink: [26,29,33], muted: [90,96,104],
line: [227,226,220], accent: [192,86,0], run: [26,127,55], danger: [201,60,55]
};
function hexToRgb(h) {
h = String(h).trim();
if (h.charAt(0) === "#") h = h.slice(1);
if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
var n = parseInt(h, 16);
if (h.length !== 6 || isNaN(n)) return null;
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function tok(name, fb) { return hexToRgb(getComputedStyle(root).getPropertyValue(name)) || fb; }
function rgba(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function readTheme() {
COL.surface = tok("--surface", COL.surface); COL.bg = tok("--bg", COL.bg);
COL.ink = tok("--ink", COL.ink); COL.muted = tok("--muted", COL.muted);
COL.line = tok("--line", COL.line); COL.accent = tok("--accent", COL.accent);
COL.run = tok("--run", COL.run); COL.danger = tok("--danger", COL.danger);
}
var MONO = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
var mqDark = window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)") : null;
var mqRM = window.matchMedia ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
var reduced = !!(mqRM && mqRM.matches);
/* ── formatting ─────────────────────────────────────────────────────────── */
function fmt(n) {
n = Math.round(n);
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function compact(n) {
if (n >= 1e6) return trim0((n / 1e6).toFixed(n >= 1e7 ? 0 : 1)) + "M";
if (n >= 1e3) return trim0((n / 1e3).toFixed(n >= 1e4 ? 0 : 1)) + "k";
return String(Math.round(n));
}
function trim0(s) { return s.indexOf(".") >= 0 ? s.replace(/\.?0+$/, "") : s; }
/* "1 person" / "2 people" — the extremes of the sliders really do land on 1 */
function people(n) { return fmt(n) + (Math.round(n) === 1 ? " person" : " people"); }
function pct(x) { return (x * 100).toFixed(x >= 0.1 ? 1 : 2) + "%"; }
/* ── model ──────────────────────────────────────────────────────────────── */
var HORIZON = 360, DT = 0.1, STEPS = Math.round(HORIZON / DT);
function simulate(N, beta, D, I0, cutDay, cut) {
var gamma = 1 / D;
var S = N - I0, I = I0, R = 0;
var day = new Float64Array(HORIZON + 1);
var sS = new Float64Array(HORIZON + 1), sI = new Float64Array(HORIZON + 1), sR = new Float64Array(HORIZON + 1);
var peak = 0, peakT = 0, lastHot = -1, floorI = Math.max(0.5, N * 5e-4);
for (var k = 0; k <= STEPS; k++) {
var t = k * DT;
if (I > peak) { peak = I; peakT = t; }
if (I >= floorI) lastHot = t;
if (k % 10 === 0) { var d = k / 10; day[d] = t; sS[d] = S; sI[d] = I; sR[d] = R; }
var b = (cut > 0 && t >= cutDay) ? beta * (1 - cut) : beta;
var c = b / N;
/* classic RK4 on (S, I); R follows from the total */
var a1S = -c * S * I, a1I = c * S * I - gamma * I;
var S2 = S + DT / 2 * a1S, I2 = I + DT / 2 * a1I;
var a2S = -c * S2 * I2, a2I = c * S2 * I2 - gamma * I2;
var S3 = S + DT / 2 * a2S, I3 = I + DT / 2 * a2I;
var a3S = -c * S3 * I3, a3I = c * S3 * I3 - gamma * I3;
var S4 = S + DT * a3S, I4 = I + DT * a3I;
var a4S = -c * S4 * I4, a4I = c * S4 * I4 - gamma * I4;
S += DT / 6 * (a1S + 2 * a2S + 2 * a3S + a4S);
I += DT / 6 * (a1I + 2 * a2I + 2 * a3I + a4I);
if (S < 0) S = 0; if (I < 0) I = 0;
if (S > N) S = N;
R = N - S - I;
if (R < 0) R = 0;
}
var tail = lastHot < 0 ? 0 : (lastHot >= HORIZON - DT ? HORIZON : Math.ceil(lastHot));
return { day: day, S: sS, I: sI, R: sR, peak: peak, peakT: peakT,
total: N - S, tail: tail, tookOff: peak > I0 * 1.02, Sfinal: S };
}
/* ── state ──────────────────────────────────────────────────────────────── */
var POP = [1000, 2000, 5000, 10000, 20000, 50000, 100000, 200000, 500000, 1000000, 2000000, 5000000];
var st = { betaIdx: 30, D: 9, popIdx: 6, I0: 5, cut: 40, day: 40, view: "all" };
var run = null, base = null, P = null;
var cursor = null; /* day under the pointer / keyboard cursor */
var sweep = 1; /* 0..1 draw progress */
var sweepRaf = 0;
function params() {
var N = POP[st.popIdx];
var I0 = Math.max(1, Math.min(st.I0, Math.floor(N * 0.1)));
return { N: N, beta: st.betaIdx / 100, D: st.D, I0: I0,
cutDay: st.day, cut: st.cut / 100 };
}
/* ── presets ────────────────────────────────────────────────────────────── */
var PRESETS = [
{ id: "flat", name: "flatten it", s: { betaIdx: 30, D: 9, popIdx: 6, I0: 5, cut: 40, day: 40 } },
{ id: "late", name: "acted too late", s: { betaIdx: 30, D: 9, popIdx: 6, I0: 5, cut: 40, day: 61 } },
{ id: "under", name: "pushed under 1", s: { betaIdx: 30, D: 9, popIdx: 6, I0: 5, cut: 70, day: 40 } },
{ id: "slow", name: "slow burn", s: { betaIdx: 16, D: 14, popIdx: 6, I0: 10, cut: 35, day: 75 } },
{ id: "fast", name: "fast and brief", s: { betaIdx: 85, D: 4, popIdx: 6, I0: 40, cut: 55, day: 8 } },
{ id: "none", name: "do nothing", s: { betaIdx: 30, D: 9, popIdx: 6, I0: 5, cut: 0, day: 40 } }
];
var activePreset = "flat";
var presetBox = $("presets");
PRESETS.forEach(function (p) {
var b = document.createElement("button");
b.type = "button"; b.className = "b"; b.textContent = p.name;
b.setAttribute("aria-pressed", p.id === activePreset ? "true" : "false");
b.addEventListener("click", function () {
activePreset = p.id;
for (var k in p.s) if (Object.prototype.hasOwnProperty.call(p.s, k)) st[k] = p.s[k];
syncInputs(); cursor = null; recompute(); startSweep();
});
p.btn = b; presetBox.appendChild(b);
});
function markPresets() {
PRESETS.forEach(function (p) { p.btn.setAttribute("aria-pressed", p.id === activePreset ? "true" : "false"); });
}
/* ── inputs ─────────────────────────────────────────────────────────────── */
var sBeta = $("sBeta"), sD = $("sD"), sN = $("sN"), sI0 = $("sI0"), sCut = $("sCut"), sDay = $("sDay");
function bind(el, key, transform) {
el.addEventListener("input", function () {
st[key] = transform ? transform(+el.value) : +el.value;
activePreset = null; markPresets();
recompute();
});
}
bind(sBeta, "betaIdx"); bind(sD, "D"); bind(sN, "popIdx");
bind(sI0, "I0"); bind(sCut, "cut"); bind(sDay, "day");
function syncInputs() {
sBeta.value = st.betaIdx; sD.value = st.D; sN.value = st.popIdx;
sI0.value = st.I0; sCut.value = st.cut; sDay.value = st.day;
markPresets();
}
var vAll = $("vAll"), vInf = $("vInf");
vAll.addEventListener("click", function () { setView("all"); });
vInf.addEventListener("click", function () { setView("inf"); });
function setView(v) {
if (st.view === v) return;
st.view = v;
vAll.setAttribute("aria-pressed", v === "all" ? "true" : "false");
vInf.setAttribute("aria-pressed", v === "inf" ? "true" : "false");
$("lgS").hidden = v === "inf"; $("lgR").hidden = v === "inf";
draw();
}
$("bReplay").addEventListener("click", function () { startSweep(true); });
/* ── canvas ─────────────────────────────────────────────────────────────── */
var plot = $("plot"), g = plot.getContext("2d");
var PW = 0, PH = 0, L = 46, Rp = 14, T = 12, B = 26;
function fit() {
var r = plot.getBoundingClientRect();
var w = Math.max(1, Math.round(r.width)), h = Math.max(1, Math.round(r.height));
var dpr = Math.min(2, window.devicePixelRatio || 1);
var nw = Math.round(w * dpr), nh = Math.round(h * dpr);
if (plot.width !== nw || plot.height !== nh) { plot.width = nw; plot.height = nh; }
g.setTransform(dpr, 0, 0, dpr, 0, 0);
PW = w; PH = h;
L = w < 420 ? 38 : 46;
}
var LADDER = [60, 90, 120, 180, 240, 300, 360];
function windowDays() {
var want = Math.max(run.tail * 1.06, P.cut > 0 ? P.cutDay + 22 : 0, 45);
for (var i = 0; i < LADDER.length; i++) if (LADDER[i] >= want) return LADDER[i];
return 360;
}
function niceCeil(v) {
if (v <= 0) return 1;
var e = Math.pow(10, Math.floor(Math.log(v) / Math.LN10));
var m = v / e;
var s = m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10;
return s * e;
}
var W_DAYS = 240, Y_MAX = 1;
function geom() {
W_DAYS = windowDays();
if (st.view === "inf") {
var m = run.peak;
if (P.cut > 0) m = Math.max(m, base.peak);
Y_MAX = niceCeil(m * 1.12);
} else {
Y_MAX = P.N;
}
}
function X(d) { return L + (d / W_DAYS) * (PW - L - Rp); }
function Y(v) { return PH - B - (v / Y_MAX) * (PH - T - B); }
function line(arr, upto, color, width, dash) {
g.save();
g.beginPath();
g.lineWidth = width; g.strokeStyle = color; g.lineJoin = "round"; g.lineCap = "round";
if (dash) g.setLineDash(dash);
for (var d = 0; d <= upto; d++) {
var x = X(d), y = Y(arr[d]);
if (d === 0) g.moveTo(x, y); else g.lineTo(x, y);
}
g.stroke();
g.restore();
}
function draw() {
if (!run) return;
fit(); geom();
var W = PW, H = PH;
g.clearRect(0, 0, W, H);
g.fillStyle = rgba(COL.surface, 1); g.fillRect(0, 0, W, H);
var upto = Math.max(1, Math.round(W_DAYS * sweep));
/* grid + y labels */
g.font = "11px " + MONO; g.textAlign = "right"; g.textBaseline = "middle";
var ticks = 4, i, v, y;
for (i = 0; i <= ticks; i++) {
v = Y_MAX * i / ticks; y = Y(v);
g.strokeStyle = rgba(COL.line, i === 0 ? 1 : 0.75);
g.beginPath(); g.moveTo(L, y); g.lineTo(W - Rp, y); g.stroke();
g.fillStyle = rgba(COL.muted, 1);
g.fillText(i === 0 ? "0" : compact(v), L - 7, y);
}
/* x labels */
g.textAlign = "center"; g.textBaseline = "top";
var xs = 6, step = W_DAYS / xs;
if (W < 420) { xs = 4; step = W_DAYS / xs; }
for (i = 0; i <= xs; i++) {
var d = Math.round(step * i);
g.textAlign = i === xs ? "right" : "center";
g.fillStyle = rgba(COL.muted, 1);
g.fillText(i === xs ? d + " d" : String(d), X(d), H - B + 7);
}
/* the slowdown band + marker */
if (P.cut > 0 && P.cutDay <= W_DAYS && P.cutDay <= upto) {
var xc = X(P.cutDay);
g.fillStyle = rgba(COL.accent, 0.055);
g.fillRect(xc, T, (W - Rp) - xc, H - B - T);
g.save();
g.setLineDash([4, 4]); g.strokeStyle = rgba(COL.accent, 0.85); g.lineWidth = 1.5;
g.beginPath(); g.moveTo(xc, T); g.lineTo(xc, H - B); g.stroke();
g.restore();
g.font = "10px " + MONO; g.textBaseline = "top";
var lab = "slowdown · day " + P.cutDay;
var wlab = g.measureText(lab).width;
var leftSide = xc + wlab + 12 > W - Rp;
g.textAlign = leftSide ? "right" : "left";
g.fillStyle = rgba(COL.accent, 1);
g.fillText(lab, leftSide ? xc - 5 : xc + 5, T + 2);
}
/* infected area fill */
g.beginPath();
g.moveTo(X(0), Y(0));
for (var d2 = 0; d2 <= upto; d2++) g.lineTo(X(d2), Y(run.I[d2]));
g.lineTo(X(upto), Y(0)); g.closePath();
g.fillStyle = rgba(COL.danger, 0.13); g.fill();
if (st.view === "all") {
line(run.S, upto, rgba(COL.muted, 0.95), 2, null);
line(run.R, upto, rgba(COL.run, 0.95), 2, null);
}
if (P.cut > 0) line(base.I, upto, rgba(COL.accent, 0.9), 1.8, [5, 4]);
line(run.I, upto, rgba(COL.danger, 1), 2.6, null);
/* peak marker */
if (run.peakT <= upto && run.peak > 0) {
var px = X(run.peakT), py = Y(run.peak);
g.beginPath(); g.arc(px, py, 3.4, 0, Math.PI * 2);
g.fillStyle = rgba(COL.danger, 1); g.fill();
g.strokeStyle = rgba(COL.surface, 1); g.lineWidth = 1.6; g.stroke();
}
/* cursor */
if (cursor !== null && sweep >= 1) {
var cx = X(cursor);
g.save();
g.strokeStyle = rgba(COL.ink, 0.28); g.lineWidth = 1;
g.beginPath(); g.moveTo(cx, T); g.lineTo(cx, H - B); g.stroke();
g.restore();
var pts = st.view === "inf" ? [[run.I, COL.danger]]
: [[run.S, COL.muted], [run.I, COL.danger], [run.R, COL.run]];
for (i = 0; i < pts.length; i++) {
g.beginPath(); g.arc(cx, Y(pts[i][0][cursor]), 3, 0, Math.PI * 2);
g.fillStyle = rgba(pts[i][1], 1); g.fill();
g.strokeStyle = rgba(COL.surface, 1); g.lineWidth = 1.4; g.stroke();
}
}
/* frame */
g.strokeStyle = rgba(COL.line, 1); g.lineWidth = 1;
g.beginPath(); g.moveTo(L, T - 0.5); g.lineTo(L, H - B); g.stroke();
}
/* ── sweep animation ────────────────────────────────────────────────────── */
function startSweep(force) {
if (sweepRaf) { cancelAnimationFrame(sweepRaf); sweepRaf = 0; }
if (reduced) { sweep = 1; draw(); return; }
if (!force && document.hidden) { sweep = 1; draw(); return; }
var t0 = 0;
sweep = 0;
sweepRaf = requestAnimationFrame(function step(ts) {
if (!t0) t0 = ts;
var k = Math.min(1, (ts - t0) / 620);
sweep = 1 - Math.pow(1 - k, 3);
draw();
if (k < 1) sweepRaf = requestAnimationFrame(step);
else { sweepRaf = 0; sweep = 1; draw(); }
});
}
/* ── readouts ───────────────────────────────────────────────────────────── */
function setTxt(id, s) { $(id).textContent = s; }
function recompute() {
P = params();
var t0 = (window.performance && performance.now) ? performance.now() : 0;
run = simulate(P.N, P.beta, P.D, P.I0, P.cutDay, P.cut);
base = P.cut > 0 ? simulate(P.N, P.beta, P.D, P.I0, 0, 0) : run;
var ms = ((window.performance && performance.now) ? performance.now() : 0) - t0;
/* control labels */
setTxt("vBeta", P.beta.toFixed(2) + " /day");
setTxt("vD", P.D + (P.D === 1 ? " day" : " days"));
setTxt("vN", fmt(P.N));
setTxt("vI0", fmt(P.I0));
setTxt("vCut", st.cut + "%");
setTxt("vDay", "day " + st.day);
sN.setAttribute("aria-valuetext", fmt(P.N) + " people");
sBeta.setAttribute("aria-valuetext", P.beta.toFixed(2) + " new cases per day");
sCut.setAttribute("aria-valuetext", st.cut === 0 ? "no slowdown" : st.cut + " percent drop");
sDay.setAttribute("aria-valuetext", st.cut === 0 ? "no slowdown, this has no effect" : "day " + st.day);
$("lgG").hidden = P.cut === 0;
$("subI0").textContent = st.I0 > P.I0
? "capped at 10% of the crowd (" + fmt(P.I0) + ")"
: "where the whole thing starts";
$("subDay").textContent = st.cut === 0 ? "no slowdown — the drop above is 0%"
: (st.day === 0 ? "in place from the very first day" : "counted from the first cases");
/* headline numbers */
var R0 = P.beta * P.D, Re = R0 * (1 - P.cut);
/* R actually in force the day the slowdown lands: the crowd is already partly used up */
var cutIdx = Math.max(0, Math.min(HORIZON, Math.round(P.cutDay)));
var Reff = Re * (run.S[cutIdx] / P.N);
var peakPct = run.peak / P.N, totPct = run.total / P.N;
setTxt("oPeak", fmt(run.peak));
setTxt("oPeakSub", "day " + Math.round(run.peakT) + " · " + pct(peakPct) + " of everyone");
setTxt("oTotal", fmt(run.total));
setTxt("oTotalSub", pct(totPct) + " of everyone, eventually");
setTxt("oR0", R0.toFixed(2));
setTxt("oR0Sub", R0 >= 1 ? "one case → " + R0.toFixed(1) + " more" : "already fading");
var oRe = $("oRe"), oReSub = $("oReSub");
if (P.cut === 0) {
oRe.textContent = "—"; oRe.className = "v";
oReSub.textContent = "no slowdown set"; oReSub.className = "s";
} else {
oRe.textContent = Re.toFixed(2);
oRe.className = Re < 1 ? "v ok" : (Reff < 1 ? "v" : "v up");
if (Re < 1) {
oReSub.textContent = "below 1 · stops growing";
oReSub.className = "s ok";
} else if (Reff < 1) {
/* above 1 on paper, but the crowd is already thin enough to stop it anyway */
oReSub.textContent = "but only " + Reff.toFixed(2) + " by day " + P.cutDay;
oReSub.className = "s";
} else {
oReSub.textContent = "above 1 · still grows";
oReSub.className = "s";
}
}
/* chips */
var chips = [];
if (P.cut > 0) {
var dP = base.peak > 0 ? (1 - run.peak / base.peak) : 0;
var dT = base.total > 0 ? (1 - run.total / base.total) : 0;
chips.push({ t: "peak <b>" + (dP > 0.005 ? "−" + Math.round(dP * 100) + "%" : "unchanged") + "</b> vs no slowdown" });
chips.push({ t: "total <b>" + (dT > 0.005 ? "−" + Math.round(dT * 100) + "%" : "unchanged") + "</b> vs no slowdown" });
if (Re < 1) chips.push({ t: "R below 1 from day " + P.cutDay, ok: true });
else if (Reff < 1) chips.push({ t: "R was already under 1 by day <b>" + P.cutDay + "</b>" });
} else {
chips.push({ t: "no slowdown — the curve runs free" });
}
chips.push({ t: !run.tookOff ? "never takes off"
: run.tail < HORIZON ? "fades out by <b>day " + run.tail + "</b>"
: "still going at <b>day 360</b>" });
$("chips").innerHTML = chips.map(function (c) {
return '<span class="chip' + (c.ok ? " ok" : "") + '">' + c.t + "</span>";
}).join("");
/* the sentence that does the teaching */
var ins;
var bp = fmt(base.peak), rp = fmt(run.peak);
if (R0 <= 1.0001) {
ins = "R0 is <b>" + R0.toFixed(2) + "</b> — at or below 1 from the very first day, so each case "
+ "replaces itself at best and the line only ever goes down. There is no outbreak to flatten: "
+ "just <b>" + people(run.total) + "</b> affected in total. Nudge the spread rate or the "
+ "days contagious up until R0 clears 1 and watch the shape appear.";
} else if (P.cut === 0) {
ins = "Nothing slows it down, so the curve runs its course: <b>" + people(run.peak) + "</b> sick at the "
+ "same time around day <b>" + Math.round(run.peakT) + "</b>, and <b>" + pct(totPct)
+ "</b> of the crowd affected before it burns out. It ends only because it runs out of people.";
} else if (P.cutDay >= base.peakT - 0.5) {
ins = "The slowdown lands on day <b>" + P.cutDay + "</b>, after the peak on day <b>"
+ Math.round(base.peakT) + "</b> — so the peak is <b>untouched</b>, and the total only moves from "
+ pct(base.total / P.N) + " to <b>" + pct(totPct) + "</b>. The crowd had already done the "
+ "flattening. Timing beats strength: the same cut, landing before the peak instead of after "
+ "it, is a different outbreak.";
} else if (Re < 1) {
ins = "A <b>" + st.cut + "%</b> cut on day <b>" + P.cutDay + "</b> pushes R to <b>" + Re.toFixed(2)
+ "</b>, under 1 — the outbreak stops growing the moment it lands and the peak is whatever it "
+ "had already reached. <b>" + pct(totPct) + "</b> affected instead of " + pct(base.total / P.N) + ".";
} else {
var dp = Math.round((1 - run.peak / base.peak) * 100);
ins = "Acting on day <b>" + P.cutDay + "</b> cuts the peak from " + bp + " to <b>" + rp + "</b> ("
+ (dp > 0 ? dp + "% lower" : "no lower") + ") and pushes it to day <b>" + Math.round(run.peakT)
+ "</b> — but R is still <b>" + Re.toFixed(2) + "</b>, above 1, so <b>" + pct(totPct)
+ "</b> of people are affected in the end anyway. Flattening buys time; it does not cancel the outbreak.";
}
$("insight").innerHTML = ins;
setTxt("calcTag", (ms < 10 ? ms.toFixed(1) : Math.round(ms)) + " ms · " + (P.cut > 0 ? "7,200" : "3,600") + " steps");
setTxt("chartTag", fmt(P.N) + " people · " + windowDaysSafe() + " days");
plot.setAttribute("aria-label",
"Outbreak curves over " + windowDaysSafe() + " days for " + fmt(P.N) + " people. "
+ "Peak of " + fmt(run.peak) + " sick at once on day " + Math.round(run.peakT) + ". "
+ fmt(run.total) + " affected in total, " + pct(totPct) + " of the crowd. "
+ "R0 " + R0.toFixed(2) + (P.cut > 0 ? ", R after the slowdown " + Re.toFixed(2) : "") + ".");
if (cursor !== null && cursor > windowDaysSafe()) cursor = null;
draw();
showHover();
}
function windowDaysSafe() { if (!run) return 240; geom(); return W_DAYS; }
function showHover() {
var h = $("hover");
if (cursor === null) {
h.innerHTML = '<span class="k">peak</span> <b>' + fmt(run.peak) + "</b> sick at once on day <b>"
+ Math.round(run.peakT) + '</b><br><span class="k">hover the chart, or focus it and use ← →, to read any day</span>';
return;
}
var d = cursor;
var Rt = P.beta * P.D * (run.S[d] / P.N) * ((P.cut > 0 && d >= P.cutDay) ? (1 - P.cut) : 1);
h.innerHTML = '<span class="k">day</span> <b>' + d + "</b> "
+ '<span class="k">sick now</span> <b>' + fmt(run.I[d]) + "</b> "
+ '<span class="k">not yet</span> <b>' + fmt(run.S[d]) + "</b> "
+ '<span class="k">past it</span> <b>' + fmt(run.R[d]) + "</b>"
+ '<br><span class="k">R right now</span> <b>' + Rt.toFixed(2) + "</b> "
+ '<span class="k">' + (Rt >= 1 ? "· above 1, still growing" : "· below 1, shrinking") + "</span>"
+ (P.cut > 0 ? ' <span class="k">· without the slowdown</span> <b>' + fmt(base.I[d]) + "</b>" : "");
}
function dayFromX(clientX) {
var r = plot.getBoundingClientRect();
var x = clientX - r.left;
var innerW = (PW - L - Rp);
if (innerW <= 0) return null;
var d = Math.round(((x - L) / innerW) * W_DAYS);
return Math.max(0, Math.min(W_DAYS, d));
}
function onMove(e) {
var d = dayFromX(e.clientX);
if (d === null) return;
cursor = d; draw(); showHover();
}
plot.addEventListener("pointermove", onMove);
plot.addEventListener("pointerdown", function (e) { onMove(e); plot.focus(); });
plot.addEventListener("pointerleave", function (e) {
/* a touch tap ends with a leave — keep the reading on screen instead of flashing it */
if (e && e.pointerType === "touch") return;
cursor = null; draw(); showHover();
});
plot.addEventListener("focus", function () {
if (cursor === null) {
cursor = Math.min(windowDaysSafe(), Math.round(run.peakT));
draw(); showHover();
}
});
plot.addEventListener("blur", function () { cursor = null; draw(); showHover(); });
plot.addEventListener("keydown", function (e) {
var k = e.key;
if (k !== "ArrowRight" && k !== "ArrowLeft" && k !== "Home" && k !== "End") return;
var step = e.shiftKey ? 10 : 1, max = windowDaysSafe();
if (cursor === null) cursor = Math.min(max, Math.round(run.peakT));
if (k === "ArrowRight") cursor = Math.min(max, cursor + step);
else if (k === "ArrowLeft") cursor = Math.max(0, cursor - step);
else if (k === "Home") cursor = 0;
else cursor = max;
e.preventDefault(); draw(); showHover();
});
/* ── theme + resize wiring ──────────────────────────────────────────────── */
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
root.setAttribute("data-theme", d.theme);
readTheme(); draw();
}
});
function onScheme() { readTheme(); draw(); }
if (mqDark) {
if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
else if (mqDark.addListener) mqDark.addListener(onScheme);
}
function onRM() { reduced = !!(mqRM && mqRM.matches); if (reduced && sweepRaf) { cancelAnimationFrame(sweepRaf); sweepRaf = 0; sweep = 1; draw(); } }
if (mqRM) {
if (mqRM.addEventListener) mqRM.addEventListener("change", onRM);
else if (mqRM.addListener) mqRM.addListener(onRM);
}
var rt = 0;
window.addEventListener("resize", function () {
clearTimeout(rt);
rt = setTimeout(function () { draw(); }, 90);
});
/* ── go ─────────────────────────────────────────────────────────────────── */
readTheme();
syncInputs();
recompute();
startSweep();
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.