The simulation below runs a discrete-time SEIR model on a population of 10 000. Adjust the sliders to change the transmission rate β, the incubation rate σ (how fast exposed people become infectious), and the recovery rate γ.
<!-- SEIR simulation controls and chart canvas -->
<div class="controls">
<label>
<span>β — Transmission rate (contacts × probability):</span>
<input type="range" id="beta" min="0.05" max="0.8" step="0.01" value="0.3">
<span id="betaVal" class="val">0.30</span>
</label>
<label>
<span>σ — Incubation rate (1/σ = incubation days):</span>
<input type="range" id="sigma" min="0.05" max="1.0" step="0.01" value="0.2">
<span id="sigmaVal" class="val">0.20</span>
</label>
<label>
<span>γ — Recovery rate (1/γ = infectious days):</span>
<input type="range" id="gamma" min="0.05" max="0.5" step="0.01" value="0.1">
<span id="gammaVal" class="val">0.10</span>
</label>
</div>
<canvas id="chart" width="560" height="260" aria-label="SEIR epidemic curve chart"></canvas>
<div id="info" class="info"></div>
<div class="legend">
<span class="dot s"></span>Susceptible
<span class="dot e"></span>Exposed
<span class="dot i"></span>Infectious
<span class="dot r"></span>Recovered
</div>
/* Styles for the SEIR model demo */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; padding: .5rem; }
.controls { display: flex; flex-direction: column; gap: .4rem; margin-bottom: .6rem; }
label { display: flex; align-items: center; gap: .5rem; font-size: .85rem; }
label span:first-child { width: 13rem; flex-shrink: 0; }
input[type=range] { flex: 1; accent-color: #1d3557; }
.val { width: 3rem; text-align: right; font-variant-numeric: tabular-nums; }
canvas { display: block; width: 100%; max-width: 560px; border: 1px solid #dde3ea; border-radius: 8px; background: #f8fafc; }
.info { font-size: .85rem; margin-top: .5rem; color: #444; min-height: 2.2em; line-height: 1.5; }
.legend { display: flex; align-items: center; gap: .8rem; font-size: .8rem; margin-top: .4rem; flex-wrap: wrap; }
.dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; margin-right: .2rem; }
.dot.s { background: #457b9d; }
.dot.e { background: #e9c46a; }
.dot.i { background: #e63946; }
.dot.r { background: #2a9d8f; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// SEIR compartmental epidemic model — Susceptible, Exposed, Infectious, Recovered
// Parameters: beta=transmission rate, sigma=incubation rate, gamma=recovery rate
const N = 10000;
const DAYS = 200;
const COLORS = { S: '#457b9d', E: '#e9c46a', I: '#e63946', R: '#2a9d8f' };
// Run the SEIR model for DAYS time steps using Euler integration
function runSEIR(beta, sigma, gamma) {
// Start: one infectious individual in a fully susceptible population
let S = N - 1, E = 0, I = 1, R = 0;
const ts = [], ts_E = [], ts_I = [], ts_S = [], ts_R = [];
for (let d = 0; d <= DAYS; d++) {
ts.push(d);
ts_S.push(S / N);
ts_E.push(E / N);
ts_I.push(I / N);
ts_R.push(R / N);
// Discrete-time Euler step for the SEIR ODEs
const newExposed = beta * S * I / N;
const newInfectious = sigma * E;
const newRecovered = gamma * I;
S -= newExposed;
E += newExposed - newInfectious;
I += newInfectious - newRecovered;
R += newRecovered;
}
return { ts, ts_S, ts_E, ts_I, ts_R };
}
// Draw the epidemic curves on the canvas
function draw() {
const beta = parseFloat(document.getElementById('beta').value);
const sigma = parseFloat(document.getElementById('sigma').value);
const gamma = parseFloat(document.getElementById('gamma').value);
document.getElementById('betaVal').textContent = beta.toFixed(2);
document.getElementById('sigmaVal').textContent = sigma.toFixed(2);
document.getElementById('gammaVal').textContent = gamma.toFixed(2);
const { ts, ts_S, ts_E, ts_I, ts_R } = runSEIR(beta, sigma, gamma);
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PAD = { top: 14, right: 14, bottom: 30, left: 40 };
const cw = W - PAD.left - PAD.right;
const ch = H - PAD.top - PAD.bottom;
ctx.clearRect(0, 0, W, H);
// Draw coordinate axes
ctx.strokeStyle = '#c8d4de';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD.left, PAD.top);
ctx.lineTo(PAD.left, PAD.top + ch);
ctx.lineTo(PAD.left + cw, PAD.top + ch);
ctx.stroke();
// Draw horizontal percentage gridlines
ctx.setLineDash([3, 3]);
ctx.strokeStyle = '#dde3ea';
for (let g = 0.25; g < 1; g += 0.25) {
const y = PAD.top + ch - g * ch;
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((g * 100).toFixed(0) + '%', PAD.left - 4, y + 4);
}
ctx.setLineDash([]);
// Draw day labels on the x-axis
ctx.fillStyle = '#888';
ctx.font = '10px system-ui';
ctx.textAlign = 'center';
for (let d = 0; d <= DAYS; d += 50) {
const x = PAD.left + (d / DAYS) * cw;
ctx.fillText(d, x, PAD.top + ch + 14);
}
// Draw S, E, I, R curves
const series = [
{ data: ts_S, color: COLORS.S },
{ data: ts_E, color: COLORS.E },
{ data: ts_I, color: COLORS.I },
{ data: ts_R, color: COLORS.R }
];
for (const { data, color } of series) {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < ts.length; i++) {
const x = PAD.left + (ts[i] / DAYS) * cw;
const y = PAD.top + ch - data[i] * ch;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
}
// Compute summary statistics for the info bar
const peakDay = ts_I.indexOf(Math.max(...ts_I));
const peakPct = (Math.max(...ts_I) * 100).toFixed(1);
const r0 = (beta / gamma).toFixed(2);
const totalInfected = (ts_R[ts_R.length - 1] * 100).toFixed(1);
const incubDays = (1 / sigma).toFixed(1);
document.getElementById('info').innerHTML =
i18nFmt('R₀ = %r0% · Peak infectious: %peak%% on day %day% · Mean incubation: %incub% days · Total infected: %total%%', { r0, peak: peakPct, day: peakDay, incub: incubDays, total: totalInfected });
}
// Re-draw whenever any slider moves
['beta', 'sigma', 'gamma'].forEach(id => {
document.getElementById(id).addEventListener('input', draw);
});
draw();
<!-- SEIR simulation controls and chart canvas -->
<div class="controls">
<label>
<span>β — Transmission rate (contacts × probability):</span>
<input type="range" id="beta" min="0.05" max="0.8" step="0.01" value="0.3">
<span id="betaVal" class="val">0.30</span>
</label>
<label>
<span>σ — Incubation rate (1/σ = incubation days):</span>
<input type="range" id="sigma" min="0.05" max="1.0" step="0.01" value="0.2">
<span id="sigmaVal" class="val">0.20</span>
</label>
<label>
<span>γ — Recovery rate (1/γ = infectious days):</span>
<input type="range" id="gamma" min="0.05" max="0.5" step="0.01" value="0.1">
<span id="gammaVal" class="val">0.10</span>
</label>
</div>
<canvas id="chart" width="560" height="260" aria-label="SEIR epidemic curve chart"></canvas>
<div id="info" class="info"></div>
<div class="legend">
<span class="dot s"></span>Susceptible
<span class="dot e"></span>Exposed
<span class="dot i"></span>Infectious
<span class="dot r"></span>Recovered
</div>
/* Styles for the SEIR model demo */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; padding: .5rem; }
.controls { display: flex; flex-direction: column; gap: .4rem; margin-bottom: .6rem; }
label { display: flex; align-items: center; gap: .5rem; font-size: .85rem; }
label span:first-child { width: 13rem; flex-shrink: 0; }
input[type=range] { flex: 1; accent-color: #1d3557; }
.val { width: 3rem; text-align: right; font-variant-numeric: tabular-nums; }
canvas { display: block; width: 100%; max-width: 560px; border: 1px solid #dde3ea; border-radius: 8px; background: #f8fafc; }
.info { font-size: .85rem; margin-top: .5rem; color: #444; min-height: 2.2em; line-height: 1.5; }
.legend { display: flex; align-items: center; gap: .8rem; font-size: .8rem; margin-top: .4rem; flex-wrap: wrap; }
.dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; margin-right: .2rem; }
.dot.s { background: #457b9d; }
.dot.e { background: #e9c46a; }
.dot.i { background: #e63946; }
.dot.r { background: #2a9d8f; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// SEIR compartmental epidemic model — Susceptible, Exposed, Infectious, Recovered
// Parameters: beta=transmission rate, sigma=incubation rate, gamma=recovery rate
const N = 10000;
const DAYS = 200;
const COLORS = { S: '#457b9d', E: '#e9c46a', I: '#e63946', R: '#2a9d8f' };
// Run the SEIR model for DAYS time steps using Euler integration
function runSEIR(beta, sigma, gamma) {
// Start: one infectious individual in a fully susceptible population
let S = N - 1, E = 0, I = 1, R = 0;
const ts = [], ts_E = [], ts_I = [], ts_S = [], ts_R = [];
for (let d = 0; d <= DAYS; d++) {
ts.push(d);
ts_S.push(S / N);
ts_E.push(E / N);
ts_I.push(I / N);
ts_R.push(R / N);
// Discrete-time Euler step for the SEIR ODEs
const newExposed = beta * S * I / N;
const newInfectious = sigma * E;
const newRecovered = gamma * I;
S -= newExposed;
E += newExposed - newInfectious;
I += newInfectious - newRecovered;
R += newRecovered;
}
return { ts, ts_S, ts_E, ts_I, ts_R };
}
// Draw the epidemic curves on the canvas
function draw() {
const beta = parseFloat(document.getElementById('beta').value);
const sigma = parseFloat(document.getElementById('sigma').value);
const gamma = parseFloat(document.getElementById('gamma').value);
document.getElementById('betaVal').textContent = beta.toFixed(2);
document.getElementById('sigmaVal').textContent = sigma.toFixed(2);
document.getElementById('gammaVal').textContent = gamma.toFixed(2);
const { ts, ts_S, ts_E, ts_I, ts_R } = runSEIR(beta, sigma, gamma);
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PAD = { top: 14, right: 14, bottom: 30, left: 40 };
const cw = W - PAD.left - PAD.right;
const ch = H - PAD.top - PAD.bottom;
ctx.clearRect(0, 0, W, H);
// Draw coordinate axes
ctx.strokeStyle = '#c8d4de';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD.left, PAD.top);
ctx.lineTo(PAD.left, PAD.top + ch);
ctx.lineTo(PAD.left + cw, PAD.top + ch);
ctx.stroke();
// Draw horizontal percentage gridlines
ctx.setLineDash([3, 3]);
ctx.strokeStyle = '#dde3ea';
for (let g = 0.25; g < 1; g += 0.25) {
const y = PAD.top + ch - g * ch;
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((g * 100).toFixed(0) + '%', PAD.left - 4, y + 4);
}
ctx.setLineDash([]);
// Draw day labels on the x-axis
ctx.fillStyle = '#888';
ctx.font = '10px system-ui';
ctx.textAlign = 'center';
for (let d = 0; d <= DAYS; d += 50) {
const x = PAD.left + (d / DAYS) * cw;
ctx.fillText(d, x, PAD.top + ch + 14);
}
// Draw S, E, I, R curves
const series = [
{ data: ts_S, color: COLORS.S },
{ data: ts_E, color: COLORS.E },
{ data: ts_I, color: COLORS.I },
{ data: ts_R, color: COLORS.R }
];
for (const { data, color } of series) {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < ts.length; i++) {
const x = PAD.left + (ts[i] / DAYS) * cw;
const y = PAD.top + ch - data[i] * ch;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
}
// Compute summary statistics for the info bar
const peakDay = ts_I.indexOf(Math.max(...ts_I));
const peakPct = (Math.max(...ts_I) * 100).toFixed(1);
const r0 = (beta / gamma).toFixed(2);
const totalInfected = (ts_R[ts_R.length - 1] * 100).toFixed(1);
const incubDays = (1 / sigma).toFixed(1);
document.getElementById('info').innerHTML =
i18nFmt('R₀ = %r0% · Peak infectious: %peak%% on day %day% · Mean incubation: %incub% days · Total infected: %total%%', { r0, peak: peakPct, day: peakDay, incub: incubDays, total: totalInfected });
}
// Re-draw whenever any slider moves
['beta', 'sigma', 'gamma'].forEach(id => {
document.getElementById(id).addEventListener('input', draw);
});
draw();
Comments
Loading comments...