Problem
In all the examples I’ve come across so far, (like this, this or this) I see stacked charts in d3 have an ordinal scale. I presume this is because stacked charts are considered an extension of bar charts which are generally of ordinal scale on the one of the axis.
In my case, using sample data
{"a":[20,10],"b":[50,0],"c":[45,60],"d":[10,40]}
I need to generate a stacked chart with a linear scale x-axis like below.
(The width of each column is a scale of the sum of 2 elements of the array, and the 2 cells in each column represent the values of the array elements as a % of the height. The columns are sorted left to right on sum desc)
I tried to use d3.stack
to generate such a chart using the below code, but this seems a little messy to me, as I’m accessing the data
object of the stack array.
var body = d3.select('body');
var svg = body.append('svg').attr('height',200).attr('width',500);
var maing = svg.append('g');
var inputData = JSON.parse('{"a":[20,10],"b":[50,0],"c":[45,60],"d":[10,40]}')
// generate class
var colors = d3.schemeCategory10
var mysheet = document.styleSheets[0];
Object.keys(inputData).forEach(function(d,i){
mysheet.insertRule(`.${d}{fill:${colors[i]}; opacity:0.8}`);
mysheet.insertRule(`.${d}_h{fill:${colors[i]}; opacity:0.6}`);
});
// convert to flattened array
var vals = Array("p", "h");
var cumT = +0
var transformedData = Object.keys(inputData).map(function(d,i){
let o = {}; o.mp = d;
inputData[d].forEach(function(d,i){o[vals[i]]=d})
o.t=o.p+o.h;
return o;
}).sort(function(a,b){
return b.t - a.t
}).map(function(d,i){
let o = d; o.cumT = cumT += d.t
return o;
})
var stack = d3.stack().keys(vals);
var stackData = stack(transformedData);
var g = maing.selectAll('g')
.data(stackData).enter().append('g')
var x = d3.scaleLinear().domain([0,230]).range([0,100]) // TODO: use d3.max here
var y = d3.scaleLinear().domain([0,1]).range([0,140]) // some predefined height
g.selectAll('rect').data(function(d){return d})
.enter().append('rect')
.attr('x', function(d,i){return x(d.data.cumT-d.data.t)})
.attr('y', function(d,i){return y(d[0]/d.data.t)})
.attr('width', function(d,i){return x(d.data.t)})
.attr('height', function(d,i){return y((d[1]-d[0])/d.data.t) })
//.style('stroke-width', 1).style('stroke', 'black')
.attr('class', function(d){
return d3.select(this.parentNode).datum().key === "p" ? d.data.mp : d.data.mp+"_h";
})
;
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Stack Dev</title>
<style media="screen">
</style>
</head>
<body>
<script src="https://d3js.org/d3.v4.min.js" charset="utf-8"></script>
</body>
</html>
What would be a better way to go about generating something like this? Especially if I have to generate many many small charts like this on a single page?
Solution
Before dealing with your main question here, which concerns the SVG rectangles, here are some advices:
-
Use an ordinal scale for the colors: instead of manipulating
document.styleSheets
and using classes, as you’re doing right now, just create a proper ordinal scale…var colorScale = d3.scaleOrdinal(d3.schemeCategory10);
… and use it to fill your rectangles, with
style("fill", ...)
method. -
Don’t call your scales
x
andy
. Use more descriptive names, likexScale
andyScale
. Doing that, you avoid confusion with thex
andy
methods which are very common in D3, like in line generators or area generators, for instance. -
Use
d3.max
in youryScale
domain:.domain([0, d3.max(transformedData, function(d) { return d.cumT })])
-
The default domain in a D3 linear scale is
([0, 1])
. Therefore, you don’t need to set it in youryScale
.
Back to your main issue:
Using a stack generator is “messy”, according to you. However, “messy” is quite opinion based: for instance, I don’t see anything messy here (except for those classes, which you don’t need if you follow my advice about the color scale).
But if you still feel that using a stack generator creates a messy code here is my solution, using your non-stacked data array. First, pass your data array directly to the <g>
elements as the data:
var g = maing.selectAll('g')
.data(transformedData)
.enter()
.append('g');
Then, create the lower and upper rectangles using a each
:
g.each(function(d, i){
//code here
});
Inside the each you need two selections, one for the upper rectangles and other for the lower rectangles:
//these are the upper rectangles
var upperRect = d3.select(this).append("rect")
.attr("x", function() {
return xScale(d.cumT - d.t)
})
.attr("y", yScale(0))
.attr("width", function() {
return xScale(d.t)
})
.attr("height", function() {
return yScale(d.p / d.t)
})
.style("fill", function() {
return colorScale(i)
})
.style("opacity", .8);
//and these are the lower rectangles
var lowerRect = d3.select(this).append("rect")
.attr("x", function() {
return xScale(d.cumT - d.t)
})
.attr("y", yScale(d.p / d.t))
.attr("width", function() {
return xScale(d.t)
})
.attr("height", function() {
return yScale(d.h / d.t)
})
.style("fill", function() {
return colorScale(i)
})
.style("opacity", .6);
And here is the demo with those modifications:
The colors are different from your snippet because, in your snippet, you’re creating the array first and sorting later. But this is easy to change, if you want:
Finally, have in mind that the stack generator, despite being complicated, is the best approach if you have several rectangles per column. In my solution, I have two selections inside the each
… however, if you had 5 rectangles per column, we would have 5 selections inside the each
, and so on, which is a lot of duplicated code.