We have been hearing a lot about building herd immunity against the Coronavirus, and a lot about React, a JavaScript library for building user interfaces. So we decided to combine the two to make a calculator that will help us compare the “wear a damn mask” against the “everybody gets it” scenario. This extends our COVID-19 simulator, where we learned Typescript and ES6 JavaScript while simulating a virus in a community. That was helpful for understanding how simulators are built, as well as some modern-ish JavaScript.
If you want to go play with the calculator, by all means do that, and come back here to see how we built it.
TL;DR: Herd Immunity is a stupid thing to try to do with a deadly virus. See more conclusions at the end, or discuss in the comments.
Starting Resources
We really liked these resources:
- Introduction to React: https://jaxenter.com/introduction-react-147054.html
- Introduction to Grid CSS: https://webdesign.tutsplus.com/tutorials/how-to-build-web-form-layouts-with-css-grid–cms-28776
- JSX: https://reactjs.org/docs/introducing-jsx.html
- React-Bootstrap, providing some components to get us started: https://react-bootstrap.github.io/components/cards/#card-columns-props
We used Chocolaty to trick out our Windows box, it defintely made booting easier. We installed NPM and Node.js first, see https://vocon-it.com/2019/11/19/install-npm-using-chocolatey-on-windows/
Starting the Project
mkdir herd-calculator && cd herd-calculator
npx create-react-app herd-calculator
npm start
http://localhost:3000 to verify empty project
Development Gotchas
Development was rather straightforward. Realizing that JSX made it really easy to bundle and reuse scraps of HTML helped a lot in the results tables, and finding the bootstrap-react components made the reactive functionality much easier to achieve.
When we started with the app, it was clumsy and awkward to declare a separate useState() for each property. As soon as we put them in objects though, we ran into invisible problems with the state of the app.
Problem 1 was one of “initialization.” Because the initial state is stored in an object, when the app started up before doCalculate ran, things were undefined.
So we tried defining them using the defaults. JavaScript uses assign-by-value for simple types, and assign-by-reference for complex types. This bit us and took us a long time to understand what was going on.
This seemed like a good idea
const emptyData={infected:{},affected:{},unemployed:{},hospital:{},disabled:{},dead:{},costs:{}}; const [results,setResults]=useState({community:emptyData,herd:emptyData});
But when this runs, because JavaScript uses assign-by-reference, results.community
and results.herd
point to exactly the same memory location, emptyData
, and when you change results.community.infected
it also changes results.herd.infected
like magic!
We created a minimal empty structure and set a flag on initialization to prevent the application from displaying the results state before it was calculated for the first time.
const [results,setResults]=useState({community:{costs:{}},herd:{costs:{}}});
After that we were careful to make a fresh object and copy it to the property by value all at once, that seems to have ended the “spooky action” we were getting by updating the same state in both the UI and the doCalculate.
Here’s the App
import React,{useState, useEffect} from 'react'; // from https://react-bootstrap.github.io/components/buttons/ import Accordion from 'react-bootstrap/Accordion'; import Button from 'react-bootstrap/Button'; import Card from 'react-bootstrap/Card'; import CardColumns from 'react-bootstrap/CardColumns'; import Form from 'react-bootstrap/Form'; import Table from 'react-bootstrap/Table'; import 'bootstrap/dist/css/bootstrap.min.css'; // from https://github.com/s-yadav/react-number-format import NumberFormat from 'react-number-format'; import './App.css'; function App() { const [initialized, setInitialized]=useState(false); // used to prevent loops between drawing and calculating:w const [population,setPopulation]=useState(0); const [results,setResults]=useState({community:{costs:{}},herd:{costs:{}}}); const defaultPopulation=331002651; const defaultAge=38; const defaultSex=49; const headingColor="#a1b2b3"; const communityWord="Community"; const communityColor="#32bcff"; const herdWord="Herd"; const herdColor="#ff3255"; const defaults={ community:{ infectionPercent: 12, unemployedPercent: 12 }, herd:{ infectionPercent: 82, unemployedPercent: 2 }, affectedPercent:10, adultPercent:76, adjustments:{ age:0, sex:0, saturation:0 }, affectedDays:10, hospitalPercent:20, hospitalDays:14, disabledPercent:20, deadPercent:30, costs:{ affected:80, hospital:1000, disabled:10000, dead:5000 } } // calculate on startup // eslint-disable-next-line useEffect(()=>{ if (!initialized) doCalculation(); },[]); // Reset on button push const Clear=(e)=>{ e.preventDefault(); document.querySelector('form').reset(); doCalculation(); } // calculate on button push const Calculate=(e)=>{ e.preventDefault(); doCalculation(); } // actually do the calculation and set the values const doCalculation=()=>{ let population=Math.round(document.querySelector('#population').value*defaults.adultPercent/100); setPopulation(population); let affectedPercent=document.querySelector('#affectedPercent').value; let affectedDays=document.querySelector('#affectedDays').value; let hospitalPercent=document.querySelector('#hospitalPercent').value; let hospitalDays=document.querySelector('#hospitalDays').value; let disabledPercent=document.querySelector('#disabledPercent').value; let deadPercent=document.querySelector('#deadPercent').value; let affectedCost=document.querySelector('#affectedCost').value; let hospitalCost=document.querySelector('#hospitalCost').value; let disabledCost=document.querySelector('#disabledCost').value; let deadCost=document.querySelector('#deadCost').value; let communityInfectedPercent=document.querySelector('#communityInfectedPercent').value; let herdInfectedPercent=document.querySelector('#herdInfectedPercent').value; let communityUnemployedPercent=document.querySelector('#communityUnemployedPercent').value; let herdUnemployedPercent=document.querySelector('#herdUnemployedPercent').value; const doCalculateResults=(r,i,u)=>{ r.infected=Math.round(population*i/100); r.unemployed=Math.round(population*u/100); r.unemployedDays=r.unemployed*affectedDays; r.affected=Math.round(r.infected*affectedPercent/100); r.affectedDays=r.affected*affectedDays; r.hospital=r.affected*hospitalPercent/100; r.disabled=r.hospital*disabledPercent/100; r.dead=r.hospital*deadPercent/100; r.affectedCost=r.affected*affectedDays*affectedCost; r.costs={ total:0, unemployed:r.unemployed*affectedDays*affectedCost, affected:r.affected*affectedDays*affectedCost, hospital:r.hospital*hospitalDays*hospitalCost, disabled:r.disabled*disabledCost, dead:r.dead*deadCost }; r.costs.total= Object.values(r.costs).reduce((a, b) => a + b, 0); } let results={community:{costs:{}},herd:{costs:{}}}; // new object doCalculateResults(results.community, communityInfectedPercent, communityUnemployedPercent); doCalculateResults(results.herd, herdInfectedPercent, herdUnemployedPercent); console.log(results); setInitialized(true); setResults({}); setInitialized(true); setResults(results); } // display values template const showRow=(n,l,r)=>{ if (!initialized) return; // discourages first render where values aren't defined return ( <tr> <td align="left">{n}</td> <td align="right">{l}</td> <td align="right">{r}</td> </tr> ); } const showFormattedRow=(n,l,r,p)=>{ return showRow( n, formatCell(l,p), formatCell(r,p) ); } const formatCell=(v,p)=>{ if(isNaN(v)) return ( {v} ); if(p===undefined || Number.isNaN(p)) return ( <NumberFormat value={v} displayType={'text'} decimalScale="0" thousandSeparator={true} /> ); if(p==='$') return ( <NumberFormat value={v} displayType={'text'} prefix={'$'} decimalScale="2" thousandSeparator={true} /> ); if(isNaN(p)) return ( <><NumberFormat value={v} displayType={'text'} thousandSeparator={true} />{' '}{p}</> ); if(p===0) return ( <NumberFormat value={v} displayType={'text'} decimalScale="0" thousandSeparator={true} /> ); let pv=Math.round(v/p*100*100)/100; return ( <> <NumberFormat value={v} displayType={'text'} decimalScale="0" thousandSeparator={true} /> {' '}(<NumberFormat value={pv} displayType={'text'} thousandSeparator={true} />%) </>); } let accordionKey=21; return ( <div className="App"> <div className="app-title"> <h1> Basic Form Calculator</h1> </div> <form inline> <CardColumns> <Card bg="light"> <Card.Body> <Card.Title>Inputs</Card.Title> <Card.Text> <Form.Group controlId="population"> <Form.Label>Population</Form.Label> <Form.Control type="number" defaultValue={defaultPopulation} /> </Form.Group> <Button variant="primary" className="left" type="submit" onClick={Calculate}>Calculate</Button> <Button variant="secondary" className="left" type="button" onClick={Clear}>Reset</Button> </Card.Text> </Card.Body> </Card> <Card bg="light"> <Card.Body> <Card.Title>Results</Card.Title> <Card.Text> <Table striped bordered hover> <thead> <tr> <th bgColor={headingColor}>Population</th> <th align="right" bgColor={communityColor}>{communityWord} Scenario</th> <th align="right" bgColor={herdColor}>{herdWord} Scenario</th> </tr> </thead> <tbody> {showFormattedRow("Adult Population", population, population, "People")} {showFormattedRow("People infected", results.community.infected, results.herd.infected, population)} {showFormattedRow("People sick", results.community.affected, results.herd.affected)} {showFormattedRow("- Days missed", results.community.affectedDays, results.herd.affectedDays, "days")} {showFormattedRow("People hospitalized", results.community.hospital, results.herd.hospital)} {showFormattedRow("Long-term disabled", results.community.disabled, results.herd.disabled)} {showFormattedRow("Dead", results.community.dead, results.herd.dead, population)} {showFormattedRow("People unemployed", results.community.unemployed, results.herd.unemployed, population)} {showFormattedRow("- People-days unemployed", results.community.unemployedDays, results.herd.unemployedDays, "days")} </tbody> </Table> <Table striped bordered hover> <thead> <tr> <th bgColor={headingColor}>Costs</th> <th align="right" bgColor={communityColor}>{communityWord} Scenario</th> <th align="right" bgColor={herdColor}>{herdWord} Scenario</th> </tr> </thead> <tbody> {showFormattedRow("Days missed - sick", results.community.costs.affected, results.herd.costs.affected, '$')} {showFormattedRow("Days missed - unemployed", results.community.costs.unemployed, results.herd.costs.unemployed, '$')} {showFormattedRow("People hospitalized", results.community.costs.hospital, results.herd.costs.hospital, '$')} {showFormattedRow("Long-term disabled", results.community.costs.disabled, results.herd.costs.disabled, '$')} {showFormattedRow("Dead", results.community.costs.dead, results.herd.costs.dead, '$')} {showFormattedRow("Total", results.community.costs.total, results.herd.costs.total, "$")} </tbody> </Table> </Card.Text> </Card.Body> </Card> <Card bg="light"> <Card.Body> <Card.Title>Assumptions</Card.Title> <Card.Text> <Accordion > <Card> <Accordion.Toggle as={Card.Header} eventKey={accordionKey}> Infection Rates </Accordion.Toggle> <Accordion.Collapse eventKey={accordionKey++}> <Card.Body> <Form.Group controlId="population"> <Form.Label>Population</Form.Label> <Form.Control type="number" defaultValue={defaultPopulation} /> </Form.Group> <Form.Group controlId="communityInfectedPercent"> <Form.Label>Infection rate - {communityWord}</Form.Label> <Form.Control type="number" min="0" max="100" defaultValue={defaults.community.infectionPercent} /> <Form.Text className="text-muted"> This is the average percent of the population who have antibodies as a result of an infection in the {communityWord} scenario. </Form.Text> </Form.Group> <Form.Group controlId="herdInfectedPercent"> <Form.Label>Infection rate - {herdWord}</Form.Label> <Form.Control type="number" min="0" max="100" defaultValue={defaults.herd.infectionPercent} /> <Form.Text className="text-muted"> This is the average percent of the population who have antibodies as a result of an infection in the {herdWord} scenario. </Form.Text> </Form.Group> <Form.Group controlId="age"> <Form.Label>Average Age</Form.Label> <Form.Control type="number" defaultValue={defaultAge} /> <Form.Text className="text-muted"> An older population has fewer children (who are less affected economically and physiologically), more workers (who are more affected economically) and seniors (who are more affected physiologically). Adjust this value to suit your population. </Form.Text> </Form.Group> <Form.Group controlId="gender"> <Form.Label>Percentage Male</Form.Label> <Form.Control type="number" min="0" max="100" defaultValue={defaultSex} /> <Form.Text className="text-muted"> Males are more likely to work and more likely to get sick when they get infected. Adjust this value to suit your population. </Form.Text> </Form.Group> </Card.Body> </Accordion.Collapse> </Card> <Card> <Accordion.Toggle as={Card.Header} eventKey={accordionKey}> Unemployment Rates </Accordion.Toggle> <Accordion.Collapse eventKey={accordionKey++}> <Card.Body> <Form.Group controlId="communityUnemployedPercent"> <Form.Label>Unemployment rate - {communityWord}</Form.Label> <Form.Control type="number" min="0" max="100" defaultValue={defaults.community.unemployedPercent} /> <Form.Text className="text-muted"> This is the average unemployment rate in the {communityWord} scenario. </Form.Text> </Form.Group> <Form.Group controlId="herdUnemployedPercent"> <Form.Label>Unemployment rate - {herdWord}</Form.Label> <Form.Control type="number" min="0" max="100" defaultValue={defaults.herd.unemployedPercent} /> <Form.Text className="text-muted"> This is the average unemployment rate in the {herdWord} scenario. </Form.Text> </Form.Group> </Card.Body> </Accordion.Collapse> </Card> <Card> <Accordion.Toggle as={Card.Header} eventKey={accordionKey}> Affected Rates </Accordion.Toggle> <Accordion.Collapse eventKey={accordionKey++}> <Card.Body> <Form.Group controlId="affectedPercent"> <Form.Label>Affected rate</Form.Label> <Form.Control type="number" min="0" max="100" defaultValue={defaults.affectedPercent} /> <Form.Text className="text-muted"> This is the chance that an individual will be affected if they are infected. </Form.Text> </Form.Group> <Form.Group controlId="adjustmentAge"> <Form.Label>Adjustment for Age</Form.Label> <Form.Control type="number" min="0" max="100" defaultValue={defaults.adjustments.age} /> <Form.Text className="text-muted"> As the population skews older, the Affected rate grows by this percentage. </Form.Text> </Form.Group> <Form.Group controlId="adjustmentSex"> <Form.Label>Adjustment for Sex</Form.Label> <Form.Control type="number" min="-100" max="100" defaultValue={defaults.adjustments.sex} /> <Form.Text className="text-muted"> Chances that a male will be affected more than a female. </Form.Text> </Form.Group> <Form.Group controlId="adjustmentSaturation"> <Form.Label>Adjustment for Age</Form.Label> <Form.Control type="number" min="0" max="100" defaultValue={defaults.adjustments.saturation} /> <Form.Text className="text-muted"> Allows outcomes to get worse if hosptials are saturated and affected to respond. </Form.Text> </Form.Group> </Card.Body> </Accordion.Collapse> </Card> <Card> <Accordion.Toggle as={Card.Header} eventKey={accordionKey}> Conversion/Outcome Costs </Accordion.Toggle> <Accordion.Collapse eventKey={accordionKey++}> <Card.Body> <Form.Group controlId="affectedCost"> <Form.Label>Cost of a day off work</Form.Label> <Form.Control type="number" min="0" max="100" defaultValue={defaults.costs.affected} /> <Form.Text className="text-muted"> </Form.Text> </Form.Group> <Form.Group controlId="hospitalCost"> <Form.Label>Cost of a day in the hospital</Form.Label> <Form.Control type="number" min="0" max="100" defaultValue={defaults.costs.hospital} /> <Form.Text className="text-muted"> </Form.Text> </Form.Group> <Form.Group controlId="disabledCost"> <Form.Label>Cost of a long-term disability</Form.Label> <Form.Control type="number" min="0" max="100" defaultValue={defaults.costs.disabled} /> <Form.Text className="text-muted"> </Form.Text> </Form.Group> <Form.Group controlId="deadCost"> <Form.Label>Cost of a death</Form.Label> <Form.Control type="number" min="0" max="100" defaultValue={defaults.costs.dead} /> <Form.Text className="text-muted"> </Form.Text> </Form.Group> </Card.Body> </Accordion.Collapse> </Card> </Accordion> </Card.Text> </Card.Body> </Card> <Card bg="light"> <Card.Body> <Card.Title>References</Card.Title> <Card.Text> <Accordion> <Card> <Accordion.Toggle as={Card.Header} eventKey={accordionKey}> Population </Accordion.Toggle> <Accordion.Collapse eventKey={accordionKey++}> <Card.Body> <wp-p> We got the basic distribution of the population from <a href="https://www.census.gov/prod/cen2010/briefs/c2010br-03.pdf" alt="PDF: Age and Sex Composition of the United States">the 2010 Census</a>. </wp-p> <wp-p> Interesting facts that we used: <ul> <li>US Population was 308,745,538.</li> <li>The average age is 38 years.</li> <li>76% of the population is 18 or over.</li> <li>13% are 65 or older.</li> <li>49% male to 51% female.</li> </ul> </wp-p> </Card.Body> </Accordion.Collapse> </Card> <Card> <Accordion.Toggle as={Card.Header} eventKey={accordionKey}> Suceptability </Accordion.Toggle> <Accordion.Collapse eventKey={accordionKey++}> <Card.Body> <wp-p> It turns out that even though similar numbers of males and females get infected, 56% of males get sick. <a href="https://www.sfchronicle.com/health/article/Unwelcome-mat-Male-sex-hormones-appear-to-help-15395442.php" alt="SF Chronicle story about gender and COVID">according to statistics from cases in California</a>. </wp-p> </Card.Body> </Accordion.Collapse> </Card> <Card> <Accordion.Toggle as={Card.Header} eventKey={accordionKey}> Costs </Accordion.Toggle> <Accordion.Collapse eventKey={accordionKey++}> <Card.Body> <wp-p> <a href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4191510/" alt="National Institutes of Health: The economic costs of illness: A replication and update">The economic costs of illness: A replication and update</a> Dorothy P. Rice, Thomas A. Hodgson, and Andrea N. Kopstein, 1985 </wp-p> </Card.Body> </Accordion.Collapse> </Card> </Accordion> </Card.Text> </Card.Body> </Card> </CardColumns> </form> </div> ); }
Testing
Playing with the calculator, the numbers wound up tracking the reported numbers quite accurately for the nation and our state and city. That gave us good confidence in the restricted scenario.
Similarly, the numbers in the herd immunity scenario wound up tracking the “worst case” numbers that various people have put out there. My guess is that they’re using a spreadsheet quite like this calculator.
Building and Hosting
The npm development environment was terrific for developing the app, but we had a bit of a challenge embedding the app in a WordPress web page.
To build the app, you go to your shell and type npm run build
. This transports the stuff in your src
folder into your build
folder where you can distribute it as a fully minimized and production-ready app.
Unfortunately, we wanted to embed the calculator on a web page, and that didn’t go smoothly. The documentation at https://reactjs.org/docs/add-react-to-a-website.html wasn’t really helpful, and stripping it out of the index.html in the build wasn’t fruitful either.
In the end we wound up using an iframe.
Upgrades
There are two upgrades that will make this more personal, so as an individual one can decide what your best behavior should be.
We would like to turn this around to give individual chances of various outcomes (e.g. “death”) based on individual specifications and the spread in the different scenarios.
We would like to use the US Census Data API to fetch population information for a community.
Both those are on the backs of the napkin, so look for an update soon.
Analysis
Playing with the calculator, the numbers wound up tracking the reported numbers quite accurately for the nation and our state and city. That gave us good confidence in the restricted scenario.
We couldn’t find a scenario where the cost of herd immunity was less than three times the cost of doing the minimum to restrict the spread. Most of this cost comes from long-term disabilities and deaths, but a big chunk is lost work from being sick vs. lost work from being unemployed.
Getting from facts to opinion, government intervention can minimize the impact from being unemployed, but it can’t do nothin’ to reduce the impact of being sick, disabled or dead. That’s why this whole “herd immunity” idea is so stupid and reckless – it is a small number of queer-thinkers making an ill-informed decision for the rest of us.
I sure wish we could “walk and chew gum at the same time.” Reduce the spread of the virus while resuming the things that we can safely. Right now it sounds like 80% of the spread is in bars, restaurants and concerts, so if we cut those out, we can open our manufacturing, shops, salons and theme parks without incuring the cost of “herd immunity.”