Adjust the sliders to set the transmission rate β (how fast the disease spreads) and the recovery rate γ (how fast infected people clear the infection). The simulation runs the SIS differential equations in discrete time steps and plots the fraction of the population that is currently infected.
<!-- Controls for beta (transmission rate) and gamma (recovery rate) -->
<div class="controls">
<div class="slider-row">
<label for="beta">Transmission rate <span class="param-name">β</span></label>
<input type="range" id="beta" min="0.1" max="1.0" step="0.01" value="0.4">
<span id="beta-val" class="val-badge">0.40</span>
</div>
<div class="slider-row">
<label for="gamma">Recovery rate <span class="param-name">γ</span></label>
<input type="range" id="gamma" min="0.05" max="0.8" step="0.01" value="0.2">
<span id="gamma-val" class="val-badge">0.20</span>
</div>
<div class="r0-badge" id="r0-badge">R₀ = 2.00</div>
</div>
<canvas id="chart" width="480" height="220"></canvas>
<div class="status" id="status"></div>
<div class="btns">
<button id="run" type="button">Run simulation</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
/* Styles for the SIS endemic model simulator */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .7rem; }
.slider-row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
label { font-size: .85rem; width: 8rem; flex-shrink: 0; }
.param-name { font-weight: 700; font-style: italic; }
input[type=range] { flex: 1; min-width: 120px; accent-color: #1d3557; }
.val-badge { font-size: .85rem; font-weight: 600; width: 3rem; text-align: right; color: #1d3557; }
.r0-badge { font-size: 1rem; font-weight: 700; color: #fff; background: #1d3557;
display: inline-block; padding: .25rem .7rem; border-radius: 8px; margin-top: .2rem; align-self: flex-start; }
.r0-badge.endemic { background: #e63946; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 8px; width: 100%; max-width: 480px; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.extinct { color: #0a7d33; }
.status.endemic { color: #e63946; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// SIS model: di/dt = beta*i*(1-i) - gamma*i
// Endemic equilibrium i* = 1 - gamma/beta = 1 - 1/R0 (exists only when R0 > 1)
// Disease-free equilibrium i* = 0 is stable iff R0 <= 1
const STEPS = 200; // number of discrete time steps to simulate
const DT = 0.5; // step size in time units
const I0 = 0.01; // initial fraction infected (1%)
// DOM element references
const betaSlider = document.getElementById('beta');
const gammaSlider = document.getElementById('gamma');
const betaVal = document.getElementById('beta-val');
const gammaVal = document.getElementById('gamma-val');
const r0Badge = document.getElementById('r0-badge');
const canvas = document.getElementById('chart');
const statusEl = document.getElementById('status');
const ctx = canvas.getContext('2d');
function getParams() {
return { beta: parseFloat(betaSlider.value), gamma: parseFloat(gammaSlider.value) };
}
// Euler integration of the SIS ODE over STEPS steps
function simulate(beta, gamma) {
const series = [I0];
let i = I0;
for (let t = 1; t <= STEPS; t++) {
const di = beta * i * (1 - i) - gamma * i;
i = Math.max(0, Math.min(1, i + DT * di));
series.push(i);
}
return series;
}
// Render the prevalence curve onto the canvas
function draw(series, beta, gamma) {
const W = canvas.width, H = canvas.height;
const pad = { top: 20, right: 20, bottom: 30, left: 44 };
const w = W - pad.left - pad.right;
const h = H - pad.top - pad.bottom;
ctx.clearRect(0, 0, W, H);
// horizontal grid lines and y-axis labels
ctx.strokeStyle = '#e0e7ee';
ctx.lineWidth = 1;
for (let k = 0; k <= 4; k++) {
const y = pad.top + (h / 4) * k;
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + w, y); ctx.stroke();
ctx.fillStyle = '#888'; ctx.font = '10px system-ui'; ctx.textAlign = 'right';
ctx.fillText((1 - k / 4).toFixed(2), pad.left - 4, y + 4);
}
// dashed red line marking the endemic equilibrium when R0 > 1
const R0 = beta / gamma;
if (R0 > 1) {
const endemic = 1 - 1 / R0;
const ey = pad.top + h * (1 - endemic);
ctx.strokeStyle = '#e63946'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(pad.left, ey); ctx.lineTo(pad.left + w, ey); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#e63946'; ctx.font = '10px system-ui'; ctx.textAlign = 'left';
ctx.fillText('I* = ' + endemic.toFixed(2), pad.left + 2, ey - 3);
}
// prevalence curve in dark blue
ctx.strokeStyle = '#1d3557'; ctx.lineWidth = 2.5; ctx.setLineDash([]);
ctx.beginPath();
series.forEach((v, t) => {
const x = pad.left + (t / STEPS) * w;
const y = pad.top + h * (1 - v);
t === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
// x-axis label
ctx.fillStyle = '#888'; ctx.font = '10px system-ui'; ctx.textAlign = 'center';
ctx.fillText('0', pad.left, pad.top + h + 16);
ctx.fillText(String(STEPS), pad.left + w, pad.top + h + 16);
ctx.fillText('time steps', pad.left + w / 2, pad.top + h + 16);
}
function updateBadge(R0) {
r0Badge.textContent = 'R₀ = ' + R0.toFixed(2);
r0Badge.className = 'r0-badge' + (R0 > 1 ? ' endemic' : '');
}
function setStatus(text, cls) {
statusEl.textContent = text;
statusEl.className = 'status ' + cls;
}
// Run a simulation with current slider values and update the display
function run() {
const { beta, gamma } = getParams();
const R0 = beta / gamma;
const series = simulate(beta, gamma);
draw(series, beta, gamma);
updateBadge(R0);
const finalI = series[series.length - 1];
if (R0 <= 1 || finalI < 1e-4) {
setStatus(i18nFmt('R₀ = %r0% ≤ 1 — the disease dies out.', { r0: R0.toFixed(2) }), 'extinct');
} else {
const endemic = (1 - 1 / R0);
setStatus(i18nFmt('R₀ = %r0% > 1 — endemic at I* ≈ %star% of the population.', { r0: R0.toFixed(2), star: endemic.toFixed(2) }), 'endemic');
}
}
// Live-update numeric labels as sliders move
betaSlider.addEventListener('input', () => {
betaVal.textContent = parseFloat(betaSlider.value).toFixed(2);
});
gammaSlider.addEventListener('input', () => {
gammaVal.textContent = parseFloat(gammaSlider.value).toFixed(2);
});
document.getElementById('run').addEventListener('click', run);
document.getElementById('reset').addEventListener('click', () => {
betaSlider.value = '0.4'; gammaSlider.value = '0.2';
betaVal.textContent = '0.40'; gammaVal.textContent = '0.20';
setStatus('', '');
const { beta, gamma } = getParams();
updateBadge(beta / gamma);
draw(simulate(beta, gamma), beta, gamma);
});
// Draw the initial simulation on page load
run();
<!-- Controls for beta (transmission rate) and gamma (recovery rate) -->
<div class="controls">
<div class="slider-row">
<label for="beta">Transmission rate <span class="param-name">β</span></label>
<input type="range" id="beta" min="0.1" max="1.0" step="0.01" value="0.4">
<span id="beta-val" class="val-badge">0.40</span>
</div>
<div class="slider-row">
<label for="gamma">Recovery rate <span class="param-name">γ</span></label>
<input type="range" id="gamma" min="0.05" max="0.8" step="0.01" value="0.2">
<span id="gamma-val" class="val-badge">0.20</span>
</div>
<div class="r0-badge" id="r0-badge">R₀ = 2.00</div>
</div>
<canvas id="chart" width="480" height="220"></canvas>
<div class="status" id="status"></div>
<div class="btns">
<button id="run" type="button">Run simulation</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
/* Styles for the SIS endemic model simulator */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .7rem; }
.slider-row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
label { font-size: .85rem; width: 8rem; flex-shrink: 0; }
.param-name { font-weight: 700; font-style: italic; }
input[type=range] { flex: 1; min-width: 120px; accent-color: #1d3557; }
.val-badge { font-size: .85rem; font-weight: 600; width: 3rem; text-align: right; color: #1d3557; }
.r0-badge { font-size: 1rem; font-weight: 700; color: #fff; background: #1d3557;
display: inline-block; padding: .25rem .7rem; border-radius: 8px; margin-top: .2rem; align-self: flex-start; }
.r0-badge.endemic { background: #e63946; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 8px; width: 100%; max-width: 480px; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.extinct { color: #0a7d33; }
.status.endemic { color: #e63946; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// SIS model: di/dt = beta*i*(1-i) - gamma*i
// Endemic equilibrium i* = 1 - gamma/beta = 1 - 1/R0 (exists only when R0 > 1)
// Disease-free equilibrium i* = 0 is stable iff R0 <= 1
const STEPS = 200; // number of discrete time steps to simulate
const DT = 0.5; // step size in time units
const I0 = 0.01; // initial fraction infected (1%)
// DOM element references
const betaSlider = document.getElementById('beta');
const gammaSlider = document.getElementById('gamma');
const betaVal = document.getElementById('beta-val');
const gammaVal = document.getElementById('gamma-val');
const r0Badge = document.getElementById('r0-badge');
const canvas = document.getElementById('chart');
const statusEl = document.getElementById('status');
const ctx = canvas.getContext('2d');
function getParams() {
return { beta: parseFloat(betaSlider.value), gamma: parseFloat(gammaSlider.value) };
}
// Euler integration of the SIS ODE over STEPS steps
function simulate(beta, gamma) {
const series = [I0];
let i = I0;
for (let t = 1; t <= STEPS; t++) {
const di = beta * i * (1 - i) - gamma * i;
i = Math.max(0, Math.min(1, i + DT * di));
series.push(i);
}
return series;
}
// Render the prevalence curve onto the canvas
function draw(series, beta, gamma) {
const W = canvas.width, H = canvas.height;
const pad = { top: 20, right: 20, bottom: 30, left: 44 };
const w = W - pad.left - pad.right;
const h = H - pad.top - pad.bottom;
ctx.clearRect(0, 0, W, H);
// horizontal grid lines and y-axis labels
ctx.strokeStyle = '#e0e7ee';
ctx.lineWidth = 1;
for (let k = 0; k <= 4; k++) {
const y = pad.top + (h / 4) * k;
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + w, y); ctx.stroke();
ctx.fillStyle = '#888'; ctx.font = '10px system-ui'; ctx.textAlign = 'right';
ctx.fillText((1 - k / 4).toFixed(2), pad.left - 4, y + 4);
}
// dashed red line marking the endemic equilibrium when R0 > 1
const R0 = beta / gamma;
if (R0 > 1) {
const endemic = 1 - 1 / R0;
const ey = pad.top + h * (1 - endemic);
ctx.strokeStyle = '#e63946'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(pad.left, ey); ctx.lineTo(pad.left + w, ey); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#e63946'; ctx.font = '10px system-ui'; ctx.textAlign = 'left';
ctx.fillText('I* = ' + endemic.toFixed(2), pad.left + 2, ey - 3);
}
// prevalence curve in dark blue
ctx.strokeStyle = '#1d3557'; ctx.lineWidth = 2.5; ctx.setLineDash([]);
ctx.beginPath();
series.forEach((v, t) => {
const x = pad.left + (t / STEPS) * w;
const y = pad.top + h * (1 - v);
t === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
// x-axis label
ctx.fillStyle = '#888'; ctx.font = '10px system-ui'; ctx.textAlign = 'center';
ctx.fillText('0', pad.left, pad.top + h + 16);
ctx.fillText(String(STEPS), pad.left + w, pad.top + h + 16);
ctx.fillText('time steps', pad.left + w / 2, pad.top + h + 16);
}
function updateBadge(R0) {
r0Badge.textContent = 'R₀ = ' + R0.toFixed(2);
r0Badge.className = 'r0-badge' + (R0 > 1 ? ' endemic' : '');
}
function setStatus(text, cls) {
statusEl.textContent = text;
statusEl.className = 'status ' + cls;
}
// Run a simulation with current slider values and update the display
function run() {
const { beta, gamma } = getParams();
const R0 = beta / gamma;
const series = simulate(beta, gamma);
draw(series, beta, gamma);
updateBadge(R0);
const finalI = series[series.length - 1];
if (R0 <= 1 || finalI < 1e-4) {
setStatus(i18nFmt('R₀ = %r0% ≤ 1 — the disease dies out.', { r0: R0.toFixed(2) }), 'extinct');
} else {
const endemic = (1 - 1 / R0);
setStatus(i18nFmt('R₀ = %r0% > 1 — endemic at I* ≈ %star% of the population.', { r0: R0.toFixed(2), star: endemic.toFixed(2) }), 'endemic');
}
}
// Live-update numeric labels as sliders move
betaSlider.addEventListener('input', () => {
betaVal.textContent = parseFloat(betaSlider.value).toFixed(2);
});
gammaSlider.addEventListener('input', () => {
gammaVal.textContent = parseFloat(gammaSlider.value).toFixed(2);
});
document.getElementById('run').addEventListener('click', run);
document.getElementById('reset').addEventListener('click', () => {
betaSlider.value = '0.4'; gammaSlider.value = '0.2';
betaVal.textContent = '0.40'; gammaVal.textContent = '0.20';
setStatus('', '');
const { beta, gamma } = getParams();
updateBadge(beta / gamma);
draw(simulate(beta, gamma), beta, gamma);
});
// Draw the initial simulation on page load
run();
Watch what happens as you cross the threshold R0=β/γ=1. Below it the curve falls to zero — the disease dies out. Above it the curve rises and levels off at the endemic equilibrium I∗=1−γ/β, no matter where it started. That plateau is the model's signature: prevalence stabilizes, but never reaches zero.
Comments
Loading comments...