├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── card.png ├── favicon.ico ├── index.html └── manifest.json └── src ├── App.css ├── App.js ├── App.test.js ├── Colors.js ├── RNG.js ├── Utils.js ├── components ├── Aside.js ├── Button.js ├── Constants.js ├── Figure.js ├── Grid.js ├── Interval.js ├── Models.js ├── NodeLegend.js ├── Plot.js └── WidgetButton.jsx ├── images └── network_diagram.png ├── index.css ├── index.js ├── logo.svg ├── quickselect.js └── serviceWorker.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | .idea/ 4 | 5 | # dependencies 6 | /node_modules 7 | /.pnp 8 | .pnp.js 9 | 10 | # testing 11 | /coverage 12 | 13 | # production 14 | /build 15 | deploy.sh 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A playable disease simulator. 2 | 3 | Apologies that the code is not better organized and commented. I threw this together at the last minute =/. 4 | 5 | Here's how I compile and deploy: 6 | 7 | ``` 8 | #!/bin/bash 9 | 10 | echo "BUILDING new version" 11 | npm run build 12 | 13 | echo "RSYNCING to deployment server" 14 | rsync -avze ssh build/ username@host:/path/to/deploy/dir 15 | ``` 16 | 17 | What follows is boilerplate from the Create React App initialization code: 18 | 19 | —— 20 | 21 | This project compiles using NPM and was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 22 | 23 | ## Available Scripts 24 | 25 | In the project directory, you can run: 26 | 27 | ### `npm start` 28 | 29 | Runs the app in the development mode.
30 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 31 | 32 | The page will reload if you make edits.
33 | You will also see any lint errors in the console. 34 | 35 | ### `npm test` 36 | 37 | Launches the test runner in the interactive watch mode.
38 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 39 | 40 | ### `npm run build` 41 | 42 | Builds the app for production to the `build` folder.
43 | It correctly bundles React in production mode and optimizes the build for the best performance. 44 | 45 | The build is minified and the filenames include the hashes.
46 | Your app is ready to be deployed! 47 | 48 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 49 | 50 | ## Learn More 51 | 52 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "iblog_outbreak", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://www.meltingasphalt.com.com/interactive/outbreak", 6 | "dependencies": { 7 | "@material-ui/core": "^3.5.1", 8 | "@material-ui/lab": "^3.0.0-alpha.23", 9 | "color": "^3.1.0", 10 | "d3": "^5.7.0", 11 | "react": "^16.6.3", 12 | "react-dom": "^16.6.3", 13 | "react-scripts": "2.1.1", 14 | "react-waypoint": "^8.1.0" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "eslintConfig": { 23 | "extends": "react-app" 24 | }, 25 | "browserslist": [ 26 | ">0.2%", 27 | "not dead", 28 | "not ie <= 11", 29 | "not op_mini all" 30 | ], 31 | "devDependencies": {} 32 | } 33 | -------------------------------------------------------------------------------- /public/card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinsimler/outbreak/bd9f09a7e2deeeb9d37b853825f1c662234d8c64/public/card.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinsimler/outbreak/bd9f09a7e2deeeb9d37b853825f1c662234d8c64/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 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 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 44 | 45 | 46 | 47 | 48 | 49 | 53 | 54 | 63 | Outbreak — Melting Asphalt 64 | 65 | 66 | 69 |
70 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video { 2 | border: 0; 3 | /*font-size: 100%;*/ 4 | /*font: inherit;*/ 5 | margin: 0; 6 | padding: 0; 7 | vertical-align: baseline; 8 | } 9 | 10 | HTML { 11 | overflow-x: hidden; 12 | overflow-y: auto; 13 | } 14 | 15 | BODY { 16 | line-height: 1; 17 | 18 | overflow-x: hidden; 19 | overflow-y: hidden; 20 | } 21 | 22 | UL, OL { 23 | margin-left: 2em; 24 | } 25 | 26 | A { 27 | outline: none !important; 28 | text-decoration: none; 29 | } 30 | A:link, A:visited, A:active { 31 | color: #A00; 32 | } 33 | A:hover { 34 | color: #B80000; 35 | text-decoration: underline; 36 | } 37 | 38 | /****** FONTS */ 39 | HTML, BODY { 40 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 41 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 42 | sans-serif; 43 | -webkit-font-smoothing: antialiased; 44 | -moz-osx-font-smoothing: grayscale; 45 | } 46 | 47 | .main-container { 48 | display: grid; 49 | grid-template-areas: 50 | "header header header" 51 | "blank-l content blank-r" 52 | "footer footer footer"; 53 | grid-template-columns: auto minmax(auto, 650px) auto; 54 | grid-template-rows: 50px minmax(calc(100% - 40px), auto) 20px; 55 | } 56 | 57 | @media only screen and (max-width: 375px) { 58 | .main-container { 59 | grid-template-columns: auto minmax(auto, 375px) auto; 60 | } 61 | } 62 | 63 | 64 | .header { 65 | grid-area: header; 66 | /*background-color: #888;*/ 67 | } 68 | 69 | .blank-l { 70 | grid-area: blank-l; 71 | } 72 | 73 | .blank-r { 74 | grid-area: blank-r; 75 | } 76 | 77 | .content { 78 | grid-area: content; 79 | 80 | padding: 3em 1em; 81 | } 82 | 83 | .footer { 84 | grid-area: footer; 85 | /*background-color: #F88;*/ 86 | } 87 | 88 | 89 | /********* HYPHENATION *********/ 90 | 91 | .post-content { 92 | -webkit-hyphens: auto; 93 | -moz-hyphens: auto; 94 | -ms-hyphens: auto; 95 | hyphens: auto; 96 | } 97 | 98 | H1, H2, H3, H4, H5 { 99 | word-break: keep-all; 100 | 101 | -webkit-hyphens: none; 102 | -moz-hyphens: none; 103 | -ms-hyphens: none; 104 | hyphens: none; 105 | } 106 | 107 | .nohyphen { 108 | -webkit-hyphens: none; 109 | -moz-hyphens: none; 110 | -ms-hyphens: none; 111 | hyphens: none; 112 | } 113 | 114 | 115 | 116 | 117 | .post-content DIV { 118 | font-size: 13pt; 119 | line-height: 140%; 120 | } 121 | 122 | .post-content H5 { 123 | font-size: 13pt; 124 | line-height: 140%; 125 | 126 | font-weight: normal; 127 | } 128 | 129 | .post-content H1 { 130 | margin-top: 0.8em; 131 | margin-bottom: 0; 132 | 133 | font-size: 44pt; 134 | 135 | font-family: "Rosarivo", "Lato", sans-serif; 136 | 137 | line-height: 1.0; 138 | font-style: normal; 139 | font-weight: 700; 140 | 141 | text-align: center; 142 | } 143 | 144 | .post-content H1 + H5 { 145 | margin-top: 1rem; 146 | margin-bottom: 4rem; 147 | 148 | text-align: center; 149 | } 150 | 151 | .post-content H3 { 152 | font-size: 24pt; 153 | line-height: 100%; 154 | } 155 | 156 | .post-content BLOCKQUOTE { 157 | margin: 0 2rem; 158 | padding: 1rem 0; 159 | 160 | font-size: 11pt; 161 | line-height: 120%; 162 | } 163 | 164 | 165 | 166 | 167 | 168 | 169 | .post-content > DIV { 170 | text-align: left; 171 | margin-bottom: 1em; 172 | } 173 | 174 | .post-content > DIV > H3 { 175 | margin-top: 4rem; 176 | } 177 | 178 | .post-content IMG { 179 | max-width: 100%; 180 | } 181 | 182 | 183 | 184 | 185 | 186 | .playback-controls-container { 187 | display: flex; 188 | flex-direction: row; 189 | align-items: stretch; 190 | justify-content: center; 191 | } 192 | 193 | .figure-container { 194 | display: flex; 195 | flex-direction: column; 196 | align-items: center; 197 | justify-content: flex-start; 198 | 199 | padding: 2.5em 0; 200 | } 201 | 202 | .figure-title { 203 | margin-bottom: 2em; 204 | 205 | font-size: 12pt; 206 | color: #000; 207 | font-weight: bold; 208 | } 209 | 210 | .figure-caption { 211 | margin-top: 1.5em; 212 | 213 | font-size: 10pt; 214 | color: #444; 215 | /*font-style: italic;*/ 216 | } 217 | 218 | .figure-body { 219 | width: 100%; 220 | } 221 | 222 | .figure-body.image { 223 | width: initial; 224 | max-width: 100%; 225 | } 226 | 227 | 228 | .aside-container { 229 | color: #000; 230 | margin: -0.5rem -1rem; 231 | padding: 0.5rem 1rem; 232 | 233 | cursor: pointer; 234 | } 235 | 236 | .aside-container.expanded { 237 | background-color: #f0f0f0; 238 | } 239 | 240 | .aside-teaser { 241 | font-size: 11pt !important; 242 | color: #555; 243 | } 244 | 245 | .aside-content { 246 | margin-top: 1rem; 247 | 248 | font-size: 11pt !important; 249 | } 250 | 251 | CODE { 252 | font-family: "Courier",monospace,sans-serif; 253 | } 254 | 255 | .code-susceptible { 256 | background-color: #f0f1f2; 257 | } 258 | 259 | .code-infectious { 260 | background-color: #ffc3c8; 261 | } 262 | 263 | .code-exposed { 264 | background-color: #ffeef5; 265 | } 266 | 267 | .code-removed { 268 | background-color: #e4e5e6; 269 | } 270 | 271 | .code-dead { 272 | background-color: #000; 273 | color: #fff; 274 | } 275 | 276 | .code-quarantined { 277 | background-color: #b5c9e6; 278 | } 279 | 280 | 281 | .author { 282 | color: #AAA; 283 | } 284 | 285 | .deemphasized { 286 | color: #999; 287 | } 288 | 289 | .deemphasized A { 290 | color: #999; 291 | } 292 | 293 | 294 | .plot-container { 295 | margin-top: 1.0em; 296 | 297 | display: grid; 298 | grid-template-areas: 299 | "yaxis chart" 300 | "none xaxis" 301 | "legend legend"; 302 | grid-template-columns: auto auto; 303 | grid-template-rows: auto auto auto; 304 | 305 | grid-row-gap: 0; 306 | /*background-color: #fafafa;*/ 307 | } 308 | 309 | .plot-legend { 310 | grid-area: legend; 311 | 312 | margin-top: 0.5em; 313 | 314 | display: flex; 315 | flex-direction: row; 316 | justify-content: flex-start; 317 | align-items: flex-start; 318 | } 319 | 320 | .plot-legend-button { 321 | margin-left: 2em; 322 | margin-right: 1em; 323 | } 324 | 325 | .plot-legend DIV { 326 | font-size: 10pt !important; 327 | } 328 | 329 | .plot-xaxis { 330 | grid-area: xaxis; 331 | 332 | justify-self: center; 333 | align-self: center; 334 | 335 | font-size: 10pt !important; 336 | } 337 | 338 | .plot-yaxis { 339 | grid-area: yaxis; 340 | 341 | justify-self: stretch; 342 | align-self: center; 343 | 344 | transform: rotate(-90deg); 345 | 346 | font-size: 10pt !important; 347 | 348 | margin: 0 -1.4em; 349 | } 350 | 351 | .plot-chart { 352 | grid-area: chart; 353 | 354 | height: 150px; 355 | } 356 | 357 | 358 | .simulation-prompt { 359 | /*font-weight: bold;*/ 360 | } 361 | 362 | 363 | 364 | .highlighted { 365 | background-color: #FBB !important; 366 | } 367 | 368 | 369 | .slider-container { 370 | width: 100%; 371 | 372 | display: grid; 373 | grid-template-areas: 374 | "name minus plus" 375 | "slider minus plus"; 376 | grid-template-columns: minmax(auto, 100%) auto auto; 377 | grid-template-rows: auto auto; 378 | 379 | grid-row-gap: 0; 380 | 381 | background-color: #f4f4f4; 382 | } 383 | 384 | .slider-container + .slider-container { 385 | margin-top: 0.40rem; 386 | /*border-top: 1px solid #aaa;*/ 387 | } 388 | 389 | .slider-name { 390 | grid-area: name; 391 | 392 | justify-self: stretch; 393 | align-self: center; 394 | 395 | margin-left: 0.5em; 396 | margin-top: 0.5em; 397 | margin-bottom: -0.7em; 398 | font-size: 11pt !important; 399 | font-weight: bold; 400 | } 401 | 402 | /*.slider-value {*/ 403 | /* grid-area: value;*/ 404 | 405 | /* justify-self: start;*/ 406 | /* align-self: center;*/ 407 | 408 | /* margin-left: 1em;*/ 409 | /* margin-top: 0.5em;*/ 410 | /* margin-bottom: -0.5em;*/ 411 | /* font-size: 11pt !important;*/ 412 | /*}*/ 413 | 414 | .slider-slider { 415 | grid-area: slider; 416 | 417 | justify-self: stretch; 418 | align-self: center; 419 | 420 | margin-left: 0.25em; 421 | padding: 0 0.25em; 422 | } 423 | 424 | .slider-minus { 425 | grid-area: minus; 426 | 427 | align-self: center; 428 | } 429 | 430 | .slider-plus { 431 | grid-area: plus; 432 | 433 | align-self: center; 434 | } 435 | 436 | .slider-slider-container { 437 | padding: 1em 0; 438 | } 439 | 440 | 441 | 442 | .plus-minus-button { 443 | font-weight: bold; 444 | } 445 | 446 | 447 | 448 | .spoiler { 449 | color: #444; 450 | background: #444; 451 | } 452 | 453 | .spoiler-revealed { 454 | background: #EEE; 455 | } 456 | 457 | .reveal-button { 458 | background: #CFC; 459 | } 460 | 461 | 462 | .figure-table { 463 | border-collapse: collapse; 464 | border: 1px solid #666; 465 | } 466 | 467 | .figure-table TD, 468 | .figure-table TH { 469 | border: 1px solid #666; 470 | padding: 0.5em; 471 | } 472 | 473 | 474 | 475 | 476 | .post-content DIV.end-of-post-divider { 477 | content: "⇌"; /* ‴∭≖ */ 478 | text-align: center; 479 | font-size: 18pt; 480 | margin: 120px 0 0 0; 481 | } 482 | 483 | .post-content DIV.signature-line { 484 | color: #888; 485 | font-size: 10pt; 486 | text-align: center; 487 | margin: 10px 0 120px 0; 488 | } 489 | 490 | 491 | 492 | 493 | 494 | 495 | .post-content DIV.subscription-footer { 496 | margin-top: 2em; 497 | margin-bottom: 8em; 498 | /* color: #888; 499 | text-align: center; 500 | */ 501 | } 502 | 503 | .post-content DIV.subscription-footer A { 504 | /* color: #444; 505 | */ 506 | } 507 | 508 | .post-content DIV.subscription-footer .mc4wp-form { 509 | margin: 0.5em 0 0 0; 510 | text-align: center; 511 | } 512 | 513 | .post-content DIV.subscription-footer .mc4wp-form input { 514 | font-family: inherit; 515 | } 516 | 517 | #mc4wp_email { 518 | display: inline; 519 | width: 200px; 520 | margin-right: 0.5em; 521 | padding: .3em .4em .15em; 522 | font-family: inherit; 523 | font-size: 12pt; 524 | } 525 | 526 | .post-content DIV.subscription-footer .mc4wp-form input[type=submit] { 527 | 528 | -moz-box-shadow:inset 0px 1px 0px 0px #ffffff; 529 | 530 | -webkit-box-shadow:inset 0px 1px 0px 0px #ffffff; 531 | 532 | box-shadow:inset 0px 1px 0px 0px #ffffff; 533 | 534 | background:-webkit-gradient(linear, left top, left bottom, color-stop(0.05, #ededed), color-stop(1, #dfdfdf)); 535 | 536 | background:-moz-linear-gradient(top, #ededed 5%, #dfdfdf 100%); 537 | 538 | background:-webkit-linear-gradient(top, #ededed 5%, #dfdfdf 100%); 539 | 540 | background:-o-linear-gradient(top, #ededed 5%, #dfdfdf 100%); 541 | 542 | background:-ms-linear-gradient(top, #ededed 5%, #dfdfdf 100%); 543 | 544 | background:linear-gradient(to bottom, #ededed 5%, #dfdfdf 100%); 545 | 546 | filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ededed', endColorstr='#dfdfdf',GradientType=0); 547 | 548 | background-color:#ededed; 549 | 550 | -moz-border-radius:6px; 551 | 552 | -webkit-border-radius:6px; 553 | 554 | border-radius:6px; 555 | 556 | border:1px solid #dcdcdc; 557 | 558 | display:inline-block; 559 | 560 | cursor:pointer; 561 | 562 | color:#777777; 563 | 564 | font-family:arial; 565 | 566 | font-size:15px; 567 | 568 | font-weight:bold; 569 | 570 | padding:6px 24px; 571 | 572 | text-decoration:none; 573 | 574 | text-shadow:0px 1px 0px #ffffff; 575 | 576 | } 577 | 578 | .post-content DIV.subscription-footer .mc4wp-form input[type=submit]:hover { 579 | 580 | background:-webkit-gradient(linear, left top, left bottom, color-stop(0.05, #dfdfdf), color-stop(1, #ededed)); 581 | 582 | background:-moz-linear-gradient(top, #dfdfdf 5%, #ededed 100%); 583 | 584 | background:-webkit-linear-gradient(top, #dfdfdf 5%, #ededed 100%); 585 | 586 | background:-o-linear-gradient(top, #dfdfdf 5%, #ededed 100%); 587 | 588 | background:-ms-linear-gradient(top, #dfdfdf 5%, #ededed 100%); 589 | 590 | background:linear-gradient(to bottom, #dfdfdf 5%, #ededed 100%); 591 | 592 | filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#dfdfdf', endColorstr='#ededed',GradientType=0); 593 | 594 | background-color:#dfdfdf; 595 | 596 | } 597 | 598 | .post-content DIV.subscription-footer .mc4wp-form input[type=submit]:active { 599 | 600 | position:relative; 601 | 602 | top:1px; 603 | 604 | } 605 | 606 | 607 | 608 | .header { 609 | background: #EEE; 610 | width: 100%; 611 | height: 50px; 612 | } 613 | 614 | #header { 615 | background: #EEE; 616 | width: 100%; 617 | max-width: 960px; 618 | height: 50px; 619 | margin: 0 auto; 620 | display: table; 621 | } 622 | 623 | #logo { 624 | display: table-cell; 625 | vertical-align: middle; 626 | margin: 0; 627 | padding-top: 4px; 628 | padding-left: 15px; 629 | padding-right: 15px; 630 | } 631 | 632 | #nav-logo { 633 | width: 50px; 634 | vertical-align: middle; 635 | margin-bottom: 4px; 636 | } 637 | 638 | .site-name { 639 | margin-left: 0.3em; 640 | line-height: 1.0em; 641 | } 642 | 643 | .site-name a { 644 | font-weight: 400; 645 | font-size: 14pt; 646 | color: #666; 647 | } 648 | 649 | .site-name a { 650 | text-decoration: none; 651 | } -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import './App.css' 3 | import Grid from "./components/Grid"; 4 | import NodeLegend from "./components/NodeLegend"; 5 | import Figure from "./components/Figure"; 6 | 7 | type Props = { 8 | } 9 | 10 | type State = { 11 | spoilersVisible: boolean, 12 | } 13 | 14 | class App extends Component { 15 | constructor(props: Props) { 16 | super(props); 17 | 18 | this.state = { 19 | spoilersVisible: false, 20 | } 21 | } 22 | 23 | // noinspection JSMethodCanBeStatic 24 | renderMainPost() { 25 | let spoilerOrNot; 26 | let showSpoilerButton; 27 | if (!this.state.spoilersVisible) { 28 | spoilerOrNot = "spoiler"; 29 | // showSpoilerButton = { this.setState({criticalThresholdVisible: true}); } } >Show spoilers 30 | } else { 31 | spoilerOrNot = "spoiler-revealed"; 32 | // showSpoilerButton = { this.setState({criticalThresholdVisible: false}); } } >Hide spoilers 33 | } 34 | showSpoilerButton = ; 35 | 36 | 37 | let exposed_you = you; 38 | 39 | let susceptible = Susceptible; 40 | let infected = Infected; 41 | let recovered = Recovered; 42 | let dead = Dead; 43 | let selfQuarantined = Self-quarantined; 44 | 45 | // noinspection HtmlRequiredAltAttribute 46 | return ( 47 |
48 |
49 |

Outbreak

50 |
by Kevin Simler
March 16, 2020
51 |
52 |
53 | Translations: español, Русский язык 54 |
55 |
56 | Harry Stevens at The Washington Post recently published a very elegant simulation of how a disease like COVID-19 spreads. If you haven't already, I highly recommend checking it out. 57 |
58 |
59 | Today I want to follow up with something I've been working on: playable simulations of a disease outbreak. "Playable" means you'll get to tweak parameters (like transmission and mortality rates) and watch how the epidemic unfolds. 60 |
61 |
62 | By the end of this article, I hope you'll have a better understanding — perhaps better intuition — for what it takes to contain this thing. But first!... 63 |
64 | {/*
*/} 65 | {/* Last year, I wrote a viral article about viral growth.*/} 66 | {/*
*/} 67 | {/*
*/} 68 | {/* It featured playable simulations of things that spread across a population. Things like viruses (yes), but also ideas, fashions, and other trends.*/} 69 | {/*
*/} 70 | {/*
*/} 71 | {/* Today, in light of our current crisis, I wanted a chance to revisit these simulations. And you can play with them in just a moment. But first...*/} 72 | {/*
*/} 73 |
74 | AN IMPORTANT WARNING: 75 |
76 |
77 | This is not an attempt to model COVID-19. 78 |
79 |
80 | What follows is a simplified model of a disease process. The goal is to learn how epidemics unfold in general. 81 |
82 |
83 | WARNING #2: I'm not an epidemiologist! I defer to infectious disease experts (and so should you). I have almost certainly made mistakes in this article, but I'll correct them as quickly as I can. If you see any problems, please get in touch. 84 |
85 |
86 | Alright? 87 |
88 |
89 | Let's do this. 90 |
91 |
92 |

A grid of people

93 |
94 |
95 | We're going to build our model up slowly, one piece at a time. 96 |
97 |
98 | The first thing a disease needs is a population, i.e., the set of people who can potentially catch the disease. Ours will live in neat rows and columns, like the 9x9 grid you see here: 99 |
100 |
101 | 114 |
115 |
116 | Each square represents a single person. The poor soul at the center, as you may have guessed, is {infected}. Meanwhile, everyone else is {susceptible}. 117 |
118 |
119 |

Time

120 |
121 |
122 | Now let's incorporate time into our model. 123 |
124 |
125 | The "Step" button (below) moves the simulation forward 1 day per click. Or you can press the ▷ button to watch things happen on their own: 126 |
127 |
128 | 141 |
142 |
143 | Oh no. It looks like everyone sneezed on their immediate neighbors — north, east, south, west — and the whole world got sick. 144 |
145 |
146 |

Recovery

147 |
148 |
149 | But people don't stay sick forever. Let's see what happens when they get better after 2 steps (i.e., 2 days): 150 |
151 |
152 | 165 |
166 |
167 | Great, now people can transition from {infected} to {recovered}. 168 |
169 |
170 | Here's a handy legend: 171 |
172 |
173 |
    174 |
  •  Susceptible
  • 175 |
  •  Infected
  • 176 |
  •  Recovered
  • 177 |
178 |
179 |
180 | For purposes of our simulation, once someone is {recovered}, they can't get reinfected. This is hopefully (and probably) true for COVID-19, but not certain. 181 |
182 |
183 |

Incubation period

184 |
185 |
186 | In discussions of COVID-19, you may have heard that the disease has a long incubation period. This is the time between when a person initially contracts the disease and the onset of first symptoms. 187 |
188 |
189 | With COVID-19, it seems that patients are contagious during the incubation period. They may not even realize they're sick, but they're still able to infect others. 190 |
191 |
192 | We will replicate this feature in our disease model. (But remember, we're not trying to model COVID-19 precisely!) 193 |
194 |
195 | Here's what an incubation period looks like: 196 |
197 |
198 | 210 |
211 |
212 | The way I've chosen to model this disease, there's no important distinction between the pink and red states. As far as the virus is concerned, both states behave the same. 213 |
214 |
215 | Nevertheless, I wanted to include the incubation period as a (visual) reminder that carriers of COVID-19 are lurking among us, hidden from the official statistics, totally unaware that they're infected. 216 |
217 |
218 | ... unaware that they're spreading the disease to others. 219 |
220 |
221 | Even as you read this, {exposed_you} may be such a person. 222 |
223 |
224 |
    225 |
  •  Susceptible
  • 226 |
  •  Infected (incubation period, no symptoms)
  • 227 |
  •  Infected (with symptoms)
  • 228 |
  •  Recovered
  • 229 |
230 |
231 |
232 |

Probabilistic infection

233 |
234 |
235 | OK, enough. 236 |
237 |
238 | Real diseases don't spread outward with 100 percent certainty. They spread probabilistically. 239 |
240 |
241 | So let's introduce a new parameter: the transmission rate. This controls the chance that an infection gets passed from person to person. 242 |
243 |
244 | Can you find a value for the transmission rate that keeps the disease from spreading to the entire population? 245 |
246 |
247 | 262 |
263 |
264 | Q: What's the largest transmission rate where the disease doesn't seem capable of spreading forever (e.g., reaching all four edges of the grid)? 265 |
266 |
267 | {showSpoilerButton} 268 |
269 |
270 | In my experiments, it seems to be around 0.35, maybe 0.34. Below that, I've seen the infection fizzle out every time. Above, it generally infects most of the grid. 271 |
272 |
273 | Here's how transmission works in our disease model. 274 |
275 |
276 | Every day, each person has a fixed number of encounters with the people nearby. 277 |
278 |
279 | Thus far, we've allowed people to interact only with their immediate neighbors, for a total of 4 encounters per day. We'll vary these assumptions below. 280 |
281 |
282 | During each encounter, the transmission rate determines the probability that an {infected} person will give the disease to a {susceptible} person. The higher the transmission rate, the more likely the disease gets passed along. 283 |
284 |
285 | In reality, there are many different types of encounters. You might brush past someone on the sidewalk. Or sit next to them on a bus. Perhaps you'll share an ice cream cone. Each of these encounters would result in a different probability of transmitting the infection. But in our model, for simplicity, all encounters share the same transmission rate. 286 |
287 |
288 | —— 289 |
290 |
291 | As you continue playing with these simulations (above and below) and thinking about their relevance to coronavirus/COVID-19, here's something to keep in mind: 292 |
293 |
294 | Transmission rate is partly a function of the disease itself (how naturally infectious it is), but also a function of the environment that the disease lives in. This includes both the physical environment (e.g., air temperature and humidity) as well as the social environment (e.g., people's behaviors). 295 |
296 |
297 | For example, when people wash their hands and wear masks to contain coughs, the transmission rate per encounter goes down — even if the virus itself doesn't change. 298 |
299 |
300 | Now, for any viral-growth process, it's possible to find a transmission rate low enough to completely stop the spread. This is called the "critical threshold," and you can learn more about it here. 301 |
302 |
303 | But COVID-19 is so infectious, it's hard to get below the critical transmission rate. We can only wash our hands so many times a day. Even wearing masks out in public won't be enough enough to bring transmission down far enough (though every inch is helpful). 304 |
305 |
306 | We could all wear hazmat suits every time we leave the house; technically that would solve the transmission problem (without changing our patterns of social interaction). But since that's, uh, impractical, let's consider other ways to keep this disease from consuming us. 307 |
308 |
309 |

Travel

310 |
311 |
312 | Here's another unrealistic assumption we've been making: we've been allowing people to interact only with their immediate neighbors. 313 |
314 |
315 | What happens when we let people travel farther afield? (We're still assuming 4 encounters per day, a parameter we'll expose in the next section.) 316 |
317 |
318 | As you pull the travel radius slider below, you'll see a sample of the encounters that the center person will have on any given day. (We can't draw everyone's encounters because it would get too crowded. You'll just have to use your imagination.) Note that in our model, unlike in real life, each day brings a new (random) set of encounters. 319 |
320 |
321 | 336 |
337 |
338 | Note that if you restrict travel from the beginning (e.g., to a radius of 2 units), you can slow the infection down a great deal. 339 |
340 |
341 | But what happens when you start with unrestricted travel, let the infection spread pretty much everywhere, and only restrict travel later? 342 |
343 |
344 | In other words, how early in the infection curve do you have to curtail travel in order for it to meaningful slow the outbreak? 345 |
346 |
347 | Go ahead, try it. Start with a travel radius of 25. Then play the simulation, pausing when you get to about 10 percent infected. Then reduce the travel radius to 2 and play it out. What happens? 348 |
349 |
350 | Takeaway: travel restrictions are most useful when they're applied early, at least for the purpose of flattening the curve. (So let's get them in place!) 351 |
352 |
353 | But travel restrictions can help even in the later stages of an outbreak, for at least two reasons: 354 |
355 |
356 |
    357 |
  1. Buses, trains, and airports are places where people gather together in cramped quarters. When people stop using these modes of transport, they reduce the number of encounters they have with potentially infected people. (We'll explore this more below.)
  2. 358 |
  3. Reducing travel is critical in concert with regional containment measures. If one region gets the outbreak under control, but neighboring regions are still on fire, you have to protect the controlled region. (We're not going to explore containment measures in this article, but they may be important soon, and if you're interested, you might start here.)
  4. 359 |
360 |
361 |
362 |

Number of encounters

363 |
364 |
365 | Alright, let's really open this thing up. 366 |
367 |
368 | In the simulation below, you can vary the encounters per day. 369 |
370 |
371 | Let's start at 20. What's the minimum value we need to keep the outbreak contained? 372 |
373 |
374 | 390 |
391 | {/*
*/} 392 | {/* Here's another question you might try to answer: For a fixed number of encounters (e.g., 5 per day), how much do you need to reduce the travel radius to keep the disease in check?*/} 393 | {/*
*/} 394 |
395 | As you can see, reducing encounters per day has a dramatic effect on the outbreak. It easily flattens the curve, and even has the potential (when taken very seriously) to completely quench an outbreak. 396 |
397 |
398 | This is the effect we're hoping for when we call for "social distance." This is why so many people are pleading with their officials to stop the parades and close the schools, and why all of us should stay away from bars and coffee shops and restaurants, and work from home as much as possible. 399 |
400 |
401 | The NBA did their fans a tremendous service by canceling the rest of the season. Now we need to follow suit and cancel everything. 402 |
403 |
404 | In my understanding (again, not an expert), this is the single most important lever we have for fighting this thing. 405 |
406 |
407 |

Death

408 |
409 |
410 | Not every patient recovers from a disease. Many end up {dead}. 411 |
412 |
413 | Enter the fatality rate. 414 |
415 |
416 | In our simulation, fatality rate is the probability that a patient who gets infected will ultimately die of the infection, assuming they get normal/adequate medical care. 417 |
418 |
419 | (Update: an earlier version of this article made a distinction between case fatality rate and mortality rate, but failed to define the terms correctly. Collapsing this distinction and using the term "fatality rate" instead.) 420 |
421 |
422 | The fatality rate for COVID-19 has been estimated between 1 percent and 6 percent. It might turn out to be lower than 1 percent, if there are a lot of undiagnosed cases. It's definitely higher when the medical system is overburdened (more on that in a minute). 423 |
424 |
425 | We'll start at a 3 percent fatality rate for our disease model, but you can vary the parameter below: 426 |
427 |
428 | 442 |
443 |
444 | Those scattered black dots may not look like much. But remember, each is a human life lost to the disease. 445 |
446 |
447 |

Hospital capacity

448 |
449 |
450 | Below you'll find one last new slider. It controls hospital capacity. 451 |
452 |
453 | This is the number of patients (expressed as a percentage of the population) that can be treated by our medical system at any one time. 454 |
455 |
456 | Why does hospital capacity matter? 457 |
458 |
459 | When there are more patients than the system can handle, they can’t get the treatment they need. And as a result, they have significantly worse outcomes. As we've seen in Italy, some may be left to die in the hallways. 460 |
461 |
462 | I've heard people speak of hospital capacity as the “number of beds,” or “number of ICU beds.” My take is that mere "beds" can be set up in a gymnasium on very short notice. I think the real bottleneck is medical equipment — specifically ventilators. But I'm not sure. Maybe it’s medical personnel. 463 |
464 |
465 | In reality, this matters a lot. We need to identify what the bottleneck is, and do our best to alleviate pressure there. But for a simulation, we can just wave our hands and assume there's limited capacity somewhere in the system. Remember, we're not trying to model reality too carefully. 466 |
467 |
468 | In our disease model, here's how the medical system breaks: 469 |
470 |
471 | When there are more infections than hospital capacity, the fatality rate doubles. 472 |
473 |
474 | Give it a try. Pay special attention to the input fatality rate (the value on the slider), which defines how often people die even in the best circumstances, vs. the actual death rate (highlighted below the chart), which tells us how the system behaves under strain. 475 |
476 |
477 | 495 |
496 |
497 |

"Flatten the curve"

498 |
499 |
500 | You've heard this before. You know why it's important. But now you're about to get a feel for it. 501 |
502 |
503 | This is your final test today. 504 |
505 |
506 | The input fatality rate is fixed at 3 percent. Hospital capacity is fixed at 5 percent. 507 |
508 |
509 | Play out the simulation and note the actual death rate: 6 percent. Then try to bring that number down. 510 |
511 |
512 | In other words, flatten the curve: 513 |
514 |
515 | 533 |
534 |
535 | However this worked out for you in simulation, reality is going to be so much harder. Real people don't respond like sliders in a UI. 536 |
537 |
538 | And here's the kicker: even if we manage to "flatten the curve" enough to meaningfully space out the case load, we're still positioned to lose millions and millions of lives. 539 |
540 |
541 | Maybe we won't lose as many as a worst-case scenario; maybe we won't lose them in hospital hallways. But as long as the virus continues to spread — which it shows every sign of doing — there's an unthinkable amount of suffering in our future. 542 |
543 |
544 | Unless we do the right things today. 545 |
546 |
547 | Stop traveling. Stop going out. Stop visiting your parents and your friends. Stop eating at restaurants. Pause everything you possibly can. If you're in charge of things, cancel them. Lock. It. All. Down. 548 |
549 |
550 | Please: take decisive action now. 551 |
552 |
553 | COVID-19 is coming for us, and it won't be stopped by half-measures. 554 |
555 | 556 | 557 | 558 | 559 |
560 |   561 |
562 |
563 |   564 |
565 |
566 | —— 567 |
568 |
569 |   570 |
571 | {/*
*/} 572 | {/* Thanks for reading. If this has been helpful, I hope you'll consider sharing.*/} 573 | {/*
*/} 574 |
575 | License 576 |
577 |
578 | CC0 — no rights reserved. You're free to use this work however you see fit, including copying it, modifying it, and distributing it on your own site. 579 |
580 |
581 | Source code 582 |
583 |
584 | Full model 585 |
586 |
587 | The full model, with all sliders exposed, can be found at the very bottom of the page. 588 |
589 |
590 | Acknowledgments 591 |
592 |
593 | I'd like to thank Nick Barr, Ian Padgham, Diana Huang, Kellie Jack, Brian Naughton, Yaneer Bar-Yam, and Adam D'Angelo for helpful feedback and encouragement. 594 |
595 |
596 | Further reading 597 |
598 |
599 |
    600 |
  • Coronavirus: Why You Must Act Now — Tomas Pueyo explains why we've been systematically underestimating this thing, and why that needs to change. Just read it.
  • 601 |
  • Don’t "Flatten the Curve," Stop It! — Joscha Bach does some calculations on hospital capacity and concludes that "flattening the curve" won't be enough; we have to completely stop the outbreak.
  • 602 |
  • The Washington Post's excellent simulation — brilliant use of billiard balls to show transmission and social distancing.
  • 603 |
  • Going Critical — my previous exploration of diffusion and viral growth processes, including the nuclear reactions and the growth of knowledge.
  • 604 |
605 |
606 | 607 | {this.renderEndOfPostDivider(true)} 608 | 609 |
610 | Melting Asphalt is maintained by Kevin Simler.

I publish very infrequently, so you might want to get notified about new posts:
611 | {this.renderSubscribeForm()} 612 | {/*
(This is a very low frequency mailing list. Pinky swear.)
*/} 613 |
You can also find me on Twitter. 614 |
 
615 |
616 | 617 | 618 | 619 | 620 |
621 |   622 |
623 |
624 | 625 |

Self-quarantine

626 |
627 |
628 | (Thanks to Jason Legate for suggesting and coding this addition to the disease model.) 629 |
630 |
631 | In the simulation below, you can vary the self-quarantine rate, i.e., the chance that a patient will choose to isolate themselves once they become symptomatic. Patients who become {selfQuarantined} will be drawn in blue instead of red. 632 |
633 |
634 | Additionally, you can vary how strict they are with the self-quarantine strictness parameters. At 100 percent strictness, patients who are isolating themselves have 0 encounters with other people. At 0 percent strictness, they have their normal number of encounters. And it varies linearly in between. 635 |
636 |
637 | Let's start the self-quarantine rate at 25 percent and the strictness also at 25 percent. What does it take to keep the outbreak contained? 638 |
639 |
640 | 658 |
659 |
660 | As you can see, if people voluntarily self-quarantine (once they show symptoms) and are strict about isolating themselves, the spread can be mitigated. Unfortunately, because patients are contagious during the incubation period (before they have a chance to notice their own symptoms), it's hard to stop the spread entirely. 661 |
662 |
663 | For most diseases, self-quarantine won't solve the problem on its own. Rather, it's one tool among many (including better hygiene, social distances, travel restrictions, etc.) that all together can bring an outbreak under control. A big lesson here is that every strategy complements every other strategy. 664 |
665 |
666 |

Full model

667 |
668 |
669 | 687 |
688 | 689 |
690 | ); 691 | } 692 | 693 | renderSubscribeForm() { 694 | return ( 695 |
696 | 697 |