<!-- SIR model demo — two sliders control R0 and recovery rate -->
<div class="controls">
<label>
<span>R₀ (basic reproduction number): <strong id="r0val">2.5</strong></span>
<input type="range" id="r0" min="0.5" max="5" step="0.1" value="2.5">
</label>
<label>
<span>Recovery rate γ (per day): <strong id="gammaval">0.10</strong></span>
<input type="range" id="gamma" min="0.05" max="0.30" step="0.01" value="0.10">
</label>
</div>
<canvas id="chart" width="560" height="280"></canvas>
<div class="legend">
<span class="dot s"></span> Susceptible
<span class="dot i"></span> Infected
<span class="dot r"></span> Recovered
</div>
<div id="summary" class="summary"></div>
/* Styles for the SIR epidemic model demo */
* { box-sizing: border-box; margin: 0; }
body { font-family: system-ui, sans-serif; color: #222; padding: .5rem; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .8rem; }
label { display: flex; align-items: center; gap: .8rem; font-size: .9rem; }
label span { min-width: 10rem; }
input[type=range] { flex: 1; accent-color: #1d3557; }
canvas { display: block; width: 100%; max-width: 560px; border: 1px solid #cdd9e3; border-radius: 8px; background: #f8fafc; }
.legend { display: flex; gap: 1.2rem; font-size: .85rem; margin-top: .5rem; align-items: center; }
.dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; margin-right: .3rem; }
.dot.s { background: #1d6fa4; }
.dot.i { background: #e63946; }
.dot.r { background: #2a9d5c; }
.summary { margin-top: .6rem; font-size: .9rem; line-height: 1.6; color: #333; }
strong { font-weight: 700; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// SIR model: S = susceptible, I = infected, R = recovered (fractions of population)
// dS/dt = -beta*S*I, dI/dt = beta*S*I - gamma*I, dR/dt = gamma*I
// R0 = beta / gamma; outbreak grows when R0 > 1
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PAD = { top: 16, right: 16, bottom: 32, left: 40 };
// Run a discrete-time Euler approximation of the SIR ODEs
function simulate(R0, gamma) {
const beta = R0 * gamma;
// start with 0.1% infected, rest susceptible
let S = 0.999, I = 0.001, R = 0;
const out = [{ S, I, R }];
const dt = 0.5; // days per step
for (let t = 0; t < 300; t++) {
const dS = -beta * S * I * dt;
const dI = (beta * S * I - gamma * I) * dt;
const dR = gamma * I * dt;
S = Math.max(0, S + dS);
I = Math.max(0, I + dI);
R = Math.min(1, R + dR);
out.push({ S, I, R });
}
return out;
}
// Draw S, I, R curves on the canvas
function draw(data, R0, gamma) {
ctx.clearRect(0, 0, W, H);
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
// Draw axes
ctx.strokeStyle = '#cdd9e3';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD.left, PAD.top);
ctx.lineTo(PAD.left, PAD.top + plotH);
ctx.lineTo(PAD.left + plotW, PAD.top + plotH);
ctx.stroke();
// Day labels on x-axis and proportion labels on y-axis
ctx.fillStyle = '#666';
ctx.font = '11px system-ui, sans-serif';
ctx.textAlign = 'center';
for (let x = 0; x <= 5; x++) {
const px = PAD.left + (x / 5) * plotW;
const label = Math.round((x / 5) * data.length * 0.5);
ctx.fillText(label, px, PAD.top + plotH + 18);
}
ctx.textAlign = 'right';
for (let y = 0; y <= 4; y++) {
const py = PAD.top + (y / 4) * plotH;
ctx.fillText((1 - y / 4).toFixed(2), PAD.left - 4, py + 4);
}
// Draw colored lines for each compartment
function line(key, color) {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
data.forEach((d, i) => {
const px = PAD.left + (i / (data.length - 1)) * plotW;
const py = PAD.top + (1 - d[key]) * plotH;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
});
ctx.stroke();
}
line('S', '#1d6fa4');
line('I', '#e63946');
line('R', '#2a9d5c');
}
// Compute and display the epidemic summary statistics
function updateSummary(data, R0, gamma) {
const peakI = Math.max(...data.map(d => d.I));
const peakDay = data.findIndex(d => d.I >= peakI - 1e-9);
const finalR = data[data.length - 1].R;
const threshold = 1 - 1 / Math.max(R0, 0.01);
const survived = 1 - finalR;
const el = document.getElementById('summary');
if (R0 <= 1) {
el.innerHTML = i18nFmt('<b>R₀ = %r0%</b> ≤ 1 — no outbreak: each infected person passes the disease to fewer than one other, so it fades away immediately.', { r0: R0.toFixed(1) });
} else {
el.innerHTML = i18nFmt('<b>R₀ = %r0%</b> — Peak infected: <b>%peak%%</b> of the population (day %day%). Total ever infected: <b>%total%%</b>. Never infected: <b>%survived%%</b>. Herd-immunity threshold: %threshold%%.', {
r0: R0.toFixed(1),
peak: (peakI * 100).toFixed(1),
day: peakDay,
total: (finalR * 100).toFixed(1),
survived: (survived * 100).toFixed(1),
threshold: (threshold * 100).toFixed(1)
});
}
}
function update() {
const R0 = parseFloat(document.getElementById('r0').value);
const gamma = parseFloat(document.getElementById('gamma').value);
document.getElementById('r0val').textContent = R0.toFixed(1);
document.getElementById('gammaval').textContent = gamma.toFixed(2);
const data = simulate(R0, gamma);
draw(data, R0, gamma);
updateSummary(data, R0, gamma);
}
document.getElementById('r0').addEventListener('input', update);
document.getElementById('gamma').addEventListener('input', update);
update();
<!-- SIR model demo — two sliders control R0 and recovery rate -->
<div class="controls">
<label>
<span>R₀ (basic reproduction number): <strong id="r0val">2.5</strong></span>
<input type="range" id="r0" min="0.5" max="5" step="0.1" value="2.5">
</label>
<label>
<span>Recovery rate γ (per day): <strong id="gammaval">0.10</strong></span>
<input type="range" id="gamma" min="0.05" max="0.30" step="0.01" value="0.10">
</label>
</div>
<canvas id="chart" width="560" height="280"></canvas>
<div class="legend">
<span class="dot s"></span> Susceptible
<span class="dot i"></span> Infected
<span class="dot r"></span> Recovered
</div>
<div id="summary" class="summary"></div>
/* Styles for the SIR epidemic model demo */
* { box-sizing: border-box; margin: 0; }
body { font-family: system-ui, sans-serif; color: #222; padding: .5rem; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .8rem; }
label { display: flex; align-items: center; gap: .8rem; font-size: .9rem; }
label span { min-width: 10rem; }
input[type=range] { flex: 1; accent-color: #1d3557; }
canvas { display: block; width: 100%; max-width: 560px; border: 1px solid #cdd9e3; border-radius: 8px; background: #f8fafc; }
.legend { display: flex; gap: 1.2rem; font-size: .85rem; margin-top: .5rem; align-items: center; }
.dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; margin-right: .3rem; }
.dot.s { background: #1d6fa4; }
.dot.i { background: #e63946; }
.dot.r { background: #2a9d5c; }
.summary { margin-top: .6rem; font-size: .9rem; line-height: 1.6; color: #333; }
strong { font-weight: 700; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// SIR model: S = susceptible, I = infected, R = recovered (fractions of population)
// dS/dt = -beta*S*I, dI/dt = beta*S*I - gamma*I, dR/dt = gamma*I
// R0 = beta / gamma; outbreak grows when R0 > 1
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PAD = { top: 16, right: 16, bottom: 32, left: 40 };
// Run a discrete-time Euler approximation of the SIR ODEs
function simulate(R0, gamma) {
const beta = R0 * gamma;
// start with 0.1% infected, rest susceptible
let S = 0.999, I = 0.001, R = 0;
const out = [{ S, I, R }];
const dt = 0.5; // days per step
for (let t = 0; t < 300; t++) {
const dS = -beta * S * I * dt;
const dI = (beta * S * I - gamma * I) * dt;
const dR = gamma * I * dt;
S = Math.max(0, S + dS);
I = Math.max(0, I + dI);
R = Math.min(1, R + dR);
out.push({ S, I, R });
}
return out;
}
// Draw S, I, R curves on the canvas
function draw(data, R0, gamma) {
ctx.clearRect(0, 0, W, H);
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
// Draw axes
ctx.strokeStyle = '#cdd9e3';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD.left, PAD.top);
ctx.lineTo(PAD.left, PAD.top + plotH);
ctx.lineTo(PAD.left + plotW, PAD.top + plotH);
ctx.stroke();
// Day labels on x-axis and proportion labels on y-axis
ctx.fillStyle = '#666';
ctx.font = '11px system-ui, sans-serif';
ctx.textAlign = 'center';
for (let x = 0; x <= 5; x++) {
const px = PAD.left + (x / 5) * plotW;
const label = Math.round((x / 5) * data.length * 0.5);
ctx.fillText(label, px, PAD.top + plotH + 18);
}
ctx.textAlign = 'right';
for (let y = 0; y <= 4; y++) {
const py = PAD.top + (y / 4) * plotH;
ctx.fillText((1 - y / 4).toFixed(2), PAD.left - 4, py + 4);
}
// Draw colored lines for each compartment
function line(key, color) {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
data.forEach((d, i) => {
const px = PAD.left + (i / (data.length - 1)) * plotW;
const py = PAD.top + (1 - d[key]) * plotH;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
});
ctx.stroke();
}
line('S', '#1d6fa4');
line('I', '#e63946');
line('R', '#2a9d5c');
}
// Compute and display the epidemic summary statistics
function updateSummary(data, R0, gamma) {
const peakI = Math.max(...data.map(d => d.I));
const peakDay = data.findIndex(d => d.I >= peakI - 1e-9);
const finalR = data[data.length - 1].R;
const threshold = 1 - 1 / Math.max(R0, 0.01);
const survived = 1 - finalR;
const el = document.getElementById('summary');
if (R0 <= 1) {
el.innerHTML = i18nFmt('<b>R₀ = %r0%</b> ≤ 1 — no outbreak: each infected person passes the disease to fewer than one other, so it fades away immediately.', { r0: R0.toFixed(1) });
} else {
el.innerHTML = i18nFmt('<b>R₀ = %r0%</b> — Peak infected: <b>%peak%%</b> of the population (day %day%). Total ever infected: <b>%total%%</b>. Never infected: <b>%survived%%</b>. Herd-immunity threshold: %threshold%%.', {
r0: R0.toFixed(1),
peak: (peakI * 100).toFixed(1),
day: peakDay,
total: (finalR * 100).toFixed(1),
survived: (survived * 100).toFixed(1),
threshold: (threshold * 100).toFixed(1)
});
}
}
function update() {
const R0 = parseFloat(document.getElementById('r0').value);
const gamma = parseFloat(document.getElementById('gamma').value);
document.getElementById('r0val').textContent = R0.toFixed(1);
document.getElementById('gammaval').textContent = gamma.toFixed(2);
const data = simulate(R0, gamma);
draw(data, R0, gamma);
updateSummary(data, R0, gamma);
}
document.getElementById('r0').addEventListener('input', update);
document.getElementById('gamma').addEventListener('input', update);
update();
Comments
Loading comments...