Below is a small contact network. One person is infected (red). Each day the disease spreads to their contacts; at the same time, the tracing algorithm tries to find and quarantine exposed nodes before they infect others.
<!-- HTML layout: controls, canvas for the network graph, and stats bar -->
<div class="controls">
<label>Tracing speed: <input type="range" id="speed" min="0" max="100" value="50">
<span id="speed-val">50%</span></label>
<button id="btn-step" type="button">Step</button>
<button id="btn-run" type="button">Run</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<canvas id="canvas" width="480" height="280"></canvas>
<div class="stats">
<span id="stat-day">Day 0</span> |
<span id="stat-infected">Infected: 1</span> |
<span id="stat-quarantined">Quarantined: 0</span> |
<span id="stat-r">Rₑ​ⁱⁱ ~1.00</span>
</div>
<div id="status" class="status"></div>
/* Styles: controls row, canvas, stats, status message */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap;
margin-bottom: .5rem; }
label { font-size: .85rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 100px; }
button { font: 600 13px system-ui; padding: .35rem .75rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
canvas { display: block; border-radius: 8px; background: #f4f7fa;
width: 100%; max-width: 480px; }
.stats { font-size: .8rem; color: #555; margin: .45rem 0; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.3em; }
.status.win { color: #0a7d33; }
.status.lose { color: #c92f3c; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Simulation of an outbreak on a small contact network with configurable tracing speed */
/* 8-node contact network (nodes A-H) with fixed positions on the canvas */
const NODES = [
{ id: 0, x: 60, y: 140, label: 'A' },
{ id: 1, x: 150, y: 60, label: 'B' },
{ id: 2, x: 150, y: 220, label: 'C' },
{ id: 3, x: 260, y: 100, label: 'D' },
{ id: 4, x: 260, y: 180, label: 'E' },
{ id: 5, x: 370, y: 60, label: 'F' },
{ id: 6, x: 370, y: 180, label: 'G' },
{ id: 7, x: 440, y: 120, label: 'H' },
];
const EDGES = [
[0,1],[0,2],[1,3],[2,4],[3,4],[3,5],[4,6],[5,7],[6,7]
];
/* Build adjacency list from edge list */
const adj = Array.from({ length: NODES.length }, () => []);
for (const [a, b] of EDGES) { adj[a].push(b); adj[b].push(a); }
/* Node status constants */
const STATE_SUSCEPTIBLE = 'S';
const STATE_INFECTED = 'I';
const STATE_QUARANTINED = 'Q';
const STATE_RECOVERED = 'R';
let state, day, running, timer;
function initState() {
/* Initialize every node; node 0 (A) starts infected */
state = NODES.map((_, i) => ({
status: i === 0 ? STATE_INFECTED : STATE_SUSCEPTIBLE,
infectedDay: i === 0 ? 0 : -1,
traced: false,
}));
day = 0;
running = false;
clearTimeout(timer);
updateStats();
draw();
document.getElementById('status').textContent = '';
document.getElementById('status').className = 'status';
}
function getSpeed() {
return parseInt(document.getElementById('speed').value, 10) / 100;
}
/* Advance the simulation by one day: spread, trace, recover */
function step() {
if (isOver()) return;
day++;
/* Spread: every infected node may infect susceptible neighbors */
const newlyInfected = [];
for (let i = 0; i < NODES.length; i++) {
if (state[i].status !== STATE_INFECTED) continue;
for (const j of adj[i]) {
if (state[j].status === STATE_SUSCEPTIBLE) {
newlyInfected.push(j);
}
}
}
for (const j of newlyInfected) {
state[j].status = STATE_INFECTED;
state[j].infectedDay = day;
}
/* Trace: each infected node is quarantined with probability = speed */
const speed = getSpeed();
for (let i = 0; i < NODES.length; i++) {
if (state[i].status !== STATE_INFECTED) continue;
/* Higher slider value = faster tracing */
if (Math.random() < speed) {
state[i].status = STATE_QUARANTINED;
state[i].traced = true;
/* Also quarantine susceptible neighbors with same probability */
for (const j of adj[i]) {
if (state[j].status === STATE_SUSCEPTIBLE && Math.random() < speed) {
state[j].status = STATE_QUARANTINED;
state[j].traced = true;
}
}
}
}
/* Recover: infected nodes recover after 3 days */
for (let i = 0; i < NODES.length; i++) {
if (state[i].status === STATE_INFECTED && day - state[i].infectedDay >= 3) {
state[i].status = STATE_RECOVERED;
}
}
updateStats();
draw();
if (isOver()) {
endMessage();
running = false;
} else if (running) {
timer = setTimeout(step, 900);
}
}
function isOver() {
return state.every(s =>
s.status === STATE_QUARANTINED ||
s.status === STATE_RECOVERED ||
s.status === STATE_SUSCEPTIBLE
);
}
/* Rough effective R estimate: current infected / newly infected yesterday */
function estimateReff() {
const infected = state.filter(s => s.status === STATE_INFECTED).length;
const prev = state.filter(s => s.infectedDay === day - 1 && s.status === STATE_INFECTED).length || 1;
return (infected / prev).toFixed(2);
}
function updateStats() {
const infected = state.filter(s => s.status === STATE_INFECTED).length;
const quarantined = state.filter(s => s.status === STATE_QUARANTINED).length;
document.getElementById('stat-day').textContent =
'Day ' + day;
document.getElementById('stat-infected').textContent =
'Infected: ' + infected;
document.getElementById('stat-quarantined').textContent =
'Quarantined: ' + quarantined;
document.getElementById('stat-r').textContent =
'Rₑⁱⁱ ~' + estimateReff();
}
/* Render the contact network on the canvas */
function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
/* Draw edges first (behind nodes) */
ctx.strokeStyle = '#cdd5df';
ctx.lineWidth = 1.5;
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 each node with color reflecting its status */
const COLOR = {
[STATE_SUSCEPTIBLE]: '#a8d4f5',
[STATE_INFECTED]: '#e63946',
[STATE_QUARANTINED]: '#f4a261',
[STATE_RECOVERED]: '#57cc99',
};
for (const n of NODES) {
const s = state[n.id];
ctx.beginPath();
ctx.arc(n.x, n.y, 18, 0, 2 * Math.PI);
ctx.fillStyle = COLOR[s.status];
ctx.fill();
ctx.strokeStyle = '#444';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = '#111';
ctx.font = 'bold 13px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(n.label, n.x, n.y);
}
/* Legend at the bottom of the canvas */
const legend = [
{ color: '#a8d4f5', label: 'Susceptible' },
{ color: '#e63946', label: 'Infected' },
{ color: '#f4a261', label: 'Quarantined' },
{ color: '#57cc99', label: 'Recovered' },
];
let lx = 8, ly = H - 22;
ctx.font = '11px system-ui, sans-serif';
ctx.textBaseline = 'middle';
for (const { color, label } of legend) {
ctx.fillStyle = color;
ctx.fillRect(lx, ly - 6, 12, 12);
ctx.strokeStyle = '#666';
ctx.lineWidth = .8;
ctx.strokeRect(lx, ly - 6, 12, 12);
ctx.fillStyle = '#333';
ctx.textAlign = 'left';
ctx.fillText(label, lx + 15, ly);
lx += ctx.measureText(label).width + 28;
}
}
function endMessage() {
const active = state.filter(s => s.status === STATE_INFECTED).length;
const el = document.getElementById('status');
if (active === 0) {
const total = state.filter(s => s.status === STATE_RECOVERED || s.status === STATE_QUARANTINED).length;
el.textContent = i18nFmt('Outbreak controlled — %total% nodes affected total.', { total });
el.className = 'status win';
} else {
el.textContent = 'Outbreak escaped — tracing was too slow.';
el.className = 'status lose';
}
}
document.getElementById('btn-step').addEventListener('click', () => {
running = false; clearTimeout(timer); step();
});
document.getElementById('btn-run').addEventListener('click', () => {
if (isOver()) return;
running = true; step();
});
document.getElementById('btn-reset').addEventListener('click', initState);
document.getElementById('speed').addEventListener('input', function () {
document.getElementById('speed-val').textContent = this.value + '%';
});
initState();
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Simulation of an outbreak on a small contact network with configurable tracing speed */
/* 8-node contact network (nodes A-H) with fixed positions on the canvas */
const NODES = [
{ id: 0, x: 60, y: 140, label: 'A' },
{ id: 1, x: 150, y: 60, label: 'B' },
{ id: 2, x: 150, y: 220, label: 'C' },
{ id: 3, x: 260, y: 100, label: 'D' },
{ id: 4, x: 260, y: 180, label: 'E' },
{ id: 5, x: 370, y: 60, label: 'F' },
{ id: 6, x: 370, y: 180, label: 'G' },
{ id: 7, x: 440, y: 120, label: 'H' },
];
const EDGES = [
[0,1],[0,2],[1,3],[2,4],[3,4],[3,5],[4,6],[5,7],[6,7]
];
/* Build adjacency list from edge list */
const adj = Array.from({ length: NODES.length }, () => []);
for (const [a, b] of EDGES) { adj[a].push(b); adj[b].push(a); }
/* Node status constants */
const STATE_SUSCEPTIBLE = 'S';
const STATE_INFECTED = 'I';
const STATE_QUARANTINED = 'Q';
const STATE_RECOVERED = 'R';
let state, day, running, timer;
function initState() {
/* Initialize every node; node 0 (A) starts infected */
state = NODES.map((_, i) => ({
status: i === 0 ? STATE_INFECTED : STATE_SUSCEPTIBLE,
infectedDay: i === 0 ? 0 : -1,
traced: false,
}));
day = 0;
running = false;
clearTimeout(timer);
updateStats();
draw();
document.getElementById('status').textContent = '';
document.getElementById('status').className = 'status';
}
function getSpeed() {
return parseInt(document.getElementById('speed').value, 10) / 100;
}
/* Advance the simulation by one day: spread, trace, recover */
function step() {
if (isOver()) return;
day++;
/* Spread: every infected node may infect susceptible neighbors */
const newlyInfected = [];
for (let i = 0; i < NODES.length; i++) {
if (state[i].status !== STATE_INFECTED) continue;
for (const j of adj[i]) {
if (state[j].status === STATE_SUSCEPTIBLE) {
newlyInfected.push(j);
}
}
}
for (const j of newlyInfected) {
state[j].status = STATE_INFECTED;
state[j].infectedDay = day;
}
/* Trace: each infected node is quarantined with probability = speed */
const speed = getSpeed();
for (let i = 0; i < NODES.length; i++) {
if (state[i].status !== STATE_INFECTED) continue;
/* Higher slider value = faster tracing */
if (Math.random() < speed) {
state[i].status = STATE_QUARANTINED;
state[i].traced = true;
/* Also quarantine susceptible neighbors with same probability */
for (const j of adj[i]) {
if (state[j].status === STATE_SUSCEPTIBLE && Math.random() < speed) {
state[j].status = STATE_QUARANTINED;
state[j].traced = true;
}
}
}
}
/* Recover: infected nodes recover after 3 days */
for (let i = 0; i < NODES.length; i++) {
if (state[i].status === STATE_INFECTED && day - state[i].infectedDay >= 3) {
state[i].status = STATE_RECOVERED;
}
}
updateStats();
draw();
if (isOver()) {
endMessage();
running = false;
} else if (running) {
timer = setTimeout(step, 900);
}
}
function isOver() {
return state.every(s =>
s.status === STATE_QUARANTINED ||
s.status === STATE_RECOVERED ||
s.status === STATE_SUSCEPTIBLE
);
}
/* Rough effective R estimate: current infected / newly infected yesterday */
function estimateReff() {
const infected = state.filter(s => s.status === STATE_INFECTED).length;
const prev = state.filter(s => s.infectedDay === day - 1 && s.status === STATE_INFECTED).length || 1;
return (infected / prev).toFixed(2);
}
function updateStats() {
const infected = state.filter(s => s.status === STATE_INFECTED).length;
const quarantined = state.filter(s => s.status === STATE_QUARANTINED).length;
document.getElementById('stat-day').textContent =
'Day ' + day;
document.getElementById('stat-infected').textContent =
'Infected: ' + infected;
document.getElementById('stat-quarantined').textContent =
'Quarantined: ' + quarantined;
document.getElementById('stat-r').textContent =
'Rₑⁱⁱ ~' + estimateReff();
}
/* Render the contact network on the canvas */
function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
/* Draw edges first (behind nodes) */
ctx.strokeStyle = '#cdd5df';
ctx.lineWidth = 1.5;
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 each node with color reflecting its status */
const COLOR = {
[STATE_SUSCEPTIBLE]: '#a8d4f5',
[STATE_INFECTED]: '#e63946',
[STATE_QUARANTINED]: '#f4a261',
[STATE_RECOVERED]: '#57cc99',
};
for (const n of NODES) {
const s = state[n.id];
ctx.beginPath();
ctx.arc(n.x, n.y, 18, 0, 2 * Math.PI);
ctx.fillStyle = COLOR[s.status];
ctx.fill();
ctx.strokeStyle = '#444';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = '#111';
ctx.font = 'bold 13px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(n.label, n.x, n.y);
}
/* Legend at the bottom of the canvas */
const legend = [
{ color: '#a8d4f5', label: 'Susceptible' },
{ color: '#e63946', label: 'Infected' },
{ color: '#f4a261', label: 'Quarantined' },
{ color: '#57cc99', label: 'Recovered' },
];
let lx = 8, ly = H - 22;
ctx.font = '11px system-ui, sans-serif';
ctx.textBaseline = 'middle';
for (const { color, label } of legend) {
ctx.fillStyle = color;
ctx.fillRect(lx, ly - 6, 12, 12);
ctx.strokeStyle = '#666';
ctx.lineWidth = .8;
ctx.strokeRect(lx, ly - 6, 12, 12);
ctx.fillStyle = '#333';
ctx.textAlign = 'left';
ctx.fillText(label, lx + 15, ly);
lx += ctx.measureText(label).width + 28;
}
}
function endMessage() {
const active = state.filter(s => s.status === STATE_INFECTED).length;
const el = document.getElementById('status');
if (active === 0) {
const total = state.filter(s => s.status === STATE_RECOVERED || s.status === STATE_QUARANTINED).length;
el.textContent = i18nFmt('Outbreak controlled — %total% nodes affected total.', { total });
el.className = 'status win';
} else {
el.textContent = 'Outbreak escaped — tracing was too slow.';
el.className = 'status lose';
}
}
document.getElementById('btn-step').addEventListener('click', () => {
running = false; clearTimeout(timer); step();
});
document.getElementById('btn-run').addEventListener('click', () => {
if (isOver()) return;
running = true; step();
});
document.getElementById('btn-reset').addEventListener('click', initState);
document.getElementById('speed').addEventListener('input', function () {
document.getElementById('speed-val').textContent = this.value + '%';
});
initState();
Comments
Loading comments...