class TimeLapse { constructor(parent, data, endDate) { this.parent = parent; this.clearLoading(); this.data = data; this.sortedArray = [ { text: "cases at date", value: "current", description: "sorted by cases at date shown", }, { text: "peak cases", value: "peak", description: "sorted by highest peak number of cases as of date shown", }, { text: "by country", value: "alphabetical", description: "sorted alphabetically by country", }, ]; this.sorted = this.sortedArray[0].value; this.metricArray = [ { text: "Average per million", value: "avgPM", description: "Average COVID-19 cases per day, per million people", }, { text: "Average daily cases", value: "avg", description: "Average COVID-19 cases per day", }, { text: "Total per million", value: "totalPM", description: "Total COVID-19 cases per million people", }, { text: "Total cases", value: "total", description: "Total COVID-19 cases", }, ]; this.metric = this.metricArray[0].value; this.capArray = [{ text: "All", value: this.data.length }]; for (let i = 1; i <= data.length; i++) { this.capArray.push({ text: i, value: i }); } this.cap = this.capArray[0].value; this.playing = false; this.playback = { rate: [0.25, 0.5, 1, 1.5, 2, 3, 4], rateIndex: 2, speed: 500, transition: 0.7, get duration() { return this.frame * this.transition; }, get frame() { return this.speed / this.currentRate; }, get currentRate() { return this.rate[this.rateIndex]; }, }; this.dateArray = this.data.find((d) => d.country === "USA").dates; this.maxIndex = endDate ? this.dateArray.findIndex((d) => d === endDate) : this.dateArray.length - 1; this.index = 46; this.previousIndex = this.index; this.dataMax = this.calcDataMax(); this.interval = this.makeInterval(); this.size = { left: 30, numPad: 5, height: 28, margin: 3, get total() { return this.height + this.margin; }, }; this.highlights = []; this.controls = this.parent.append("div").classed("controls", true); const buttonGroups = this.controls .append("div") .classed("button-groups", true); const buttonContainer = buttonGroups .append("div") .classed("button-container", true); const sliderContainer = this.controls .append("div") .classed("slider-container", true); this.playbackContainer = this.controls .append("div") .classed("button-container playback", true); this.dropdownContainer = this.controls .append("div") .classed("dropdown-container", true); this.makeDropdown(this.dropdownContainer, "metric", this.metricArray); this.dropdownContainer.append("span").text("sorted by"); this.makeDropdown( this.dropdownContainer, "sorted", this.sortedArray, "sorted-dropdown" ); this.dropdownContainer.append("span").text(" — "); this.makeDropdown(this.dropdownContainer, "cap", this.capArray); this.dropdownContainer.append("span").text("regions"); this.minimized = false; this.minimizeButton = this.makeButton( this.controls, "minimize button", () => { this.minimizeButton.text( this.minimized ? "expand_less" : "expand_more" ); this.minimized = !this.minimized; this.updateState(this.index); this.setHeight(); }, "expand_less" ); this.controls.append("div").classed("line", true); this.date = this.controls .append("div") .classed("date", true) .text(this.dateSlashToText(this.dateArray[this.index])); this.description = this.controls.append("div").classed("description", true); this.slider = sliderContainer .append("input") .attr("type", "range") .attr("min", 0) .attr("max", this.maxIndex) .attr("value", this.index) .classed("slider", true) .on("input", (e) => { if (this.playing) this.pause(); this.updateDate(+e.target.value); }) .on("change", (e) => this.updateState(+e.target.value)); this.container = this.parent.append("div").classed("container", true); this.rankContainer = this.container .append("div") .classed("rank-container", true); this.data.forEach((_, i) => this.rankContainer .append("div") .classed("rank", true) .text(i + 1) .style("height", `${this.size.height}px`) .style("width", `${this.size.left - this.size.numPad}px`) .style("transform", `translate(0px,${i * this.size.total}px)`) ); this.regionContainer = this.container .append("div") .classed("region-container", true); this.stepZeroButton = this.makeButton( buttonContainer, "step-zero button minor", () => this.updateState(0), "first_page" ); this.stepBackwardButton = this.makeButton( buttonContainer, "step-backward button minor", () => this.updateState(this.index - 1), "navigate_before" ); this.playButton = this.makeButton( buttonContainer, "play button", () => this.togglePlay(), "play_arrow" ); this.stepForwardButton = this.makeButton( buttonContainer, "step button minor", () => this.updateState(this.index + 1), "navigate_next" ); this.stepLastButton = this.makeButton( buttonContainer, "step-last button minor", () => this.updateState(this.maxIndex), "last_page" ); this.slowDownButton = this.makeButton( this.playbackContainer, "speed button minor", () => this.changeSpeed(-1), "remove" ); this.speedText = this.playbackContainer .append("div") .classed("speed-text", true); this.speedUpButton = this.makeButton( this.playbackContainer, "speed button minor", () => this.changeSpeed(1), "add" ); this.update(); } setHeight() { this.parent.style( "height", this.cap * this.size.total + this.controls.node().offsetHeight + 10 + 30 + "px" ); } clearLoading() { this.parent.html(""); this.parent .style("flex-direction", "column") .style("background-color", "white"); } makeInterval() { return setInterval(() => { if (!this.playing) return; this.updateState(this.index + 1); if (this.index === this.maxIndex) { this.pause(); } }, this.playback.frame); } changeSpeed(dx) { clearInterval(this.interval); this.playback.rateIndex += dx; if (this.playback.rateIndex >= this.playback.rate.length) { this.playback.rateIndex = this.playback.rate.length - 1; } else if (this.playback.rateIndex < 0) { this.playback.rateIndex = 0; } this.interval = this.makeInterval(); this.updateState(this.index); } updateDate(temp) { this.date.text(this.dateSlashToText(this.dateArray[temp])); } updateState(newIndex) { if (newIndex > this.maxIndex) return; if (newIndex < 0) return; this.previousIndex = this.index; this.index = newIndex; this.slider.node().value = newIndex; this.update(); } togglePlay() { this.playButton.text(this.playing ? "play_arrow" : "pause"); this.playing = !this.playing; } play() { this.playing = true; this.playButton.text("pause"); } pause() { this.playing = false; this.playButton.text("play_arrow"); } makeButton(parent, className, func, icon) { return ( parent .append("button") .classed(className, true) .on("click", func) // .on("click", () => { // if ("ontouchstart" in document.documentElement) return; // func(); // }) .append("span") .classed("material-icons", true) .classed("md-24", className.split(" ").includes("minor")) .classed("md-36", !className.split(" ").includes("minor")) .text(icon) ); } calcDataMax() { return Math.max(...this.data.map((d) => d[this.metric]).flat()); } makeDropdown(parent, metric, metricArray, className = "") { const dropdown = parent.append("select").classed(className, true); dropdown .on("change", () => { this[metric] = dropdown.node().value; this.dataMax = this.calcDataMax(); this.updateState(this.index); }) .selectAll("option") .data(metricArray) .join("option") .text((d) => d.text) .attr("value", (d) => d.value); return dropdown; } dateSlashToText(date) { const [month, day, year] = date.split("/"); const monthArray = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; return `${monthArray[month - 1]}${ month === "5" ? "" : "." } ${day}, 20${year}`; } update() { this.date.text(this.dateSlashToText(this.dateArray[this.index])); this.rankContainer.style( "display", this.sorted === "alphabetical" ? "none" : "flex" ); this.description.style("display", this.minimized ? "none" : "flex"); this.playbackContainer.style("display", this.minimized ? "none" : "flex"); this.dropdownContainer.style("display", this.minimized ? "none" : "flex"); const metricDescription = this.metricArray.find( (desc) => desc.value === this.metric ).description; const sortedDescription = this.sortedArray.find( (desc) => desc.value === this.sorted ).description; const showPeakBrackets = this.sorted === "peak" && !["total", "totalPM"].includes(this.metric); this.description.text( `${metricDescription}${ showPeakBrackets ? " (peak value to date in brackets)" : "" }, ${sortedDescription}.` ); this.speedText.html( `${this.playback.currentRate}close` ); const translate = (num) => `translate(${this.size.left}px,${num}px)`; const translateInterpolator = (start, end) => d3.interpolateString(translate(start), translate(end)); const fractions = [ undefined, { minimumFractionDigits: this.metric === "total" ? 0 : 1, maximumFractionDigits: 1, }, ]; const htmlString = (d, _, arr, test) => { if (showPeakBrackets) { const max = Math.max( ...d[this.metric].filter((_, i) => i <= this.index) ); return `${d.region}${d[this.metric][this.index].toLocaleString( ...fractions )} (${max.toLocaleString(...fractions)})`; } else { return `${d.region}${d[this.metric][this.index].toLocaleString( ...fractions )}`; } }; const toggleHighlight = (_, d) => { this.highlights.includes(d.region) ? (this.highlights = this.highlights.filter((r) => r !== d.region)) : this.highlights.push(d.region); this.previousIndex = this.index; this.update(); }; this.regionContainer .selectAll(".region") .data(this.format(this.index), (d) => d.region) .join( (enter) => enter .append("div") .classed("region", true) .classed("province", (d) => d.country === "Canada") .classed("highlight", (d) => this.highlights.includes(d.region)) .classed("lowlight", this.highlights.length > 0) .html(htmlString) .style("height", this.size.height + "px") .style("opacity", 1) .style("transform", (_, i) => translate(i * this.size.total)) .on("click", toggleHighlight), (update) => update .html(htmlString) .classed("highlight", (d) => this.highlights.includes(d.region)) .classed("lowlight", this.highlights.length > 0) .call((update) => update .transition() .duration(this.playback.duration) .styleTween("transform", (d, i) => translateInterpolator( d.previous * this.size.total, i * this.size.total ) ) .style("opacity", 1) ), (exit) => exit.call((exit) => exit .style("opacity", 1) .transition() .duration(this.playback.duration) .style("opacity", 0) .remove() ) ) .append("canvas") .attr("height", this.size.height) .attr("width", "100%") .each((d, i, arr) => { const canvas = arr[i]; canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; const [w, h] = [canvas.width, canvas.height]; const xScale = d3 .scaleLinear() .domain([0, d[this.metric].length - 1]) .range([0, w]); const yScale = d3.scaleLinear().domain([0, this.dataMax]).range([0, h]); const c = canvas.getContext("2d"); const usGradient = c.createLinearGradient(0, 0, 0, h); usGradient.addColorStop(1, "rgb(255, 189, 105)"); usGradient.addColorStop(0, "rgb(199, 0, 57)"); const canGradient = c.createLinearGradient(0, 0, 0, h); canGradient.addColorStop(1, "rgb(118, 177, 169)"); canGradient.addColorStop(0, "rgb(17, 88, 77)"); const barW = Math.ceil(xScale(1)) + 0.5; c.fillStyle = d.country === "Canada" ? canGradient : usGradient; d[this.metric].forEach((day, j) => { c.fillRect( xScale(j), yScale(this.dataMax) - yScale(day), barW, yScale(day) ); }); }); this.setHeight(); } format(index) { const sortWithIndex = (ind) => { const data = this.data.map((d) => { const copy = { ...d }; copy[this.metric] = d[this.metric].filter((_, i) => i <= ind); return copy; }); if (this.sorted === "current") { return data.sort((a, b) => b[this.metric][ind] - a[this.metric][ind]); } else if (this.sorted === "peak") { const max = (array) => Math.max(...array.filter((_, i) => i <= this.index)); return data.sort((a, b) => max(b[this.metric]) - max(a[this.metric])); } else if (this.sorted === "alphabetical") { return data .sort((a, b) => a.region.localeCompare(b.region)) .sort((a, b) => a.country.localeCompare(b.country)); } return data; }; const sortedCurrent = sortWithIndex(index); const sortedPrevious = sortWithIndex(this.previousIndex); sortedCurrent.forEach((region) => { region.previous = sortedPrevious.findIndex( (r) => r.region === region.region ); }); return sortedCurrent.filter((_, i) => i < this.cap); } } const formatUsaData = (raw, pop) => { const stateData = []; raw.forEach((rawCounty) => { const county = { country: "USA", region: rawCounty.Admin2, name: rawCounty.Admin2, state: rawCounty.Province_State, Fips: +rawCounty.FIPS, }; const popObj = pop.find((popCounty) => +popCounty.Fips === county.Fips); county.population = popObj ? +popObj.POPESTIMATE2019 : null; county.dates = []; county.total = []; let nonDateKeys = [ "population", "pop", "UID", "iso2", "iso3", "code3", "FIPS", "Admin2", "Province_State", "Country_Region", "Lat", "Long_", "Combined_Key", ]; for (let key in rawCounty) { if (!nonDateKeys.includes(key)) { county.dates.push(key); county.total.push(+rawCounty[key]); } } const state = stateData.find((s) => s.region === county.state); if (!state) { stateData.push({ country: "USA", region: county.state, counties: [county], population: county.population, dates: county.dates, total: county.total, }); } else { state.counties.push(county); state.population += county.population ? county.population : 0; state.total = state.total.map((d, i) => d + county.total[i]); } }); const data = stateData.filter((state) => state.population); data.forEach((state) => { state.dates = state.dates.filter((_, i) => i > 2); state.total = state.total.filter((_, i) => i > 2); state.new = state.total.map((d, i, arr) => (i > 0 ? d - arr[i - 1] : 0)); state.avg = state.new.map((_, i, arr) => i >= 6 ? arr.slice(i - 6, i + 1).reduce((a, b) => a + b, 0) / 7 : 0 ); state.avgPM = state.avg.map((d) => (d * 1000000) / state.population); if (state.avgPM[state.avgPM.length - 1] < 0) { state.avgPM[state.avgPM.length - 1] = null; } state.totalPM = state.total.map((d) => (d * 1000000) / state.population); }); return data; }; const formatCanadaData = (raw, dateLength) => { const populationArray = [ { name: "British Columbia", short: "BC", population: "5110917" }, { name: "Alberta", short: "AB", population: "4413146" }, { name: "Saskatchewan", short: "SK", population: "1181666" }, { name: "Manitoba", short: "MB", population: "1377517" }, { name: "Ontario", short: "ON", population: "14711827" }, { name: "Quebec", short: "QC", population: "8537674" }, { name: "New Brunswick", short: "NB", population: "779993" }, { name: "Nova Scotia", short: "NS", population: "977457" }, { name: "Prince Edward Island", short: "PE", population: "158158" }, { name: "Newfoundland and Labrador", short: "NL", population: "521365" }, { name: "Yukon", short: "YT", population: "41078" }, { name: "Northwest Territories", short: "NT", population: "44904" }, { name: "Nunavut", short: "NU", population: "39097" }, ]; const canadaData = []; raw .filter((d) => d.Date !== "") .forEach((day) => { for (let key in day) { const short = key.split("_")[0]; const type = key.split("_")[1]; if (!["Date", "Can"].includes(short) && type === "Total") { const province = canadaData.find((region) => region.short === short); const provObj = populationArray.find((prov) => prov.short === short); if (!province) { canadaData.push({ country: "Canada", region: provObj.name, short: short, population: +provObj.population, total: [+day[key]], dates: [day.Date], }); } else { province.total.push(+day[key]); province.dates.push(day.Date); } } } }); canadaData.forEach((province) => { province.dates = province.dates.filter((_, i) => i < dateLength); province.total = province.total.filter((_, i) => i < dateLength); province.new = province.total.map((d, i, arr) => i > 0 ? d - arr[i - 1] : 0 ); province.avg = province.new.map((_, i, arr) => i >= 6 ? arr.slice(i - 6, i + 1).reduce((a, b) => a + b, 0) / 7 : 0 ); province.avgPM = province.avg.map( (d) => (d * 1000000) / province.population ); province.totalPM = province.total.map( (d) => (d * 1000000) / province.population ); }); return canadaData.sort((a, b) => a.region.localeCompare(b.region)); }; const url = { canadaData: "https://beta.ctvnews.ca/content/dam/common/exceltojson/COVID-19-Canada-New.txt", usaData: "https://beta.ctvnews.ca/content/dam/common/exceltojson/us-data.txt", usaPopulation: "https://beta.ctvnews.ca/content/dam/common/exceltojson/us-population.txt", }; Promise.all([ d3.csv(url.usaData), d3.csv(url.usaPopulation), d3.json(url.canadaData), ]).then(([usaRaw, usaPop, canadaRaw]) => { const usaData = formatUsaData(usaRaw, usaPop); const canadaData = formatCanadaData(canadaRaw, usaData[0].dates.length); new TimeLapse( d3.select(".covid-time-lapse"), [...canadaData, ...usaData], "7/18/22" ); });