Skip to content

Instantly share code, notes, and snippets.

@lorenzopub
Created October 12, 2017 16:31
Show Gist options
  • Save lorenzopub/5a6e1618bdbf61ad6610d708f6c1e124 to your computer and use it in GitHub Desktop.
Save lorenzopub/5a6e1618bdbf61ad6610d708f6c1e124 to your computer and use it in GitHub Desktop.
Stacked-to-Grouped Bars III
license: mit

Part III - Highlighted layers

One of the challenges with stacked bar charts is comparing values across bars, since the baseline is offset for all except the bottom layer. In this version, the entire layer is highlighed on mouseover, the bars adjust vertically to match up the layer baseline, and the y axis shifts to set the baseline to zero.

One remaining issue with this approach is that some bars may move above the top of the visualization. Any suggestions on how to deal with this best would be welcome!

Earlier versions:
Part I
Part II

Originally forked from mbostock's block: Stacked-to-Grouped Bars

Updated for D3 4.0

forked from chornbaker's block: Stacked-to-Grouped Bars III

forked from lorenzopub's block: Stacked-to-Grouped Bars III

forked from Thanaporn-sk's block: Stacked-to-Grouped Bars III

<!DOCTYPE html>
<meta charset="utf-8">
<style>
body {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
margin: auto;
position: relative;
width: 960px;
}
text {
font: 10px sans-serif;
}
.axis path,
.axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
form {
position: absolute;
right: 10px;
top: 10px;
}
</style>
<form>
<label><input type="radio" name="mode" value="percent"> Percent</label>
<label><input type="radio" name="mode" value="grouped"> Grouped</label>
<label><input type="radio" name="mode" value="stacked" checked> Stacked</label>
</form>
<script src="//d3js.org/d3.v4.min.js"></script>
<script>
var n = 4, // number of layers
m = 58, // number of samples per layer
stack = d3.stack(),
data = d3.range(n).map(function() { return bumpLayer(m, .1); }),
yOffset = {},
yRecover = {},
yRecoverDomain,
isTransitioning = true,
transitionDuration = 500;
var formatPercent = d3.format(".0%");
var formatNumber = d3.format(",");
// transpose data
data = data[0].map(function(col, i) {
return data.map(function(row) {
return row[i]
})
});
var layers = stack.keys(d3.range(n))(data),
yStackMax = d3.max(layers, function(layer) { return d3.max(layer, function(d) { return d[1]; }); }),
yGroupMax = d3.max(layers, function(layer) { return d3.max(layer, function(d) { return d[1] - d[0]; }); });
var margin = {top: 40, right: 10, bottom: 20, left: 35},
width = 960 - margin.left - margin.right,
height = 500 - margin.top - margin.bottom;
var x = d3.scaleBand()
.domain(d3.range(m))
.rangeRound([0, width])
.padding(0.1)
.align(0.1);
var y = d3.scaleLinear()
.domain([0, yStackMax])
.rangeRound([height, 0]);
var color = d3.scaleLinear()
.domain([0, n - 1])
.range(["#aad", "#556"]);
var xAxis = d3.axisBottom()
.scale(x)
.tickSize(0)
.tickPadding(6);
var yAxis = d3.axisLeft()
.scale(y)
.tickSize(2)
.tickPadding(6);
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 + ")");
var layer = svg.selectAll(".layer")
.data(layers)
.enter().append("g")
.attr("class", "layer")
.attr("id", function(d) { return d.key; })
.style("fill", function(d, i) { return color(i); })
var rect = layer.selectAll("rect")
.data(function(d) { return d; })
.enter().append("rect")
.attr("x", function(d, i) { return x(i); })
.attr("y", height)
.attr("width", x.bandwidth())
.attr("height", 0)
.on("mouseenter", highlightLayer)
.on("mouseout", restoreLayer);
rect.transition()
.delay(function(d, i) {return i * 10; })
.attr("y", function(d) { return y(d[1]); })
.attr("height", function(d) { return y(d[0]) - y(d[1]); });
svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
.call(xAxis);
svg.append("g")
.attr("class", "y axis")
.attr("transform", "translate(" + 0 + ",0)")
.style("font-size", "10px")
.call(yAxis);
d3.selectAll("input").on("change", change);
// Automated initial transitions
var timeout = setTimeout(function() {
d3.select("input[value=\"grouped\"]").property("checked", true).each(change);
setTimeout(function() {
d3.select("input[value=\"percent\"]").property("checked", true).each(change);
}, 2000);
}, 2000);
function change() {
clearTimeout(timeout);
isTransitioning = true;
setTimeout(function() {
isTransitioning = false;
}, transitionDuration * 3.5);
if (this.value === "grouped") transitionGrouped();
else if (this.value === "stacked") transitionStacked();
else if (this.value === "percent") transitionPercent();
}
function transitionGrouped() {
y.domain([0, yGroupMax]);
rect.transition()
.duration(transitionDuration)
.delay(function(d, i) { return i * 10; })
.attr("x", function(d, i, j) { return x(i) + x.bandwidth() / n * parseInt(this.parentNode.id); })
.attr("width", x.bandwidth() / n)
.transition()
.attr("y", function(d) { return height - (y(d[0]) - y(d[1])); })
.attr("height", function(d) { return y(d[0]) - y(d[1]); })
.on("end", setYRecover);
yAxis.tickFormat(formatNumber)
svg.selectAll(".y.axis").transition()
.delay(transitionDuration)
.duration(transitionDuration)
.call(yAxis)
}
function transitionStacked() {
y.domain([0, yStackMax]);
rect.transition()
.duration(transitionDuration)
.delay(function(d, i) { return i * 10; })
.attr("y", function(d) { return y(d[1]); })
.attr("height", function(d) { return y(d[0]) - y(d[1]); })
.transition()
.attr("x", function(d, i) { return x(i); })
.attr("width", x.bandwidth())
.on("end", setYRecover);
yAxis.tickFormat(formatNumber)
svg.selectAll(".y.axis").transition()
.delay(transitionDuration)
.duration(transitionDuration)
.call(yAxis);
}
function transitionPercent() {
y.domain([0, 1]);
rect.transition()
.duration(transitionDuration)
.delay(function(d, i) { return i * 10; })
.attr("y", function(d) {
var total = d3.sum(d3.values(d.data));
return y(d[1] / total); })
.attr("height", function(d) {
var total = d3.sum(d3.values(d.data));
return y(d[0] / total) - y(d[1] / total); })
.transition()
.attr("x", function(d, i) { return x(i); })
.attr("width", x.bandwidth())
.on("end", setYRecover);
yAxis.tickFormat(formatPercent)
svg.selectAll(".y.axis").transition()
.delay(transitionDuration)
.duration(transitionDuration)
.call(yAxis);
}
function setYRecover(d,i) {
j = parseInt(d3.select(this.parentNode).attr("id"))
if (typeof(yRecover[i]) === 'undefined'){ yRecover[i] = {}};
yRecover[i][j] = parseFloat(d3.select(this).attr("y"));
yRecoverDomain = y.domain();
}
function highlightLayer(d,i) {
if (isTransitioning == false) {
// Highlight layer
var j = parseInt(d3.select(this.parentNode).attr("id"))
layer.transition()
.style("opacity", function() {
return parseInt(this.id) == j? 1 : 0.2;
});
// Align bottom of selected layer
var layerRects = d3.selectAll(this.parentNode.childNodes).selectAll(".rect")._parents;
var baseline = yRecover[i][j] + parseFloat(d3.select(this).attr("height"));
layerRects.forEach(function(d, i) {
yOffset[i] = baseline - (parseFloat(d3.select(d).attr("y")) + parseFloat(d3.select(d).attr("height")));
})
rect.transition()
.attr("y", function(d, i) {
return parseFloat(d3.select(this).attr("y")) + yOffset[i];
});
// Match y axis to bottom of layer
y.domain([y.domain()[0] - y.invert(baseline), y.domain()[1] - y.invert(baseline) ])
svg.selectAll(".y.axis").transition()
.call(yAxis);
}
}
function restoreLayer(d,i) {
if (isTransitioning == false) {
// Restore layer opacity
layer.transition()
.style("opacity", 1)
// Restore bar Y values
rect.transition()
.attr("y", function(d, i) {
j = parseInt(d3.select(this.parentNode).attr("id"))
return yRecover[i][j];
})
// Restore y axis
y.domain(yRecoverDomain)
svg.selectAll(".y.axis").transition()
.call(yAxis);
}
}
// Inspired by Lee Byron's test data generator.
function bumpLayer(n, o) {
function bump(a) {
var x = 1 / (.1 + Math.random()),
y = 2 * Math.random() - .5,
z = 10 / (.1 + Math.random());
for (var i = 0; i < n; i++) {
var w = (i / n - y) * z;
a[i] += x * Math.exp(-w * w);
}
}
var a = [], i;
for (i = 0; i < n; ++i) a[i] = o + o * Math.random();
for (i = 0; i < 5; ++i) bump(a);
return a.map(function(d, i) { return Math.max(0, d); });
}
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment