The network below has 20 nodes connected by contact edges. One node starts infected (red). At each step every infected node tries to infect each susceptible neighbour with probability β; then infected nodes recover with probability γ.
<!-- SIR epidemic demo: contact network with 20 nodes -->
<div class="controls">
<label>β (infection prob): <input id="betaSlider" type="range" min="0.05" max="0.95" step="0.05" value="0.35">
<span id="betaVal">0.35</span></label>
<label>γ (recovery prob): <input id="gammaSlider" type="range" min="0.05" max="0.95" step="0.05" value="0.20">
<span id="gammaVal">0.20</span></label>
</div>
<div class="canvas-wrap">
<canvas id="net" width="420" height="280"></canvas>
</div>
<div class="stats" id="stats"></div>
<div class="btns">
<button id="btnStep" type="button">Step</button>
<button id="btnRun" type="button">Run</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
<div class="status" id="status"></div>
/* Styles for the SIR network epidemic demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.controls { display: flex; flex-wrap: wrap; gap: .6rem 1.2rem; margin-bottom: .5rem; font-size: .88rem; }
.controls label { display: flex; align-items: center; gap: .35rem; }
.controls input[type=range] { width: 110px; }
.canvas-wrap { border: 1px solid #cdd9e3; border-radius: 8px; overflow: hidden; background: #f7f9fb; }
canvas { display: block; }
.stats { font-size: .82rem; color: #555; margin: .35rem 0; min-height: 1.2em; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .35rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.info { color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// SIR (Susceptible-Infected-Recovered) spreading on a contact graph.
// Each step: infected nodes try to infect susceptible neighbours with prob beta; recover with prob gamma.
// Fixed (x,y) positions for the 20 nodes on the canvas
const NODES = [
{x:60,y:60},{x:150,y:40},{x:260,y:55},{x:360,y:70},{x:400,y:160},
{x:380,y:250},{x:290,y:265},{x:180,y:255},{x:80,y:240},{x:30,y:155},
{x:200,y:140},{x:120,y:110},{x:305,y:130},{x:220,y:210},{x:140,y:185},
{x:60,y:140},{x:360,y:195},{x:250,y:95},{x:95,y:175},{x:330,y:225}
];
// Contact edges — an outer ring + hub connections + cross-edges
const EDGES = [
[0,1],[1,2],[2,3],[3,4],[4,5],[5,6],[6,7],[7,8],[8,9],[9,0],
[10,11],[10,12],[10,13],[10,14],[10,15],[10,16],[10,17],[10,18],[10,19],
[0,11],[1,17],[2,12],[3,4],[5,19],[6,13],[7,14],[8,18],[9,15],[4,16],
[11,14],[12,17],[13,7],[15,18],[16,6]
];
const N = NODES.length;
// Node states: S=Susceptible (blue), I=Infected (red), R=Recovered (green)
const S = 'S', I = 'I', R = 'R';
const COLOR = { S:'#4a90d9', I:'#e63946', R:'#6dbb7a' };
const SEED = 10; // node 10 is the hub — starts infected
let state = [], running = false, timer = null;
function initState() {
state = Array(N).fill(S);
state[SEED] = I;
}
function beta() { return parseFloat(document.getElementById('betaSlider').value); }
function gamma() { return parseFloat(document.getElementById('gammaSlider').value); }
// Build adjacency list from edge list
const adj = Array.from({length:N}, () => []);
for (const [a,b] of EDGES) { adj[a].push(b); adj[b].push(a); }
function step() {
// Synchronous SIR step: compute next state before writing any changes
const next = [...state];
for (let i = 0; i < N; i++) {
if (state[i] === I) {
for (const j of adj[i]) {
if (state[j] === S && Math.random() < beta()) next[j] = I;
}
if (Math.random() < gamma()) next[i] = R;
}
}
state = next;
}
const canvas = document.getElementById('net');
const ctx = canvas.getContext('2d');
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw contact edges
ctx.strokeStyle = '#b0bec5';
ctx.lineWidth = 1;
for (const [a,b] of EDGES) {
ctx.beginPath();
ctx.moveTo(NODES[a].x, NODES[a].y);
ctx.lineTo(NODES[b].x, NODES[b].y);
ctx.stroke();
}
// Draw nodes colored by state; hub node is slightly larger
for (let i = 0; i < N; i++) {
ctx.beginPath();
ctx.arc(NODES[i].x, NODES[i].y, i === SEED ? 11 : 8, 0, Math.PI * 2);
ctx.fillStyle = COLOR[state[i]];
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
function counts() {
const c = {S:0,I:0,R:0};
for (const s of state) c[s]++;
return c;
}
function updateStats() {
const c = counts();
document.getElementById('stats').textContent =
i18nFmt('S: %s% I: %i% R: %r%', { s: c.S, i: c.I, r: c.R });
}
function checkEnd() {
if (counts().I === 0) {
stopRun();
const c = counts();
document.getElementById('status').textContent =
i18nFmt('Epidemic ended — %r% recovered, %s% never infected.', { r: c.R, s: c.S });
document.getElementById('status').className = 'status ok';
}
}
function doStep() {
step(); draw(); updateStats(); checkEnd();
}
function startRun() {
if (running) return;
running = true;
document.getElementById('btnRun').textContent = 'Pause';
document.getElementById('status').textContent = 'Epidemic spreading...';
document.getElementById('status').className = 'status info';
timer = setInterval(() => {
doStep();
if (counts().I === 0) stopRun();
}, 220);
}
function stopRun() {
running = false;
clearInterval(timer); timer = null;
document.getElementById('btnRun').textContent = 'Run';
}
function reset() {
stopRun();
initState(); draw(); updateStats();
document.getElementById('status').textContent = '';
document.getElementById('status').className = 'status';
}
document.getElementById('betaSlider').addEventListener('input', function() {
document.getElementById('betaVal').textContent = (+this.value).toFixed(2);
});
document.getElementById('gammaSlider').addEventListener('input', function() {
document.getElementById('gammaVal').textContent = (+this.value).toFixed(2);
});
document.getElementById('btnStep').addEventListener('click', () => { stopRun(); doStep(); });
document.getElementById('btnRun').addEventListener('click', () => { running ? stopRun() : startRun(); });
document.getElementById('btnReset').addEventListener('click', reset);
initState(); draw(); updateStats();
<!-- SIR epidemic demo: contact network with 20 nodes -->
<div class="controls">
<label>β (infection prob): <input id="betaSlider" type="range" min="0.05" max="0.95" step="0.05" value="0.35">
<span id="betaVal">0.35</span></label>
<label>γ (recovery prob): <input id="gammaSlider" type="range" min="0.05" max="0.95" step="0.05" value="0.20">
<span id="gammaVal">0.20</span></label>
</div>
<div class="canvas-wrap">
<canvas id="net" width="420" height="280"></canvas>
</div>
<div class="stats" id="stats"></div>
<div class="btns">
<button id="btnStep" type="button">Step</button>
<button id="btnRun" type="button">Run</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
<div class="status" id="status"></div>
/* Styles for the SIR network epidemic demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.controls { display: flex; flex-wrap: wrap; gap: .6rem 1.2rem; margin-bottom: .5rem; font-size: .88rem; }
.controls label { display: flex; align-items: center; gap: .35rem; }
.controls input[type=range] { width: 110px; }
.canvas-wrap { border: 1px solid #cdd9e3; border-radius: 8px; overflow: hidden; background: #f7f9fb; }
canvas { display: block; }
.stats { font-size: .82rem; color: #555; margin: .35rem 0; min-height: 1.2em; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .35rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.info { color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// SIR (Susceptible-Infected-Recovered) spreading on a contact graph.
// Each step: infected nodes try to infect susceptible neighbours with prob beta; recover with prob gamma.
// Fixed (x,y) positions for the 20 nodes on the canvas
const NODES = [
{x:60,y:60},{x:150,y:40},{x:260,y:55},{x:360,y:70},{x:400,y:160},
{x:380,y:250},{x:290,y:265},{x:180,y:255},{x:80,y:240},{x:30,y:155},
{x:200,y:140},{x:120,y:110},{x:305,y:130},{x:220,y:210},{x:140,y:185},
{x:60,y:140},{x:360,y:195},{x:250,y:95},{x:95,y:175},{x:330,y:225}
];
// Contact edges — an outer ring + hub connections + cross-edges
const EDGES = [
[0,1],[1,2],[2,3],[3,4],[4,5],[5,6],[6,7],[7,8],[8,9],[9,0],
[10,11],[10,12],[10,13],[10,14],[10,15],[10,16],[10,17],[10,18],[10,19],
[0,11],[1,17],[2,12],[3,4],[5,19],[6,13],[7,14],[8,18],[9,15],[4,16],
[11,14],[12,17],[13,7],[15,18],[16,6]
];
const N = NODES.length;
// Node states: S=Susceptible (blue), I=Infected (red), R=Recovered (green)
const S = 'S', I = 'I', R = 'R';
const COLOR = { S:'#4a90d9', I:'#e63946', R:'#6dbb7a' };
const SEED = 10; // node 10 is the hub — starts infected
let state = [], running = false, timer = null;
function initState() {
state = Array(N).fill(S);
state[SEED] = I;
}
function beta() { return parseFloat(document.getElementById('betaSlider').value); }
function gamma() { return parseFloat(document.getElementById('gammaSlider').value); }
// Build adjacency list from edge list
const adj = Array.from({length:N}, () => []);
for (const [a,b] of EDGES) { adj[a].push(b); adj[b].push(a); }
function step() {
// Synchronous SIR step: compute next state before writing any changes
const next = [...state];
for (let i = 0; i < N; i++) {
if (state[i] === I) {
for (const j of adj[i]) {
if (state[j] === S && Math.random() < beta()) next[j] = I;
}
if (Math.random() < gamma()) next[i] = R;
}
}
state = next;
}
const canvas = document.getElementById('net');
const ctx = canvas.getContext('2d');
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw contact edges
ctx.strokeStyle = '#b0bec5';
ctx.lineWidth = 1;
for (const [a,b] of EDGES) {
ctx.beginPath();
ctx.moveTo(NODES[a].x, NODES[a].y);
ctx.lineTo(NODES[b].x, NODES[b].y);
ctx.stroke();
}
// Draw nodes colored by state; hub node is slightly larger
for (let i = 0; i < N; i++) {
ctx.beginPath();
ctx.arc(NODES[i].x, NODES[i].y, i === SEED ? 11 : 8, 0, Math.PI * 2);
ctx.fillStyle = COLOR[state[i]];
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
function counts() {
const c = {S:0,I:0,R:0};
for (const s of state) c[s]++;
return c;
}
function updateStats() {
const c = counts();
document.getElementById('stats').textContent =
i18nFmt('S: %s% I: %i% R: %r%', { s: c.S, i: c.I, r: c.R });
}
function checkEnd() {
if (counts().I === 0) {
stopRun();
const c = counts();
document.getElementById('status').textContent =
i18nFmt('Epidemic ended — %r% recovered, %s% never infected.', { r: c.R, s: c.S });
document.getElementById('status').className = 'status ok';
}
}
function doStep() {
step(); draw(); updateStats(); checkEnd();
}
function startRun() {
if (running) return;
running = true;
document.getElementById('btnRun').textContent = 'Pause';
document.getElementById('status').textContent = 'Epidemic spreading...';
document.getElementById('status').className = 'status info';
timer = setInterval(() => {
doStep();
if (counts().I === 0) stopRun();
}, 220);
}
function stopRun() {
running = false;
clearInterval(timer); timer = null;
document.getElementById('btnRun').textContent = 'Run';
}
function reset() {
stopRun();
initState(); draw(); updateStats();
document.getElementById('status').textContent = '';
document.getElementById('status').className = 'status';
}
document.getElementById('betaSlider').addEventListener('input', function() {
document.getElementById('betaVal').textContent = (+this.value).toFixed(2);
});
document.getElementById('gammaSlider').addEventListener('input', function() {
document.getElementById('gammaVal').textContent = (+this.value).toFixed(2);
});
document.getElementById('btnStep').addEventListener('click', () => { stopRun(); doStep(); });
document.getElementById('btnRun').addEventListener('click', () => { running ? stopRun() : startRun(); });
document.getElementById('btnReset').addEventListener('click', reset);
initState(); draw(); updateStats();
Watch how the epidemic percolates — or fails to. The same parameters can produce a small local outbreak when the seed is isolated, or a near-total sweep when the seed is a hub. The network structure is doing the work, not the averages.
Comments
Loading comments...