class Chart { constructor(root, rawCanada, rawUSA) { this.daysCutOff = 99; this.data = this.format(rawCanada, rawUSA); this.root = root; this.svg = root.append("svg"); this.layers = { title: this.svg.append("g").classed("title", true), yAxis: this.svg.append("g").classed("y-axis", true), xAxis: this.svg.append("g").classed("x-axis", true), chart: this.svg.append("g").classed("chart", true), legend: this.svg.append("g").classed("legend", true), }; this.pad = { top: 100, right: 35, bottom: 30, left: 30, }; this.update(); window.addEventListener("resize", () => this.update()); } perPop(array, population, number) { return array.map((d) => (d * number) / population); } extrapolate(array, value, days) { return [ array.map((d, i) => (i < array.length - 1 ? null : d)), Array.from(Array(days).keys()).map( (d) => array[array.length - 1] + (d + 1) * value ), ].flat(); } format(rawCanada, rawUSA) { const usaFiltered = rawUSA.filter( (d) => d.location === "United States" && +d.date.split("-")[0] > 2020 ); const usaObj = { name: "United States", short: "US", population: 331002647, total: { raw: { total: usaFiltered.map((d) => +d.total_vaccinations), new: usaFiltered.map((d) => +d.new_vaccinations), }, per100: { total: usaFiltered.map((d) => +d.total_vaccinations_per_hundred), }, }, first: { raw: { total: usaFiltered.map((d) => +d.people_vaccinated), }, per100: { total: usaFiltered.map((d) => +d.people_vaccinated_per_hundred), }, }, fully: { raw: { total: usaFiltered.map((d) => +d.people_fully_vaccinated), }, per100: { total: usaFiltered.map((d) => +d.people_fully_vaccinated_per_hundred), }, }, }; // TOTAL USA usaObj.total.raw.avg = usaObj.total.raw.new.map((_, i, arr) => i >= 6 ? arr.slice(i - 6, i + 1).reduce((a, b) => a + b, 0) / 7 : 0 ); usaObj.total.per100.avg = this.perPop( usaObj.total.raw.avg, usaObj.population, 100 ); // FIRST USA usaObj.first.raw.new = usaObj.first.raw.total.map( (d, i, arr) => d - (i > 0 ? arr[i - 1] : 0) ); usaObj.first.raw.avg = usaObj.first.raw.new.map((_, i, arr) => i >= 6 ? arr.slice(i - 6, i + 1).reduce((a, b) => a + b, 0) / 7 : 0 ); usaObj.first.per100.avg = this.perPop( usaObj.first.raw.avg, usaObj.population, 100 ); // FULLY USA usaObj.fully.raw.new = usaObj.fully.raw.total.map( (d, i, arr) => d - (i > 0 ? arr[i - 1] : 0) ); usaObj.fully.raw.avg = usaObj.fully.raw.new.map((_, i, arr) => i >= 6 ? arr.slice(i - 6, i + 1).reduce((a, b) => a + b, 0) / 7 : 0 ); usaObj.fully.per100.avg = this.perPop( usaObj.fully.raw.avg, usaObj.population, 100 ); const usaFirst = usaObj.first.per100; usaFirst.rate = usaFirst.avg[usaFirst.avg.length - 1]; usaObj.first.per100.extrapolated = this.extrapolate( usaFirst.total, usaFirst.rate, 14 ); const usaFully = usaObj.fully.per100; usaFully.rate = usaFully.avg[usaFully.avg.length - 1]; usaObj.fully.per100.extrapolated = this.extrapolate( usaFully.total, usaFully.rate, 14 ); const canadaFiltered = rawCanada.filter( (d) => d.Date !== "" && !isNaN(+d.Date) ); canadaFiltered.pop(); //remove last date, since it will be incomplete and current avg value will be lower than end-of-day this.dates = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] .map((d, i) => { const days = []; for (let j = 1; j <= d; j++) { days.push(`${i + 1}/${j}/${2021}`); } return days; }) .flat() .filter((_, i) => i > this.daysCutOff); const data = []; this.provinceArray.forEach((province) => { // TOTAL DOSES const totalArrayBlanks = canadaFiltered.map( (day) => day[`${province.short}_Doses`] ); const totalArray = []; totalArrayBlanks.forEach((day, i) => totalArray.push(day === "" ? (i === 0 ? 0 : totalArray[i - 1]) : +day) ); const newArray = totalArray.map( (d, i, arr) => d - (i > 0 ? arr[i - 1] : 0) ); const avgArray = newArray.map((_, i, arr) => i >= 6 ? arr.slice(i - 6, i + 1).reduce((a, b) => a + b, 0) / 7 : 0 ); // FULLY VACCINATED const fullyArrayBlanks = canadaFiltered.map( (day) => day[`${province.short}_2nd`] ); const fullyArray = []; fullyArrayBlanks.forEach((day, i) => fullyArray.push(day === "" ? (i === 0 ? 0 : fullyArray[i - 1]) : +day) ); const fullyNewArray = fullyArray.map( (d, i, arr) => d - (i > 0 ? arr[i - 1] : 0) ); const fullyAvgArray = fullyNewArray.map((_, i, arr) => i >= 6 ? arr.slice(i - 6, i + 1).reduce((a, b) => a + b, 0) / 7 : 0 ); // FIRST DOSE const firstArray = totalArray.map((d, i) => d - fullyArray[i]); const firstNewArray = firstArray.map( (d, i, arr) => d - (i > 0 ? arr[i - 1] : 0) ); const firstAvgArray = firstNewArray.map((_, i, arr) => i >= 6 ? arr.slice(i - 6, i + 1).reduce((a, b) => a + b, 0) / 7 : 0 ); const obj = { ...province, total: { raw: { total: totalArray, new: newArray, avg: avgArray }, per100: { total: this.perPop(totalArray, province.population, 100), new: this.perPop(newArray, province.population, 100), avg: this.perPop(avgArray, province.population, 100), }, }, fully: { raw: { total: fullyArray, new: fullyNewArray, avg: fullyAvgArray }, per100: { total: this.perPop(fullyArray, province.population, 100), new: this.perPop(fullyNewArray, province.population, 100), avg: this.perPop(fullyAvgArray, province.population, 100), }, }, first: { raw: { total: firstArray, new: firstNewArray, avg: firstAvgArray }, per100: { total: this.perPop(firstArray, province.population, 100), new: this.perPop(firstNewArray, province.population, 100), avg: this.perPop(firstAvgArray, province.population, 100), }, }, }; const first = obj.first.per100; first.rate = first.avg[first.avg.length - 1]; obj.first.per100.extrapolated = this.extrapolate( first.total, first.rate, 14 ); const fully = obj.fully.per100; fully.rate = fully.avg[fully.avg.length - 1]; obj.fully.per100.extrapolated = this.extrapolate( fully.total, fully.rate, 14 ); data.push(obj); }); return [data, usaObj].flat(); } provinceArray = [ { name: "British Columbia", short: "BC", population: 5147712 }, { name: "Alberta", short: "AB", population: 4421876 }, { name: "Saskatchewan", short: "SK", population: 1178681 }, { name: "Manitoba", short: "MB", population: 1379263 }, { name: "Ontario", short: "ON", population: 14734014 }, { name: "Quebec", short: "QC", population: 8574571 }, { name: "New Brunswick", short: "NB", population: 781476 }, { name: "Nova Scotia", short: "NS", population: 979351 }, { name: "Prince Edward Island", short: "PE", population: 159625 }, { name: "Newfoundland and Labrador", short: "NL", population: 522103 }, { name: "Yukon", short: "YT", population: 42052 }, { name: "Northwest Territories", short: "NT", population: 45161 }, { name: "Nunavut", short: "NU", population: 39353 }, { name: "Canada", short: "Canada", population: 38005238 }, ]; update() { for (let layer in this.layers) { this.layers[layer].html(""); } const metric = "first"; const canada = this.data[13][metric].per100.total.filter( (_, i) => i > this.daysCutOff ); const canadaExtrapolated = this.data[13][metric].per100.extrapolated.filter( (_, i) => i > this.daysCutOff ); const usa = this.data[14][metric].per100.total.filter( (_, i) => i > this.daysCutOff ); const usaExtrapolated = this.data[14][metric].per100.extrapolated.filter( (_, i) => i > this.daysCutOff ); let index = canada.findIndex((d, i) => d > usa[i]); let happened = true; if (index === -1) { index = canadaExtrapolated.findIndex((d, i) => d > usaExtrapolated[i]); happened = false; } const { width, height } = this.svg.node().getBoundingClientRect(); this.layers.title .append("text") .text("Percentage of population with at least one vaccine dose") .attr("x", width / 2) .attr("y", 30) .attr("font-size", width > 500 ? 16 : 12); const frac = { minimumFractionDigits: 2, maximumFractionDigits: 2, }; this.layers.title .append("text") .text( "Canada (" + this.data[13].first.per100.rate.toLocaleString(undefined, frac) + "%) is outpacing the U.S. (" + this.data[14].first.per100.rate.toLocaleString(undefined, frac) + "%) in first doses per day" ) .attr("x", width / 2) .attr("y", this.pad.top / 2 + 10) .attr("font-size", width > 500 ? 14 : 11) .classed("sub-title", true); this.layers.title .append("text") .text( "and " + (happened ? "passed" : "is set to pass") + " in percentage of population vaccinated on " + this.dates[index] ) .attr("x", width / 2) .attr("y", this.pad.top / 2 + 28) .attr("font-size", width > 500 ? 14 : 11) .classed("sub-title", true); const legendArray = [ { text: "Canada", class: "canada" }, { text: "Canada (projected)", class: "canada dashed" }, { text: "U.S.", class: "usa" }, { text: "U.S. (projected)", class: "usa dashed" }, ]; const L = this.layers.legend .selectAll("g") .data(legendArray) .join("g") .attr( "transform", (_, i) => `translate(${this.pad.left + 20}, ${this.pad.top + 14 + i * 14})` ); L.append("line") .attr("x1", -3) .attr("x2", -20) .attr("y1", -4) .attr("y2", -4) .attr("class", (d) => d.class); L.append("text").text((d) => d.text); const axisArray = [0, 20, 40, 60]; const xScale = d3 .scaleLinear() .domain([0, canadaExtrapolated.length]) .range([this.pad.left, width - this.pad.right]); const yScale = d3 .scaleLinear() .domain([0, Math.max(...axisArray)]) .range([height - this.pad.bottom, this.pad.top]); const line = d3 .line() .x((d, i) => xScale(i)) .y((d) => yScale(d)) .defined((d) => d !== null); this.layers.yAxis .selectAll("line") .data(axisArray) .join("line") .attr("x1", xScale(xScale.domain()[0])) .attr("x2", xScale(xScale.domain()[1])) .attr("y1", (d) => yScale(d)) .attr("y2", (d) => yScale(d)); this.layers.yAxis .selectAll("text") .data(axisArray) .join("text") .attr("x", xScale(xScale.domain()[1]) + 5) .attr("y", (d) => yScale(d) + 3) .text((d) => d + "%"); this.layers.xAxis .selectAll("text") .data(this.dates.filter((_, i) => i <= canadaExtrapolated.length)) .join("text") .attr("x", (d, i) => xScale(i)) .attr("y", (d) => yScale(yScale.domain()[0]) + 16) .text((d) => d) .attr("display", (d) => ["1", "15"].includes(d.split("/")[1]) ? "inline" : "none" ); this.layers.xAxis .selectAll("line") .data(this.dates.filter((_, i) => i <= canadaExtrapolated.length)) .join("line") .attr("x1", (d, i) => xScale(i)) .attr("x2", (d, i) => xScale(i)) .attr("y1", (d) => yScale(yScale.domain()[0])) .attr("y2", (d) => yScale(yScale.domain()[0]) + 4) .text((d) => d) .attr("display", (d) => ["1", "15"].includes(d.split("/")[1]) ? "inline" : "none" ); this.layers.chart .append("path") .datum(canada) .classed("canada", true) .classed("dashed", false) .attr("d", line); this.layers.chart .append("path") .datum(canadaExtrapolated) .classed("canada", true) .classed("dashed", true) .attr("d", line); this.layers.chart .append("path") .datum(usa) .classed("usa", true) .classed("dashed", false) .attr("d", line); this.layers.chart .append("path") .datum(usaExtrapolated) .classed("usa", true) .classed("dashed", true) .attr("d", line); // Marked line this.layers.chart .append("line") .attr("x1", xScale(index)) .attr("x2", xScale(index)) .attr("y1", yScale(yScale.domain()[0]) + 15) .attr("y2", yScale(yScale.domain()[1])); this.layers.chart .append("text") .attr("x", xScale(index)) .attr("y", yScale(yScale.domain()[0]) + 26) .text(this.dates[index]) .classed("date-highlight", true); } } Promise.all([ d3.json( "https://beta.ctvnews.ca/content/dam/common/exceltojson/Vaccine-Dose-Test.txt" ), d3.csv( "https://beta.ctvnews.ca/content/dam/common/exceltojson/world_data.txt" ), ]).then(([canadaRaw, usaRaw]) => { new Chart(d3.select(".ctv-graphic"), canadaRaw, usaRaw); });