Skip to content

Instantly share code, notes, and snippets.

@GerHobbelt
Forked from milroc/README.md
Created June 26, 2012 17:03
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save GerHobbelt/2997144 to your computer and use it in GitHub Desktop.
Save GerHobbelt/2997144 to your computer and use it in GitHub Desktop.
d3.js: positive/negative stacked bar chart with sum line / bar (flex layout)
# Editor backup files
*.bak
*~

TODO …… ? 8

  • implement Q1...Qn data transitions.
  • which includes adding 'sales data' next to the current 'P&L' data.
  • the colors suck like a Hoover --> add ASE support (because I want it and heck, it gotta happen some day)

Usage

Click the radio buttons for Q1+Q2, Q3, Q4, ... to see various stacked bar renderings of the data.

Note that Q3, Q4, ... imply that the data has a different meaning than for Q1+Q2: read the text below to understand which questions are asked (Q1, Q2, Q3, etc.) and try to answer those questions while the graph for another Q question is shown: this might be an easy or a hard task. You'll find out.

Stacked bar charts with positive and negative values: their usefulness

Question: When are stacked bar charts with both negative and positive blocks (components) useful?

Answer: one example is when looking at P&L (profit and loss) numbers on a per-region or per-quarter basis. Here we'll assume that we want to look at components on a quarterly basis, i.e. our X axis represents time.

Note: for the latter scenario (looking at the numbers per region) X would represent (geo)location, but that doesn't change anything in this story. And, yes, we're aware that one can flip the graph: 'X' isn't always equal to 'horizontal' that way.

The questions

Say you want to answer these questions quickly:

  • Q1: which components made a profit or a loss in which quarter? How much of a loss (or profit)? ⇒ stacked bars

  • Q2: How much loss and profit do we have on a quarterly basis and how does it pan out, consolidated? I.e. what's the number at the bottom line ~ do our profitable components balance out the losing ones every quarter? Do we achieve anything here? ⇒ cummulative / sum line

Forum problem: I want to see the total height of the stacked bar (pos+neg)

I can't come up with a real-life scenario where one would want that, or rather: where that would be an intuitive way to communicate information. Not just in the financial domain but anywhere.

Given the financial reporting scenario above, there's very little information, if any at all, in the sum of the absolute P&L numbers. The only place where I expect to encounter such numbers is in spreadsheets, where they are employed as col-vs-row checksums, i.e. as pure technical tools to help assure quality of manual input / internal operation.

What you really want to know is the sum of the actual values, because that answers, at a consolidated level, our Q2: did we gain anything after all?

Thoughts on positive and negative values and a 'sum of the absolute values'

The scenario I can come up with where you want to recognize positive and negative values and yet want to see a stacked bar starting at zero and only going up, thus having the 'sum of the absolute values' immediately visible, is when your positive and negative signs convey another information dimension than the value that comes with it. (I cannot, after some pondering, think of any use where you want the Q1+Q2 pos+neg stacked bars, yet also want the sum of the absolute values depicted in those stacked bars. We'll continue here with a viable scenario where the 'sum of absolute values' is an important datum to see in the graph, though that's mostly due to the numbers contributing to the sum being naturally positive, rather then there being any need for Math.abs there.)

Say you want to know the total sales, while you also want to see the measure how much each sales' component contributed in terms of sales (not P&L!) and you want to know whether these components contributed a loss or a profit to the revenue figures each. Technically, you're treating 'profit or loss' as a boolean parameter here (whether you agree or not, you're looking at a 3D chart anyway, because you're looking at time vs. sales vs. P&L = 3 dimensions instead of 2 as in the original scanario ( time vs. P&L) and this fact has considerable impact on your visualization anyhow: which dimensions are you going to emphasize when you stick to the requirement that this has to fit in a bar chart, which is a rather 2D-ish visualisation type?

In this scenario, where we need to stick with a (stacked) bar chart for whatever reason, one might think positive and negative stacks are an answer, but you're hard pressed to produce the sum of the absolute values of profit and loss by simply looking at it. (Take the Q1+Q2 graph, then try to answer the question how much the absolute sum is. You'll need to go and calculate the sum of the height of the negative stack and the positive stack. Not 'easy'.)

Of course, you can repurpose the 'sum line', but IMHO that's rather counter-intuitive, as is placing the bars themselves in either the negative or positive stack, because the components' height now represents sales instead of profit/loss and there's no such thing as a 'negative sales'. (Unless your CFO is way too smart for your good; it's similar to the notorious negative cash and all it'll get you is all the wrong people perking up and taking an unhealthy interest in your activities. Mme. Zodiac says: "You will experience the pleasure of wearing some very nice cuffs in your near future." (If you understand Dutch and are a bit curious: step 3, item 6 in Checklist for (fiscal) due diligence might be a lesson there. I'm sorry I don't know the English jargon; pull this one through Google Translate for a good laugh, some head scratching and remember that the Dutch 'kas' is the English 'cash box', but we Dutch like to repurpose so Google calls it 'cash' one time and a 'greenhouse' another time; each correct when considered completely out of context, but in context this will offer you Close Encounters Of The Bablefish Kind.

Anyway, given that negative values in a sales graph are ludicrous, one might use the negative sign as a boolean signal to tell the world that this component right here contributed a loss (without saying how much of a loss) while positive values would then tell us that we're looking at a profitable component instead.

Visualizing that +/- boolean would be better done using color or other styling, e.g. boxing the component in a red border box when it's a loss and no border when it's the 'desirable kind': a sales component which produces a profit.

Hence when we ask Q3 below, we should change the stacked bar chart to a single-sided one.

  • Q3: what's our total sales figure each quarter, how much did each component contribute in terms of sales and do we have components which produced a nett loss rather than a profit?

Further thoughts

One might want to give 'sales' and 'P&L' dimensions an equal measure of attention; here we ponder what it would bring us when we combine these by 'floating the zero level for the P&L bars' - though that's the bottom-up tech wording of the problem of wanting to see these numbers and coming up with the possible solution of floating the stacks. This is useful when we ask:

  • Q4: how does our revenue develop over time and how much does each of the components contribute to each annual quater? ⇒ each P&L +/- stacked bars combo is offset against the revenue-to-date (here: accrued revenue till start of annual quarter) base bar.

This would allow us to see the revenue develop, hence might assist in the prognosis of our annual revenue, and it will show us the quarterly impact of the individual sales components. Hence we might wonder:

  • Q5: are we on target? How much do we depend on Summer / Christmas Sales? ( Horeca and retail business respectively: horeca ~ hotel, restaurant or cafe)

Since we are really considering change of focus here, there's also the diving into the details focus where one wishes to have a closer look at a particular (recurring?) component:

  • Q ?: how does component A contribute over time? ⇒ click on a component bar to shift it in the stack so it ends up at the baseline ('zero line') to ease reading off the values for each quarter for this particular component.
<!DOCTYPE html>
<html>
<head>
<script src="http://mbostock.github.com/d3/d3.v2.js"></script>
<script src="Adobe.ASE.load.js"></script>
<title>barStack with sum line (cummulative=consolidated revenue as line + components' profit/loss as bars)</title>
<style>
.axis text {
font: 10px sans-serif;
}
.axis path {
fill: none;
stroke: black;
stroke-width:4;
shape-rendering: crispEdges;
}
.axis line {
/* display: none; */
}
label {
}
input[type="radio"] {
width: 1em;
float: left;
}
</style>
</head>
<body>
<svg>
</svg>
<script type="text/javascript" src="http://gerhobbelt.github.com/bl.ocks.org-hack/fixit.js" ></script>
<script type="text/javascript" >
function barStack(d) {
var l = d[0].length;
while (l--) {
var posBase = 0, negBase = 0, sum = 0;
d.forEach(function(d) {
d=d[l]
sum += d.y;
d.size = Math.abs(d.y);
if (d.y < 0) {
d.y0 = negBase;
negBase -= d.size;
} else {
posBase += d.size;
d.y0 = posBase;
}
});
d[0][l].sum = sum;
}
d.extent = d3.extent(d3.merge(d3.merge(d.map(function(e) {
return e.map(function(f) {
return [f.y0, f.y0 - f.size]
})
})),d[0].map(function(e) {
return e.sum;
})));
return d
}
/* Here is an example */
var data = [[{x:1,y:-33},{x:2,y:6},{x:3,y:-3},{x:4,y:1}],
[{x:1,y:4},{x:2,y:-2},{x:3,y:-9},{x:4,y:-11}],
[{x:1,y:10},{x:2,y:-3},{x:3,y:4},{x:4,y:26}]]
var h=500
,w=500
,margin=20
,color = d3.scale.category10()
//,color = d3.scale.ordinal().range(d3_category10); -- what we really want is feed this bugger some ASE file...
,x = d3.scale.ordinal()
.domain(d3.range(1, data[0].length + 1))
.rangeRoundBands([margin, w - margin], .1)
,y = d3.scale.linear()
.range([h - margin, 0 + margin])
,xAxis = d3.svg.axis().scale(x).orient("bottom").tickSize(6, 0)
,yAxis = d3.svg.axis().scale(y).orient("left");
barStack(data);
y.domain(data.extent);
// I want the green and red color from the palette for the sum-lines!
for (i = 0; i < 2; i++) color(i); // we know our own data indexes run from 0..M (M = 2 ~ data.length-1)
color(-1); color(-2);
for (i = 2; i < 8; i++) color(i); // we know our own data indexes run from 0..M (M = 2 ~ data.length-1)
svg = d3.select("svg")
.attr("height",h)
.attr("width",w);
svg.selectAll(".series").data(data)
.enter().append("g").classed("series",true).style("fill", function(d,i) {
return color(i)
})
.selectAll("rect").data(Object)
.enter().append("rect");
var sums_g = svg.selectAll(".sums").data([0])
.enter().append("g").classed("sums",true).style("fill", "none")
.style("stroke-width", "3")
.style("stroke-dasharray", "5 3")
.selectAll("rect").data(data[0])
.enter()
sums_g.append("rect");
svg.append("g").attr("class","axis x")
svg.append("g").attr("class","axis y")
// If we have an ASE swatch set available, have that one override the default color set:
adobe.ase.load("kuler.adobe/Streetlamp.ase", function(swatches) {
var cs = ["red", "green"].concat(swatches);
color = d3.scale.ordinal().range(swatches);
// I want the green and red color from the palette for the sum-lines!
for (i = 0; i < cs.length - 2; i++) color(i); // we know our own data indexes run from 0..M (M = 2 ~ data.length-1)
color(-1); color(-2);
svg.selectAll(".series")
.style("fill", function(d,i) {
return color(i)
});
redraw(null, 1);
});
var layout = 1, dur = 0;
redraw(null, 1);
dur = 1500;
function redraw(d, i) {
if (!d) {
// flip button clicked or redraw() called?
if (!i)
layout = !layout;
} else {
// one of the Q radios is clicked:
}
if (layout) {
/* Readjust the range to width and height */
x.rangeRoundBands([margin, w - margin], .1);
y.range([h - margin, 0 + margin]);
/* Reposition and redraw axis */
svg.select(".x.axis")
.transition().duration(dur)
.attr("transform","translate (0 "+y(0)+")")
.call(xAxis.orient("bottom"));
svg.select(".y.axis")
.transition().duration(dur)
.attr("transform","translate ("+x(1)+" 0)")
.call(yAxis.orient("left"));
/* Reposition the elements */
svg.selectAll(".series rect")
.transition().duration(dur)
.attr("x",function(d,i) { return x(d.x); })
.attr("y",function(d) { return y(d.y0); })
.attr("height",function(d) { return y(0) - y(d.size); })
.attr("width",x.rangeBand());
svg.selectAll(".sums rect")
.transition().duration(dur)
.style("stroke", function(d,i) {
return color(-2 + (d.sum >= 0));
})
.attr("x",function(d,i) {
return x(d.x) - x.rangeBand() * 0.05 / 2;
})
.attr("y",function(d, i) {
return y(d.sum);
})
.attr("width",function(d,i) {
return x.rangeBand() * 1.05;
})
.attr("height","0.01");
} else {
/* Readjust the range to width and height */
x.rangeRoundBands([h - margin, 0 + margin], .1);
y.range([margin, w - margin]);
/* Reposition and redraw axis */
svg.select(".x.axis")
.transition().duration(dur)
.attr("transform","translate ("+y(0)+" 0)")
.call(xAxis.orient("left"));
svg.select(".y.axis")
.transition().duration(dur)
.attr("transform","translate (0 "+(x(1) + x.rangeBand() /* (x(2) - x(1)) */ )+")")
.call(yAxis.orient("bottom"));
/* Reposition the elements */
svg.selectAll(".series rect")
.transition().duration(dur)
.attr("y",function(d,i) { return x(d.x); })
.attr("x",function(d) { return y(d.y0 - d.size); })
.attr("width",function(d) { return y(d.size) - y(0); })
.attr("height",x.rangeBand());
svg.selectAll(".sums rect")
.transition().duration(dur)
.style("stroke", function(d,i) {
return color(-2 + (d.sum >= 0));
})
.attr("y",function(d,i) {
return x(d.x) - x.rangeBand() * 0.05 / 2;
})
.attr("x",function(d, i) {
return y(d.sum);
})
.attr("height",function(d,i) {
return x.rangeBand() * 1.05;
})
.attr("width","0.01");
}
}
d3.select("body").append("button")
.attr("type","button")
.text("Flip Layout")
.style("position","absolute")
.style("left","5px")
.style("top","5px")
.on("click",redraw);
d3.select("body").append("div").selectAll("input")
.data([{text: "Q1+Q2", q: 1, onClick: redraw},
{text: "Q3", q: 3, onClick: redraw},
{text: "Q4", q: 4, onClick: redraw},
{text: "Q5+Q6", q: 5, onClick: redraw}])
.enter()
.append("label")
.style("position","absolute")
.style("left", function(d, i) {
return (10 + i * 5) + "em";
})
.style("top","5px")
.text(function(d, i) {
return d.text;
})
.append("input")
.attr("type","radio")
.attr("name","question") // group the buggers
.on("click", function(d, i) {
return d.onClick(d, i);
})
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment