The network below has a fixed structure. Its spectral radius λ1 is computed from the adjacency matrix. The epidemic threshold for the SIS model is τc=1/λ1.
<!-- Main layout — info bar + slider + canvas + controls -->
<div class="info-bar">
<span>λ₁ (spectral radius): <b id="lambda-val">…</b></span>
<span>τ_c = 1/λ₁: <b id="tau-c-val">…</b></span>
<span>τ (infection rate): <b id="tau-display">…</b></span>
</div>
<div class="slider-row">
<label for="tau-slider">Infection rate τ:</label>
<input id="tau-slider" type="range" min="0.01" max="1.00" step="0.01" value="0.30">
</div>
<canvas id="net" width="380" height="260"></canvas>
<div id="status-bar" class="status-bar">Adjust τ and step the simulation.</div>
<div class="btns">
<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>
/* Base styles — layout, colors, typography */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.info-bar { display: flex; flex-wrap: wrap; gap: .6rem 1.4rem; font-size: .88rem;
background: #eef2f6; border-radius: 8px; padding: .45rem .8rem; margin-bottom: .5rem; }
.info-bar span { white-space: nowrap; }
.slider-row { display: flex; align-items: center; gap: .6rem; margin-bottom: .4rem; font-size: .9rem; }
#tau-slider { flex: 1; accent-color: #1d3557; }
canvas { display: block; background: #f7f9fb; border-radius: 10px;
border: 1px solid #d1dae3; width: 100%; height: auto; }
.status-bar { font-size: .92rem; font-weight: 600; min-height: 1.4em; margin: .45rem 0; }
.status-bar.above { color: #c92f3c; }
.status-bar.below { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui; 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] ?? ''));
/* Node positions (x,y) on a 380×270 canvas; hub node 6 is central */
const NODES = [
{id:0,x:190,y:40}, {id:1,x:310,y:100}, {id:2,x:340,y:210},
{id:3,x:210,y:270}, {id:4,x:80,y:220}, {id:5,x:50,y:110},
{id:6,x:190,y:140}, {id:7,x:130,y:60}, {id:8,x:270,y:60},
{id:9,x:130,y:190}
];
/* Undirected edges — hub node 6 connects to many others, raising λ₁ */
const EDGES = [
[0,1],[0,7],[0,8],[1,2],[1,6],[2,3],[2,6],[3,4],[3,9],[4,5],[4,9],[5,7],
[6,7],[6,8],[6,9],[7,0],[8,1],[9,3]
];
const n = NODES.length;
/* Build adjacency matrix from edge list */
const A = Array.from({length:n}, () => new Array(n).fill(0));
for (const [u,v] of EDGES) { A[u][v]=1; A[v][u]=1; }
/* Power iteration to compute leading eigenvalue λ₁ of adjacency matrix */
function powerIter(mat, dim, steps=80) {
let v = new Array(dim).fill(1/dim);
let lam = 0;
for (let s=0; s<steps; s++) {
const w = new Array(dim).fill(0);
for (let i=0; i<dim; i++) for (let j=0; j<dim; j++) w[i] += mat[i][j]*v[j];
lam = Math.max(...w.map(Math.abs));
if (lam === 0) break;
v = w.map(x => x/lam);
}
return lam;
}
const lambda1 = powerIter(A, n);
const tauC = 1 / lambda1;
/* SIS state: true = infected, false = susceptible */
let infected = new Array(n).fill(false);
/* Seed one infected node (the hub) to start the simulation */
infected[6] = true;
let running = false;
let animId = null;
const canvas = document.getElementById('net');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
/* Scale node positions to actual canvas pixel dimensions */
const PX = NODES.map(nd => ({
x: nd.x * W / 380,
y: nd.y * H / 270
}));
function getTau() { return parseFloat(document.getElementById('tau-slider').value); }
/* Draw the network — edges first, then nodes colored by infection state */
function draw() {
ctx.clearRect(0,0,W,H);
/* Draw undirected edges in light gray */
ctx.strokeStyle = '#c0ccd8';
ctx.lineWidth = 1.4;
for (const [u,v] of EDGES) {
ctx.beginPath();
ctx.moveTo(PX[u].x, PX[u].y);
ctx.lineTo(PX[v].x, PX[v].y);
ctx.stroke();
}
/* Draw nodes: red = infected, blue = susceptible */
for (let i=0; i<n; i++) {
const {x,y} = PX[i];
ctx.beginPath();
ctx.arc(x, y, 14, 0, 2*Math.PI);
ctx.fillStyle = infected[i] ? '#e63946' : '#a8d8ea';
ctx.fill();
ctx.strokeStyle = infected[i] ? '#9b2230' : '#4a90b8';
ctx.lineWidth = 1.8;
ctx.stroke();
}
}
/* One discrete SIS step — each node heals or spreads independently */
function sisStep() {
const tau = getTau();
const next = infected.slice();
for (let i=0; i<n; i++) {
if (infected[i]) {
/* Recovery: infected node heals with fixed probability 0.3 (δ) */
if (Math.random() < 0.3) next[i] = false;
} else {
/* Spread: susceptible node gets exposed by each infected neighbor with prob τ (β) */
let expose = false;
for (let j=0; j<n; j++) {
if (A[i][j] && infected[j] && Math.random() < tau) { expose=true; break; }
}
if (expose) next[i] = true;
}
}
infected = next;
}
function updateInfo() {
const tau = getTau();
document.getElementById('lambda-val').textContent = lambda1.toFixed(3);
document.getElementById('tau-c-val').textContent = tauC.toFixed(3);
document.getElementById('tau-display').textContent = tau.toFixed(2);
const sb = document.getElementById('status-bar');
if (tau > tauC) {
sb.textContent = i18nFmt('τ > τ_c (%tauC%) — above threshold: epidemic can persist!', { tauC: tauC.toFixed(3) });
sb.className = 'status-bar above';
} else {
sb.textContent = i18nFmt('τ ≤ τ_c (%tauC%) — below threshold: infection dies out.', { tauC: tauC.toFixed(3) });
sb.className = 'status-bar below';
}
}
function step() { sisStep(); draw(); updateInfo(); }
function toggleRun() {
running = !running;
document.getElementById('btn-run').textContent = running ? 'Pause' : 'Run';
if (running) animate();
else if (animId) cancelAnimationFrame(animId);
}
let lastT = 0;
function animate(t=0) {
if (!running) return;
if (t - lastT > 180) { sisStep(); draw(); lastT = t; }
animId = requestAnimationFrame(animate);
}
function reset() {
running = false;
if (animId) cancelAnimationFrame(animId);
document.getElementById('btn-run').textContent = 'Run';
infected = new Array(n).fill(false);
/* Restart with a single infected hub node */
infected[6] = true;
draw(); updateInfo();
}
document.getElementById('tau-slider').addEventListener('input', () => { updateInfo(); });
document.getElementById('btn-step').addEventListener('click', step);
document.getElementById('btn-run').addEventListener('click', toggleRun);
document.getElementById('btn-reset').addEventListener('click', reset);
draw();
updateInfo();
<!-- Main layout — info bar + slider + canvas + controls -->
<div class="info-bar">
<span>λ₁ (spectral radius): <b id="lambda-val">…</b></span>
<span>τ_c = 1/λ₁: <b id="tau-c-val">…</b></span>
<span>τ (infection rate): <b id="tau-display">…</b></span>
</div>
<div class="slider-row">
<label for="tau-slider">Infection rate τ:</label>
<input id="tau-slider" type="range" min="0.01" max="1.00" step="0.01" value="0.30">
</div>
<canvas id="net" width="380" height="260"></canvas>
<div id="status-bar" class="status-bar">Adjust τ and step the simulation.</div>
<div class="btns">
<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>
/* Base styles — layout, colors, typography */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.info-bar { display: flex; flex-wrap: wrap; gap: .6rem 1.4rem; font-size: .88rem;
background: #eef2f6; border-radius: 8px; padding: .45rem .8rem; margin-bottom: .5rem; }
.info-bar span { white-space: nowrap; }
.slider-row { display: flex; align-items: center; gap: .6rem; margin-bottom: .4rem; font-size: .9rem; }
#tau-slider { flex: 1; accent-color: #1d3557; }
canvas { display: block; background: #f7f9fb; border-radius: 10px;
border: 1px solid #d1dae3; width: 100%; height: auto; }
.status-bar { font-size: .92rem; font-weight: 600; min-height: 1.4em; margin: .45rem 0; }
.status-bar.above { color: #c92f3c; }
.status-bar.below { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui; 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] ?? ''));
/* Node positions (x,y) on a 380×270 canvas; hub node 6 is central */
const NODES = [
{id:0,x:190,y:40}, {id:1,x:310,y:100}, {id:2,x:340,y:210},
{id:3,x:210,y:270}, {id:4,x:80,y:220}, {id:5,x:50,y:110},
{id:6,x:190,y:140}, {id:7,x:130,y:60}, {id:8,x:270,y:60},
{id:9,x:130,y:190}
];
/* Undirected edges — hub node 6 connects to many others, raising λ₁ */
const EDGES = [
[0,1],[0,7],[0,8],[1,2],[1,6],[2,3],[2,6],[3,4],[3,9],[4,5],[4,9],[5,7],
[6,7],[6,8],[6,9],[7,0],[8,1],[9,3]
];
const n = NODES.length;
/* Build adjacency matrix from edge list */
const A = Array.from({length:n}, () => new Array(n).fill(0));
for (const [u,v] of EDGES) { A[u][v]=1; A[v][u]=1; }
/* Power iteration to compute leading eigenvalue λ₁ of adjacency matrix */
function powerIter(mat, dim, steps=80) {
let v = new Array(dim).fill(1/dim);
let lam = 0;
for (let s=0; s<steps; s++) {
const w = new Array(dim).fill(0);
for (let i=0; i<dim; i++) for (let j=0; j<dim; j++) w[i] += mat[i][j]*v[j];
lam = Math.max(...w.map(Math.abs));
if (lam === 0) break;
v = w.map(x => x/lam);
}
return lam;
}
const lambda1 = powerIter(A, n);
const tauC = 1 / lambda1;
/* SIS state: true = infected, false = susceptible */
let infected = new Array(n).fill(false);
/* Seed one infected node (the hub) to start the simulation */
infected[6] = true;
let running = false;
let animId = null;
const canvas = document.getElementById('net');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
/* Scale node positions to actual canvas pixel dimensions */
const PX = NODES.map(nd => ({
x: nd.x * W / 380,
y: nd.y * H / 270
}));
function getTau() { return parseFloat(document.getElementById('tau-slider').value); }
/* Draw the network — edges first, then nodes colored by infection state */
function draw() {
ctx.clearRect(0,0,W,H);
/* Draw undirected edges in light gray */
ctx.strokeStyle = '#c0ccd8';
ctx.lineWidth = 1.4;
for (const [u,v] of EDGES) {
ctx.beginPath();
ctx.moveTo(PX[u].x, PX[u].y);
ctx.lineTo(PX[v].x, PX[v].y);
ctx.stroke();
}
/* Draw nodes: red = infected, blue = susceptible */
for (let i=0; i<n; i++) {
const {x,y} = PX[i];
ctx.beginPath();
ctx.arc(x, y, 14, 0, 2*Math.PI);
ctx.fillStyle = infected[i] ? '#e63946' : '#a8d8ea';
ctx.fill();
ctx.strokeStyle = infected[i] ? '#9b2230' : '#4a90b8';
ctx.lineWidth = 1.8;
ctx.stroke();
}
}
/* One discrete SIS step — each node heals or spreads independently */
function sisStep() {
const tau = getTau();
const next = infected.slice();
for (let i=0; i<n; i++) {
if (infected[i]) {
/* Recovery: infected node heals with fixed probability 0.3 (δ) */
if (Math.random() < 0.3) next[i] = false;
} else {
/* Spread: susceptible node gets exposed by each infected neighbor with prob τ (β) */
let expose = false;
for (let j=0; j<n; j++) {
if (A[i][j] && infected[j] && Math.random() < tau) { expose=true; break; }
}
if (expose) next[i] = true;
}
}
infected = next;
}
function updateInfo() {
const tau = getTau();
document.getElementById('lambda-val').textContent = lambda1.toFixed(3);
document.getElementById('tau-c-val').textContent = tauC.toFixed(3);
document.getElementById('tau-display').textContent = tau.toFixed(2);
const sb = document.getElementById('status-bar');
if (tau > tauC) {
sb.textContent = i18nFmt('τ > τ_c (%tauC%) — above threshold: epidemic can persist!', { tauC: tauC.toFixed(3) });
sb.className = 'status-bar above';
} else {
sb.textContent = i18nFmt('τ ≤ τ_c (%tauC%) — below threshold: infection dies out.', { tauC: tauC.toFixed(3) });
sb.className = 'status-bar below';
}
}
function step() { sisStep(); draw(); updateInfo(); }
function toggleRun() {
running = !running;
document.getElementById('btn-run').textContent = running ? 'Pause' : 'Run';
if (running) animate();
else if (animId) cancelAnimationFrame(animId);
}
let lastT = 0;
function animate(t=0) {
if (!running) return;
if (t - lastT > 180) { sisStep(); draw(); lastT = t; }
animId = requestAnimationFrame(animate);
}
function reset() {
running = false;
if (animId) cancelAnimationFrame(animId);
document.getElementById('btn-run').textContent = 'Run';
infected = new Array(n).fill(false);
/* Restart with a single infected hub node */
infected[6] = true;
draw(); updateInfo();
}
document.getElementById('tau-slider').addEventListener('input', () => { updateInfo(); });
document.getElementById('btn-step').addEventListener('click', step);
document.getElementById('btn-run').addEventListener('click', toggleRun);
document.getElementById('btn-reset').addEventListener('click', reset);
draw();
updateInfo();
Notice how the transition is sharp: just crossing the eigenvalue threshold switches the network from "safe" to "endemic." The spectral radius is not just a metaphor — it is the exact boundary, provable from the SIS differential equations.
Comments
Loading comments...