class Chart { constructor(selector, dataset, region = "Canada") { this.root = d3.select(selector); this.dataset = dataset.sort((a, b) => a.region.localeCompare(b.region)); this.region = region; this.metric = "total"; //total, new, avg this.strain = ["B117", "B1351", "P1", "B1617", "Omicron", "Screen"]; this.dropdown = {}; this.locked = false; const top = this.root.append("div").attr("class", "dropdowns"); this.dropdown.region = top .append("select") .on("change", () => this.updateRegion(this.dropdown.region.node().value, !this.locked)); this.dataset.forEach((region) => { const op = this.dropdown.region.append("option").attr("value", region.region).text(region.region); if (region.region === this.region) op.attr("selected", true); }); const metrics = [ { short: "total", long: "Total cases" }, { short: "new", long: "New cases" }, { short: "avg", long: "Daily average" }, ]; this.dropdown.metric = top .append("select") .on("change", () => this.updateMetric(this.dropdown.metric.node().value)); metrics.forEach((metric) => { const op = this.dropdown.metric.append("option").attr("value", metric.short).text(metric.long); if (metric.short === this.metric) op.attr("selected", true); }); const strains = [ { short: ["B117", "B1351", "P1", "B1617", "Omicron", "Screen"], long: "Sequenced + Screened" }, { short: ["B117", "B1351", "P1", "B1617", "Omicron"], long: "Sequenced" }, ]; this.dropdown.strains = top.append("select").on("change", () => { this.updateStrains(this.dropdown.strains.node().value.split(",")); }); strains.forEach((strain) => { const op = this.dropdown.strains.append("option").attr("value", strain.short).text(strain.long); if (JSON.stringify(strain.short) === JSON.stringify(this.strain)) op.attr("selected", true); }); this.svg = this.root.append("svg"); this.layers = { background: this.svg.append("g").attr("class", "background"), bars: this.svg.append("g").attr("class", "bars"), overlay: this.svg.append("g").attr("class", "overlay"), tooltip: this.svg.append("g").attr("class", "tooltip"), axes: this.svg.append("g").attr("class", "axes"), legend: this.svg.append("g").attr("class", "legend"), }; this.pad = { top: 20, right: 55, bottom: 30, left: 25, }; this.scale = { y: null, x: null, array: null, }; this.variantTypes = ["B117", "B1351", "P1", "B1617", "Omicron", "Screen"]; this.variantColors = ["#4a3933", "#f0a500", "#e45826", "#A0ACAD", "purple", "#e6d5b8"]; //9EADC8 this.colorScale = d3.scaleOrdinal().domain(this.variantTypes).range(this.variantColors); const legend = top.append("div").attr("class", "variant-legend"); const legendText = { P1: "Gamma", B117: "Alpha", B1351: "Beta", B1617: "Delta", Omicron: "Omicron", Screen: "Screened", }; const legendData = legend .selectAll("div") .data(this.variantTypes, (d) => d) .join("div") .attr("class", "legend-container"); legendData .append("div") .attr("class", "legend-square") .style("background-color", (d) => this.colorScale(d)); legendData .append("div") .attr("class", "legend-text") .text((d) => legendText[d]); this.tooltip = { container: this.layers.tooltip.append("g"), }; this.tooltip.rect = this.tooltip.container.append("rect"); this.tooltip.container.attr("visibility", "hidden"); this.tooltip.textContainer = this.tooltip.container.append("g"); this.tooltip.text = { date: this.tooltip.textContainer .append("text") .attr("y", 18) .attr("font-family", "ÐÇ¿Õ´«Ã½Sans-Bold") .attr("font-size", "12px"), B117: this.tooltip.textContainer.append("text").attr("y", 40).text("B.1.1.7: "), B1351: this.tooltip.textContainer.append("text").attr("y", 58).text("B.1.351: "), P1: this.tooltip.textContainer.append("text").attr("y", 76).text("P.1: "), B1617: this.tooltip.textContainer.append("text").attr("y", 94).text("B.1.617: "), Omicron: this.tooltip.textContainer.append("text").attr("y", 112).text("Omicron: "), Screen: this.tooltip.textContainer.append("text").attr("y", 130).text("Screened: "), }; window.addEventListener("resize", () => this.update(false)); } get data() { return this.dataset.find((d) => d.region === this.region).data.map((d) => d[this.metric]); } roundup(v, partial = false) { const digits = (Math.round(v) + "").length; const n = 10 ** (digits - (partial || digits > 3 ? 1 : 0)); return Math.ceil(v / n) * n; } updateRegion(region, transition = true) { console.log(region); this.region = region; this.dropdown.region.node().value = this.region; this.update(transition); return this; } updateMetric(metric, transition = true) { this.metric = metric; this.update(transition); return this; } updateStrains(strain, transition = true) { this.strain = strain; this.update(transition); return this; } update(transition = true) { // Establish boundaries & scales const { top, right, bottom, left } = this.pad; const { width, height } = this.svg.node().getBoundingClientRect(); const stack = d3.stack().keys(this.strain).order(d3.stackOrderNone).offset(d3.stackOffsetNone); const data = stack(this.data); const [min, max] = d3.extent(data.flat().flat()); //const roundMax = Math.max(this.roundup(max, true), 10); // Round to nearest 20, 400, 2000, etc const roundMax = Math.max(this.roundup(max), 10); // Round to 10, 100, 1000, etc (or 2000, 3000 if >1000) const x = d3 .scaleLinear() .domain([0, this.data.length]) .range([left, width - right]); let y = d3 .scaleLinear() .domain([/*Math.min(0, min)*/ 0, roundMax]) .range([height - bottom, top]); if (!this.scale.y) this.scale.y = y; if (max === 0) y = this.scale.y; const [delay, dur] = transition ? [750, 750] : [0, 0]; this.locked = true; window.setTimeout(() => (this.locked = false), delay + dur); const bars = (layer, s0, s1, data) => { layer .selectAll("g") .data(data, (d) => d.key) .join("g") .attr("fill", (d) => this.colorScale(d.key)) .selectAll("rect") .data( (d) => d, (d, i) => i ) .join( (enter) => enter .append("rect") .attr("x", (d, i) => x(i)) .attr("width", (d, i) => x(i + 1) - x(i)) .attr("y", (d) => s0(d[0])) .attr("height", 0) .attr("opacity", 1) .call((enter) => enter .transition() .duration(delay) .attr("y", (d) => s0(d[1])) .attr("height", (d) => Math.max(0, s0(d[0]) - s0(d[1]))) .transition() .duration(dur) .attr("y", (d) => s1(d[1])) .attr("height", (d) => Math.max(0, s1(d[0]) - s1(d[1]))) ), (update) => update.call((update) => update .transition() .duration(delay) .attr("y", (d) => s0(d[1])) .attr("height", (d) => Math.max(0, s0(d[0]) - s0(d[1]))) .transition() .duration(dur) .attr("x", (d, i) => x(i)) .attr("width", (d, i) => x(i + 1) - x(i)) .attr("y", (d) => s1(d[1])) .attr("height", (d) => Math.max(0, s1(d[0]) - s1(d[1]))) ), (exit) => exit.call((exit) => exit .transition() .duration(dur) .delay(delay) .attr("y", (d) => s1(d[1])) .attr("height", (d) => 0) .attr("opacity", 0) .remove() ) ); }; const highlight = (e, layer) => { e.target.style.opacity = 0.1; }; const unhighlight = (e, layer) => { layer.selectAll("rect").style("opacity", 0); }; const hover = (e, d) => { this.tooltip.container.attr("visibility", "visible"); this.tooltip.text.B117.text(`B.1.1.7 — ${d.B117.toLocaleString(undefined, { maximumFractionDigits: 1 })}`); this.tooltip.text.B1351.text(`B.1.351 — ${d.B1351.toLocaleString(undefined, { maximumFractionDigits: 1 })}`); this.tooltip.text.P1.text(`P.1 — ${d.P1.toLocaleString(undefined, { maximumFractionDigits: 1 })}`); this.tooltip.text.B1617.text(`B.1.617 — ${d.B1617.toLocaleString(undefined, { maximumFractionDigits: 1 })}`); this.tooltip.text.Omicron.text(`Omicron — ${d.Omicron.toLocaleString(undefined, { maximumFractionDigits: 1 })}`); this.tooltip.text.Screen.text(`Screened — ${d.Screen.toLocaleString(undefined, { maximumFractionDigits: 1 })}`); this.tooltip.text.date.text(d.date); const { width, height } = this.tooltip.textContainer.node().getBBox(); this.tooltip.rect .attr("width", width + 16) .attr("height", height + 10) .attr("x", -8); this.tooltip.container.attr( "transform", `translate(${e.offsetX + 8 - (width + 16) / 2}, ${e.offsetY - height - 15})` ); }; const unhover = (e, d) => { this.tooltip.container.attr("visibility", "hidden"); }; const hoverBars = (layer, data) => { layer .selectAll("rect") .data(data, (d, i) => i) //TODO: Use date as key instead of index .join( (enter) => enter .append("rect") .attr("x", (d, i) => x(i)) .attr("width", (d, i) => x(i + 1) - x(i)) .attr("y", top - 1) .attr("height", height - top - bottom) .on("mousemove", (e, d) => { highlight(e, layer); hover(e, d); }) .on("mouseout", (e, d) => { unhighlight(e, layer); unhover(e, d); }), (update) => update.call((update) => update .transition() .duration(delay) .transition() .duration(dur) .attr("x", (d, i) => x(i)) .attr("width", (d, i) => x(i + 1) - x(i)) ), (exit) => exit.call((exit) => exit.transition().duration(dur).delay(delay).remove()) ); }; //TODO: LEGEND //TODO: TOOLTIP // X Axis const labelInterval = Math.floor((100 * this.data.length) / width); // Ticks this.layers.axes .selectAll(".tick") .data(this.data, (d, i) => i) //TD: Use date as key instead of index .join( (enter) => enter .append("line") .attr("class", (d, i) => `tick ${i % labelInterval === 0 ? "major" : ""}`) .attr("x1", (d, i) => x(i) + (x(i + 1) - x(i)) / 2) .attr("x2", (d, i) => x(i) + (x(i + 1) - x(i)) / 2) .attr("y1", height - bottom) .attr("y2", (d, i) => height - bottom + (i % labelInterval === 0 ? 5 : 3)), (update) => update.call((update) => update .transition() .duration(delay) .transition() .duration(dur) .attr("x1", (d, i) => x(i) + (x(i + 1) - x(i)) / 2) .attr("x2", (d, i) => x(i) + (x(i + 1) - x(i)) / 2) .attr("y2", (d, i) => height - bottom + (i % labelInterval === 0 ? 5 : 3)) ), (exit) => exit.call((exit) => exit.transition().duration(dur).delay(delay).remove()) ); // Labels this.layers.axes .selectAll("text") .data(this.data, (d, i) => i) //TD: Use date as key instead of index .join( (enter) => enter .append("text") .attr("class", (d, i, arr) => `x-axis text`) .attr("x", (d, i) => x(i) + (x(i + 1) - x(i)) / 2) .attr("y", height - bottom + 17) .style("display", (d, i, arr) => (i % labelInterval === 0 ? "inline" : "none")) .text((d) => d.date), (update) => update.call((update) => update .transition() .duration(delay) .transition() .duration(dur) .style("display", (d, i, arr) => (i % labelInterval === 0 ? "inline" : "none")) .attr("x", (d, i) => x(i) + (x(i + 1) - x(i)) / 2) ), (exit) => exit.call((exit) => exit.transition().duration(dur).delay(delay).remove()) ); // Y Axis const numAx = 5; let yArray = Array.from(new Array(numAx + 1)) .map((d, i) => (i * roundMax) / numAx) .filter((d) => Number.isInteger(d * 2) || Number.isInteger(d * 4)); if (!this.scale.array) this.scale.array = yArray; if (max === 0) yArray = this.scale.array; const lines = (layer, scale1, scale2, array) => { layer .selectAll("line") .data(array, (d) => d) .join( (enter) => enter .append("line") .attr("x1", left) .attr("x2", width - right + 5) .attr("y1", (d) => scale1(d)) .attr("y2", (d) => scale1(d)) .attr("class", (d) => `axis-line ${d === 0 ? "zero" : ""}`) .style("opacity", 0) .call((enter) => enter .transition() .duration(dur) .delay(delay) .attr("y1", (d) => scale2(d)) .attr("y2", (d) => scale2(d)) .style("opacity", 1) ), (update) => update.call((update) => update .attr("y1", (d) => scale1(d)) .attr("y2", (d) => scale1(d)) .transition() .duration(dur) .delay(delay) .attr("y1", (d) => scale2(d)) .attr("y2", (d) => scale2(d)) .attr("x1", left) .attr("x2", width - right + 5) ), (exit) => exit.call((exit) => exit .transition() .duration(dur) .delay(delay) .attr("y1", (d) => scale2(d)) .attr("y2", (d) => scale2(d)) .style("opacity", 0) .remove() ) ); }; const text = (layer, scale1, scale2, array) => { layer .selectAll("text") .data(array, (d) => d) .join( (enter) => enter .append("text") .attr("x", (d, i) => width - right + 5 + 4) .attr("y", (d) => scale1(d) + 3) .attr("class", (d, i, arr) => `y-axis axis-text ${i === arr.length - 1 ? "black" : ""}`) .style("opacity", 0) .text((d) => d.toLocaleString()) .call((enter) => enter .transition() .duration(dur) .delay(delay) .attr("y", (d) => scale2(d) + 3) .style("opacity", 1) ), (update) => update.call((update) => update .attr("y", (d) => scale1(d) + 3) .transition() .duration(dur) .delay(delay) .attr("y", (d) => scale2(d) + 3) .attr("x", (d, i) => width - right + 5 + 4) ), (exit) => exit.call((exit) => exit .transition() .duration(dur) .delay(delay) .attr("y", (d) => scale2(d) + 4) .style("opacity", 0) .remove() ) ); }; bars(this.layers.bars, this.scale.y, y, data); hoverBars(this.layers.overlay, this.data); //ticks(this.layers.axes, this.data); lines(this.layers.background, this.scale.y, y, yArray); text(this.layers.background, this.scale.y, y, yArray); this.scale.y = y; this.scale.x = x; this.scale.array = yArray; return this; } }