function createCanadaVaccineApp() {
d3.json("https://beta.ctvnews.ca/content/dam/common/exceltojson/Vaccine-Dose-Test.txt").then((raw) => {
//Format Data
const filtered = raw.filter((row) => row.Date);
const provinceNameKey = {
BC: "British Columbia",
AB: "Alberta",
SK: "Saskatchewan",
MB: "Manitoba",
ON: "Ontario",
QC: "Quebec",
NB: "New Brunswick",
NS: "Nova Scotia",
PE: "Prince Edward Island",
NL: "Newfoundland and Labrador",
YT: "Yukon",
NT: "Northwest Territories",
NU: "Nunavut",
Canada: "Canada",
};
const total = filtered.find((row) => row.Date === "Total");
const updated = filtered.find((row) => row.Date === "Updated");
const data = [];
const thirdDoseException = {
SK: true,
};
const protoProv = {
get percent() {
return (100 * this.first) / this.population;
},
get percent16() {
return (100 * this.first) / this.population16;
},
get percent12() {
return (100 * this.first) / this.population12;
},
get percent5() {
return (100 * this.first) / this.population5;
},
get percent2nd() {
return (100 * this.second) / this.population;
},
get percent2nd16() {
return (100 * this.second) / this.population16;
},
get percent2nd12() {
return (100 * this.second) / this.population12;
},
get percent2nd5() {
return (100 * this.second) / this.population5;
},
get distPercent() {
return this.distributed > 0 ? (100 * this.total) / this.distributed : 0;
},
get first() {
return this.total - this.second - (thirdDoseException[this.regionShort] ? 0 : this.third);
},
get date() {
const date = new Date(Date.UTC(0, 0, this.dateNum, -19));
//const monthArray = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
//const year = String(date.getUTCFullYear());
return `${month}-${day}`;
},
};
Object.keys(total).forEach((key) => {
const short = key.split("_")[0];
const long = provinceNameKey[short];
const region = data.find((d) => d.region === long);
if (!region) {
if (long) {
const totalUpdated = canadaPopulation[long]["total updated"];
const totalStat = canadaPopulation[long]["All ages"];
const twelveUpdated = canadaPopulation[long]["12 years updated"];
const twelveStat = canadaPopulation[long]["12 years and over"];
const fiveUpdated = canadaPopulation[long]["5 years updated"];
const fiveStat = canadaPopulation[long]["5 years and over"];
const newObj = {
region: long,
regionShort: short,
total: +total[`${short}_Doses`],
second: +total[`${short}_2nd`],
third: +total[`${short}_3rd`],
distributed: +total[`${short}_Dist`],
dateNum: +updated[`${short}_Doses`],
//population: +population[long],
population: totalUpdated === "" ? +totalStat : +totalUpdated,
population16: +canadaPopulation[long]["16 years and over"],
population12: twelveUpdated === "" ? +twelveStat : +twelveUpdated,
population5: fiveUpdated === "" ? +fiveStat : +fiveUpdated,
data: [],
};
Object.setPrototypeOf(newObj, protoProv);
data.push(newObj);
}
} else {
/**/
}
});
const canada = data.find((obj) => obj.region === "Canada");
canada.population = data.filter((d) => d.region !== "Canada").reduce((memo, curr) => memo + curr.population, 0);
canada.population12 = data.filter((d) => d.region !== "Canada").reduce((memo, curr) => memo + curr.population12, 0);
canada.population5 = data.filter((d) => d.region !== "Canada").reduce((memo, curr) => memo + curr.population5, 0);
data.forEach((region) => {
region.data = filtered
.filter((row) => !["Updated", "Total"].includes(row.Date))
.map((row, i, arr) => {
const short = region.regionShort;
const newObj = {
regionShort: short,
dateNum: +row[`Date`],
total: row[`${short}_Doses`] !== "" ? +row[`${short}_Doses`] : null,
second: row[`${short}_2nd`] !== "" ? +row[`${short}_2nd`] : null,
third: row[`${short}_3rd`] !== "" ? +row[`${short}_3rd`] : null,
distributed: row[`${short}_Dist`] !== "" ? +row[`${short}_Dist`] : null,
population: region.population,
population16: region.population16,
population12: region.population12,
population5: region.population5,
};
Object.setPrototypeOf(newObj, protoProv);
return newObj;
});
});
// DAILY + AVG + FILL IN BLANKS
data.forEach((region) => {
region.data.forEach((day, i, arr) => {
day.added = [];
Object.keys(day).forEach((key) => {
if (day[key] === null) {
day[key] = i === 0 ? 0 : arr[i - 1][key];
day.added.push(key);
}
});
});
region.data.max = function (metric) {
return d3.max(this, (d) => +d[metric]);
};
});
data.max = function (metric) {
return d3.max(this, (d) => +d.data.max(metric));
};
// TABLES /////////////////////
//
// TOP
const topTableContainer = d3.select(".top-table-canada");
const topTableContainer2 = d3.select(".top-table-canada-2");
const bottomTableContainer = d3.select(".bottom-table-canada");
const totalAndNew = (d, i, arr) => {
const num = d.toLocaleString(undefined, { maximumFractionDigits: 0 });
const difference = arr[arr.length - 1] - arr[arr.length - 2];
const diff = difference.toLocaleString(undefined, { maximumFractionDigits: 0 });
return `${num}
${difference >= 0 ? "+" : ""}${diff}
`;
};
const totalAndNewPct = (d, i, arr) => {
const num = d.toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 });
const difference = arr[arr.length - 1] - arr[arr.length - 2];
const diff = difference.toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 });
return `${num}%${difference >= 0 ? "+" : ""}${diff}%
`;
};
const topOptions = [
topTableContainer,
[data.find((row) => row.region === "Canada")],
[
{
head: "Total
Percentage of population vaccinated",
body: "percent",
width: "10%",
fn: totalAndNewPct,
},
{
head: "Total
Percentage of population fully vaccinated",
body: "percent2nd",
width: "10%",
fn: totalAndNewPct,
},
/* <>
{
head: "Eligible (12+)
Percentage of population vaccinated ",
body: "percent12",
width: "10%",
fn: totalAndNewPct,
},
{
head: "Eligible (12+)
Percentage of population fully vaccinated ",
body: "percent2nd12",
width: "10%",
fn: totalAndNewPct,
},
*/
{
head: "Eligible (5+)
Percentage of population vaccinated ",
body: "percent5",
width: "10%",
fn: totalAndNewPct,
},
{
head: "Eligible (5+)
Percentage of population fully vaccinated ",
body: "percent2nd5",
width: "10%",
fn: totalAndNewPct,
},
],
"vaccine-table top",
"Canada",
"Canada",
];
const topOptions2 = [
topTableContainer2,
[data.find((row) => row.region === "Canada")],
[
{
head: "Total doses administered",
body: "total",
width: "10%",
fn: totalAndNew,
},
{
head: "First doses",
body: "first",
width: "10%",
fn: totalAndNew,
},
{
head: "Second doses",
body: "second",
width: "10%",
fn: totalAndNew,
},
{
head: "Third+ doses",
body: "third",
width: "10%",
fn: totalAndNew,
},
// {
// head: "Received from manufacturer",
// body: "distributed",
// width: "10%",
// fn: totalAndNew,
// },
{
head: "Received doses administered",
body: "distPercent",
width: "10%",
fn: totalAndNewPct,
},
],
"vaccine-table top",
"Canada",
"Canada",
];
const botOptions = [
bottomTableContainer,
data,
[
{ head: "Province/Territory", body: "region", width: "35%", fn: (d) => d },
// {
// head: "% of population vaccinated (at least one dose)",
// span: 2,
// body: "percent",
// width: "15%",
// fn: (d) => ``,
// },
{
head: "Total
Percentage vaccinated",
span: 1,
body: "percent",
width: "15%",
fn: (d) =>
d.toLocaleString(undefined, { maximumFractionDigits: 1, minimumFractionDigits: 1 }) +
"%",
},
{
head: "Total
% Fully vaccinated",
span: 1,
body: "percent2nd",
width: "15%",
fn: (d) =>
d.toLocaleString(undefined, { maximumFractionDigits: 1, minimumFractionDigits: 1 }) +
"%",
},
/*<>
{
head: "Eligible (12+)
Percentage vaccinated",
body: "percent12",
width: "15%",
fn: (d) =>
d.toLocaleString(undefined, { maximumFractionDigits: 1, minimumFractionDigits: 1 }) +
"%",
},
{
head: "Eligible (12+)
% Fully vaccinated",
body: "percent2nd12",
width: "15%",
fn: (d) =>
d.toLocaleString(undefined, { maximumFractionDigits: 1, minimumFractionDigits: 1 }) +
"%",
},
*/
{
head: "Eligible (5+)
Percentage vaccinated",
body: "percent5",
width: "15%",
fn: (d) =>
d.toLocaleString(undefined, { maximumFractionDigits: 1, minimumFractionDigits: 1 }) +
"%",
},
{
head: "Eligible (5+)
% Fully vaccinated",
body: "percent2nd5",
width: "15%",
fn: (d) =>
d.toLocaleString(undefined, { maximumFractionDigits: 1, minimumFractionDigits: 1 }) +
"%",
},
// {
// head: "First doses",
// body: "first",
// width: "12%",
// fn: (d) => d.toLocaleString(),
// },
// {
// head: "Second doses",
// body: "second",
// width: "12%",
// fn: (d) => d.toLocaleString(),
// },
/*
{
head: "Received from manufacturer",
body: "distributed",
width: "10%",
fn: (d) => d.toLocaleString(),
},
*/
{
head: "Third+ doses",
body: "third",
width: "10%",
fn: (d) => d.toLocaleString(),
},
{
head: "Received doses administered",
body: "distPercent",
width: "8%",
fn: (d) =>
d.toLocaleString(undefined, { maximumFractionDigits: 1, minimumFractionDigits: 1 }) +
"%",
},
{
head: "Updated",
body: "date",
width: "6%",
fn: (d) => `${datePadNumToName(d)}`,
},
],
"vaccine-table bottom",
"Canada",
"Canada",
];
const chartSVG = d3.select(".chart-canada").append("svg").attr("class", "svg-chart");
const canadaChart = new Chart(chartSVG, data, "Canada", "percent5");
canadaChart.create();
window.addEventListener("resize", () => {
canadaChart.update();
});
const canadaTableTop = new Table(...topOptions, null, null);
canadaTableTop.update();
const canadaTableTop2 = new Table(...topOptions2, null, null).update();
const canadaTableBottom = new Table(...botOptions, "percent5", canadaChart);
canadaTableBottom.sort();
canadaTableBottom.update();
//
// MAP ///////////////////////////
d3.json("https://beta.ctvnews.ca/content/dam/common/exceltojson/canada_provinces.txt").then((json) => {
d3.select(".map-canada").selectAll(".loading").remove();
json.features.forEach((feature) => {
const province = data.find((prov) => prov.region === feature.properties.name);
feature.datum = province;
});
const container = d3.select(".map-canada");
const width = 750;
const height = 400;
const projection = d3
.geoConicConformal()
.rotate([103, 0])
.fitExtent(
[
[0, -height / 1.3],
[width, height * 1],
],
json
);
const geoGenerator = d3.geoPath().projection(projection);
const mapSVG = container
.append("svg")
.attr("class", "map-svg")
.attr("width", "100%")
.attr("viewBox", `0 0 ${width} ${height}`);
mapSVG
.append("rect")
.attr("width", "100%")
.attr("height", "100%")
.attr("fill", "#ffffff")
.on("click", () => provinceClick({ datum: { region: "Canada" } }));
const mapLayer = mapSVG.append("g").attr("class", "map-layer");
const circleLayer = mapSVG.append("g").attr("class", "circle-layer");
const lineLayer = mapSVG.append("g").attr("class", "line-layer");
const textLayer = mapSVG.append("g").attr("class", "text-layer");
const backButton = mapSVG
.append("g")
.attr("class", "back-button")
.attr("transform", `translate(${width / 2}, ${height - 8})`)
.attr("display", "none");
backButton.on("click", () => provinceClick({ datum: { region: "Canada" } }));
const backRect = backButton
.append("rect")
.attr("width", 140)
.attr("height", 26)
.attr("x", -70)
.attr("y", -18)
.attr("fill", "#fafafa");
const backText = backButton
.append("text")
.attr("class", "back-text bold")
.attr("text-anchor", "middle")
.attr("cursor", "pointer")
.attr("font-size", 12)
.attr("fill", "#222")
.text("< Back to Canada");
const provinceClick = (d) => {
canadaChart.region = d.datum.region;
canadaChart.update();
canadaTableBottom.region = d.datum.region;
canadaTableBottom.update();
if (d.datum.region === "Canada") {
backButton.attr("display", "none");
} else {
backButton.attr("display", "block");
}
};
// PROVINCES SHAPES
//console.log(json.features)
const m = mapLayer.selectAll("path").data(json.features);
m.enter()
.append("path")
.attr("class", (d) => `province ${d.datum.region.split(" ").join("-")}`)
//.attr("id", (d) => `${d.properties.PRENAME.split(" ").join("-")}-path`)
.attr("d", (d) => geoGenerator(d))
.attr("fill", (d) => colorScale(d.datum.percent))
.attr("stroke", "#fff")
.attr("stroke-width", 1)
.on("click", (e, d) => provinceClick(d));
// Adjustment factors for centering text compared to province centroids
const adjust = {
"British Columbia": { x: 0, y: 0 },
Alberta: { x: 0, y: 0 },
Saskatchewan: { x: 0, y: -10 },
Manitoba: { x: 0, y: 0 },
Ontario: { x: 0, y: -8 },
Quebec: { x: 0, y: 0 },
"New Brunswick": { x: -2, y: 42 },
"Nova Scotia": { x: 6, y: 52 },
"Prince Edward Island": { x: 1, y: -22 },
"Newfoundland and Labrador": { x: -10, y: -10 },
Yukon: { x: 0, y: 23 },
"Northwest Territories": { x: 23, y: 63 },
Nunavut: { x: -40, y: 149 },
};
// CIRCLES
const c = circleLayer.selectAll("circle").data(json.features);
c.enter()
.append("circle")
.attr("class", (d) => `map-circle ${d.datum.region.split(" ").join("-")}`)
.attr("cx", (d) => geoGenerator.centroid(d)[0] + adjust[d.properties.name].x - 1)
.attr("cy", (d) => geoGenerator.centroid(d)[1] + adjust[d.properties.name].y)
.attr("r", (d) => (Math.round(d.datum.percent) >= 10 ? 25 : 21))
//.attr("r", (d) => 5 + 3 * Math.max(2, d.datum.first.toString().length))
.on("click", (e, d) => provinceClick(d));
// NUMBERS
const t = textLayer.selectAll("text").data(json.features);
t.enter()
.append("text")
.attr("class", (d) => `map-text ${d.datum.region.split(" ").join("-")}`)
.attr("x", (d) => geoGenerator.centroid(d)[0] + adjust[d.properties.name].x)
.attr("y", (d) => geoGenerator.centroid(d)[1] + adjust[d.properties.name].y + 6)
.html(
(d) =>
d.datum.percent5.toLocaleString(undefined, { maximumFractionDigits: 1, minimumFractionDigits: 1 }) +
`%`
)
//.text(d.datum.first.toLocaleString())
.on("click", (e, d) => provinceClick(d));
// LINES
const lines = [
{ x1: 652.5, y1: 281, x2: 652.5, y2: 296 },
{ x1: 662.5, y1: 313, x2: 662.5, y2: 356 },
{ x1: 623.5, y1: 320, x2: 623.5, y2: 337 },
];
lines.forEach((line) => {
lineLayer
.append("line")
.attr("class", "map-line")
.attr("x1", line.x1)
.attr("y1", line.y1)
.attr("x2", line.x2)
.attr("y2", line.y2);
});
// LEGEND
buildLegend(mapSVG, colorScale, 16, 3, 5, maxScalePercent);
});
});
}