├── .gitignore ├── assets └── og.jpg ├── src ├── dom.js ├── collisions.js ├── options.js ├── results.js ├── app.js ├── Ball.js ├── index.html └── favicon.svg ├── .editorconfig ├── LICENSE ├── README.md └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | .now 4 | .DS_Store 5 | public -------------------------------------------------------------------------------- /assets/og.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/covid-19-spread-simulator/master/assets/og.jpg -------------------------------------------------------------------------------- /src/dom.js: -------------------------------------------------------------------------------- 1 | const $ = id => document.getElementById(id) 2 | 3 | export const replayButton = $('replay') 4 | export const deathFilter = $('deaths') 5 | export const stayHomeFilter = $('stay-home') 6 | export const graphElement = $('graph') 7 | export const replayElement = $('replay') 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | charset = utf-8 11 | indent_style = space 12 | indent_size = 2 -------------------------------------------------------------------------------- /src/collisions.js: -------------------------------------------------------------------------------- 1 | const { hypot } = Math 2 | 3 | export const calculateChangeDirection = ({ dx, dy }) => { 4 | const hyp = hypot(dx, dy); 5 | const ax = dx / hyp; 6 | const ay = dy / hyp 7 | return { ax, ay } 8 | } 9 | 10 | export const checkCollision = ({ dx, dy, diameter }) => { 11 | const distance2 = dx * dx + dy * dy 12 | return distance2 < diameter * diameter 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Miguel Ángel Durán (@midudev) 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # COVID-19 (Coronavirus) spread simulator 🦠 2 | 3 | Check simulations about how confinement people could help to stop spreading Coronavirus. 4 | 5 | [Based on Washington Post Article: Why outbreaks like coronavirus spread exponentially, and how to “flatten the curve” - Washington Post](https://www.washingtonpost.com/graphics/2020/world/corona-simulator/) 6 | 7 | ## How to start 8 | 9 | Install all the project dependencies with: 10 | ``` 11 | npm install 12 | ``` 13 | 14 | And start the development server with: 15 | ``` 16 | npm run dev 17 | ``` 18 | 19 | ## Browser support 20 | 21 | This project is using EcmaScript Modules, therefore, only browsers with this compatibility will work. (Sorry Internet Explorer 11 and old Edge users). 22 | 23 | ## Next content 24 | - Customize strategies (number of static people and mortality) 25 | - Customize colors 26 | - Iframe support 27 | - I18N 28 | - New strategies 29 | - Improve the code so I don't get so ashamed. 😳 30 | -------------------------------------------------------------------------------- /src/options.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_FILTERS = { 2 | death: false, 3 | stayHome: false 4 | } 5 | 6 | export const CANVAS_SIZE = { 7 | height: 880, 8 | width: 360 9 | } 10 | 11 | export const DESKTOP_CANVAS_SIZE = { 12 | height: 400, 13 | width: 800 14 | } 15 | 16 | export const BALL_RADIUS = 5 17 | export const COLORS = { 18 | death: '#c50000', 19 | recovered: '#D88DBC', 20 | infected: '#5ABA4A', 21 | well: '#63C8F2' 22 | } 23 | 24 | export const STATES = { 25 | infected: 'infected', 26 | well: 'well', 27 | recovered: 'recovered', 28 | death: 'death' 29 | } 30 | 31 | export const COUNTERS = { 32 | ...STATES, 33 | 'max-concurrent-infected': 'max-concurrent-infected' 34 | } 35 | 36 | export const STARTING_BALLS = { 37 | [STATES.infected]: 1, 38 | [STATES.well]: 199, 39 | [STATES.recovered]: 0, 40 | [STATES.death]: 0, 41 | 'max-concurrent-infected': 0 42 | } 43 | 44 | export const RUN = { 45 | filters: { ...DEFAULT_FILTERS }, 46 | results: { ...STARTING_BALLS }, 47 | tick: 0 48 | } 49 | 50 | export const MORTALITY_PERCENTATGE = 5 51 | export const SPEED = 1 52 | export const TOTAL_TICKS = 1600 53 | export const TICKS_TO_RECOVER = 500 54 | export const STATIC_PEOPLE_PERCENTATGE = 25 55 | 56 | export const resetRun = () => { 57 | RUN.results = { ...STARTING_BALLS } 58 | RUN.tick = 0 59 | } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coronavirus-spread-simulator", 3 | "version": "1.0.1", 4 | "private": true, 5 | "description": "Simulator about how Coronavirus spreads with free movement of people and confinement", 6 | "main": "", 7 | "scripts": { 8 | "build": "npm run create:public && npm run minify:files && npm run copy:assets", 9 | "create:public": "mkdir -p public", 10 | "minify:files": "cd src && for f in ./*; do minify $f > ../public/$f; done;", 11 | "copy:assets": "cp -r ./assets/ ./public/assets", 12 | "lint": "eslint src", 13 | "dev": "servor src index.html 1234 --browser --reload", 14 | "test": "echo \"Error: no test specified\" && exit 1" 15 | }, 16 | "keywords": [], 17 | "author": "Miguel Ángel Durán García - https://midu.dev", 18 | "license": "ISC", 19 | "devDependencies": { 20 | "husky": "4.2.3", 21 | "minify": "5.1.0", 22 | "servor": "3.2.0", 23 | "standard": "14.3.3" 24 | }, 25 | "husky": { 26 | "hooks": { 27 | "pre-commit": "npm run lint", 28 | "pre-push": "npm run lint" 29 | } 30 | }, 31 | "eslintConfig": { 32 | "extends": "eslint-config-standard", 33 | "rules": { 34 | "indent": [ 35 | "error", 36 | 2 37 | ], 38 | "max-len": [ 39 | "error", 40 | { 41 | "code": 100 42 | } 43 | ] 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /src/results.js: -------------------------------------------------------------------------------- 1 | import { 2 | COLORS, 3 | RUN, 4 | TOTAL_TICKS, 5 | STATES, 6 | COUNTERS, 7 | resetRun 8 | } from './options.js' 9 | 10 | import { 11 | graphElement, 12 | replayElement 13 | } from './dom.js' 14 | 15 | let graphPoint = 0 16 | const matchMedia = window.matchMedia('(min-width: 800px)') 17 | 18 | let isDesktop = matchMedia.matches 19 | 20 | const domElements = Object.fromEntries( 21 | Object.keys(COUNTERS).map(state => { 22 | const el = document.getElementById(state) 23 | if (el) { 24 | el.parentNode.style = `color: ${COLORS[state]}` 25 | } 26 | return [state, document.getElementById(state)] 27 | }) 28 | ) 29 | 30 | const updateGraph = () => { 31 | let y = 0 32 | const rects = Object.entries(RUN.results).map(([state, count]) => { 33 | const color = COLORS[state] 34 | if (count > 0) { 35 | const percentatge = count / 200 * 50 36 | const rect = `` 37 | y += percentatge 38 | return rect 39 | } 40 | return '' 41 | }).join('') 42 | 43 | const newGraphPoint = `${rects}` 44 | graphPoint++ 45 | graphElement.insertAdjacentHTML('beforeend', newGraphPoint) 46 | } 47 | 48 | export const resetValues = (isDesktopNewValue = isDesktop) => { 49 | graphElement.innerHTML = '' 50 | replayElement.style.display = 'none' 51 | graphPoint = 0 52 | isDesktop = isDesktopNewValue 53 | resetRun() 54 | } 55 | 56 | export const updateCount = () => { 57 | if (RUN.tick < TOTAL_TICKS) { 58 | // calculate max concurrent infected 59 | if (RUN.results[STATES.infected] > RUN.results['max-concurrent-infected']) { 60 | RUN.results['max-concurrent-infected']++ 61 | } 62 | 63 | Object.entries(domElements).forEach(([state, domElement]) => { 64 | if (domElement) { 65 | domElement.innerText = RUN.results[state] 66 | } 67 | }) 68 | 69 | if (isDesktop) { 70 | RUN.tick % 2 === 0 && updateGraph() 71 | } else { 72 | RUN.tick % 4 === 0 && updateGraph() 73 | } 74 | } 75 | 76 | if (RUN.tick === TOTAL_TICKS) { 77 | replayElement.style.display = 'flex' 78 | } else { 79 | RUN.tick++ 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import { 2 | BALL_RADIUS, 3 | CANVAS_SIZE, 4 | DESKTOP_CANVAS_SIZE, 5 | STARTING_BALLS, 6 | RUN, 7 | STATIC_PEOPLE_PERCENTATGE, 8 | STATES 9 | } from './options.js' 10 | 11 | import { 12 | replayButton, 13 | deathFilter, 14 | stayHomeFilter 15 | } from './dom.js' 16 | 17 | import { Ball } from './Ball.js' 18 | 19 | import { 20 | resetValues, 21 | updateCount 22 | } from './results.js' 23 | 24 | let balls = [] 25 | const matchMedia = window.matchMedia('(min-width: 800px)') 26 | 27 | let isDesktop = matchMedia.matches 28 | 29 | export const canvas = new window.p5(sketch => { // eslint-disable-line 30 | const startBalls = () => { 31 | let id = 0 32 | balls = [] 33 | Object.keys(STARTING_BALLS).forEach(state => { 34 | Array.from({ length: STARTING_BALLS[state] }, () => { 35 | const hasMovement = RUN.filters.stayHome 36 | ? sketch.random(0, 100) < STATIC_PEOPLE_PERCENTATGE || state === STATES.infected 37 | : true 38 | 39 | balls[id] = new Ball({ 40 | id, 41 | sketch, 42 | state, 43 | hasMovement, 44 | x: sketch.random(BALL_RADIUS, sketch.width - BALL_RADIUS), 45 | y: sketch.random(BALL_RADIUS, sketch.height - BALL_RADIUS) 46 | }) 47 | id++ 48 | }) 49 | }) 50 | } 51 | 52 | const createCanvas = () => { 53 | const { height, width } = isDesktop 54 | ? DESKTOP_CANVAS_SIZE 55 | : CANVAS_SIZE 56 | 57 | sketch.createCanvas(width, height) 58 | } 59 | 60 | sketch.setup = () => { 61 | createCanvas() 62 | startBalls() 63 | 64 | matchMedia.addListener(e => { 65 | isDesktop = e.matches 66 | createCanvas() 67 | startBalls() 68 | resetValues() 69 | }) 70 | 71 | replayButton.onclick = () => { 72 | startBalls() 73 | resetValues() 74 | } 75 | 76 | deathFilter.onclick = () => { 77 | RUN.filters.death = !RUN.filters.death 78 | document.getElementById('death-count').classList.toggle('show', RUN.filters.death) 79 | startBalls() 80 | resetValues() 81 | } 82 | 83 | stayHomeFilter.onchange = () => { 84 | RUN.filters.stayHome = !RUN.filters.stayHome 85 | startBalls() 86 | resetValues() 87 | } 88 | } 89 | 90 | sketch.draw = () => { 91 | sketch.background('white') 92 | balls.forEach(ball => { 93 | ball.checkState() 94 | ball.checkCollisions({ others: balls }) 95 | ball.move() 96 | ball.render() 97 | }) 98 | updateCount() 99 | } 100 | }, document.getElementById('canvas')) 101 | -------------------------------------------------------------------------------- /src/Ball.js: -------------------------------------------------------------------------------- 1 | import { 2 | BALL_RADIUS, 3 | COLORS, 4 | MORTALITY_PERCENTATGE, 5 | TICKS_TO_RECOVER, 6 | RUN, 7 | SPEED, 8 | STATES 9 | } from './options.js' 10 | import { checkCollision, calculateChangeDirection } from './collisions.js' 11 | 12 | const diameter = BALL_RADIUS * 2 13 | 14 | export class Ball { 15 | constructor ({ x, y, id, state, sketch, hasMovement }) { 16 | this.x = x 17 | this.y = y 18 | this.vx = sketch.random(-1, 1) * SPEED 19 | this.vy = sketch.random(-1, 1) * SPEED 20 | this.sketch = sketch 21 | this.id = id 22 | this.state = state 23 | this.timeInfected = 0 24 | this.hasMovement = hasMovement 25 | this.hasCollision = true 26 | this.survivor = false 27 | } 28 | 29 | checkState () { 30 | if (this.state === STATES.infected) { 31 | if (RUN.filters.death && !this.survivor && this.timeInfected >= TICKS_TO_RECOVER / 2) { 32 | this.survivor = this.sketch.random(100) >= MORTALITY_PERCENTATGE 33 | if (!this.survivor) { 34 | this.hasMovement = false 35 | this.state = STATES.death 36 | RUN.results[STATES.infected]-- 37 | RUN.results[STATES.death]++ 38 | return 39 | } 40 | } 41 | 42 | if (this.timeInfected >= TICKS_TO_RECOVER) { 43 | this.state = STATES.recovered 44 | RUN.results[STATES.infected]-- 45 | RUN.results[STATES.recovered]++ 46 | } else { 47 | this.timeInfected++ 48 | } 49 | } 50 | } 51 | 52 | checkCollisions ({ others }) { 53 | if (this.state === STATES.death) return 54 | 55 | for (let i = this.id + 1; i < others.length; i++) { 56 | const otherBall = others[i] 57 | const { state, x, y } = otherBall 58 | if (state === STATES.death) continue 59 | 60 | const dx = x - this.x 61 | const dy = y - this.y 62 | 63 | if (checkCollision({ dx, dy, diameter: BALL_RADIUS * 2 })) { 64 | const { ax, ay } = calculateChangeDirection({ dx, dy }) 65 | 66 | this.vx -= ax 67 | this.vy -= ay 68 | otherBall.vx = ax 69 | otherBall.vy = ay 70 | 71 | // both has same state, so nothing to do 72 | if (this.state === state) return 73 | // if any is recovered, then nothing happens 74 | if (this.state === STATES.recovered || state === STATES.recovered) return 75 | // then, if some is infected, then we make both infected 76 | if (this.state === STATES.infected || state === STATES.infected) { 77 | this.state = otherBall.state = STATES.infected 78 | RUN.results[STATES.infected]++ 79 | RUN.results[STATES.well]-- 80 | } 81 | } 82 | } 83 | } 84 | 85 | move () { 86 | if (!this.hasMovement) return 87 | 88 | this.x += this.vx 89 | this.y += this.vy 90 | 91 | // check horizontal walls 92 | if ( 93 | (this.x + BALL_RADIUS > this.sketch.width && this.vx > 0) || 94 | (this.x - BALL_RADIUS < 0 && this.vx < 0)) { 95 | this.vx *= -1 96 | } 97 | 98 | // check vertical walls 99 | if ( 100 | (this.y + BALL_RADIUS > this.sketch.height && this.vy > 0) || 101 | (this.y - BALL_RADIUS < 0 && this.vy < 0)) { 102 | this.vy *= -1 103 | } 104 | } 105 | 106 | render () { 107 | const color = COLORS[this.state] 108 | this.sketch.noStroke() 109 | this.sketch.fill(color) 110 | this.sketch.ellipse(this.x, this.y, diameter, diameter) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Coronavirus Spread Simulator 🦠 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 177 | 178 | 179 | Compartir 180 |

COVID-19 Spread Simulator 🦠

181 |
182 |
183 |
184 | 187 | 190 |
191 | 192 |
193 |
Healthy
0
194 |
Recovered
0
195 |
Sick
0
196 |
Deaths
0
197 |
Max Concurrently Sick
0
198 |
199 | 200 | 201 | Graph of virus spread 202 | 203 |
204 |
205 |
206 | 207 |
208 |
209 |
210 | 211 | 214 | 215 | -------------------------------------------------------------------------------- /src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | --------------------------------------------------------------------------------