3
\$\begingroup\$

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.

Stack chart (or is it one?)

(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?

\$\endgroup\$
3
\$\begingroup\$

Before dealing with your main question here, which concerns the SVG rectangles, here are some advices:

  1. 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.

  2. Don't call your scales x and y. Use more descriptive names, like xScale and yScale. Doing that, you avoid confusion with the x and y methods which are very common in D3, like in line generators or area generators, for instance.

  3. Use d3.max in your yScale domain:

    .domain([0, d3.max(transformedData, function(d) {
        return d.cumT
    })])
    
  4. The default domain in a D3 linear scale is ([0, 1]). Therefore, you don't need to set it in your yScale.

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:

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 colorScale = d3.scaleOrdinal(d3.schemeCategory10);

// 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 g = maing.selectAll('g')
  .data(transformedData)
  .enter()
  .append('g')

var xScale = d3.scaleLinear()
  .domain([0, d3.max(transformedData, function(d) {
    return d.cumT
  })])
  .range([0, 100]);
// TODO: use d3.max here
var yScale = d3.scaleLinear()
  .range([0, 140]) // some predefined height

g.each(function(d, i) {
  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);

  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);
})
<script src="https://d3js.org/d3.v4.min.js" charset="utf-8"></script>

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:

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 colorScale = d3.scaleOrdinal(d3.schemeCategory10);

// 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;
});

colorScale.domain(Object.keys(inputData));

var g = maing.selectAll('g')
  .data(transformedData)
  .enter()
  .append('g')

var xScale = d3.scaleLinear()
  .domain([0, d3.max(transformedData, function(d) {
    return d.cumT
  })])
  .range([0, 100]);
// TODO: use d3.max here
var yScale = d3.scaleLinear()
  .range([0, 140]) // some predefined height

g.each(function(d, i) {
  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(d) {
      return colorScale(d.mp)
    })
    .style("opacity", .8);

  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(d) {
      return colorScale(d.mp)
    })
    .style("opacity", .6);
})
<script src="https://d3js.org/d3.v4.min.js"></script>

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.

\$\endgroup\$
  • \$\begingroup\$ Thanks for all the suggestions! Really appreciate you taking the time to explain them. You're right about "messy". I guess I just felt it was messy, as I haven't done any stacked charts (or for that matter any charts!) in d3 before. I think the reason I felt it was messy was because I was going to the "data" object frequently to obtain the values to achieve my goal. As I'm getting used to the stacked charts, going to "data" doesn't feel all that bad, and it doesn't feel as messy as before. \$\endgroup\$ – mrbrahman Nov 24 '17 at 15:32
  • \$\begingroup\$ Don't worry about using d.data, it's there to be used. Several generators create a data object, like the stack generator, the hierarchy generator, the pie generator etc. There is nothing wrong about using it. \$\endgroup\$ – Gerardo Furtado Nov 25 '17 at 4:32

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.