class PaceTracker { constructor(container, data) { this.container = d3.select(container); if (!this.container.node()) return; this.data = this.format(data); console.log(this.data); const settings = { ...this.container.node().dataset }; this.province = settings.province || "Canada"; this.section = { intro: this.container.append("div").classed("section", true), population: this.container.append("div").classed("section", true), targets: this.container.append("div").classed("section target", true), text: this.container.append("div").classed("section text", true), chart: this.container.append("div").classed("section chart", true), note: this.container.append("div").classed("section note", true), }; this.section.intro .append("h2") .classed("intro-title bold", true) .text("Vaccine pace tracker"); this.section.intro .append("div") .classed("intro-text", true) .text( "Based on our current vaccination rates, when will we hit our targets?" ); this.metric = { province: new DropDown( this, this.section.intro, "Province", this.data.map((d) => d.name), settings.province || "Canada" ), population: new RadioButtons( this, this.section.population, "Population", [ { text: "Total Population", value: "total" }, { text: "Eligible (12+)", value: "eligible" }, //{ text: "Adult (18+)", value: "adult" }, ], settings.population || "eligible" ), }; this.firstCard = this.section.targets .append("div") .classed("target-card", true); this.fullyCard = this.section.targets .append("div") .classed("target-card", true); const targetArray = [ { text: "First dose", value: "first", parent: this.firstCard, default: +settings.first || 70, }, { text: "Fully vaccinated", value: "fully", parent: this.fullyCard, default: +settings.fully || 20, }, ]; targetArray.forEach((target) => { this.metric[target.value] = new Toggle( this, target.parent, target.text, settings.doses ? settings.doses .split(",") .map((d) => d.trim()) .includes(target.value) : true ); target.parent .append("div") .attr("class", "target-title black") .text(target.text); target.parent .append("div") .attr("class", "target-label") .text("Current percentage:"); target.parent.append("div").attr("class", "current percentage bold"); target.parent .append("div") .attr("class", "target-label") .text("Current rate:"); target.parent.append("div").attr("class", "current rate bold"); target.parent.append("div").attr("class", "target-label").text("Target:"); this.metric[target.value + "Target"] = new NumberInput( this, target.parent, "Target %", [0, 100], target.default ); }); this.text = this.section.text.append("div").classed("target-text", true); console.log(this.text); this.chart = new Chart(this.section.chart, this.data, this); this.section.note .append("div") .html( "NOTE: The tracker currently shows a low projection for full vaccination due to the low rate of second dose administration. As the rate of second doses increases each day, the projection will update. This tracker is useful for answering the question of “If we keep up today’s pace, when will we hit our targets?” but it doesn’t incorporate future events that could change the rates of vaccination." ); window.addEventListener("resize", () => this.update()); this.update(); } update() { for (let key in this.metric) { this.metric[key].update(); } this.chart.update(); } format(rawCanada) { 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(); 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, population: { total: +this.canadaPopulation[province.name]["All ages"], adult: +this.canadaPopulation[province.name]["18 years and over"], eligible: +this.canadaPopulation[province.name]["12 years and over"], }, dates: this.dates, total: { raw: { total: totalArray, new: newArray, avg: avgArray }, }, fully: { raw: { total: fullyArray, new: fullyNewArray, avg: fullyAvgArray }, }, first: { raw: { total: firstArray, new: firstNewArray, avg: firstAvgArray }, }, }; obj.rate = { first: obj.first.raw.avg[obj.first.raw.avg.length - 1], fully: obj.fully.raw.avg[obj.fully.raw.avg.length - 1], }; data.push(obj); }); return data; } provinceArray = [ { name: "British Columbia", short: "BC" }, { name: "Alberta", short: "AB" }, { name: "Saskatchewan", short: "SK" }, { name: "Manitoba", short: "MB" }, { name: "Ontario", short: "ON" }, { name: "Quebec", short: "QC" }, { name: "New Brunswick", short: "NB" }, { name: "Nova Scotia", short: "NS" }, { name: "Prince Edward Island", short: "PE" }, { name: "Newfoundland and Labrador", short: "NL" }, { name: "Yukon", short: "YT" }, { name: "Northwest Territories", short: "NT" }, { name: "Nunavut", short: "NU" }, { name: "Canada", short: "Canada" }, ]; canadaPopulation = { Canada: { Region: "Canada", "All ages": "38005238", "16 years and over": "31568102", "18 years and over": "30754887", "12 years and over": "33198268", }, "Newfoundland and Labrador": { Region: "Newfoundland and Labrador", "All ages": "522103", "16 years and over": "446985", "18 years and over": "436312", "12 years and over": "467776", }, "Prince Edward Island": { Region: "Prince Edward Island", "All ages": "159625", "16 years and over": "133390", "18 years and over": "129799", "12 years and over": "140601", }, "Nova Scotia": { Region: "Nova Scotia", "All ages": "979351", "16 years and over": "833085", "18 years and over": "813389", "12 years and over": "871111", }, "New Brunswick": { Region: "New Brunswick", "All ages": "781476", "16 years and over": "661556", "18 years and over": "645289", "12 years and over": "693386", }, Quebec: { Region: "Quebec", "All ages": "8574571", "16 years and over": "7138076", "18 years and over": "6972707", "12 years and over": "7489220", }, Ontario: { Region: "Ontario", "All ages": "14734014", "16 years and over": "12296737", "18 years and over": "11971129", "12 years and over": "12932471", }, Manitoba: { Region: "Manitoba", "All ages": "1379263", "16 years and over": "1101731", "18 years and over": "1068553", "12 years and over": "1168942", }, Saskatchewan: { Region: "Saskatchewan", "All ages": "1178681", "16 years and over": "933947", "18 years and over": "905623", "12 years and over": "993244", }, Alberta: { Region: "Alberta", "All ages": "4421876", "16 years and over": "3547485", "18 years and over": "3445146", "12 years and over": "3761130", }, "British Columbia": { Region: "British Columbia", "All ages": "5147712", "16 years and over": "4378888", "18 years and over": "4273972", "12 years and over": "4577078", }, Yukon: { Region: "Yukon", "All ages": "42052", "16 years and over": "34480", "18 years and over": "33660", "12 years and over": "36209", }, "Northwest Territories": { Region: "Northwest Territories", "All ages": "45161", "16 years and over": "35535", "18 years and over": "34430", "12 years and over": "37917", }, Nunavut: { Region: "Nunavut", "All ages": "39353", "16 years and over": "26207", "18 years and over": "24878", "12 years and over": "29183", }, }; } class DropDown { constructor(tracker, parent, label, options, defaultOption) { this.tracker = tracker; this.container = parent.append("div").attr("class", "dropdown-container"); this.container .append("span") .attr("class", "material-icons dropdown-arrow") .text("expand_more"); this.dropdown = this.container .append("select") .classed("dropdown", true) .on("change", () => { this.tracker.update(); }); options.forEach((option) => { const op = this.dropdown .append("option") .attr("value", option) .text(option); if (option === defaultOption) { op.attr("selected", true); } }); this.value = defaultOption; this.label = label; } update() { this.value = this.dropdown.node().value; } } class NumberInput { constructor(tracker, parent, label, options, defaultOption) { this.tracker = tracker; this.container = parent .append("div") .classed("number-input container", true); this.label = label; this.min = options[0]; this.max = options[1]; this.default = defaultOption; const button = (valueChange, icon) => this.container .append("button") .classed("button", true) .on("click", () => this.changeValue(valueChange)) .append("span") .classed("material-icons", true) .text(icon); this.minus = button(-1, "remove"); this.input = this.container .append("input") .attr("type", "number") .attr("value", this.default) .on("input", this.handleInput); this.container.append("span").text("%"); this.plus = button(1, "add"); } update() { //this.input.text = this.value; } changeValue(n) { this.input.node().value = +this.input.node().value + n; this.checkIfValid(); this.tracker.update(); } handleInput = () => { this.checkIfValid(); this.tracker.update(); }; checkIfValid() { this.input.node().value = Math.min(this.input.node().value, this.max); this.input.node().value = Math.max(this.input.node().value, this.min); if (isNaN(this.input.node().value)) this.input.node().value = this.default; } get value() { return +this.input.node().value; } } class RadioButtons { constructor(tracker, parent, label, options, defaultOption) { this.tracker = tracker; this.container = parent.append("div").classed("radio container", true); this.label = label; this.options = options; const chosen = this.options.find((opt) => opt.value === defaultOption); this.value = chosen?.value || this.options[0].value; } update() { this.container .selectAll("button") .data(this.options) .join("button") .text((d) => d.text) .on("click", this.handleClick) .classed("radio-option", true) .classed("active", (d) => this.value === d.value); } handleClick = (_, d) => { this.value = d.value; this.tracker.update(); }; } class Toggle { constructor(tracker, parent, label, defaultOption) { this.tracker = tracker; this.parent = parent; this.value = defaultOption; this.container = parent .append("div") .classed("toggle", true) .classed("inactive", !this.value) .on("click", this.handleClick); this.icon = this.container .append("span") .classed("material-icons", true) .text("done"); this.label = label; } update = () => { this.parent.classed("inactive", !this.value); }; handleClick = () => { this.value = !this.value; this.tracker.update(); }; } class Chart { constructor(root, data, tracker) { this.tracker = tracker; this.data = data; this.root = root; this.svg = this.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), highlight: this.svg.append("g").classed("highlight", true), chart: this.svg.append("g").classed("chart", true), legend: this.svg.append("g").classed("legend", true), }; this.pad = { top: 60, right: 35, bottom: 15, left: 10, }; } extrapolate(array, value, days) { return [ array.map((d, i) => (i < array.length - 1 ? null : d)), Array.from(Array(days - array.length).keys()).map((d) => Math.min(100, array[array.length - 1] + (d + 1) * value) ), ].flat(); } perPop(array, population, number = 100) { return array.map((d) => (d * number) / population); } dateSlashToText(date, full = false) { if (!date) return; 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}${ full ? ", " + year : "" }`; } update() { const frac = { minimumFractionDigits: 2, maximumFractionDigits: 2, }; this.firstTarget = this.tracker.metric.firstTarget.value; this.fullyTarget = this.tracker.metric.fullyTarget.value; this.first = this.tracker.metric.first.value; this.fully = this.tracker.metric.fully.value; const data = this.data.find( (d) => d.name === this.tracker.metric.province.value ); const pop = data.population[this.tracker.metric.population.value]; const first = this.perPop(data["first"].raw.total, pop); const firstRate = (data.rate["first"] * 100) / pop; this.tracker.firstCard .select(".current.percentage") .text(first[first.length - 1].toLocaleString(undefined, frac) + "%"); this.tracker.firstCard .select(".current.rate") .html( firstRate.toLocaleString(undefined, frac) + "% per day" ); const firstExtrapolated = this.extrapolate(first, firstRate, 365); const fully = this.perPop(data["fully"].raw.total, pop); const fullyRate = (data.rate["fully"] * 100) / pop; this.tracker.fullyCard .select(".current.percentage") .text(fully[fully.length - 1].toLocaleString(undefined, frac) + "%"); this.tracker.fullyCard .select(".current.rate") .html( fullyRate.toLocaleString(undefined, frac) + "% per day" ); const fullyExtrapolated = this.extrapolate( fully, (data.rate["fully"] * 100) / pop, 365 ); let firstIndex = first.findIndex((d) => d >= this.firstTarget); let firstHappened = true; if (firstIndex === -1) { firstIndex = firstExtrapolated.findIndex((d) => d >= this.firstTarget); firstHappened = false; } let fullyIndex = fully.findIndex((d) => d >= this.fullyTarget); let fullyHappened = true; if (fullyIndex === -1) { fullyIndex = fullyExtrapolated.findIndex((d) => d >= this.fullyTarget); fullyHappened = false; } let index; if (this.first && this.fully) { if (Math.min(firstIndex, fullyIndex) >= 0) { index = Math.max(firstIndex, fullyIndex); } else { index = -1; } } else { index = this.first ? firstIndex : this.fully ? fullyIndex : null; } const happened = this.first && this.fully ? firstHappened && fullyHappened : this.first ? firstHappened : this.fully ? fullyHappened : null; const province = data.name; const thisYear = index >= 0; const textZero = !this.first && !this.fully ? "Select a target" : `At the current rate, the ${this.tracker.metric.population.value} population of${province}:`; const firstWill = firstIndex >= 0; const fullyWill = fullyIndex >= 0; const oneIcon = `${firstWill ? "check_circle" : "cancel"}`; const oneIntro = firstWill ? firstHappened ? "Hit " : "Will hit " : "Will not hit "; const oneNumber = `${this.firstTarget}% with one dose `; const oneDate = firstWill ? `on ${this.dateSlashToText( data.dates[firstIndex], true )}` : "in 2021"; const textOne = oneIcon + oneIntro + oneNumber + oneDate; const twoIcon = `${fullyWill ? "check_circle" : "cancel"}`; const twoIntro = fullyWill ? fullyHappened ? "Hit " : "Will hit " : "Will not hit "; const twoNumber = `${this.fullyTarget}% fully vaccinated `; const twoDate = fullyWill ? `on ${this.dateSlashToText( data.dates[fullyIndex], true )}` : "in 2021"; const textTwo = twoIcon + twoIntro + twoNumber + twoDate; this.tracker.text.html(`
${textZero}
${this.first ? textOne : ""}
${this.fully ? textTwo : ""}
`); const { width, height } = this.svg.node().getBoundingClientRect(); const axisArray = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; const xScale = d3 .scaleLinear() .domain([0, firstExtrapolated.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((_, i) => xScale(i)) .y((d) => yScale(d)) .defined((d) => d !== null); this.layers.title .selectAll("text") .data([ `Percentage of ${this.tracker.metric.population.value} population vaccinated`, ]) .join("text") .text((d) => d) .attr("x", width / 2) .attr("y", 25) .attr("font-size", 15); const legendArray = [ { text: "First dose", class: "first" }, { text: "Fully vaccinated", class: "fully" }, { text: "Projected", class: "first dashed" }, ]; const L = this.layers.legend .selectAll("g") .data(legendArray) .join("g") .attr( "transform", (_, i) => `translate(${xScale(0)}, ${Math.round( yScale(yScale.domain()[1] - 6 - i * 6) )})` ); L.selectAll("line") .data((d) => [d]) .join("line") .attr("x1", 5) .attr("x2", 24) .attr("y1", -4) .attr("y2", -4) .attr("class", (d) => d.class); L.selectAll("text") .data((d) => [d]) .join("text") .text((d) => d.text) .attr("x", 30); this.layers.yAxis .selectAll("rect") .data(axisArray) .join("rect") .attr("x", xScale(xScale.domain()[0])) .attr("y", (d) => yScale(d)) .attr("width", xScale(xScale.domain()[1]) - xScale(xScale.domain()[0])) .attr("height", Math.abs(yScale(10) - yScale(0))) .attr("fill", (_, i) => (i % 2 === 0 ? "#fafafa" : "#f3f3f3")); 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)) .classed("zero", (d) => d === 0); 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 + "%"); const month = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; this.layers.xAxis .selectAll("text") .data( data.dates.filter( (d, i) => i <= firstExtrapolated.length && ["15"].includes(d.split("/")[1]) ) ) .join( (enter) => enter .append("text") .attr("y", (_) => yScale(yScale.domain()[0]) + 12) .text((d) => month[+d.split("/")[0] - 1]), (update) => update ) .attr("x", (d) => xScale(data.dates.findIndex((date) => date === d))); this.layers.xAxis .selectAll("line") .data( data.dates.filter( (d, i) => i <= firstExtrapolated.length && ["1"].includes(d.split("/")[1]) ) ) .join( (enter) => { enter .append("line") .attr("x1", (d) => xScale(data.dates.findIndex((date) => date === d)) ) .attr("x2", (d) => xScale(data.dates.findIndex((date) => date === d)) ) .attr("y1", (_) => yScale(yScale.domain()[0])) .attr("y2", (_) => yScale(yScale.domain()[0]) + 4); }, (update) => update ) .attr("x1", (d) => xScale(data.dates.findIndex((date) => date === d))) .attr("x2", (d) => xScale(data.dates.findIndex((date) => date === d))); this.layers.chart .selectAll(".first.solid") .data(this.tracker.metric.first.value ? [first] : []) .join( (enter) => enter.append("path").attr("class", "first solid"), (update) => update, (exit) => exit.remove() ) .attr("d", line); this.layers.chart .selectAll(".first.dashed") .data(this.tracker.metric.first.value ? [firstExtrapolated] : []) .join( (enter) => enter.append("path").attr("class", "first dashed"), (update) => update, (exit) => exit.remove() ) .attr("d", line); this.layers.chart .selectAll(".fully.solid") .data(this.tracker.metric.fully.value ? [fully] : []) .join( (enter) => enter.append("path").attr("class", "fully solid"), (update) => update, (exit) => exit.remove() ) .attr("d", line); this.layers.chart .selectAll(".fully.dashed") .data(this.tracker.metric.fully.value ? [fullyExtrapolated] : []) .join( (enter) => enter.append("path").attr("class", "fully dashed"), (update) => update, (exit) => exit.remove() ) .attr("d", line); // Marked line const highlightData = []; if (this.first) highlightData.push({ index: firstIndex, target: this.firstTarget, type: "first", }); if (this.fully) highlightData.push({ index: fullyIndex, target: this.fullyTarget, type: "fully", }); const highlight = this.layers.highlight .selectAll("g.date-highlight") .data(highlightData) .join("g") .attr("class", "date-highlight") .classed("inactive", (d) => d.index < 0 || d === null); highlight .selectAll(".percent-line") .data((d) => [d]) .join( (enter) => enter .append("line") .attr("class", "percent-line") .attr("x2", xScale(xScale.domain()[1])), (update) => update, (exit) => exit.remove() ) .attr("y1", (d) => yScale(d.target)) .attr("y2", (d) => yScale(d.target)) .attr("x1", (d) => xScale(d.index)); highlight .selectAll(".index-line") .data((d) => [d]) .join( (enter) => enter .append("line") .attr("class", "index-line") .attr("y1", yScale(yScale.domain()[0])) .attr("y2", yScale(yScale.domain()[1]) - 8), (update) => update, (exit) => exit.remove() ) .attr("x1", (d) => xScale(d.index)) .attr("x2", (d) => xScale(d.index)); highlight .selectAll("rect") .data((d) => [d]) .join( (enter) => enter .append("rect") .attr("y", yScale(yScale.domain()[1]) - 26) .attr("width", 70) .attr("height", 20), (update) => update, (exit) => exit.remove() ) .attr("x", (d) => xScale(d.index || 0) - 35); //.style("fill", (_, i) => (i === 0 ? "#187ec6" : "#0a4c7a")); highlight .selectAll("text") .data((d) => [d]) .join( (enter) => enter.append("text").attr("y", yScale(yScale.domain()[1]) - 11), (update) => update.attr("x", (d) => xScale(d.index)), (exit) => exit.remove() ) .text((d) => this.dateSlashToText(data.dates[d.index])) .attr("x", (d) => xScale(d.index)); } } d3.json( "https://beta.ctvnews.ca/content/dam/common/exceltojson/Vaccine-Dose-Test.txt" ).then((data) => Array.from(document.querySelectorAll(".pace-tracker")).map( (d) => new PaceTracker(d, data) ) );