$sloprun.dev

Outbreak Curve Simulator

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 6 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

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.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/epidemic-sim" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Outbreak Curve Simulator — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/epidemic-sim

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 849 lines 41.1 KB index.html
demos/epidemic-sim/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 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong