Parcourir la source

Multi-country graph with figures for Spain

Fela Maslen il y a 5 ans
Parent
commit
5d3f2786af
9 fichiers modifiés avec 236 ajouts et 67 suppressions
  1. 3 0
      package.json
  2. 2 1
      src/app.tsx
  3. 28 0
      src/cases/es.json
  4. 47 25
      src/components/graph-cases.tsx
  5. 15 0
      src/countries.ts
  6. 30 5
      src/types.ts
  7. 4 1
      src/utils/get-cases.ts
  8. 95 35
      src/utils/regression.ts
  9. 12 0
      yarn.lock

+ 3 - 0
package.json

@@ -12,6 +12,8 @@
     "@types/react": "^16.9.0",
     "@types/react-dom": "^16.9.0",
     "date-fns": "^2.11.0",
+    "lodash": "^4.17.15",
+    "polished": "^3.4.4",
     "react": "^16.13.0",
     "react-dom": "^16.13.0",
     "react-scripts": "3.4.0",
@@ -40,6 +42,7 @@
     ]
   },
   "devDependencies": {
+    "@types/lodash": "^4.14.149",
     "@types/recharts": "^1.8.7",
     "eslint": "^6.8.0",
     "eslint-config-airbnb-base": "^14.1.0",

+ 2 - 1
src/app.tsx

@@ -1,6 +1,7 @@
 import React from 'react';
 
 import GraphCases from './components/graph-cases';
+import countries from './countries';
 
 const App: React.FC = () => {
   return (
@@ -8,7 +9,7 @@ const App: React.FC = () => {
       <header>
         <h1>COVID-19 cases and regression</h1>
       </header>
-      <GraphCases country="uk" />
+      <GraphCases countries={countries} />
     </div>
   );
 };

+ 28 - 0
src/cases/es.json

@@ -0,0 +1,28 @@
+{
+  "source": "https://en.wikipedia.org/wiki/2020_coronavirus_pandemic_in_Spain",
+  "dailyCases": [
+    ["2020-01-31", 1],
+    ["2020-02-09", 1],
+    ["2020-02-13", 1],
+    ["2020-02-24", 1],
+    ["2020-02-25", 4],
+    ["2020-02-26", 6],
+    ["2020-02-27", 12],
+    ["2020-02-28", 19],
+    ["2020-02-29", 14],
+    ["2020-03-01", 25],
+    ["2020-03-02", 41],
+    ["2020-03-03", 44],
+    ["2020-03-04", 59],
+    ["2020-03-05", 54],
+    ["2020-03-06", 83],
+    ["2020-03-07", 65],
+    ["2020-03-08", 244],
+    ["2020-03-09", 557],
+    ["2020-03-10", 464],
+    ["2020-03-11", 582],
+    ["2020-03-12", 869],
+    ["2020-03-13", 2086],
+    ["2020-03-14", 1020]
+  ]
+}

+ 47 - 25
src/components/graph-cases.tsx

@@ -1,15 +1,12 @@
 import React from 'react';
 import format from 'date-fns/format';
 import { LineChart, XAxis, YAxis, Line, CartesianGrid } from 'recharts';
+import { lighten } from 'polished';
 
-import { DataSource, Country, Data } from '../types';
+import { Countries, CountryCases, Data } from '../types';
 import { getCases } from '../utils/get-cases';
 import { processCases } from '../utils/regression';
 
-type Props = {
-  country: Country;
-};
-
 const margin = {
   top: 0,
   right: 0,
@@ -21,19 +18,43 @@ const dateTickFormatter = (date: Date): string => {
   return format(new Date(date), 'LLL do'); // 'Do MMM');
 };
 
-const GraphCases: React.FC<Props> = ({ country }) => {
-  const dataSource = React.useMemo<DataSource>(() => getCases(country), [country]);
-  const data = React.useMemo<Data>(() => processCases(dataSource.cases), [dataSource.cases]);
+type Props = {
+  countries: Countries;
+};
 
+const GraphCases: React.FC<Props> = ({ countries }) => {
   const [showCumulative, setCumulative] = React.useState<boolean>(true);
   const toggleCumulative = React.useCallback(() => setCumulative(last => !last), []);
 
+  const countryCases = React.useMemo<CountryCases>(
+    () =>
+      countries.map(({ country }) => ({
+        country,
+        dataSource: getCases(country),
+      })),
+    [countries],
+  );
+
+  const data = React.useMemo<Data>(() => processCases(countryCases, showCumulative), [
+    countryCases,
+    showCumulative,
+  ]);
+
   return (
     <div>
-      <h3>Country: {country.toUpperCase()}</h3>
-      <p>
-        Source: <a href={dataSource.source}>{dataSource.source}</a>
-      </p>
+      <>
+        {countries.map(({ country }, index) => (
+          <React.Fragment key={country}>
+            <h3>Country: {country}</h3>
+            <p>
+              Source:{' '}
+              <a href={countryCases[index].dataSource.source}>
+                {countryCases[index].dataSource.source}
+              </a>
+            </p>
+          </React.Fragment>
+        ))}
+      </>
       <p>
         <input type="checkbox" onChange={toggleCumulative} checked={showCumulative} /> Cumulative
         cases
@@ -47,24 +68,25 @@ const GraphCases: React.FC<Props> = ({ country }) => {
         />
         <YAxis tick yAxisId="left" />
         <CartesianGrid stroke="#f5f5f5" />
-        {!showCumulative && (
-          <Line type="monotone" dataKey="value" stroke="#5427b0" yAxisId="left" />
-        )}
-        {!showCumulative && (
-          <Line type="monotone" dataKey="regression" stroke="#8a5ce6" yAxisId="left" dot={false} />
-        )}
-        {showCumulative && (
-          <Line type="monotone" dataKey="valueCumulative" stroke="#cc3d55" yAxisId="left" />
-        )}
-        {showCumulative && (
+        {countries.map(({ country, color }) => (
+          <Line
+            key={`actual-${country}`}
+            type="monotone"
+            dataKey={`value.${country}`}
+            stroke={color}
+            yAxisId="left"
+          />
+        ))}
+        {countries.map(({ country, color }) => (
           <Line
+            key={`regression-${country}`}
             type="monotone"
-            dataKey="regressionCumulative"
-            stroke="#e86178"
+            dataKey={`regression.${country}`}
+            stroke={lighten(0.1)(color)}
             yAxisId="left"
             dot={false}
           />
-        )}
+        ))}
       </LineChart>
     </div>
   );

+ 15 - 0
src/countries.ts

@@ -0,0 +1,15 @@
+import { rgb } from 'polished';
+import { Country, Countries } from './types';
+
+const countries: Countries = [
+  {
+    country: Country.uk,
+    color: rgb(75, 94, 235),
+  },
+  {
+    country: Country.es,
+    color: rgb(235, 75, 75),
+  },
+];
+
+export default countries;

+ 30 - 5
src/types.ts

@@ -10,13 +10,38 @@ export type DataSource = {
   cases: Cases;
 };
 
-export type Data = {
+export enum Country {
+  uk = 'UK',
+  es = 'Spain',
+}
+
+export type Countries = {
+  country: Country;
+  color: string;
+}[];
+
+export type CountryCases = {
+  country: Country;
+  dataSource: DataSource;
+}[];
+
+type XPoint = {
   date: Date;
   xValue: number;
+};
+
+export type CountryDataPoint = XPoint & {
   value?: number;
-  valueCumulative?: number;
   regression?: number;
-  regressionCumulative?: number;
-}[];
+};
+
+export type DataPoint = XPoint & {
+  value: {
+    [x in Country]?: number;
+  };
+  regression: {
+    [x in Country]?: number;
+  };
+};
 
-export type Country = 'uk';
+export type Data = DataPoint[];

+ 4 - 1
src/utils/get-cases.ts

@@ -1,5 +1,6 @@
 import { Country, DataSource } from '../types';
 import uk from '../cases/uk.json';
+import es from '../cases/es.json';
 
 type RawCases = {
   source: string;
@@ -18,8 +19,10 @@ const processCases = (rawCases: RawCases): DataSource => ({
 
 export function getCases(country: Country): DataSource {
   switch (country) {
-    case 'uk':
+    case Country.uk:
       return processCases(uk as RawCases);
+    case Country.es:
+      return processCases(es as RawCases);
     default:
       throw new Error('Unrecognised country');
   }

+ 95 - 35
src/utils/regression.ts

@@ -1,7 +1,9 @@
 import { compose } from '@typed/compose';
+import groupBy from 'lodash/groupBy';
+import flatten from 'lodash/flatten';
 import differenceInDays from 'date-fns/differenceInDays';
 import addDays from 'date-fns/addDays';
-import { Case, Cases, Data } from '../types';
+import { Cases, Data, DataPoint, CountryCases, CountryDataPoint } from '../types';
 
 // Start the regression line after this many cases have been recorded in total
 const regressionStart = 50;
@@ -12,31 +14,49 @@ const futureDays = 10;
 const mean = (values: number[]): number =>
   values.reduce((last, value) => last + value, 0) / values.length;
 
-type CaseCumulative = Case & { valueCumulative: number };
+type WithNumericDate = Omit<CountryDataPoint, 'regression'>[];
 
-const withNumericDate = (cases: Cases): Data =>
-  cases.map(({ date, ...rest }) => ({
+const withNumericDate = (cases: Cases): WithNumericDate =>
+  cases.map(({ date, value }) => ({
     date,
     xValue: date.getTime(),
-    ...rest,
+    value,
   }));
 
-const withCumulative = (cases: Data): Data =>
-  cases.reduce(
-    (last: Data, { value = 0, ...rest }): Data => [
+type WithCumulative = (Omit<CountryDataPoint, 'regression'> & {
+  valueCumulative: number;
+})[];
+
+const withCumulative = (cumulative: boolean) => (cases: WithNumericDate): WithCumulative => {
+  const valuesWithCumulative = cases.reduce(
+    (last: WithCumulative, { date, xValue, value }): WithCumulative => [
       ...last,
       {
+        date,
+        xValue,
         value,
-        valueCumulative: value + (last[last.length - 1]?.valueCumulative || 0),
-        ...rest,
+        valueCumulative: (value || 0) + (last[last.length - 1]?.valueCumulative || 0),
       },
     ],
     [],
   );
 
+  if (cumulative) {
+    return valuesWithCumulative.map(({ valueCumulative, value, ...rest }) => ({
+      ...rest,
+      value: valueCumulative,
+      valueCumulative,
+    }));
+  }
+
+  return valuesWithCumulative;
+};
+
 const logArray = (values: number[]): number[] => values.map(value => Math.log(value));
 
-const withExponentialRegression = (cases: Data): Data => {
+const withExponentialRegression = (cumulative: boolean) => (
+  cases: WithCumulative,
+): CountryDataPoint[] => {
   const startIndex = cases.findIndex(
     ({ valueCumulative = 0 }) => valueCumulative >= regressionStart,
   );
@@ -53,31 +73,25 @@ const withExponentialRegression = (cases: Data): Data => {
   const xBar = mean(xSeries);
   const xVariance = mean(xSeries.map(value => value ** 2)) - xBar ** 2;
 
-  const ySeriesCases = logArray(casesToRegress.map(({ value = 0 }) => value));
-  const ySeriesCumulative = logArray(
-    casesToRegress.map(({ valueCumulative = 0 }) => valueCumulative),
+  const ySeries = logArray(
+    cumulative
+      ? casesToRegress.map(({ valueCumulative = 0 }) => valueCumulative)
+      : casesToRegress.map(({ value = 0 }) => value),
   );
+  const yBar = mean(ySeries);
+  const covariance =
+    xSeries.reduce((last, value, index) => last + (value - xBar) * (ySeries[index] - yBar), 0) /
+    xSeries.length;
 
-  const makeExponentialRegression = (cumulative = false): ((date: Date) => number) => {
-    const ySeries = cumulative ? ySeriesCumulative : ySeriesCases;
-    const yBar = mean(ySeries);
-
-    const covariance =
-      xSeries.reduce((last, value, index) => last + (value - xBar) * (ySeries[index] - yBar), 0) /
-      xSeries.length;
-
-    const slope = covariance / xVariance;
-    const intercept = yBar - slope * xBar;
-
-    return (date: Date): number => Math.exp(slope * differenceInDays(date, minDate) + intercept);
-  };
+  const slope = covariance / xVariance;
+  const intercept = yBar - slope * xBar;
 
-  const regressionAtDate = makeExponentialRegression(false);
-  const regressionAtDateCumulative = makeExponentialRegression(true);
+  const regressionAtDate = (date: Date): number =>
+    Math.exp(slope * differenceInDays(date, minDate) + intercept);
 
   const lastDate = cases[cases.length - 1].date;
 
-  const future: Data = new Array(futureDays)
+  const future: CountryDataPoint[] = new Array(futureDays)
     .fill(0)
     .map((_, index) => addDays(lastDate, index + 1))
     .map(date => ({ date, xValue: date.getTime() }));
@@ -85,19 +99,65 @@ const withExponentialRegression = (cases: Data): Data => {
   return [...cases, ...future].map(({ date, ...rest }) => ({
     date,
     regression: regressionAtDate(date),
-    regressionCumulative: regressionAtDateCumulative(date),
     ...rest,
   }));
 };
 
-export function processCases(cases: Cases): Data {
+function processCountryCases(cases: Cases, cumulative: boolean): CountryDataPoint[] {
   if (!cases.length) {
     return [];
   }
 
-  return compose<Cases, Data, Data, Data>(
-    withExponentialRegression,
-    withCumulative,
+  return compose<Cases, WithNumericDate, WithCumulative, CountryDataPoint[]>(
+    withExponentialRegression(cumulative),
+    withCumulative(cumulative),
     withNumericDate,
   )(cases);
 }
+
+function combineData(items: Data[]): Data {
+  const groups: {
+    [xValue: string]: Data;
+  } = groupBy(flatten(items), 'xValue');
+
+  return Object.keys(groups)
+    .sort((timeA: string, timeB: string) => Number(timeA) - Number(timeB))
+    .map(key =>
+      groups[key].reduce(
+        (last: DataPoint, item: DataPoint): DataPoint => ({
+          ...last,
+          value: {
+            ...last.value,
+            ...item.value,
+          },
+          regression: {
+            ...last.regression,
+            ...item.regression,
+          },
+        }),
+        {
+          date: groups[key][0].date,
+          xValue: groups[key][0].xValue,
+          value: {},
+          regression: {},
+        },
+      ),
+    );
+}
+
+export function processCases(countryCases: CountryCases, cumulative = true): Data {
+  const data = countryCases.map(({ country, dataSource: { cases } }) =>
+    processCountryCases(cases, cumulative).map(({ date, xValue, value, regression }) => ({
+      date,
+      xValue,
+      value: {
+        [country]: value,
+      },
+      regression: {
+        [country]: regression,
+      },
+    })),
+  );
+
+  return combineData(data);
+}

+ 12 - 0
yarn.lock

@@ -1373,6 +1373,11 @@
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
   integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==
 
+"@types/lodash@^4.14.149":
+  version "4.14.149"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.149.tgz#1342d63d948c6062838fbf961012f74d4e638440"
+  integrity sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==
+
 "@types/minimatch@*":
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
@@ -7573,6 +7578,13 @@ pnp-webpack-plugin@1.6.0:
   dependencies:
     ts-pnp "^1.1.2"
 
+polished@^3.4.4:
+  version "3.4.4"
+  resolved "https://registry.yarnpkg.com/polished/-/polished-3.4.4.tgz#ac8cd6e704887398f3b802718f9d389b9ea4307b"
+  integrity sha512-x9PKeExyI9AhWrJP3Q57I1k7GInujjiVBJMPFmycj9hX1yCOo/X9eu9eZwxgOziiXge3WbFQ5XOmkzunOntBSA==
+  dependencies:
+    "@babel/runtime" "^7.6.3"
+
 portfinder@^1.0.25:
   version "1.0.25"
   resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.25.tgz#254fd337ffba869f4b9d37edc298059cb4d35eca"