Reputation: 482
I am going to make this a bit verbose for two reasons:
Therefore this might be a bit of a write-up, but I am genuinely looking to understand the answer to my problem.
I want to create a dynamic stacked horizontal bar chart that visualises the different stages of single transferable voting processes (in local elections in Scotland to be precise).
This is very much akin to Bostock's Stacked Bar Chart, Horizontal showing the population of US states broken down into age groups.
Not surprisingly Bostock uses D3's stack generator which I was disappointed to discover organises SVG rectangles into groupings (svg g elements) across the Y axis. Take the following experiment which is based on Bostock's Stacked Bar Chart, Horizontal example:
const data = [
{month: "Jan", apples: 3840, bananas: 1920, cherries: 960, dates: 400},
{month: "Feb", apples: 1600, bananas: 1440, cherries: 960, dates: 400},
{month: "March", apples: 640, bananas: 960, cherries: 640, dates: 400},
{month: "Apr", apples: 3120, bananas: 1480, cherries: 640, dates: 400}
];
The above data is pivoted using the same method as in Bostock's example and passed to StackedBarChart()
which I have added a transition to resulting in the following being rendered:
In the example above the data binding is not by month, but by fruits.
The final state is fine but dynamic (transitioned) data changes will be hard.
This is an area of some complexity. I unashamedly confess I don't understand Bostock's use of stacks, taken from his above mentioned example:
// Compute a nested array of series where each series is [[x1, x2], [x1, x2],
// [x1, x2], …] representing the x-extent of each stacked rect. In addition,
// each tuple has an i (index) property so that we can refer back to the
// original data point (data[i]). This code assumes that there is only one
// data point for a given unique y- and z-value.
const series = d3.stack()
.keys(zDomain)
.value(([, I], z) => X[I.get(z)])
.order(order)
.offset(offset)
(d3.rollup(I, ([i]) => i, i => Y[i], i => Z[i]))
.map(s => s.map(d => Object.assign(d, {i: d.data[1].get(s.key)})));
Perhaps someone can shine further light on the above. Perhaps I am not understanding something about stacks (very likely ;). Perhaps if the chart were vertical and not horizontal things would be easier? Don't know.
I decided to abandon D3 Stacks and instead turn to data joining nested data, kind of going back to basics.
Repeated readings of Bostock's almost decade old post on the general update pattern are helpful along with reading about the newish join method, intended to further abstract the general update pattern.-*
The following code is an adaptation of the documentation nested data join example (just make sure you load the d3 library somewhere):
const svg = d3.select("body")
.append("svg");
function doD3() {
svg
.selectAll("g")
.data(generate, (d) => d[0].id.split("_")[0])
.join("g")
.attr("transform", (d, i) => "translate(0, " + i * 30 + ")")
.selectAll("text")
.data(d => d)
.join(enter => enter
.append("text").attr("fill", "green"),
update => update,
exit => exit.remove())
.attr("x", (d, i) => i * 50)
.text(d => d.value);
}
function generateRow(rowId, cols) {
let row = [];
for(let i = 0; i < cols; i++) {
row.push({"id": rowId + "_" + i, "value": highRandom()});
}
return row;
}
const lowRandom = d3.randomInt(3, 15);
const highRandom = d3.randomInt(1e2, 1e5);
function generate() {
const cols = lowRandom();
const rows = lowRandom();
let matrix = [];
for(let i = 0; i < rows; i++) {
matrix.push(generateRow(i, cols));
}
return matrix;
}
window.setInterval(doD3, 2000);
Primitive perhaps but intended to demonstrate successful data binding with a key function. The generate
and generateRow
functions together generate random matrices of objects with an id and value.
This is called every two seconds. All nodes (texts) are rendered on enter. What am I not getting right?
-* Unfortunately I cannot post links to JS Fiddle as the most recent D3 version supported is version 5 and I am (perhaps unnecessarily) using the most recent version, 7. Bostock has started using a platform that allows experimentation called Observable but I find to be confusing.
Upvotes: 0
Views: 3076
Reputation: 1571
Here is an example that uses the fruits dataset. The chart is animated so that the bars for one fruit are revealed at a time. I do this by giving the bars for each fruit a different transition delay.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script src="https://d3js.org/d3.v7.js"></script>
</head>
<body>
<div id="chart"></div>
<script>
// set up
const margin = { top: 10, right: 10, bottom: 20, left: 40 };
const width = 300 - margin.left - margin.right;
const height = 200 - margin.top - margin.bottom;
const svg = d3.select('#chart')
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// data
const data = [
{month: "Jan", apples: 3840, bananas: 1920, cherries: 960, dates: 400},
{month: "Feb", apples: 1600, bananas: 1440, cherries: 960, dates: 400},
{month: "March", apples: 640, bananas: 960, cherries: 640, dates: 400},
{month: "Apr", apples: 3120, bananas: 1480, cherries: 640, dates: 400}
];
const fruit = Object.keys(data[0]).filter(d => d != "month");
const months = data.map(d => d.month);
const stackedData = d3.stack()
.keys(fruit)(data);
const xMax = d3.max(stackedData[stackedData.length - 1], d => d[1]);
// scales
const x = d3.scaleLinear()
.domain([0, xMax]).nice()
.range([0, width]);
const y = d3.scaleBand()
.domain(months)
.range([0, height])
.padding(0.25);
const color = d3.scaleOrdinal()
.domain(fruit)
.range(d3.schemeTableau10);
// axes
const xAxis = d3.axisBottom(x).ticks(5, '~s');
const yAxis = d3.axisLeft(y);
svg.append('g')
.attr('transform', `translate(0,${height})`)
.call(xAxis)
.call(g => g.select('.domain').remove());
svg.append("g")
.call(yAxis)
.call(g => g.select('.domain').remove());
// draw bars
// create one group for each fruit
const layers = svg.append('g')
.selectAll('g')
.data(stackedData)
.join('g')
.attr('fill', d => color(d.key));
// transition for bars
const duration = 1000;
const t = d3.transition()
.duration(duration)
.ease(d3.easeLinear);
layers.each(function(_, i) {
// this refers to the group for a given fruit
d3.select(this)
.selectAll('rect')
.data(d => d)
.join('rect')
.attr('x', d => x(d[0]))
.attr('y', d => y(d.data.month))
.attr('height', y.bandwidth())
.transition(t)
// i is the index of this fruit.
// this will give the bars for each fruit a different delay
// so that the fruits will be revealed one at a time.
// using .each() instead of a normal data join is needed
// so that we have access to what fruit each bar belongs to.
.delay(i * duration)
.attr('width', d => x(d[1]) - x(d[0]));
});
</script>
</body>
</html>
Upvotes: 4