d3-project-template
Version:
Template for d3 project
656 lines (536 loc) • 18.5 kB
JavaScript
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;