You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
408 lines
10 KiB
JavaScript
408 lines
10 KiB
JavaScript
// SARS-CoV-2-Viz
|
|
// Animated COVID case count visualization
|
|
// Copyright 2022 Edward L. Platt <ed@elplatt.com>
|
|
|
|
const covidUrl = "https://raw.githubusercontent.com/CSSEGISandData/COVID-19/master/csse_covid_19_data/csse_covid_19_time_series/time_series_covid19_confirmed_US.csv";
|
|
|
|
const usUrl = "https://cdn.jsdelivr.net/npm/us-atlas@3/counties-albers-10m.json";
|
|
|
|
const metadataUrl = "https://raw.githubusercontent.com/CSSEGISandData/COVID-19/master/csse_covid_19_data/UID_ISO_FIPS_LookUp_Table.csv";
|
|
|
|
async function fetchWithProgress(url, onProgress) {
|
|
|
|
// Make request and get headers
|
|
let response = await fetch(url);
|
|
const reader = response.body.getReader();
|
|
const contentLength = response.headers.get('Content-Length');
|
|
|
|
// Receive chunks
|
|
let chunks = [];
|
|
let contentReceived = 0;
|
|
while (true) {
|
|
const {done, value} = await reader.read()
|
|
if (done) { break; }
|
|
chunks.push(value);
|
|
let bytes = value.length;
|
|
contentReceived += bytes;
|
|
|
|
// Update progress
|
|
onProgress(contentReceived);
|
|
}
|
|
|
|
// Combine chunks and decode
|
|
let contentBytes = new Uint8Array(contentReceived);
|
|
let position = 0;
|
|
chunks.forEach((chunk) => {
|
|
contentBytes.set(chunk, position);
|
|
position += chunk.length;
|
|
});
|
|
let content = new TextDecoder("utf-8").decode(contentBytes);
|
|
return content;
|
|
}
|
|
|
|
// Convert csv to tsv
|
|
// Assumes quoted strings do not contain escaped quotes
|
|
function csvToTSV(csv, onProgress) {
|
|
let quoteParts = csv.split('"');
|
|
let tsv = "";
|
|
for (const [index, part] of quoteParts.entries()) {
|
|
if (index % 2 == 0) {
|
|
// Not quoted
|
|
tsv += part.replace(/,/g, "\t");
|
|
} else {
|
|
// Quoted
|
|
tsv += part;
|
|
}
|
|
}
|
|
return tsv;
|
|
}
|
|
|
|
function tsvRowToJSON(keys, tsvRow) {
|
|
let row = {};
|
|
let rowParts = tsvRow.split("\t");
|
|
for (const [index, key] of keys.entries()) {
|
|
row[key] = rowParts[index];
|
|
}
|
|
return row;
|
|
}
|
|
|
|
// Convert tsv to json
|
|
function parseCovidData(tsv, onProgress) {
|
|
let rows = tsv.split(/[\r\n]+/);
|
|
let keys = rows[0].split("\t");
|
|
let dates = keys.slice(11);
|
|
|
|
// Initialize data array
|
|
let dataForDay = [];
|
|
for (const date of dates) {
|
|
dataForDay.push([]);
|
|
}
|
|
|
|
// Iterate through non-header rows
|
|
const rowCount = rows.length - 1;
|
|
let percent = 0;
|
|
for (const [index, row] of rows.slice(1).entries()) {
|
|
const rowData = tsvRowToJSON(keys, row);
|
|
|
|
// Only update progress when it has actually changed
|
|
let newPercent = Math.floor(100 * index / rowCount);
|
|
if (newPercent > percent) {
|
|
percent = newPercent;
|
|
onProgress(`${percent}%`);
|
|
}
|
|
|
|
let FIPS = rowData.FIPS;
|
|
if (typeof(FIPS) === "undefined") {
|
|
continue;
|
|
}
|
|
FIPS = FIPS.slice(0, FIPS.indexOf(".") || FIPS.length).padStart(5, "0");
|
|
|
|
let lastSeven = [];
|
|
for (const [index, date] of dates.entries()) {
|
|
|
|
const newCases = index == 0 ?
|
|
parseInt(rowData[date]) :
|
|
parseInt(rowData[date])
|
|
- parseInt(rowData[dates[index - 1]]);
|
|
|
|
// Update array for running average
|
|
if (lastSeven.length == 7) {
|
|
lastSeven.shift();
|
|
}
|
|
lastSeven.push(newCases);
|
|
const mean = Math.round(
|
|
lastSeven.reduce((x, y) => x + y) / lastSeven.length);
|
|
|
|
dataForDay[index].push({
|
|
FIPS: FIPS,
|
|
date: date,
|
|
count: newCases,
|
|
sevenDayMean: mean
|
|
});
|
|
}
|
|
}
|
|
onProgress("100%");
|
|
|
|
// Calculate maximum case count for each day
|
|
let maxSoFar = 0;
|
|
for (const [day, date] of dates.entries()) {
|
|
|
|
let dayMax = Math.max(
|
|
...dataForDay[day].map(
|
|
d => d.sevenDayMean));
|
|
|
|
maxSoFar = Math.max(maxSoFar, dayMax);
|
|
|
|
// Add data to each county for current day
|
|
for (let [countyIndex, d] of dataForDay[day].entries()) {
|
|
d.dayMax = dayMax;
|
|
d.maxSoFar = maxSoFar;
|
|
}
|
|
}
|
|
|
|
return dataForDay.map(data => {
|
|
const entries = new Map(data.map(d => [d.FIPS, d]));
|
|
return Object.fromEntries(entries);
|
|
});
|
|
}
|
|
|
|
async function getData(onProgress) {
|
|
const content = await fetchWithProgress(covidUrl, onProgress);
|
|
return content;
|
|
}
|
|
|
|
async function parseData(content, onProgress) {
|
|
let tsv = csvToTSV(content);
|
|
let dataForDay = parseCovidData(tsv, onProgress);
|
|
return dataForDay;
|
|
}
|
|
|
|
async function getFeatures(onProgress) {
|
|
const content = await fetchWithProgress(usUrl, onProgress);
|
|
return content;
|
|
}
|
|
|
|
async function parseFeatures(content, onProgress) {
|
|
onProgress("0%");
|
|
let features = JSON.parse(content);
|
|
onProgress("100%");
|
|
return features;
|
|
}
|
|
|
|
async function getMetadata(onProgress) {
|
|
const content = await fetchWithProgress(metadataUrl, onProgress);
|
|
return content;
|
|
}
|
|
|
|
function tsvToJSON(tsv, onProgress) {
|
|
const lines = tsv.split(/\n/);
|
|
let columnNames = [];
|
|
let data = [];
|
|
const count = lines.length;
|
|
for (const [row, line] of lines.entries()) {
|
|
if (row == 0) {
|
|
columnNames = line.split("\t").map((x) => x.trim());
|
|
} else {
|
|
let dataRow = {};
|
|
for (const [col, cell] of line.split("\t").entries()) {
|
|
dataRow[columnNames[col]] = cell.trim();
|
|
}
|
|
// Limit to US Counties
|
|
if (dataRow["iso3"] == "USA"
|
|
&& dataRow["Admin2"] != ""
|
|
&& dataRow["Admin2"] != "Unassigned"
|
|
&& dataRow["FIPS"] != "") {
|
|
data.push(dataRow);
|
|
}
|
|
}
|
|
let progress = count / row;
|
|
onProgress(progress);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function parseMetadataTSV(tsv, onProgress) {
|
|
return tsvToJSON(tsv, onProgress);
|
|
}
|
|
|
|
async function parseMetadata(content, onProgress) {
|
|
onProgress("0%");
|
|
let tsv = csvToTSV(content);
|
|
let metadata = parseMetadataTSV(tsv, onProgress);
|
|
onProgress("100%");
|
|
return metadata;
|
|
}
|
|
|
|
function createChoropleth(us, msPerFrame) {
|
|
|
|
const counties = topojson.feature(us, us.objects.counties);
|
|
const states = topojson.feature(us, us.objects.states)
|
|
const statemesh = topojson.mesh(
|
|
us, us.objects.states, (a, b) => a !== b)
|
|
const statemap = new Map(states.features.map(d => [d.id, d]));
|
|
|
|
let chart = Choropleth({
|
|
id: d => d.FIPS,
|
|
value: d => d.sevenDayMean,
|
|
domain: d => {
|
|
let domain = [0, Math.max(1, d["00000"].dayMax)];
|
|
return domain;
|
|
},
|
|
title: (f, d) => `${f.properties.name}, ${statemap.get(f.id.slice(0, 2)).properties.name}\n${d?.sevenDayMean}`,
|
|
features: counties,
|
|
borders: statemesh,
|
|
width: 975,
|
|
height: 610,
|
|
duration: msPerFrame
|
|
});
|
|
|
|
return chart;
|
|
}
|
|
|
|
function UI() {
|
|
|
|
let frames = [];
|
|
let currentFrame = 0;
|
|
let msPerFrame = 500;
|
|
let playing = false;
|
|
let onTick = null;
|
|
|
|
let animate = function () {
|
|
if (playing) {
|
|
setTimeout(animate, msPerFrame);
|
|
}
|
|
if (onTick) {
|
|
onTick(currentFrame);
|
|
}
|
|
currentFrame = (currentFrame + 7) % frames.length;
|
|
};
|
|
|
|
let node = document.createElement("div");
|
|
|
|
let controls = document.createElement("div");
|
|
node.appendChild(controls);
|
|
|
|
let play = document.createElement("button");
|
|
play.innerText = 'play';
|
|
play.id = 'play';
|
|
controls.appendChild(play);
|
|
play.addEventListener(
|
|
"click", () => {
|
|
if (playing) {
|
|
play.innerText = 'play';
|
|
playing = false;
|
|
} else {
|
|
play.innerText = 'pause';
|
|
playing = true;
|
|
animate();
|
|
}
|
|
});
|
|
|
|
let display = document.createElement("div");
|
|
display.id = "display";
|
|
let mainDisplay = document.createElement("div");
|
|
mainDisplay.id = "main-display";
|
|
let secondaryDisplays = {};
|
|
display.appendChild(mainDisplay);
|
|
node.appendChild(display);
|
|
|
|
return {
|
|
node: node,
|
|
display: (s, id=null) => {
|
|
if (id === null) {
|
|
mainDisplay.innerText = s;
|
|
} else {
|
|
secondaryDisplays[id].innerText = s;
|
|
}
|
|
},
|
|
addDisplay: (id) => {
|
|
let secondary = document.createElement("div");
|
|
secondary.id = `display-${id}`;
|
|
display.appendChild(secondary);
|
|
secondaryDisplays[id] = secondary;
|
|
},
|
|
clearDisplays: () => {
|
|
mainDisplay.innerText = '';
|
|
for (const [id, d] of Object.entries(secondaryDisplays)) {
|
|
d.remove();
|
|
}
|
|
secondaryDisplays = {};
|
|
},
|
|
onTick: (f) => { onTick = f; },
|
|
setFrames: (newFrames, current) => {
|
|
frames = newFrames;
|
|
currentFrame = current;
|
|
},
|
|
setMsPerFrame: (newMsPerFrame) => { msPerFrame = newMsPerFrame; }
|
|
}
|
|
}
|
|
|
|
function ProgressTracker(onCreate, onProgress) {
|
|
let jobProgress = [];
|
|
let tracker = {
|
|
getTracker: (initial='') => {
|
|
let id = jobProgress.length;
|
|
jobProgress.push(initial);
|
|
onCreate(id);
|
|
return (progress) => {
|
|
jobProgress[id] = progress;
|
|
onProgress(id, tracker.progress(id));
|
|
}
|
|
},
|
|
progress: (id) => jobProgress[id]
|
|
};
|
|
return tracker;
|
|
}
|
|
|
|
function main() {
|
|
let ui = UI();
|
|
let tracker = ProgressTracker(
|
|
(id) => { ui.addDisplay(id); },
|
|
(id, progress) => ui.display(progress, id)
|
|
);
|
|
document.getElementById("header").appendChild(ui.node);
|
|
|
|
let onDataProgress = tracker.getTracker();
|
|
let dataPromise = getData(
|
|
(progress) => {
|
|
onDataProgress(`Fetching data: Received ${progress} bytes`)
|
|
})
|
|
.then((dataCSV) => {
|
|
return parseData(
|
|
dataCSV,
|
|
(progress) => onDataProgress(`Parsing data: ${progress}`)
|
|
);
|
|
});
|
|
|
|
let onFeatureProgress = tracker.getTracker();
|
|
let featurePromise = getFeatures(
|
|
(progress) => {
|
|
onFeatureProgress(`Fetching features: Received ${progress} bytes`);
|
|
})
|
|
.then((features) => {
|
|
return parseFeatures(
|
|
features,
|
|
(progress) => onFeatureProgress(`Parsing features: ${progress}`));
|
|
});
|
|
|
|
let onMetadataProgress = tracker.getTracker();
|
|
let metadataPromise = getMetadata(
|
|
(progress) => {
|
|
onMetadataProgress(`Fetching metadata: ${progress}`);
|
|
})
|
|
.then((metadata) => {
|
|
return parseMetadata(
|
|
metadata,
|
|
(progress) => onMetadataProgress(`Parsing metadata: ${progress}`));
|
|
});
|
|
|
|
Promise.all([
|
|
dataPromise, featurePromise, metadataPromise
|
|
]).then((values) => {
|
|
const [dataForDay, us, metadata] = values;
|
|
console.log(metadata);
|
|
ui.clearDisplays();
|
|
ui.display("Initializing visualization");
|
|
let msPerFrame = 200;
|
|
console.log(dataForDay);
|
|
console.log(us);
|
|
console.log(metadata);
|
|
let dates = dataForDay.map((data) => data["00000"].date);
|
|
let chart = createChoropleth(us, msPerFrame);
|
|
document.getElementById("content").appendChild(chart.node);
|
|
let day = dataForDay.length - 1;
|
|
let data = dataForDay[day];
|
|
ui.display(dataForDay[day]["00000"].date);
|
|
ui.setFrames(dates, day);
|
|
ui.setMsPerFrame(msPerFrame);
|
|
chart.update(data);
|
|
ui.onTick((day) => {
|
|
chart.update(dataForDay[day]);
|
|
ui.display(dates[day]);
|
|
});
|
|
});
|
|
};
|
|
|
|
main();
|
|
|