Drag the R0 slider across the critical value of 1 and watch what happens to the simulated outbreak. The curves show the classic SIR model — Susceptible, Infected, Recovered — evolving over time in a closed population.
<!-- SIR epidemic simulation — drag R0 slider to cross the epidemic threshold -->
<div class="controls">
<label for="r0slider">
<span class="label-text">R₀ =</span>
<span id="r0val" class="r0val">2.50</span>
</label>
<input id="r0slider" type="range" min="0.1" max="5.0" step="0.05" value="2.50">
<div class="threshold-note" id="thresholdNote"></div>
</div>
<canvas id="chart" width="520" height="260"></canvas>
<div class="legend">
<span class="dot s-dot"></span> Susceptible
<span class="dot i-dot"></span> Infected
<span class="dot r-dot"></span> Recovered
</div>
<div class="stats" id="stats"></div>
<div class="btns">
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
/* Styles for the SIR epidemic threshold demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.controls { display: flex; flex-direction: column; gap: .3rem; margin-bottom: .6rem; }
label { display: flex; align-items: center; gap: .6rem; font-size: .9rem; font-weight: 600; }
.label-text { min-width: 3.2rem; }
.r0val { font-size: 1.15rem; font-weight: 700; min-width: 3rem; color: #1d3557; }
input[type=range] { flex: 1; accent-color: #1d3557; }
.threshold-note { font-size: .85rem; font-weight: 600; min-height: 1.3em; padding: .15rem .5rem; border-radius: 6px; }
.threshold-note.below { background: #d4edda; color: #155724; }
.threshold-note.above { background: #f8d7da; color: #721c24; }
canvas { display: block; width: 100%; max-width: 520px; border: 1px solid #dde3ea; border-radius: 10px; background: #f9fbfd; }
.legend { display: flex; gap: 1rem; font-size: .82rem; margin: .4rem 0; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 3px; }
.s-dot { background: #2196f3; }
.i-dot { background: #e63946; }
.r-dot { background: #43a047; }
.stats { font-size: .85rem; color: #444; min-height: 1.4em; margin-bottom: .4rem; }
.btns { display: flex; gap: .5rem; }
button { font: 600 14px system-ui, sans-serif; padding: .4rem .85rem; border: 1px solid #1d3557; background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
/* SIR epidemic threshold demo — vanilla JS, no imports */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* SIR model parameters */
const N = 10000; // total population
const I0 = 10; // initial infected
const DAYS = 160; // simulation length in days
const GAMMA = 1 / 10; // recovery rate = 1 / (10-day infectious period)
function runSIR(R0) {
// Euler integration of SIR ODEs
const beta = R0 * GAMMA;
const S = [N - I0], I = [I0], R = [0];
for (let d = 0; d < DAYS - 1; d++) {
const s = S[d], i = I[d], r = R[d];
const dS = -(beta / N) * s * i;
const dI = (beta / N) * s * i - GAMMA * i;
const dR = GAMMA * i;
S.push(Math.max(0, s + dS));
I.push(Math.max(0, i + dI));
R.push(r + dR);
}
return { S, I, R };
}
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PAD = { top: 16, right: 12, bottom: 28, left: 42 };
function drawChart(data, R0) {
// Draw S, I, R curves onto the canvas
ctx.clearRect(0, 0, W, H);
const cw = W - PAD.left - PAD.right;
const ch = H - PAD.top - PAD.bottom;
// horizontal grid lines
ctx.strokeStyle = '#e0e6ed';
ctx.lineWidth = 1;
for (let p = 0; p <= 4; p++) {
const y = PAD.top + ch * (1 - p / 4);
ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(PAD.left + cw, y); ctx.stroke();
ctx.fillStyle = '#888'; ctx.font = '10px system-ui'; ctx.textAlign = 'right';
ctx.fillText(Math.round(N * p / 4 / 1000) + 'k', PAD.left - 4, y + 4);
}
// x-axis day labels
ctx.fillStyle = '#888'; ctx.font = '10px system-ui'; ctx.textAlign = 'center';
for (let d = 0; d <= DAYS; d += 40) {
const x = PAD.left + (d / DAYS) * cw;
ctx.fillText(d, x, H - PAD.bottom + 14);
}
// faint midline when R0 < 1 to show how small the peak is
if (R0 < 1) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = '#f0b429';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(PAD.left, PAD.top + ch * 0.5);
ctx.lineTo(PAD.left + cw, PAD.top + ch * 0.5);
ctx.stroke();
ctx.setLineDash([]);
}
const series = [
{ arr: data.S, color: '#2196f3' },
{ arr: data.I, color: '#e63946' },
{ arr: data.R, color: '#43a047' },
];
series.forEach(({ arr, color }) => {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.setLineDash([]);
ctx.beginPath();
arr.forEach((v, d) => {
const x = PAD.left + (d / (DAYS - 1)) * cw;
const y = PAD.top + ch * (1 - v / N);
d === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
});
}
function update() {
const R0 = parseFloat(document.getElementById('r0slider').value);
document.getElementById('r0val').textContent = R0.toFixed(2);
const note = document.getElementById('thresholdNote');
if (R0 < 1) {
note.textContent = 'R₀ < 1 — outbreak dies out on its own.';
note.className = 'threshold-note below';
} else if (R0 === 1) {
note.textContent = 'R₀ = 1 — borderline: no growth, no extinction.';
note.className = 'threshold-note above';
} else {
note.textContent = i18nFmt('R₀ = %r% — epidemic takes off.', { r: R0.toFixed(2) });
note.className = 'threshold-note above';
}
const data = runSIR(R0);
drawChart(data, R0);
// summary statistics below the chart
const peakI = Math.round(Math.max(...data.I));
const peakDay = data.I.indexOf(Math.max(...data.I));
const totalR = Math.round(data.R[DAYS - 1]);
document.getElementById('stats').textContent =
i18nFmt('Peak infected: %peak% (day %day%) · Total infected: %total%', { peak: peakI.toLocaleString(), day: peakDay, total: totalR.toLocaleString() });
}
document.getElementById('r0slider').addEventListener('input', update);
document.getElementById('resetBtn').addEventListener('click', () => {
document.getElementById('r0slider').value = 2.50;
update();
});
update();
<!-- SIR epidemic simulation — drag R0 slider to cross the epidemic threshold -->
<div class="controls">
<label for="r0slider">
<span class="label-text">R₀ =</span>
<span id="r0val" class="r0val">2.50</span>
</label>
<input id="r0slider" type="range" min="0.1" max="5.0" step="0.05" value="2.50">
<div class="threshold-note" id="thresholdNote"></div>
</div>
<canvas id="chart" width="520" height="260"></canvas>
<div class="legend">
<span class="dot s-dot"></span> Susceptible
<span class="dot i-dot"></span> Infected
<span class="dot r-dot"></span> Recovered
</div>
<div class="stats" id="stats"></div>
<div class="btns">
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
/* Styles for the SIR epidemic threshold demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.controls { display: flex; flex-direction: column; gap: .3rem; margin-bottom: .6rem; }
label { display: flex; align-items: center; gap: .6rem; font-size: .9rem; font-weight: 600; }
.label-text { min-width: 3.2rem; }
.r0val { font-size: 1.15rem; font-weight: 700; min-width: 3rem; color: #1d3557; }
input[type=range] { flex: 1; accent-color: #1d3557; }
.threshold-note { font-size: .85rem; font-weight: 600; min-height: 1.3em; padding: .15rem .5rem; border-radius: 6px; }
.threshold-note.below { background: #d4edda; color: #155724; }
.threshold-note.above { background: #f8d7da; color: #721c24; }
canvas { display: block; width: 100%; max-width: 520px; border: 1px solid #dde3ea; border-radius: 10px; background: #f9fbfd; }
.legend { display: flex; gap: 1rem; font-size: .82rem; margin: .4rem 0; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 3px; }
.s-dot { background: #2196f3; }
.i-dot { background: #e63946; }
.r-dot { background: #43a047; }
.stats { font-size: .85rem; color: #444; min-height: 1.4em; margin-bottom: .4rem; }
.btns { display: flex; gap: .5rem; }
button { font: 600 14px system-ui, sans-serif; padding: .4rem .85rem; border: 1px solid #1d3557; background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
/* SIR epidemic threshold demo — vanilla JS, no imports */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* SIR model parameters */
const N = 10000; // total population
const I0 = 10; // initial infected
const DAYS = 160; // simulation length in days
const GAMMA = 1 / 10; // recovery rate = 1 / (10-day infectious period)
function runSIR(R0) {
// Euler integration of SIR ODEs
const beta = R0 * GAMMA;
const S = [N - I0], I = [I0], R = [0];
for (let d = 0; d < DAYS - 1; d++) {
const s = S[d], i = I[d], r = R[d];
const dS = -(beta / N) * s * i;
const dI = (beta / N) * s * i - GAMMA * i;
const dR = GAMMA * i;
S.push(Math.max(0, s + dS));
I.push(Math.max(0, i + dI));
R.push(r + dR);
}
return { S, I, R };
}
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PAD = { top: 16, right: 12, bottom: 28, left: 42 };
function drawChart(data, R0) {
// Draw S, I, R curves onto the canvas
ctx.clearRect(0, 0, W, H);
const cw = W - PAD.left - PAD.right;
const ch = H - PAD.top - PAD.bottom;
// horizontal grid lines
ctx.strokeStyle = '#e0e6ed';
ctx.lineWidth = 1;
for (let p = 0; p <= 4; p++) {
const y = PAD.top + ch * (1 - p / 4);
ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(PAD.left + cw, y); ctx.stroke();
ctx.fillStyle = '#888'; ctx.font = '10px system-ui'; ctx.textAlign = 'right';
ctx.fillText(Math.round(N * p / 4 / 1000) + 'k', PAD.left - 4, y + 4);
}
// x-axis day labels
ctx.fillStyle = '#888'; ctx.font = '10px system-ui'; ctx.textAlign = 'center';
for (let d = 0; d <= DAYS; d += 40) {
const x = PAD.left + (d / DAYS) * cw;
ctx.fillText(d, x, H - PAD.bottom + 14);
}
// faint midline when R0 < 1 to show how small the peak is
if (R0 < 1) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = '#f0b429';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(PAD.left, PAD.top + ch * 0.5);
ctx.lineTo(PAD.left + cw, PAD.top + ch * 0.5);
ctx.stroke();
ctx.setLineDash([]);
}
const series = [
{ arr: data.S, color: '#2196f3' },
{ arr: data.I, color: '#e63946' },
{ arr: data.R, color: '#43a047' },
];
series.forEach(({ arr, color }) => {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.setLineDash([]);
ctx.beginPath();
arr.forEach((v, d) => {
const x = PAD.left + (d / (DAYS - 1)) * cw;
const y = PAD.top + ch * (1 - v / N);
d === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
});
}
function update() {
const R0 = parseFloat(document.getElementById('r0slider').value);
document.getElementById('r0val').textContent = R0.toFixed(2);
const note = document.getElementById('thresholdNote');
if (R0 < 1) {
note.textContent = 'R₀ < 1 — outbreak dies out on its own.';
note.className = 'threshold-note below';
} else if (R0 === 1) {
note.textContent = 'R₀ = 1 — borderline: no growth, no extinction.';
note.className = 'threshold-note above';
} else {
note.textContent = i18nFmt('R₀ = %r% — epidemic takes off.', { r: R0.toFixed(2) });
note.className = 'threshold-note above';
}
const data = runSIR(R0);
drawChart(data, R0);
// summary statistics below the chart
const peakI = Math.round(Math.max(...data.I));
const peakDay = data.I.indexOf(Math.max(...data.I));
const totalR = Math.round(data.R[DAYS - 1]);
document.getElementById('stats').textContent =
i18nFmt('Peak infected: %peak% (day %day%) · Total infected: %total%', { peak: peakI.toLocaleString(), day: peakDay, total: totalR.toLocaleString() });
}
document.getElementById('r0slider').addEventListener('input', update);
document.getElementById('resetBtn').addEventListener('click', () => {
document.getElementById('r0slider').value = 2.50;
update();
});
update();
Notice the sharp transition. When R0<1 the infected curve barely rises before falling to zero — the outbreak burns out. The moment R0 crosses 1 the infected curve climbs, peak infections grow, and a large fraction of the population is swept through. This is not a gradual change: it is a phase transition, the same mathematical phenomenon that appears in statistical physics and percolation theory.
Comments
Loading comments...