Simulitis
Simulation
Line Chart
Code
= {
chart const margin = { top: 20, right: 30, bottom: 10, left: 10 };
const width = fullWidth - margin.left - margin.right,
= fullHeight - margin.top - margin.bottom;
height
const size = 0.96 * d3.min([width / 2, height]);
// simulation parameters
const n = 100,
= 2,
initialSpeed = 5,
radius = false;
enableIsolation
let incubationTime = defaults.incubationTime,
= defaults.recoveryTime,
recoveryTime = defaults.movementRate,
movementRate = defaults.fatalityRate;
fatalityRate
let data = [];
let timers = [];
let x = d3.scaleLinear().range([0, size]),
= d3.scaleLinear().domain([0, n]).range([size, 0.4 * size]);
y
const line = d3.line()
.x(d => x(d.x))
.y(d => y(d.y))
.curve(d3.curveLinear);
const smoothWindow = 20;
const rawLine = d => (
.length > smoothWindow ? line(d.slice(smoothWindow / 2)) : null);
d
const smoothLine = d3.line()
.x(d => x(d.x))
.y(d => y(d.y))
.curve(movingAverage().window(smoothWindow));
const window = d3.create("svg")
.attr("width", "100%")
.attr("height", "100%")
.attr("viewBox", `0 0 ${fullWidth} ${fullHeight}`)
.attr("preserveAspectRatio", "xMidYMid meet");
const svg = window.append("g")
.attr("class", "simulitis")
.attr("transform", `translate(${margin.left},${margin.top})`)
const simulationArea = svg.append("g");
.append("rect")
simulationArea.attr("width", size)
.attr("height", size)
.style("fill", "none")
.style("stroke", "black");
const plotArea = svg.append("g")
.attr("transform", `translate(${width - size},0)`);
.append("g")
plotArea.attr("class", "axis axis-y")
.attr("transform", `translate(${size},0)`)
.append("text")
.attr("transform", `translate(0,${y.range()[1]})`)
.attr("alignment-baseline", "baseline")
.attr("dy", -10)
.text("N");
.append("path")
plotArea.attr("class", "sick raw");
.append("path")
plotArea.attr("class", "sick smooth");
.append("path")
plotArea.attr("class", "healthy raw");
.append("path")
plotArea.attr("class", "healthy smooth");
const randomProb = d3.randomUniform(0, 1),
= d3.randomUniform(0, size),
randomCoord = d3.randomUniform(0, 2 * Math.PI);
randomAngle
function generatePosVec() {
return new Vec(randomCoord(), randomCoord());
}
function generateVelVec() {
const angle = randomAngle();
return new Vec(
* Math.cos(angle),
initialSpeed * Math.sin(angle),
initialSpeed ;
)
}
function generateCircle(isInfected) {
const d = {};
.isInfected = isInfected;
d
// generate circle position and velocity
.pos = generatePosVec();
d.vel = generateVelVec().scale(movementRate);
d
.infectionTime = 0;
d.infectionCount = 0;
d.isSick = false;
d.isRecovered = false;
d.isDead = false;
d
return d;
}
function handleBoundaries(d) {
const bounds = [radius, size - radius];
if (d.pos.x < bounds[0]) {
.pos.x = bounds[0];
d.vel.x *= -1;
d
}if (d.pos.x > bounds[1]) {
.pos.x = bounds[1];
d.vel.x *= -1;
d
}if (d.pos.y < bounds[0]) {
.pos.y = bounds[0];
d.vel.y *= -1;
d
}if (d.pos.y > bounds[1]) {
.pos.y = bounds[1];
d.vel.y *= -1;
d
}
}
function handleInteraction(d1, t) {
const bbox = {
x0: d1.pos.x - radius,
x1: d1.pos.x + radius,
y0: d1.pos.y - radius,
y2: d1.pos.y + radius
;
}
return function(node, x0, y0, x1, y1) {
if (node.data) {
// this is leaf node
const d2 = node.data;
if (d1 === d2)
return;
if (d1.isDead || d2.isDead)
return; // exclude dead circles from interactions
if (enableIsolation && (d1.isSick || d2.isSick))
return;
const pos = d1.pos.clone().plus(d2.pos).scale(0.5), // midpoint
= d1.pos.clone().minus(d2.pos),
d1d2 = d1d2.clone().scale(-1),
d2d1 = d1d2.length();
separation
if (separation < 2 * radius) {
if ((d1.isSick && d2.isSick) || d1.isRecovered || d2.isRecovered) {
// sick or recovered circles can not be infected again
else if (d1.isInfected && !d2.isInfected) {
} // circle 1 infects circle 2 at this time
.isInfected = true;
d2.infectionTime = t;
d2.infectionCount += 1;
d1else if (!d1.isInfected && d2.isInfected) {
} // cicle 2 infects circle 1 at this time
.isInfected = true;
d1.infectionTime = t;
d1.infectionCount += 1;
d2
}
// update velocities
const dvel1 = d1d2.clone().scale(
.vel.clone().minus(d2.vel).dot(d1d2) / (4 * radius * radius));
d1const dvel2 = d2d1.clone().scale(
.vel.clone().minus(d1.vel).dot(d2d1) / (4 * radius * radius));
d2.vel.minus(dvel1);
d1.vel.minus(dvel2);
d2const eps = 0.0001; // ensure separation after collision
.pos = pos.clone().plus(
d1.clone().scale((1 + eps) * radius / separation));
d1d2.pos = pos.clone().minus(
d2.clone().scale((1 + eps) * radius / separation));
d1d2
}
}
return x0 > bbox.x1 || x1 < bbox.x0 || y0 > bbox.y1 || y1 < bbox.y0;
;
}
}
function propagate(d, t) {
if (d.isDead)
return;
if (d.isSick && (t - d.infectionTime > incubationTime + recoveryTime)) {
// this circle either recovers or dies at this time
.isSick = false;
d.isInfected = false;
dif (randomProb() < fatalityRate) {
.isDead = true;
dreturn;
else {
} .isRecovered = true;
d
}else if (d.isInfected && (t - d.infectionTime > incubationTime)) {
} // this circle becomes sick at this time
.isSick = true;
d
}
if (enableIsolation && d.isSick)
return;
.pos.plus(d.vel);
d
}
function stop() {
for (const timer of timers) {
.stop();
timer
}
}
function start() {
stop();
.selectAll("circle")
simulationArea.remove();
.selectAll("path")
plotArea.datum(d => []);
// update and redraw plot axis (allows n to be variable)
.select(".axis-y")
plotArea.call(d3.axisRight(y));
// generate a new circle population (starting with a few infected circles)
= d3.range(n).map(i => generateCircle(i < 3));
data
= [d3.interval(updateSimulation, 20), d3.interval(updatePlot, 40)];
timers
}
function updateSimulation(t) {
.forEach(handleBoundaries);
data
const tree = d3.quadtree()
.x(d => d.pos.x)
.y(d => d.pos.y)
.addAll(data);
.forEach(d1 => tree.visit(handleInteraction(d1, t)));
data
.forEach(d => propagate(d, t));
data
.selectAll("circle")
simulationArea.data(data)
.join("circle")
.attr("r", radius)
.attr("cx", d => d.pos.x)
.attr("cy", d => d.pos.y)
.attr("class", d => classify(d));
const totalInfected = d3.sum(data, d => d.isInfected);
// stop when no infected circles are left
if (totalInfected == 0) {
stop();
const R0 = d3.mean(data,
=> d.infectionCount > 0 ? d.infectionCount : null);
d console.log(`R0 = ${R0.toFixed(2)}`);
}
}
function updatePlot(t) {
= x.domain([0, t]);
x
const totalInfected = d3.sum(data, d => d.isInfected),
= d3.sum(data, d => d.isSick),
totalSick = d3.sum(data, d => d.isDead);
totalDead
.selectAll("path.healthy")
plotArea.datum(d => {
.push({ x: t, y: n - totalInfected - totalDead });
dreturn d;
;
})
.selectAll("path.sick")
plotArea.datum(d => {
.push({ x: t, y: totalSick });
dreturn d;
;
})
.select("path.healthy.raw")
plotArea.attr("d", rawLine);
.select("path.healthy.smooth")
plotArea.attr("d", smoothLine);
.select("path.sick.raw")
plotArea.attr("d", rawLine);
.select("path.sick.smooth")
plotArea.attr("d", smoothLine);
}
function classify(d) {
if (d.isDead)
return "dead";
else if (d.isSick)
return enableIsolation ? "isolated" : "sick";
else if (d.isInfected)
return "infected";
else if (d.isRecovered)
return "recovered";
return "healthy";
}
.then(() => {
invalidationstop();
;
})
return Object.assign(window.node(), {
update(values) {
stop();
= values.incubationTime;
incubationTime = values.recoveryTime;
recoveryTime = values.movementRate;
movementRate = values.fatalityRate;
fatalityRate start();
,
}restart(value) {
if (value > 0) {
start();
},
};
}) }
This simulation tracks the spread of a fictitious disease by contact in a population of moving blue circles. The red-color disease will spread quickly as soon as the three initially infected circles start to bump into others, thus transmitting the infection. An infected circle that turns sick after the ‘incubation time’ has the change to recover its blue color after the ‘recovery time’, or dies. Note that a recovered circle can not be infected again, and that each simulation will yield a somewhat different result because the initial configuration of the circles is random.