Skip to content

Instantly share code, notes, and snippets.

@harunpehlivan
Created May 30, 2021 12:54
Show Gist options
  • Save harunpehlivan/fc082a629a86947574999c08147bf685 to your computer and use it in GitHub Desktop.
Save harunpehlivan/fc082a629a86947574999c08147bf685 to your computer and use it in GitHub Desktop.
Self-Quarantine Simulator (Washington Post)
<section>
<canvas id="population" width="2000" height="1200"></canvas>
<div class="info">
<canvas id="graph" height="400" width="1500"></canvas>
<ul>
<li><span>Recovered</span><span id="recovered">0</span></li>
<li><span>Healthy</span><span id="healthy">0</span></li>
<li><span>Sick</span><span id="sick">0</span></li>
<li><span>Dead</span><span id="dead">0</span></li>
</ul>
</div>
<div id="overlay" class="active">
<p>
A social distance simulator based on <a href="https://www.washingtonpost.com/graphics/2020/world/corona-simulator/">the incredible work done by the Washington Post</a>.
This simulator assumes a 1% mortality rate.
</p>
<div class="field is-grouped">
<span class="select">
<select id="quarantine">
<option value="0" selected>No Social Distancing</option>
<option value="0.1">One-Tenth Self-Quarantine</option>
<option value="0.25">One Quarter Self-Quarantine</option>
<option value="0.33">One Third Self-Quarantine</option>
<option value="0.5">One Half Self-Quarantine</option>
<option value="0.75">Three Quarters Self-Quarantine</option>
<option value="1">Everyone Self-Quarantines</option>
</select>
</span>
&nbsp;
<button class="button is-primary" id="run">Run</button>
</div>
</div>
</section>
console.clear();
const PI = Math.PI;
const PI2 = PI * 2;
const SPEED = 2;
const SICK_TIMEFRAME = 275;
const COLOR = {
recovered: "violet",
sick: "tomato",
dead: "white",
healthy: "#00d1b2"
};
const NORMAL = degrees => {
const radians = (degrees * PI) / 180;
return { x: Math.sin(radians), y: -Math.cos(radians) };
};
const WALLS = {
N: { velocity: NORMAL(0) },
S: { velocity: NORMAL(0) },
E: { velocity: NORMAL(90) },
W: { velocity: NORMAL(90) }
};
class Vector {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
class Person {
constructor(id, radius, width, height, sick, quarantined, vulnerable) {
this.id = id;
this.width = width;
this.height = height;
this.radius = radius;
this.position = new Vector(
Math.random() * (this.width - this.radius * 2) + this.radius,
Math.random() * (this.height - this.radius * 2) + this.radius
);
this.quarantined = quarantined;
this.sick = sick;
this.vulnerable = vulnerable;
this.sickFrame = 0;
this.recovered = false;
this.angle = Math.random() * 360;
this.updateVelocity();
}
updateVelocity() {
const radians = (this.angle * PI) / 180;
this.velocity = {
x: Math.sin(radians) * SPEED,
y: -Math.cos(radians) * SPEED
};
}
get edge() {
return {
bottom: this.position.y + this.radius,
left: this.position.x - this.radius,
right: this.position.x + this.radius,
top: this.position.y - this.radius
};
}
reflect({ velocity }) {
const x = this.velocity.x * velocity.x;
const y = this.velocity.y * velocity.y;
const d = 2 * (x + y);
this.velocity.x -= d * velocity.x;
this.velocity.y -= d * velocity.y;
}
collide({ id, position }) {
const dx = this.position.x - position.x;
const dy = this.position.y - position.y;
const d = Math.sqrt(dx * dx + dy * dy);
return id !== this.id && d < this.radius * 2;
}
tick(population) {
if (this.edge.left <= 0) this.reflect(WALLS.W);
if (this.edge.right >= this.width) this.reflect(WALLS.E);
if (this.edge.top <= 0) this.reflect(WALLS.N);
if (this.edge.bottom >= this.height) this.reflect(WALLS.S);
if (this.sick) {
population.forEach(person => {
if (!person.recovered && !person.sick && this.collide(person)) {
person.sick = true;
}
});
}
if (!this.quarantined && !this.dead) {
this.position.x += this.velocity.x;
this.position.y += this.velocity.y;
}
if (this.sick) this.sickFrame++;
if (this.sickFrame >= SICK_TIMEFRAME) {
this.sick = false;
this.recovered = true;
if (this.vulnerable) this.dead = true;
}
}
handleReflection(person) {
if (this.edge.left <= person.edge.right) this.reflect(WALLS.W);
if (this.edge.right >= person.edge.left) this.reflect(WALLS.E);
if (this.edge.top <= person.edge.bottom) this.reflect(WALLS.N);
if (this.edge.bottom >= person.edge.top) this.reflect(WALLS.S);
}
}
class Population {
constructor(size, quarantineRate, patientZeroes) {
this.people = [];
this.canvas = document.getElementById("population");
this.context = this.canvas.getContext("2d");
this.width = this.canvas.width;
this.height = this.canvas.height;
this.radius = size < 250 ? 12 : size < 500 ? 8 : 5;
for (let i = 0; i < size; i++) {
const sick = i > size - patientZeroes - 1;
const quarantined = i / size <= quarantineRate;
const vulnerable = Math.random() < 0.01;
this.people.push(
new Person(
i,
this.radius,
this.width,
this.height,
sick,
quarantined,
vulnerable
)
);
}
}
tick() {
this.context.clearRect(0, 0, this.width, this.height);
this.people.forEach(p => {
this.draw(p);
p.tick(this.people);
});
}
draw({ dead, position, recovered, sick }) {
this.context.fillStyle = sick
? COLOR.sick
: dead
? COLOR.dead
: recovered
? COLOR.recovered
: COLOR.healthy;
this.context.beginPath();
this.context.arc(position.x, position.y, this.radius, 0, PI2);
this.context.fill();
}
}
class Graph {
constructor(population) {
this.population = population;
this.canvas = document.getElementById("graph");
this.context = this.canvas.getContext("2d");
this.height = this.canvas.height;
this.width = this.canvas.width;
this.$recovered = document.getElementById("recovered");
this.$healthy = document.getElementById("healthy");
this.$sick = document.getElementById("sick");
this.$dead = document.getElementById("dead");
this.$recovered.style.color = COLOR.recovered;
this.$healthy.style.color = COLOR.healthy;
this.$sick.style.color = COLOR.sick;
this.reset();
}
reset() {
this.x = 0;
this.done = false;
}
tick() {
let recovered = 0;
let healthy = 0;
let sick = 0;
let dead = 0;
this.population.people.forEach(person => {
if (person.dead) dead++;
else if (person.recovered) recovered++;
else if (person.sick) sick++;
else healthy++;
});
const total = dead + recovered + healthy + sick;
const recoveredH = (recovered / total) * this.height;
const healthyH = (healthy / total) * this.height;
const sickH = (sick / total) * this.height;
const deadH = (dead / total) * this.height;
this.$recovered.innerText = recovered;
this.$healthy.innerText = healthy;
this.$sick.innerText = sick;
this.$dead.innerText = dead;
this.context.strokeStyle = COLOR.recovered;
this.context.beginPath();
this.context.moveTo(this.x, 0);
this.context.lineTo(this.x, recoveredH);
this.context.stroke();
this.context.strokeStyle = COLOR.healthy;
this.context.beginPath();
this.context.moveTo(this.x, recoveredH);
this.context.lineTo(this.x, recoveredH + healthyH);
this.context.stroke();
this.context.strokeStyle = COLOR.sick;
this.context.beginPath();
this.context.moveTo(this.x, recoveredH + healthyH);
this.context.lineTo(this.x, recoveredH + healthyH + sickH);
this.context.stroke();
this.context.strokeStyle = COLOR.dead;
this.context.beginPath();
this.context.moveTo(this.x, recoveredH + healthyH + sickH);
this.context.lineTo(this.x, recoveredH + healthyH + sickH + deadH);
this.context.stroke();
this.x++;
if (this.x >= this.width) this.done = true;
}
}
const $overlay = document.getElementById("overlay");
const $quarantine = document.getElementById("quarantine");
const $run = document.getElementById("run");
$run.addEventListener("click", () => {
const q = parseFloat($quarantine.value);
const pop = new Population(1000, q, 3);
const graph = new Graph(pop);
graph.context.clearRect(0, 0, graph.width, graph.height);
$overlay.classList.remove("active");
function run() {
pop.tick();
graph.tick();
if (graph.done) $overlay.classList.add("active");
else requestAnimationFrame(run);
}
run();
});
html,
body {
background: #111;
}
canvas {
background: black;
display: block;
width: 100%;
height: auto;
}
section {
margin: 10vh auto;
max-width: 1000px;
width: 95%;
position: relative;
}
#overlay {
position: absolute;
top: 0;
left: 0;
bottom: 25%;
padding: 3rem;
box-sizing: border-box;
width: 100%;
transition: opacity 250ms ease-in-out;
opacity: 0;
color: white;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.9);
}
#overlay.active {
opacity: 1;
}
#overlay p {
margin-bottom: 1rem;
text-align: center;
}
#overlay a {
color: #00d1b2;
text-decoration: none;
}
section > div.info {
display: flex;
align-content: center;
align-items: center;
flex-wrap: wrap;
border-top: 1px solid #111;
background: black;
}
section > div.info canvas {
flex: 1;
background: transparent;
}
section > div.info ul {
list-style: none;
margin: 0;
padding: 0;
text-align: center;
color: white;
padding: 1rem;
width: 100%;
box-sizing: border-box;
font-size: 0.8rem;
text-transform: uppercase;
}
@media (min-width: 600px) {
section > div.info canvas {
width: calc(100% - 200px - 1rem);
}
section > div.info ul {
width: 200px;
}
}
li {
width: 100%;
display: flex;
justify-content: space-between;
}
<link href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.8.0/css/bulma.min.css" rel="stylesheet" />
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment