nanyaks nanyaks - 4 months ago 14
Javascript Question

Grouped stack chart with D3.js

I'm very new to D3 and I've run into a bit of a problem. Wondering if someone could help.

I'm trying to create a grouped stack chart using d3. The nature of the graph is such that every group has 2 bars and the value of the second depends on the first bar. I want the second bar to be a breakdown of what I have on the first bar. A simple illustration would be if the value on the first bar is say

{x: 0, y: 3, y0: 0}
, the second bar should be
{x: 0, y: 1, y0: 0}, {x: 0, y: 1, y0: 1}, {x: 0, y: 1, y0: 2}


So for data that would be plotted for the first bar chart:

{
"series": "A",
"values": [{
"x": 0,
"y": 1,
},
{
"x": 1,
"y": 2,
},
{
"x": 2,
"y": 3,
},
{
"x": 3,
"y": 1,
},
{
"x": 4,
"y": 3,
}
]},
{
"series": "B",
"values": [{
"x": 0,
"y": 3,
},
{
"x": 1,
"y": 1,
},
{
"x": 2,
"y": 1,
},
{
"x": 3,
"y": 5,
},
{
"x": 4,
"y": 1,
}]
}


I would have these values for the second stacked bar-chart:

{
"series": "A",
"values":
[ { x: 0, y: 1, y0: 0 },
{ x: 1, y: 1, y0: 0 },
{ x: 1, y: 1, y0: 1 },
{ x: 2, y: 1, y0: 0 },
{ x: 2, y: 1, y0: 1 },
{ x: 2, y: 1, y0: 2 },
{ x: 3, y: 1, y0: 0 },
{ x: 4, y: 1, y0: 0 },
{ x: 4, y: 1, y0: 1 },
{ x: 4, y: 1, y0: 2 }]
},
{
"series": "B",
"values":
[
{ x: 0, y: 1, y0: 1 },
{ x: 0, y: 1, y0: 2 },
{ x: 0, y: 1, y0: 3 },
{ x: 1, y: 1, y0: 1 },
{ x: 2, y: 1, y0: 1 },
{ x: 3 y: 1, y0: 1 },
{ x: 3, y: 1, y0: 2 },
{ x: 3, y: 1, y0: 3 },
{ x: 3, y: 1, y0: 4 },
{ x: 3, y: 1, y0: 5 },
{ x: 4, y: 1, y0: 1 },
]
}


I used some of the code I could find from the examples I saw and tried to make it work. this is what I've been able to do so far:

See illustration

I would appreciate any help. Thanks

See screenshot of what I want to achieve.
expected-graph

Answer

Here is a fiddle I have put together using your data you provided : https://jsfiddle.net/thatoneguy/nrjt15aq/8/

Data :

var data = [
{ x: 0, y: 1, yheight: 0 },
{ x: 1, y: 1, yheight: 0 }, 
{ x: 1, y: 1, yheight: 1 }, 
{ x: 2, y: 1, yheight: 0 }, 
{ x: 2, y: 1, yheight: 1 },
{ x: 2, y: 1, yheight: 2 }, 
{ x: 3, y: 1, yheight: 0 }, 
{ x: 4, y: 1, yheight: 0 }, 
{ x: 4, y: 1, yheight: 1 }, 
{ x: 4, y: 1, yheight: 2 }
];

This data needs to be sorted so it can be properly fed to the stacked bar chart. For example, from this link : https://bl.ocks.org/mbostock/3886208 as you can see the data looks like this (i converted it to json) :

{
  "State": "WA",
  "Under 5 Years": 433119,
  "5 to 13 Years": 750274,
  "14 to 17 Years": 357782,
  "18 to 24 Years": 610378,
  "25 to 44 Years": 1850983,
  "45 to 64 Years": 1762811,
  "65 Years and Over": 783877
}

Where as yours is seperate. So, I edited yours (by hand for now, but a function could be written to do this). So your data now looks like this :

   var data = [
    { x: 0, yHeight0: 1, yHeight1: 0, yHeight2: 0 }, 
    { x: 1, yHeight0: 1, yHeight1: 1, yHeight2: 0 },
    { x: 2, yHeight0: 1, yHeight1: 1, yHeight2: 2 }, 
    { x: 3, yHeight0: 1, yHeight1: 0, yHeight2: 0 },  
    { x: 4, yHeight0: 1, yHeight1: 1, yHeight2: 2 }
    ]

Notice the different yHeights. These represent the different heights you have in your data, but I have grouped them all. Grouped them based on having the same x value.

Now it will take me a while to explain everything, but Ill explain the basics.

Bare in mind I have gone off the example linked above. The example has this colour domain :

var color = d3.scale.ordinal()
  .range(["#98abc5", "#8a89a6", "#7b6888", "#6b486b", "#a05d56", "#d0743c", "#ff8c00"]);

They know how many different stacks they are going to have. I have kept these for now, but these can be changed. This scale is then used to give the data more attributes :

color.domain(d3.keys(data[4]).filter(function(key) {
  return key !== "x";
}));

The above function is to return all the different stacks (the yHeights in your case). The below function uses these attributes and gives you attributes to help with the height and y placement of these stacks.

data.forEach(function(d) {
  var y0 = 0;
  d.ages = color.domain().map(function(name) {
    return {
      name: name,
      y0: y0,
      y1: y0 += +d[name]
    };
  });
  d.total = d.ages[d.ages.length - 1].y1;
});

Now to plot them:

var firstRects = state.selectAll("firstrect")
  .data(function(d) {
    return d.ages;
  })
  .enter().append("rect")
  .attr("width", x.rangeBand() / 2)
  .attr("y", function(d) { return y(d.y1); })
  .attr("height", function(d) { return y(d.y0) - y(d.y1); })
  .style("fill", function(d) { return color(d.name); }) 
  .style('stroke', 'black');

This gives you just a simple stacked bar chart but you want to have another one with the numbers on. So I did this by appending another bar chart to the same axis like so :

var secondRects = state.selectAll("secondrect")
  .data(function(d) { return d.ages; })
  .enter().append("rect")
  .attr("width", barWidth)
  .attr("y", function(d) {  return y(d.y1); })
  .attr("height", function(d) { 
    if (y(d.y0) - y(d.y1)) d.barHeight = y(d.y0) - y(d.y1); //this sets a height variable to be used later
    return y(d.y0) - y(d.y1);
  }) 
  .style("fill", 'white')
  .style('stroke', 'black')
  .attr("transform", function(d) {
    return "translate(" + (x.rangeBand() / 2) + ",0)";
  });

Now for the numbers on this :

var secondRectsText = state.selectAll("secondrecttext")
  .data(function(d) { 
    for (i = 0; i < d.ages.length; i++) {
      if (isNaN(d.ages[i].y0) || isNaN(d.ages[i].y1)) { 
        d.ages.splice(i--, 1);
      }
    }
    console.log('dages', d.ages);
    return d.ages;
  })
  .enter().append("text")
  .attr("width", barWidth)
  .attr("y", function(d) { 
    return y(d.y1);
  })
  .attr("transform", function(d) { 
  if(d.barHeight){ //if it hasnt got barheight it shouldnt be there
    return "translate(" + (barWidth + barWidth / 2) + "," + d.barHeight/2 + ")";
    } else {
     return "translate(" + 5000 + "," + 5000 + ")";
    }
  })
  .text(function(d, i) {
    return i;
  });

The check in the data setting is so that it doesn't use any null values. I could go on forever explaining what I have done, but hopefully you can understand the code enough to implement it to yours.

From here I would go on to create a function which organises your data, i.e groups all the values with the same x value, that way you wont have to hand edit.

Hope that helps, again apologies for the very long answer :P

Heres all of the code just incase fiddle goes down :

var data3 = [
{ x: 0, y: 1, yheight: 0 },
{ x: 1, y: 1, yheight: 0 }, 
{ x: 1, y: 1, yheight: 1 }, 
{ x: 2, y: 1, yheight: 0 }, 
{ x: 2, y: 1, yheight: 1 },
{ x: 2, y: 1, yheight: 2 }, 
{ x: 3, y: 1, yheight: 0 }, 
{ x: 4, y: 1, yheight: 0 }, 
{ x: 4, y: 1, yheight: 1 }, 
{ x: 4, y: 1, yheight: 2 }
];

var data = [
{ x: 0, yHeight0: 1, yHeight1: 0, yHeight2: 0 }, 
{ x: 1, yHeight0: 1, yHeight1: 1, yHeight2: 0 },
{ x: 2, yHeight0: 1, yHeight1: 1, yHeight2: 2 }, 
{ x: 3, yHeight0: 1, yHeight1: 0, yHeight2: 0 },  
{ x: 4, yHeight0: 1, yHeight1: 1, yHeight2: 2 }
]

//console.log(newArray)

var margin = {
    top: 20,
    right: 20,
    bottom: 30,
    left: 40
  },
  width = 800 - margin.left - margin.right,
  height = 500 - margin.top - margin.bottom;

var x = d3.scale.ordinal()
  .rangeRoundBands([0, width], .1);

var y = d3.scale.linear()
  .rangeRound([height, 0]);

var color = d3.scale.ordinal()
  .range(["#90C3D4", "#E8E8E8", "#DB9A9A", "#6b486b", "#a05d56", "#d0743c", "#ff8c00"]);

var xAxis = d3.svg.axis()
  .scale(x)
  .orient("bottom");

var yAxis = d3.svg.axis()
  .scale(y)
  .orient("left")
  .tickFormat(d3.format(".2s"));

var svg = d3.select("body").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 + ")"); 
  
  //below i purposely pick data[4] as I know thats the longest dataset so it gets all the yHeights
color.domain(d3.keys(data[4]).filter(function(key) { 
  return key !== "x";
}));

data.forEach(function(d) {
  var y0 = 0;
  d.ages = color.domain().map(function(name) {
    return {
      name: name,
      y0: y0,
      y1: y0 += +d[name]
    };
  });
  d.total = d.ages[d.ages.length - 1].y1;
});
 
x.domain(data.map(function(d) {
  return d.x;
}));
y.domain([0, d3.max(data, function(d) {
  return d.total;
})]);

svg.append("g")
  .attr("class", "x axis")
  .attr("transform", "translate(0," + height + ")")
  .call(xAxis);

svg.append("g")
  .attr("class", "y axis")
  .call(yAxis)
  .append("text")
  .attr("transform", "rotate(-90)")
  .attr("y", 6)
  .attr("dy", ".71em")
  .style("text-anchor", "end")
  .text("Tally");
console.log('test')
var state = svg.selectAll(".state")
  .data(data)
  .enter().append("g")
  .attr("class", "g")
  .attr("transform", function(d) {
    return "translate(" + x(d.x) + ",0)";
  });

var firstRects = state.selectAll("firstrect")
  .data(function(d) {
    return d.ages;
  })
  .enter().append("rect")
  .attr("width", x.rangeBand() / 2)
  .attr("y", function(d) { return y(d.y1); })
  .attr("height", function(d) { return y(d.y0) - y(d.y1); })
  .style("fill", function(d) { return color(d.name); }) 
  .style('stroke', 'black');
  
var barWidth = x.rangeBand() / 2;
var barHeight;
var secondRects = state.selectAll("secondrect")
  .data(function(d) { return d.ages; })
  .enter().append("rect")
  .attr("width", barWidth)
  .attr("y", function(d) {  return y(d.y1); })
  .attr("height", function(d) { 
    if (y(d.y0) - y(d.y1)) d.barHeight = y(d.y0) - y(d.y1);
    return y(d.y0) - y(d.y1);
  }) 
  .style("fill", 'white')
  .style('stroke', 'black')
  .attr("transform", function(d) {
    return "translate(" + (x.rangeBand() / 2) + ",0)";
  }); 
  
var secondRectsText = state.selectAll("secondrecttext")
  .data(function(d) { 
    for (i = 0; i < d.ages.length; i++) {
      if (isNaN(d.ages[i].y0) || isNaN(d.ages[i].y1)) { 
        d.ages.splice(i--, 1);
      }
    }
    console.log('dages', d.ages);
    return d.ages;
  })
  .enter().append("text")
  .attr("width", barWidth)
  .attr("y", function(d) { 
    return y(d.y1);
  })
  .attr("transform", function(d) { 
  if(d.barHeight){ //if it hasnt got barheight it shouldnt be there
    return "translate(" + (barWidth + barWidth / 2) + "," + d.barHeight/2 + ")";
    } else {
     return "translate(" + 5000 + "," + 5000 + ")";
    }
  })
  .text(function(d, i) {
    return i;
  });

var legend = svg.selectAll(".legend")
  .data(color.domain().slice().reverse())
  .enter().append("g")
  .attr("class", "legend")
  .attr("transform", function(d, i) {
    return "translate(0," + i * 20 + ")";
  });

legend.append("rect")
  .attr("x", width - 18)
  .attr("width", 18)
  .attr("height", 18)
  .style("fill", color);

legend.append("text")
  .attr("x", width - 24)
  .attr("y", 9)
  .attr("dy", ".35em")
  .style("text-anchor", "end")
  .text(function(d) {
    return d;
  });
body {
  font: 10px sans-serif;
}

.axis path,
.axis line {
  fill: none;
  stroke: #000;
  shape-rendering: crispEdges;
}

.bar {
  fill: steelblue;
}

.x.axis path {
  display: none;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>

EDIT :

Here is the fiddle that gives you exactly what you wanted from the image provided (slightly hacky but it works :)): https://jsfiddle.net/thatoneguy/nrjt15aq/10/

var data3 = [
{ x: 0, y: 1, yheight: 0 },
{ x: 1, y: 1, yheight: 0 }, 
{ x: 1, y: 1, yheight: 1 }, 
{ x: 2, y: 1, yheight: 0 }, 
{ x: 2, y: 1, yheight: 1 },
{ x: 2, y: 1, yheight: 2 }, 
{ x: 3, y: 1, yheight: 0 }, 
{ x: 4, y: 1, yheight: 0 }, 
{ x: 4, y: 1, yheight: 1 }, 
{ x: 4, y: 1, yheight: 2 }
];

var data = [
{ x: 0, yHeight0: 1, yHeight1: 0, yHeight2: 0 }, 
{ x: 1, yHeight0: 1, yHeight1: 1, yHeight2: 0 },
{ x: 2, yHeight0: 1, yHeight1: 1, yHeight2: 2 }, 
{ x: 3, yHeight0: 1, yHeight1: 0, yHeight2: 0 },  
{ x: 4, yHeight0: 1, yHeight1: 1, yHeight2: 2 }
]

//console.log(newArray)

var margin = {
    top: 20,
    right: 20,
    bottom: 30,
    left: 40
  },
  width = 800 - margin.left - margin.right,
  height = 500 - margin.top - margin.bottom;

var x = d3.scale.ordinal()
  .rangeRoundBands([0, width], .1);

var y = d3.scale.linear()
  .rangeRound([height, 0]);

var color = d3.scale.ordinal()
  .range(["#90C3D4", "#E8E8E8", "#DB9A9A", "#6b486b", "#a05d56", "#d0743c", "#ff8c00"]);

var xAxis = d3.svg.axis()
  .scale(x)
  .orient("bottom");

var yAxis = d3.svg.axis()
  .scale(y)
  .orient("left")
  .tickFormat(d3.format(".2s"));

var svg = d3.select("body").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 + ")"); 
  
  //below i purposely pick data[4] as I know thats the longest dataset so it gets all the yHeights
color.domain(d3.keys(data[4]).filter(function(key) { 
  return key !== "x";
}));

data.forEach(function(d) {
  var y0 = 0;
  d.ages = color.domain().map(function(name) {
    return {
      name: name,
      y0: y0,
      y1: y0 += +d[name]
    };
  });
  d.total = d.ages[d.ages.length - 1].y1;
});
 
x.domain(data.map(function(d) {
  return d.x;
}));
y.domain([0, d3.max(data, function(d) {
  return d.total;
})]);

svg.append("g")
  .attr("class", "x axis")
  .attr("transform", "translate(0," + height + ")")
  .call(xAxis);

svg.append("g")
  .attr("class", "y axis")
  .call(yAxis)
  .append("text")
  .attr("transform", "rotate(-90)")
  .attr("y", 6)
  .attr("dy", ".71em")
  .style("text-anchor", "end")
  .text("Tally"); 
var state = svg.selectAll(".state")
  .data(data)
  .enter().append("g")
  .attr("class", "g")
  .attr("transform", function(d) {
    return "translate(" + x(d.x) + ",0)";
  });
var barWidth = x.rangeBand() / 2;
var barHeight;
var boolTest = true;
var firstRects = state.selectAll("firstrect")
  .data(function(d) {
    return d.ages;
  })
  .enter().append("rect")
  .attr("width", x.rangeBand() / 2)
  .attr("y", function(d) { 
  if(boolTest){ boolTest=false; barHeight = y(d.y0) - y(d.y1)} 
   if((y(d.y0) - y(d.y1)) != 0){
   if(barHeight > (y(d.y0) - y(d.y1))){ barHeight = y(d.y0) - y(d.y1)}
   } 
  return y(d.y1); })
  .attr("height", function(d) { return y(d.y0) - y(d.y1); })
  .style("fill", function(d) { return color(d.name); }) 
  .style('stroke', 'black');
  
  
  function getHigheset(thisArray){
  var count = 0;
  for(var i=0;i<thisArray.length;i++){
  if(count<thisArray[i].y1){ count = thisArray[i].y1}
  }
  return count;
  }
  
  function makeArray(count){
  var newArray = []; 
  for(i=0;i<count;i++){
  newArray.push(i) 
  } 
  return newArray;
  }

var secondRects = state.selectAll("secondrect")
  .data(function(d) {  
 var thisData = makeArray(getHigheset(d.ages));
 
  return makeArray(getHigheset(d.ages)) 
  })
  .enter().append("rect")
  .attr("width", barWidth)
  .attr("y", function(d,i) { 
  return y(d) 
  })
  .attr("height", function(d) { 
    return barHeight 
  }) 
  .style("fill", 'white')
  .style('stroke', 'black')
  .attr("transform", function(d) { 
    return "translate(" + (x.rangeBand() / 2) + "," + (-barHeight)+" )";
  }); 
  
var secondRectsText = state.selectAll("secondrecttext")
  .data(function(d) { 
    for (i = 0; i < d.ages.length; i++) {
      if (isNaN(d.ages[i].y0) || isNaN(d.ages[i].y1)) { 
        d.ages.splice(i--, 1);
      }
    } 
    //return d.ages;
     return makeArray(getHigheset(d.ages))
  })
  .enter().append("text")
  .attr("width", barWidth)
  .attr("y", function(d, i) { 
   return y(d);
  })
  .attr("transform", function(d) { 
  if(barHeight){ //if it hasnt got barheight it shouldnt be there
    return "translate(" + (barWidth + barWidth / 2) + "," + (barHeight/2 -barHeight) + ")";
    } else {
     return "translate(" + 5000 + "," + 5000 + ")";
    }
  })
  .text(function(d, i) {
    return i;
  });

var legend = svg.selectAll(".legend")
  .data(color.domain().slice().reverse())
  .enter().append("g")
  .attr("class", "legend")
  .attr("transform", function(d, i) {
    return "translate(0," + i * 20 + ")";
  });

legend.append("rect")
  .attr("x", width - 18)
  .attr("width", 18)
  .attr("height", 18)
  .style("fill", color);

legend.append("text")
  .attr("x", width - 24)
  .attr("y", 9)
  .attr("dy", ".35em")
  .style("text-anchor", "end")
  .text(function(d) {
    return d;
  });
body {
  font: 10px sans-serif;
}

.axis path,
.axis line {
  fill: none;
  stroke: #000;
  shape-rendering: crispEdges;
}

.bar {
  fill: steelblue;
}

.x.axis path {
  display: none;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>

Comments