UNPKG

d3-project-template

Version:
656 lines (536 loc) 18.5 kB
import { select } from 'd3-selection'; import 'd3-transition'; import * as d3All from 'd3'; import * as d3Group from 'd3-array'; const d3 = Object.assign({}, d3Group, d3All); class BarChartRace { constructor(props) { var params = Object.assign({ container: document.body, width: 0, // calculated dynamically if zero height: 450, margin: { top: 15, right: 15, bottom: 15, left: 0 }, data: null, labelsWidth: 200, transitionTime: 300, barSize: 45, n: 12 }, props); this.params = params; this.container = select(params.container || 'body'); this.k = 10; this.colors = { value: '#cf0000', confirmed: '#ffc33b', recovered: '#26C281', deaths: '#cf0000' }; this.drag = d3.drag().on('start drag', () => { var slider = document.getElementById('slider_line'); var w = slider.getBoundingClientRect().width; var x = d3.mouse(slider)[0]; this.dragIt(Math.max(0, Math.min(x, w))); }); this.formatDate = d3.timeFormat("%B %d, %Y"); this.setChartDimensions(); } setChartDimensions() { let { width, height, margin, barSize, n } = this.params; if (!width) { var w = this.container.node().getBoundingClientRect().width; if (w) { width = w; this.params.width = w; } } if (width < 650) { this.params.labelsWidth = 120; } this.params.height = margin.top + margin.bottom + n * barSize; this.chartWidth = width - margin.left - margin.right; this.chartHeight = height - margin.top - margin.bottom; } renderContainers() { const { width, height, margin } = this.params; this.svg = this.container .append('svg') .attr('width', width) .attr('height', height); this.chart = this.svg .append('g') .attr('class', 'chart') .attr('transform', `translate(${margin.left}, ${margin.top})`); } axis() { const g = this.chart.append("g") .attr("transform", `translate(${this.params.labelsWidth})`); const axis = d3.axisTop(this.x) .ticks(this.chartWidth / 160) .tickSizeOuter(0) // .tickSizeInner(-this.params.barSize * (this.params.n + this.y.padding())); return (_, transition) => { g.transition(transition).call(axis); g.select(".tick:first-of-type text").remove(); g.selectAll(".tick:not(:first-of-type) line").attr("stroke", "white"); g.select(".domain").remove(); }; } ticker() { const now = d3.select('#ticker').text(this.formatDate(this.keyframes[0][0])); var format = d3.timeFormat("%B %d"); var extentDate = d3.extent(this.chartData, d => d.date); d3.select('#time_slider .edge-years').html(` <div>${format(extentDate[0])}</div> <div>${format(extentDate[1])}</div> `) var sliderTick = d3.select('#slider_tick'); var sliderWidth = d3.select('#slider_line').node().getBoundingClientRect().width; var self = this; d3.select('#slider_tick').call(this.drag); d3.select('#slider_line').on('click', function() { var x = d3.mouse(this)[0]; self.dragIt(Math.max(0, Math.min(x, sliderWidth))); }) var sliderScale = d3.scaleBand().range([0, sliderWidth]).domain(this.keyframes.map((_, i) => i)); this.xScale = d3.scaleLinear().range([0, sliderWidth]).clamp(true).domain([0, this.keyframes.length - 1]); this.sliderScale = sliderScale; return ([date], transition, frameIndex) => { now.text(this.formatDate(date)); var left = sliderScale(frameIndex) + sliderScale.bandwidth() / 2; sliderTick // .transition(transition) .style('left', left + 'px') }; } textTween(a, b) { const i = d3.interpolateNumber(a, b); var formatNumber = d3.format(",d"); return function (t) { this.textContent = formatNumber(i(t)); }; } labels() { let label = this.chart.append("g") .style("font", "bold 12px var(--sans-serif)") .style("font-variant-numeric", "tabular-nums") .attr("text-anchor", "start") .selectAll("text"); let { prev, next, textTween, y } = this; return ([, data], transition) => label = label .data(data.slice(0, this.params.n), d => d.name) .join( enter => enter.append("text") .attr("transform", d => `translate(0, ${y((prev.get(d) || d).rank)})`) .attr("y", y.bandwidth() / 2) .attr("x", 0) .attr("dy", "-0.25em") .text(d => d.name) .call(text => text.append("tspan") .attr("fill-opacity", 0.7) .attr("font-weight", "normal") .attr("x", 0) .attr("dy", "1.15em")), update => update, exit => exit.transition(transition).remove() .attr("transform", d => `translate(0,${y((next.get(d) || d).rank)})`) .call(g => g.select("tspan").tween("text", d => textTween(d.value, (next.get(d) || d).value))) ) .call(bar => bar.transition(transition) .attr("transform", d => `translate(0,${y(d.rank)})`) .call(g => g.select("tspan").tween("text", d => textTween((prev.get(d) || d).value, d.value)))) } bars() { let bar = this.chart .append("g").attr('transform', `translate(${this.params.labelsWidth})`) .selectAll('g') let { prev, next, x, y } = this; return ([date, data], transition) => { var dataset = data.slice(0, this.params.n).map(d => { var dt = [ 'deaths', 'recovered', 'confirmed' ].map(x => { return { category: x, value: d[x] } }); var wholeWidth = x(d.value); var scale = d3.scaleLinear().domain([0, d3.sum(dt, d => d.value)]).range([0, wholeWidth]); var cumSums = d3.cumsum(dt, d => d.value); dt.forEach((m, i) => { m.width = scale(m.value); m.x = i == 0 ? 0 : scale(cumSums[i - 1]) }) return { ...d, subBars: dt } }); // bar groups bar = bar .data(dataset, d => d.name) .join( enter => enter.append('g').attr('transform', d => `translate(${x(0)}, ${y((prev.get(d) || d).rank)})`), update => update, exit => exit.transition(transition).remove() .attr("transform", d => `translate(${x(0)}, ${y((next.get(d) || d).rank)})`) ) .call(bar => bar.transition(transition) .attr("transform", d => `translate(${x(0)}, ${y((next.get(d) || d).rank)})`) ); // bar rects bar.selectAll('rect').data(d => d.subBars, d => d.category) .join( enter => enter.append('rect').attr('fill', d => { return this.colors[d.category]; }).attr('height', y.bandwidth()).attr('x', d => d.x).attr('width', d => d.width), update => update, exit => exit ) .call(bars => bars.transition(transition).attr('x', d => d.x).attr('width', d => d.width)) } } addLegend() { var legendItems = ['deaths', 'recovered', 'confirmed']; d3.select('#legend').html( legendItems.map(item => ` <div class="legend-item"> <div class="rect" style="background-color: ${this.colors[item]}"></div> <div class="text"> <div class="label">${item}</div> <div class="value">0</div> </div> </div> `).join('') ) return ([, data], transition, frameIndex) => { var _data = legendItems.map(key => { return { category: key, value: d3.sum(data, d => d[key] || 0), prevValue: frameIndex ? d3.sum(this.keyframes[frameIndex - 1][1], d => (d[key] || 0)) : 0 } }); d3.selectAll('#legend .value').data(_data) .transition(transition).tween('text', (d) => { const i = d3.interpolateNumber(d.prevValue, d.value); var formatNumber = d3.format(",d"); return function (t) { this.innerHTML = formatNumber(i(t)); }; }) } } animate(params) { params = Object.assign({ autostart: false, drawFirstFrame: true }, params); var frameDuration = this.params.transitionTime; var ticker, index = 0, state = 'stopped'; var start = () => { ticker = setInterval(() => { this.animateFrame(this.keyframes[index], frameDuration); index++; if (index >= this.keyframes.length) { clearInterval(ticker); index = this.keyframes.length - 1; app.startPauseAnimation(); } }, frameDuration); state = 'running'; } var stop = () => { state = 'stopped' if (ticker) clearInterval(ticker); } if (params.autostart) { start(); } else if (params.drawFirstFrame) { this.animateFrame(this.keyframes[0], frameDuration); index++; } return { start: () => { start(); }, reset: (drawFirstFrame) => { index = 0; stop(); if (drawFirstFrame) { this.animateFrame(this.keyframes[0], frameDuration * 1.5); index++; } }, stop: () => { stop(); }, state: () => { return state; }, currentFrameIndex: () => { return Math.max(0, Math.min(index, this.keyframes.length - 1)); }, setIndex: (ind) => { if (ind > 0 && ind < this.keyframes.length) { index = ind; } } } } animateFrame(keyframe, duration) { var frameIndex = this.keyframes.indexOf(keyframe); const transition = d3.transition() .duration(duration) .ease(d3.easeLinear); // Extract the top bar’s value. this.x.domain([0, keyframe[1][0].value]); // this.updateAxis(keyframe, transition); this.updateBars(keyframe, transition); this.updateLabels(keyframe, transition); this.updateTicker(keyframe, transition, frameIndex); this.updateLegend(keyframe, transition, frameIndex); } async draw() { this.updateBars = this.bars(); // this.updateAxis = this.axis(); this.updateLegend = this.addLegend(); this.updateLabels = this.labels(); this.updateTicker = this.ticker(); this.animation = this.animate({ autostart: true }) } dragged(value) { var xScale = this.xScale; var x = xScale.invert(value), index = null, midPoint, cx, xVal; var rangeValues = this.sliderScale.domain(); for (var i = 1; i < rangeValues.length; i++) { if (x >= rangeValues[i - 1] && x <= rangeValues[i]) { index = i; break; } } midPoint = (rangeValues[index] + rangeValues[index + 1]) / 2; if (x < midPoint) { cx = xScale(rangeValues[index]); xVal = rangeValues[index]; } else { cx = xScale(rangeValues[index + 1]); xVal = rangeValues[index + 1]; } return { index, x: cx, value: xVal } } dragIt(x) { var res = this.dragged(x); if (res && res.index && this.keyframes[res.index]) { var animationState = this.animation.state(); if (animationState == 'running') { window.app.startPauseAnimation(); } this.animation.setIndex(res.index); this.animateFrame(this.keyframes[res.index], 0, true) } } updateTopN(topN) { this.params.n = topN; this.setChartDimensions(); this.svg.attr('height', this.params.height); } toChartData(confirmed, recovered, deaths) { var cases = {}; var timeParse = d3.timeParse('%m/%d/%y'); var dates = this.dates; confirmed.forEach(d => { var state = d['Province/State']; var country = d['Country/Region']; dates.forEach((date, i) => { var key = `${country}_${state}_${date}`; var value = d[date]; if (d[date] === null && i > 0) { value = d[dates[i - 1]] || 0; } if (cases[key]) { cases[key].confirmed = value } else { cases[key] = { country, date: timeParse(date), dateStr: date, state, confirmed: value } } }); }); recovered.forEach(d => { var state = d['Province/State']; var country = d['Country/Region']; dates.forEach((date, i) => { var key = `${country}_${state}_${date}`; var value = d[date]; if (d[date] === null && i > 0) { value = d[dates[i - 1]] || 0; } if (cases[key]) { cases[key].recovered = value; } else { cases[key] = { country, date: timeParse(date), dateStr: date, state, recovered: value } } }); }); deaths.forEach(d => { var state = d['Province/State']; var country = d['Country/Region']; dates.forEach((date, i) => { var key = `${country}_${state}_${date}`; var value = d[date]; if (d[date] === null && i > 0) { value = d[dates[i - 1]] || 0; } if (cases[key]) { cases[key].deaths = value } else { cases[key] = { country, date: timeParse(date), dateStr: date, state, deaths: value } } }); }); var dataArray = Object.values(cases).map(d => { return { ...d, name: d.state || d.country, value: d.confirmed } }); var groups = Array.from(d3.group(dataArray, d => d.country, d => d.dateStr)); var dataArr = []; groups.forEach(group => { var dateMap = group[1]; dates.forEach(date => { var arr = dateMap.get(date); var sumConfirmed = d3.sum(arr, d => d.confirmed); var sumDeaths = d3.sum(arr, d => d.deaths); var sumRecovered = d3.sum(arr, d => d.recovered); dataArr.push({ date: timeParse(date), country: group[0], value: sumConfirmed, confirmed: sumConfirmed, deaths: sumDeaths, recovered: sumRecovered, name: group[0] }) }) }) return dataArr; } processData(datasets) { var { confirmed, recovered, deaths, columns } = datasets; var k = this.k; this.dates = columns.filter(d => ['Province/State', 'Country/Region', 'Lat', 'Long'].indexOf(d) == -1); var chartData = this.toChartData(confirmed, recovered, deaths); this.names = new Set(chartData.map(d => d.name)); var datevalues = Array.from(d3.rollup(chartData, ([d]) => d, d => +d.date, d => d.name)) .map(([date, data]) => [new Date(date), data]) .sort(([a], [b]) => d3.ascending(a, b)) const keyframes = []; let ka, a, kb, b; for ([[ka, a], [kb, b]] of d3.pairs(datevalues)) { for (let i = 0; i < k; ++i) { const t = i / k; keyframes.push([ new Date(ka * (1 - t) + kb * t), this.rank( name => (a.get(name) ? a.get(name).value : 0) * (1 - t) + (b.get(name) ? b.get(name).value : 0) * t, name => (a.get(name) ? a.get(name).deaths : 0) * (1 - t) + (b.get(name) ? b.get(name).deaths : 0) * t, name => (a.get(name) ? a.get(name).recovered : 0) * (1 - t) + (b.get(name) ? b.get(name).recovered : 0) * t, ) ]); } } keyframes.push([ new Date(kb), this.rank( name => b.get(name) ? b.get(name).value : 0, name => b.get(name) ? b.get(name).deaths : 0, name => b.get(name) ? b.get(name).recovered : 0, ) ]); var nameframes = d3.groups(keyframes.flatMap(([, data]) => data), d => d.name) var prev = new Map(nameframes.flatMap(([, data]) => d3.pairs(data, (a, b) => [b, a]))) var next = new Map(nameframes.flatMap(([, data]) => d3.pairs(data))) return { keyframes, nameframes, prev, next, chartData } } rank(value, deaths, recovered) { const data = Array.from(this.names, name => { var allCases = value(name) || 0; var deathCases = deaths(name) || 0; var recoveredCases = recovered(name) || 0; var confirmedCases = allCases ? allCases - deathCases - recoveredCases : 0; return { name: name, value: allCases, deaths: deathCases, recovered: recoveredCases, confirmed: confirmedCases } }) data.sort((a, b) => d3.descending(a.value, b.value)); for (let i = 0; i < data.length; ++i) data[i].rank = i; return data; } render() { var resp = this.processData(this.params.data); this.keyframes = resp.keyframes; this.nameframes = resp.nameframes; this.prev = resp.prev; this.next = resp.next; this.chartData = resp.chartData; this.x = d3.scalePow([0, 1], [0, this.chartWidth - this.params.labelsWidth]).exponent(0.45); this.y = d3.scaleBand() .domain(d3.range(this.names.size + 1)) .rangeRound([0, this.params.barSize * (this.names.size + 1 + 0.1)]) .padding(0.1) var dropdown = Math.floor(this.names.size / 12); var el = document.getElementById('top_n_select'); el.innerHTML = ``; for (let i = 1; i <= dropdown; i++) { el.innerHTML += `<option value="${i * 12}">1-${i * 12}</option>` } if (this.names.size - dropdown * 12 > 0) { el.innerHTML += `<option value="${this.names.size}">1-${this.names.size}</option>` } this.renderContainers(); this.draw(); this.addLegend(); select('.slider').style('visibility', 'visible') } } export default BarChartRace;