I saw a terrific Coronavirus simulation about transmission called “Simulating an Epidemic”. If you haven’t seen it, you should watch it now: it communicates science well and answers a lot of my questions about social isolation, PPE and travel restrictions.
Planning
Questions
Some questions I would like to answer are:
- How does reinfection change the result?
- We typically measure the effects of this in deaths, but what is the economic impact of How much misery is incurred in terms of total time that a population is sick?
- Is there a strategy that results in less than “all” the population from getting infected?
- Do the people who “break the rules” suffer more or the same as the population?
- Can some regions “break the rules” without imperiling others?
- What effect do imperfect tests have on the total sick time?
Requirements
The user should be able to:
- Visualize the simulation and summarize the results,
- Control the speed of the simulation,
- Modify the simulation, and
- Expect the results to be meaningful without being precise.
Technologies
I chose pure ES6-compliant JavaScript and HTML5 Canvas for this exercise for several reasons:
- JavaScript is popular and easy to understand.
- Anybody can run it: it is available everywhere and doesn’t depend on a back-end server.
- There are interactive scripting tools like JSFiddle where the layperson can mess with the code, like I learned to code in BASIC.
- I need to brush up on my ES6 JavaScript and animation skills.
I chose to use one file rather than spreading it across many class files in order to make the simulation more portable and easier to fiddle with.
Licensing
I want other people to be free to use this work, talk about it and build upon it. I’m going to use a Creative Commons license, specifically the Creative Commons Attribution-ShareAlike CC BY-SA license.
This license lets others remix, adapt, and build upon your work even for commercial purposes, as long as they credit you and license their new creations under the identical terms…. All new works based on yours will carry the same license, so any derivatives will also allow commercial use.
Design
The code is going to be broken into sections, and we also proceed somewhat linearly through the sections.
- Configuration – this is where we make our settings.
- Model – lay out the data structures for the critters and the infection, and include some processing that determines how they behave.
- View – these are classes that help us display data, charts and visualizations.
- Controller – this runs the simulation.
- A simulation loop that is driven by a timer. This advances with each cycle (the equivalent of a day in our simulation) and advances each critter by triggering its model’s functionality.
- A visualization loop that is also driven by a timer. This updates the displays. Separating these loops lets us animate the simulation independently from the speed of the simulation.
- Initialization – sets up the model and views, and activates the controller.
- Conclusion – produces a final summary.
Project Plan
- Set up a visualization and demonstrate that we can animate it.
- Build a simple 2D model for a community and demonstrate that we can animate it.
- Model a population with an object that will support our simulation, e.g. location in physical space, social distancing, infection rates, etc.
- Build a loop that with each cycle, iterates over the array of critters to update their properties.
- With each cycle, calculate statistics for the population.
- Provide a visualization to keep track of what’s going on.
- Validate the simulation by plugging in values and confirming that the results match the expectations.
- Program the generic simulator to answer specific questions.
Getting Started
Basic Toolbox
Let’s using JSFiddle to sketch out the solution. Go to jsfiddle.net and start a new project. Close the “framework” window – you’re using pure JavaScript! This is going to look like Pong.
I’m going to write this in a way that sets up its own HTML and CSS, so you don’t have to put anything in the HTML or CSS boxes.
Critter in a Box
To get to model people in a community, let’s start with one critter in a box. It’s less stressful to perform these kinds of experiments on critters.
To get confidence in modeling and drawing, I’ll start by defining a critter and plotting them in a box.
// https://bigbrainsr.us/coronavirus-simulator-tutorial-in-javascript/#Critter_in_a_Box // Creative Commons Attribution-ShareAlike CC BY-SA https://creativecommons.org/licenses/by-sa/4.0/ // make a box const h=300, w=300; // h is xmax, w is ymax let theBox = document.createElement('canvas'); theBox.id = "theBox"; theBox.width=w.toString(); theBox.height=h.toString(); document.body.appendChild(theBox); var context = theBox.getContext("2d"); context.fillStyle = "white"; context.strokeStyle = "green"; context.lineWidth = "5"; context.fillRect(0, 0, w, h); context.strokeRect(0, 0, w, h); // a bit of utility // this is a good random number generator that gives a uniform distribution over the range function getRandomInt(min, max) { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1)) + min; } // define a simple critter let critter = {x:getRandomInt(0,w), y:getRandomInt(0,h), w:5, h:5, c:"red", dx:1, dy:1}; // put it in the box context.fillStyle = critter.c; context.fillRect(critter.x, critter.y, critter.w, critter.h);
I’m using “let” instead of “var” when declaring my variables. I had to look this up. Var is function-scoped, but Let is block-scoped, which reduces the chances of leakage.
Make sure this works for you before you move on.
Let the Critter Move
Now, let’s move the critter around to see what animation looks like.
// https://bigbrainsr.us/coronavirus-simulator-tutorial-in-javascript/#Let_the_Critter_Move // Creative Commons Attribution-ShareAlike CC BY-SA https://creativecommons.org/licenses/by-sa/4.0/ let fps=15; // at 15 fps it should take 20 seconds to traverse 300 px let duration=40; // 40 seconds at 15fps, it should come back to start var updateIntervalID = window.setInterval(updateCritter, 1000/fps, critter); var clearIntervalID = window.setTimeout(clearInterval, duration*1000, updateIntervalID); function updateCritter(critter) { // clear context.fillStyle = "blue"; context.fillRect(critter.x, critter.y, critter.w, critter.h); // move critter.x += critter.dx; if ((critter.x < 0)||(critter.x > w) { critter.x -= critter.dx; // undo critter.dx = -critter.dx; // reverse critter.x += critter.dx; // do } if ((critter.y < 0)||(critter.y > h) { critter.y -= critter.dy; // undo critter.dy = -critter.dy; // reverse critter.y += critter.dy; // do } // draw context.fillStyle = critter.c; context.fillRect(critter.x, critter.y, critter.w, critter.h); }
You can see that there are some issues with the boundary on the right side and the bottom because of the way that we draw the critter (which is intended to be quick), so we are going to eventually need to fix this. Try changing some of the values like the frames per second to see how fast you can get the animation to go, and try setting a random dx and/or dy and/or color.
Drawing the objects isn’t the point of the simulation, but it sure helps understand what the visualization is doing.
That wraps up a proof-of-concept of our basic building blocks. The next step is to generalize and compartmentalize it so we can expand on it easily in our simulation.
Objectifying
Here is a version that does the same thing, but puts the code into a few JavaScript classes and enables some things like creating more than one critter. Based on the tests so far, I’m also going to separate the animation from the calculation.
If you’re following along in our design, this is where we are building out the model, views and controllers.
// https://bigbrainsr.us/coronavirus-simulator-tutorial-in-javascript/#Objectifying // Creative Commons Attribution-ShareAlike CC BY-SA https://creativecommons.org/licenses/by-sa/4.0/ const settings = { xmax: 1200, ymax: 300, cycles: 3000, fps: 15 } class Utility { static getRandomInt(min, max) { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1)) + min; } static log(message) { console.log(message); } } // Models class Critter { constructor() { this.x = Utility.getRandomInt(0, settings.xmax); this.y = Utility.getRandomInt(0, settings.ymax); this.c = "red"; this.dx = Utility.getRandomInt(-2, 2); this.dy = Utility.getRandomInt(-2, 2); } update() { this.x += this.dx; if ((this.x < 0) || (this.x > settings.xmax)) { this.dx = -this.dx; // reverse this.x += this.dx; // do } this.y += this.dy; if ((this.y < 0) || (this.y > settings.ymax)) { this.dy = -this.dy; // reverse this.y += this.dy; // do } } } // Views class Visualization { constructor(divID) { this.elementID = divID; this.width = settings.width || 400; this.height = settings.height || 600; this.xmax = settings.xmax || settings.width; this.ymax = settings.ymax ||settings.height; this.fillColor = "white"; this.borderColor = settings.borderColor || "green"; this.borderSize = 5; this.pointWidth = 5; // lets you make the points big enough to see this.pointHeight = 5; // lets you make the points big enough to see this.canvas = document.getElementById(this.elementID); if (!this.canvas) { this.canvas = this.createCanvas(); } this.context = this.canvas.getContext("2d"); this.clearCanvas(); return this; } createCanvas() { let theBox = document.createElement('canvas'); theBox.id = this.elementID; theBox.width = this.width.toString(); theBox.height = this.height.toString(); document.body.appendChild(theBox); return theBox; } clearCanvas() { this.context.fillStyle = this.fillColor; this.context.fillRect(0, 0, this.width, this.height); this.context.strokeStyle = this.borderColor; this.context.lineWidth = this.borderSize; this.context.strokeRect(0, 0, this.width, this.height); // strokeRectangle } /* Quickly plots a point into the visualization. * Transforms the points so they fit in the boundries. * Assumes that all the elements that are ever drawn have the same w&h */ drawPoint(color, x, y) { let w=this.pointWidth, h=this.pointHeight; let cx = Math.floor(this.borderSize + ( (this.width - 2*this.borderSize - this.pointWidth) * x / this.xmax )); let cy = Math.floor(this.borderSize + ( (this.height - 2*this.borderSize - this.pointHeight) * y / this.ymax )); this.context.fillStyle = color; this.context.fillRect(cx, cy, this.pointWidth, this.pointHeight); } drawCritter(critter) { this.drawPoint(critter.c, critter.x, critter.y); } eraseCritter(critter) { this.drawPoint(this.fillColor, critter.x, critter.y); } } // Controllers // definitions let visualization = new Visualization("visualization"); let critters = []; function animateCritters(critters) { critters.forEach(function(critter) { visualization.eraseCritter(critter); critter.update(); visualization.drawCritter(critter); graph.drawCritter(critter); }); } // start critters.push(new Critter()); animateCritters(critters); // animate var animationIntervalID = window.setInterval(animateCritters, 1000 / fps, critters); // calculate for (let cycle = 0; cycle < settings.cycles; cycle++) { critters.forEach(function(critter) { critter.update(); }); } // stop animation clearInterval(animationIntervalID); // present final state animateCritters(critters);
At this point, you should have a good idea of the framework that we’re going to be using going forward.
Assembly
Well, now that that’s working, it’s time to give it some personality.
Configuration
Some of the things that you can set in the global configuration section are the length and speed of the simulation, how often to update the display.
Infection!
The heart of the infection modeling component is a JavaScript object that, for each state you can be in, maps out the various states you can get to. So if a critter is infected, on every cycle it has a probability of becoming symptomatic, hospitalized, removed (dead), or recovered.
this.advancements = { infected: [ { atLeast: 3, noMore: 14, chance: 25, next: "symptomatic" }, { atLeast: 7, noMore: 14, chance: 75, next: "recovered" }, ], symptomatic: [ { atLeast: 7, noMore: 14, chance: 20, next: "hospitalized" }, { atLeast: 7, noMore: 14, chance: 5, next: "dead" }, { atLeast: 2, noMore: 14, chance: 75, next: "recovered" }, ], hospitalized: [ { atLeast: 7, noMore: 14, chance: 25, next: "dead" }, { atLeast: 7, noMore: 14, chance: 75, next: "recovered" }, ] };
This matrix is implemented the update() method.
Object.keys(chances).forEach(next => { if (Utility.coinToss(chances[next])) { // congratulations, you advance a state this.status = next; return; } }
The Code
Here’s our baseline code. After the code listing there’s a fiddle so you can play with it right here, and a final section that handles the conclusion.
// https://bigbrainsr.us/coronavirus-simulator-tutorial-in-javascript/#The_Code // Creative Commons Attribution-ShareAlike CC BY-SA https://creativecommons.org/licenses/by-sa/4.0/ // Part I: Global Configuration const settings = { xmax: 300, ymax: 300, critters: 2400, //2400, cycles: 100, //100 separation: 100, // number of pixels that is considered a collision cps: 2, // cycles per second, use zero to run without interrupts fps: 4, // frames per second, use zero to turn off animation roammax: 50, // the 0<chance<100 that they'll roam on a particular cycle speedmax: 2, // how far they might move with each iteration if they roam } // Part II: Utilities class Utility { static getRandomInt(min, max) { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1)) + min; } static coinToss(pTrue = 50) { // 0<pTrue<100 if (pTrue <= 0) return false; if (pTrue >= 100) return true; return ((Math.random() * 100) < pTrue); } static log(...theArgs) { console.log(Utility.withSpaces(theArgs)); } // this takes a number and formats it to the specified precision, then uses the label if provided to plural it static niceNumber(number, precision = 0, label = "", plural = "") { let text = ""; text += number.toFixed(precision); if (label !== "") text += (" " + Utility.plural(number, label, plural)); return text; } // uses the singular if it's 1, plural if not static plural(number, singular = "", plural = "") { if (plural === "") plural = singular + "s"; // standard English plural return (number == 1) ? singular : plural; } // this takes a series of arguments and separates them with spaces static withSpaces(...theArgs) { theArgs.forEach(arg => { return Array.isArray(arg) ? arg.join(" ") : arg; }); return theArgs.join(" "); } static nicePercent(numerator, denominator, precision = 0) { return (denominator == 0) ? "" : "(" + Utility.asPercent(numerator, denominator, precision) + "%)"; } static asPercent(numerator, denominator, precision = 0) { return (denominator == 0) ? "&infinity;" : (100 * numerator / denominator).toFixed(precision); } } // Part III: Models class CritterInfection { constructor() { this.statusTextColors = [ // key, text, color ["uninfected", "Uninfected", "green"], ["infected", "Infected - no symptoms", "orange"], ["symptomatic", "Infected - symptomatic", "red"], ["hospitalized", "Infected - hospitalized", "pink"], ["recovered", "Recovered", "blue"], ["dead", "Removed", "black"], ]; // State. // this defines several probabilty distributions starting after spending atLeast cycles in a state, // you have a uniform chance/(noMore-atLeast) probabality of transitioning to the next state. // if you hit noMore in all state(s) there is no longer an option to stay in the current state // you transition immediately to that state according to the relative probablites // this prevents long-tail probablities // The chance of each transition state plus the current state if valid will add to 100 for each cycle this.advancements = { infected: [{ atLeast: 3, noMore: 14, chance: 25, next: "symptomatic" }, { atLeast: 7, noMore: 14, chance: 75, next: "recovered" }, ], symptomatic: [{ atLeast: 7, noMore: 14, chance: 20, next: "hospitalized" }, { atLeast: 7, noMore: 14, chance: 5, next: "dead" }, { atLeast: 2, noMore: 14, chance: 75, next: "recovered" }, ], hospitalized: [{ atLeast: 7, noMore: 14, chance: 25, next: "dead" }, { atLeast: 7, noMore: 14, chance: 75, next: "recovered" }, ] }; // defines some shortcut states this.contageous = ["infected", "symptomatic", "hospitalized"]; this.susceptible = ["uninfected"]; // build some shortcuts that are easier to iterate over this.statuses = []; this.statusColors = { "undefined": "Yellow" }; this.statusTexts = { "undefined": "Undefined" }; this.statusTextColors.forEach(([status, text, color]) => { this.statuses.push(status); this.statusColors[status] = color; this.statusTexts[status] = text; }); // stores the number of cycles in each state this.history = {}; // initial state this.status = "uninfected"; return this; } update(cycle) { this.history[this.status] = this.history[this.status]+1 || 1; if (this.advancements[this.status]) { // things to make it easier let advancementOptions = this.advancements[this.status]; let timeInThisState = this.history[this.status]; // things to remember let originalStatus = this.status; // things to calculate let maxNoMore = 0; // this is the maximum of the noMore cycle limits let chances = {}; // this will be a list of the probabilities of our next state options advancementOptions.forEach(advancement => { if (timeInThisState >= advancement.atLeast) { chances[advancement.next] = advancement.chance / Math.max(advancement.noMore - advancement.atLeast, 1); } maxNoMore = Math.max(advancement.noMore, maxNoMore); }); // get out your two-sided dice Object.keys(chances).forEach(next => { if (Utility.coinToss(chances[next])) { // congratulations, you advance a state this.status = next; return; } }); while ((maxNoMore < timeInThisState) && (this.status === originalStatus)) { Object.keys(chances).forEach(next => { if (Utility.coinToss(chances[next])) { // congratulations, you advance a state this.status = next; return; } }); } } } isContageous() { return (this.contageous.includes(this._status)); } wasInfected() { const infected = (element) => Object.keys(this.history).includes(element); return (this.contageous.some(infected)); } maybeInfect(otherCritter) { if (this.contageous.includes(this._status) && this.susceptible.includes(otherCritter.infection.status)) { otherCritter.infection.status = "infected"; } } maybeReinfect() { return false; } get text() { let targetStatus = this._status; return (this.statusTexts[this.status]) ? this.statusTexts[this.status] : this.statusTexts["undefined"]; } get color() { let targetStatus = this.status; return (this.statusColors[targetStatus]) ? this.statusColors[targetStatus] : this.statusColors["undefined"]; } get status() { return this._status; } set status(proposedValue) { if (this.statuses.includes(proposedValue)) { if (this._status) this.history[this._status] = this.history[this._status] - 1 || 0; this.history[proposedValue] = this.history[proposedValue] + 1 || 1; this._status = proposedValue; } else Utility.log("set status invalid proposedValue not in " + this.statuses); } } class Critter { constructor(name) { this.name = name || Utility.getRandomInt(0, 99999999); this.settings = { x: Utility.getRandomInt(0, settings.xmax), y: Utility.getRandomInt(0, settings.ymax), directionX: (Utility.coinToss()) ? -1 : 1, directionY: (Utility.coinToss()) ? -1 : 1, roaming: Utility.getRandomInt(0, settings.roammax), // the 0<=chance<-100 they will roam speed: Utility.getRandomInt(0, settings.speedmax), // how 0<=fast they will move if they do move } this.infection = new CritterInfection(); } move() { let s = this.settings; // if a random integer is less than the roaming integer, then move in some direction if (Utility.coinToss(s.roaming)) { let dx = Utility.getRandomInt(0, s.speed) * ((Utility.coinToss()) ? -1 : 1); //s.directionX; let dy = Utility.getRandomInt(0, s.speed) * ((Utility.coinToss()) ? -1 : 1); s.x += dx; if ((s.x < 0) || (s.x > settings.xmax)) { s.x -= dx; // undo s.directionX = -s.directionX; // reverse next time } s.y += dy; if ((s.y < 0) || (s.y > settings.ymax)) { s.y -= dy; // do s.directionY = -s.directionY; // reverse next time } } } isSame(otherCritter) { return this.name === otherCritter.name; } collide(otherCritter) { if (this.isSame(otherCritter)) return false; let dq = (this.x - otherCritter.x) * (this.x - otherCritter.x) + (this.y - otherCritter.y) * (this.y - otherCritter.y); return (dq < settings.separation); } get x() { return this.settings.x; } get y() { return this.settings.y; } get dx() { return this.settings.dx; } get dy() { return this.settings.dy; } get c() { return this.infection.color; } get status() { return this.infection.status; } } // definitions function doAnimateCritters(visualization, critters) { visualization.animateCritters(critters); } function doUpdateCritters(critters) { cycle++; // move critters.forEach(function(critter) { critter.move(); critter.infection.update(cycle); }); // look for collisions with contageous critters critters.forEach(function(critter) { if (critter.infection.isContageous()) { critters.forEach(function(othercritter) { if (critter.isSame(othercritter)) { critter.infection.maybeReinfect(); } else { if (critter.collide(othercritter)) critter.infection.maybeInfect(othercritter); } }); } }); // for debugging critters.forEach(function(critter) { graph.drawCritter(critter); // for debugging }); // for reporting report.replace(statistics.report(settings, critters, cycle)); // start the next cycle, maybe if (cycle === settings.cycles) { wrapup(); } else { // more to do if (settings.cps > 0) { window.setTimeout(doUpdateCritters, 1000 / settings.cps, critters); // recursion! } else { doUpdateCritters(); // unconstrained recursion! } } } // Part IV: Views class TextArea { constructor(divID) { this.elementID = divID; this.div = document.getElementById(this.elementID); if (!this.div) { this.div = this.createDiv(); } } createDiv() { // creates the named div if it wasn't found on the page let theBox = document.createElement('div'); theBox.id = this.elementID; document.body.appendChild(theBox); return theBox; } log(message) { let line = document.createElement("p"); line.innerHTML = message; this.div.appendChild(line); } static getColorSwatch(color, text = " ") { // returns a HTML color swatch in the color specified return '<span style="height:1em;width:1em;background:' + color + ' ">' + text + '</span>'; } clear() { this.div.innerHTML = ""; } replace(sections) { // messages is an array of arrays of messages this.clear(); if (!Array.isArray(sections)) sections = [sections]; sections.forEach(section => { if (!Array.isArray(section)) sections = [section]; this.log(section.join(" ")); }); } } class Visualization { constructor(divID) { this.elementID = divID; this.width = settings.width || 400; this.height = settings.height || 400; this.xmax = settings.xmax || settings.width; this.ymax = settings.ymax || settings.height; this.fillColor = settings.fillColor || "white"; this.borderColor = settings.borderColor || "green"; this.borderSize = settings.borderSize || 5; this.pointWidth = 2; // lets you make the points big enough to see this.pointHeight = 2; // lets you make the points big enough to see this.canvas = document.getElementById(this.elementID); if (!this.canvas) { this.canvas = this.createCanvas(); } this.context = this.canvas.getContext("2d"); this.clearCanvas(); return this; } createCanvas() { let theBox = document.createElement('canvas'); theBox.id = this.elementID; theBox.width = this.width.toString(); theBox.height = this.height.toString(); document.body.appendChild(theBox); return theBox; } clearCanvas() { this.context.fillStyle = this.fillColor; this.context.fillRect(0, 0, this.width, this.height); this.context.strokeStyle = this.borderColor; this.context.lineWidth = this.borderSize; this.context.strokeRect(0, 0, this.width, this.height); // strokeRectangle } /* Quickly plots a point into the visualization. * Transforms the points so they fit in the boundries. * Assumes that all the elements that are ever drawn have the same w&h */ drawPoint(color, x, y) { let cx = Math.floor(this.borderSize + ((this.width - 2 * this.borderSize - this.pointWidth) * x / this.xmax)); let cy = Math.floor(this.borderSize + ((this.height - 2 * this.borderSize - this.pointHeight) * y / this.ymax)); this.context.fillStyle = color; this.context.fillRect(cx, cy, this.pointWidth, this.pointHeight); } drawCritter(critter) { this.drawPoint(critter.c, critter.x, critter.y); } eraseCritter(critter) { this.drawPoint(this.fillColor, critter.x, critter.y); } animateCritters(critters) { this.clearCanvas(); critters.forEach(critter => this.drawCritter(critter)); } } class Statistics { constructor() { } report(settings, critters, cycle) { // summarize let outcomes = {}; // infectionStatus => count let statustimes = {}; // infectionStatus => cycles in that status critters.forEach(critter => { outcomes[critter.infection.status] = outcomes[critter.infection.status] + 1 || 1; Object.keys(critter.infection.history).forEach((historicalstatus, count) => { let statustime = critter.infection.history[historicalstatus] || 0; statustimes[historicalstatus] = statustimes[historicalstatus] + statustime || statustime; }); }); // the data summaries are available at this point if you want to tap them off // Utility.log(outcomes); // Utility.log(statustimes); // summary // The reporting display is looking for an array of arrays. // It will put sub-arrays together in blocks in the display. let acritteri = new CritterInfection(); // access to infection's colors and texts let sections = []; // these will be text sections let section = []; // this will accumulate the text in each text section. section.push("Cycles: planned " + settings.cycles + " completed " + cycle + " (" + Math.round(100 * cycle / settings.cycles) + "%)"); section.push("Critters: planned " + settings.critters + " created " + critters.length + " (" + Math.round(100 * critters.length / settings.critters) + "%)"); sections.push(section); section = []; acritteri.statuses.forEach(status => { if (outcomes[status]) { let count = outcomes[status]; let perCount = Math.round(100 * count / critters.length); acritteri.status = status; let label = acritteri.text + ":"; let text = Utility.withSpaces( TextArea.getColorSwatch(acritteri.color), label, Utility.niceNumber(count, 0, "critter"), Utility.nicePercent(count, critters.length, 1) ); section.push(text); } }); sections.push(section); section = []; let cumulativetime = 0; Object.keys(statustimes).forEach(status => { cumulativetime += statustimes[status] }); cumulativetime /= critters.length; acritteri.statuses.forEach(status => { if (statustimes[status]) { let timeper = statustimes[status] / critters.length; acritteri.status = status; let label = "Time as " + acritteri.text + ":"; let text = Utility.withSpaces( TextArea.getColorSwatch(acritteri.color), label, Utility.niceNumber(timeper, 1, "cycles/critter", "cycles/critter"), Utility.nicePercent(timeper, cumulativetime) ); section.push(text); } }); sections.push(section); return sections; } } // Part V: Controllers // things start here let visualization = new Visualization("visualization"); let graph = new Visualization("graph"); let report = new TextArea("report"); let statistics = new Statistics(); let critters = []; for (let i = 0; i < settings.critters; i++) { critters.push(new Critter("Marlin " + i)); } critters[0].infection.status = "infected"; Utility.log("Critters: planned " + settings.critters + " created " + critters.length); // start visualization visualization.animateCritters(critters); if (settings.fps > 0) { var animateIntervalID = window.setInterval(doAnimateCritters, 1000 / settings.fps, visualization, critters); } var cycle = 0; doUpdateCritters(critters); // end visualization function wrapup() { // called by updateCritters when everything is done // final animation if (settings.fps > 0) { clearInterval(animateIntervalID); } visualization.animateCritters(critters); report.replace(statistics.report(settings, critters, cycle)); report.log("Done."); }
The Simulator
Here’s my finished fiddle: https://jsfiddle.net/jtw90210/a4Ld25n9/.