Last active
September 5, 2022 21:04
-
-
Save guilhermesimoes/8913c15adf7dd2cab53a to your computer and use it in GitHub Desktop.
D3.js: Animating Stacked-to-Grouped Bars
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<meta charset="utf-8"> | |
<style> | |
body { | |
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; | |
} | |
.stacked-chart-container { | |
position: relative; | |
} | |
.stacked-chart-container .controls { | |
position: absolute; | |
top: 24px; | |
left: 18px; | |
} | |
.stacked-chart .clickable { | |
cursor: pointer; | |
} | |
.stacked-chart-container .tooltip { | |
position: absolute; | |
font-size: 13px; | |
white-space: nowrap; | |
border: 1px solid black; | |
background-color: white; | |
pointer-events: none; | |
border-radius: 5px; | |
display: none; | |
} | |
.stacked-chart-container .tooltip-wrapper { | |
position: relative; | |
padding: 6px; | |
} | |
.stacked-chart-container .tooltip-wrapper:before { | |
content: ""; | |
position: absolute; | |
width: 0; | |
height: 0; | |
bottom: -20px; | |
left: 50%; | |
transform: translateX(-50%); | |
border: 10px solid; | |
border-color: black transparent transparent transparent; | |
} | |
.stacked-chart-container .tooltip-wrapper:after { | |
content: ""; | |
position: absolute; | |
width: 0; | |
height: 0; | |
bottom: -19px; | |
left: 50%; | |
transform: translateX(-50%); | |
border: 10px solid; | |
border-color: white transparent transparent transparent; | |
} | |
.stacked-chart-container .tooltip-table { | |
text-align: right; | |
} | |
.stacked-chart path, | |
.stacked-chart line, | |
.stacked-chart rect { | |
shape-rendering: crispEdges; | |
} | |
.stacked-chart text { | |
font: 10px sans-serif; | |
} | |
.stacked-chart .axis path, | |
.stacked-chart .axis line { | |
fill: none; | |
stroke: #000; | |
} | |
.stacked-chart .series-yes { | |
fill: steelblue; | |
} | |
.stacked-chart .series-no { | |
fill: #CCC; | |
} | |
.stacked-chart .series-maybe { | |
fill: #CD4638; | |
} | |
.stacked-chart .grid-lines { | |
fill: none; | |
stroke: lightgrey; | |
} | |
.stacked-chart .layer rect { | |
opacity: 0.8; | |
transition: opacity 0.5s ease; | |
} | |
.stacked-chart .layer rect.highlighted { | |
opacity: 1; | |
} | |
.stacked-chart .overlay { | |
opacity: 0; | |
} | |
.stacked-chart .series-box { | |
stroke-width: 2px; | |
} | |
.stacked-chart .series-yes .series-box { | |
stroke: steelblue; | |
} | |
.stacked-chart .series-no .series-box { | |
stroke: #CCC; | |
} | |
.stacked-chart .series-maybe .series-box { | |
stroke: #CD4638; | |
} | |
.stacked-chart .disabled .series-box { | |
fill-opacity: 0; | |
} | |
.stacked-chart .series-label { | |
fill: black; | |
text-anchor: end; | |
alignment-baseline: central; | |
} | |
</style> | |
<body> | |
<div class="stacked-chart-container js-stacked-chart-container"> | |
<form class="controls"> | |
<label><input type="radio" name="mode" value="stacked" checked>Stacked</label> | |
<label><input type="radio" name="mode" value="grouped">Grouped</label> | |
</form> | |
<svg class="stacked-chart js-stacked-chart"></svg> | |
<div class="tooltip js-tooltip"> | |
<div class="tooltip-wrapper"> | |
<table class="tooltip-table js-tooltip-table"></table> | |
</div> | |
</div> | |
</div> | |
<script type="text/x-underscore" class="js-tooltip-table-content"> | |
<table> | |
<% _.each(bars, function (bar) { %> | |
<tr> | |
<td><%= bar.name %></td> | |
<td><%= bar.value %></td> | |
</tr> | |
<% }); %> | |
</table> | |
</script> | |
<script src="https://d3js.org/d3.v3.min.js" integrity="sha384-N8EP0Yml0jN7e0DcXlZ6rt+iqKU9Ck6f1ZQ+j2puxatnBq4k9E8Q6vqBcY34LNbn" crossorigin="anonymous"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js" integrity="sha384-FZY+KSLVXVyc1qAlqH9oCx1JEOlQh6iXfw3o2n3Iy32qGjXmUPWT9I0Z9e9wxYe3" crossorigin="anonymous"></script> | |
<script src="https://code.jquery.com/jquery-3.1.0.min.js" integrity="sha384-nrOSfDHtoPMzJHjVTdCopGqIqeYETSXhZDFyniQ8ZHcVy08QesyHcnOUpMpqnmWq" crossorigin="anonymous"></script> | |
<script type="text/javascript"> | |
"use strict"; | |
/*global $, d3, _ */ | |
var seriesNames = ["Maybe", "No", "Yes"], | |
numSamples = 22, | |
numSeries = seriesNames.length, | |
data = seriesNames.map(function (name) { | |
return { | |
name: name, | |
values: bumpLayer(numSamples, 0.1) | |
}; | |
}), | |
stack = d3.layout.stack().values(function (d) { return d.values; }); | |
stack(data); | |
var chartMode = "stacked", | |
numEnabledSeries = numSeries, | |
lastHoveredBarIndex, | |
containerWidth = document.querySelector(".js-stacked-chart-container").clientWidth, | |
containerHeight = 500, | |
margin = {top: 80, right: 30, bottom: 20, left: 30}, | |
width = containerWidth - margin.left - margin.right, | |
height = containerHeight - margin.top - margin.bottom, | |
widthPerStack = width / numSamples, | |
animationDuration = 400, | |
delayBetweenBarAnimation = 10, | |
numYAxisTicks = 8, | |
maxStackY = d3.max(data, function (series) { return d3.max(series.values, function (d) { return d.y0 + d.y; }); }), | |
paddingBetweenLegendSeries = 5, | |
legendSeriesBoxX = 0, | |
legendSeriesBoxY = 0, | |
legendSeriesBoxWidth = 15, | |
legendSeriesBoxHeight = 15, | |
legendSeriesHeight = legendSeriesBoxHeight + paddingBetweenLegendSeries, | |
legendSeriesLabelX = -5, | |
legendSeriesLabelY = legendSeriesBoxHeight / 2, | |
legendMargin = 20, | |
legendX = containerWidth - legendSeriesBoxWidth - legendMargin, | |
legendY = legendMargin, | |
tooltipTemplate = _.template(document.querySelector(".js-tooltip-table-content").innerHTML), | |
overlayTopPadding = 20, | |
tooltipBottomMargin = 12; | |
var binsScale = d3.scale.ordinal() | |
.domain(d3.range(numSamples)) | |
.rangeBands([0, width], 0.1, 0.05); | |
var xScale = d3.scale.linear() | |
.domain([0, numSamples]) | |
.range([0, width]); | |
var yScale = d3.scale.linear() | |
.domain([0, maxStackY]) | |
.range([height, 0]); | |
var heightScale = d3.scale.linear() | |
.domain([0, maxStackY]) | |
.range([0, height]); | |
var xAxis = d3.svg.axis() | |
.scale(xScale) //binsScale) | |
.ticks(numSamples) | |
.orient("bottom"); | |
var yAxis = d3.svg.axis() | |
.scale(yScale) | |
.orient("left"); | |
var enabledSeries = function () { return _.reject(data, function (series) { return series.disabled; }); }; | |
var seriesClass = function (seriesName) { return "series-" + seriesName.toLowerCase(); }; | |
var layerClass = function (d) { return "layer " + seriesClass(d.name); }; | |
var legendSeriesClass = function (d) { return "clickable " + seriesClass(d); }; | |
var barDelay = function (d, i) { return i * delayBetweenBarAnimation; }; | |
var joinKey = function (d) { return d.name; }; | |
var stackedBarX = function (d) { return binsScale(d.x); }; | |
var stackedBarY = function (d) { return yScale(d.y0 + d.y); }; | |
var stackedBarBaseY = function (d) { return yScale(d.y0); }; | |
var stackedBarWidth = binsScale.rangeBand(); | |
var groupedBarX = function (d, i, j) { return binsScale(d.x) + j * groupedBarWidth(); }; | |
var groupedBarY = function (d) { return yScale(d.y); }; | |
var groupedBarBaseY = height; | |
var groupedBarWidth = function () { return binsScale.rangeBand() / numEnabledSeries; }; | |
var barHeight = function (d) { return heightScale(d.y); }; | |
var transitionStackedBars = function (selection) { | |
selection.transition() | |
.duration(animationDuration) | |
.delay(barDelay) | |
.attr("y", stackedBarY) | |
.attr("height", barHeight); | |
}; | |
var svg = d3.select(".js-stacked-chart") | |
.attr("width", containerWidth) | |
.attr("height", containerHeight); | |
var mainArea = svg.append("g") | |
.attr("class", "main-area") | |
.attr("transform", "translate(" + margin.left + "," + margin.top + ")"); | |
mainArea.append("g") | |
.attr("class", "grid-lines") | |
.selectAll(".grid-line").data(yScale.ticks(numYAxisTicks)) | |
.enter().append("line") | |
.attr("class", "grid-line") | |
.attr("x1", 0) | |
.attr("x2", width) | |
.attr("y1", yScale) | |
.attr("y2", yScale); | |
var layersArea = mainArea.append("g") | |
.attr("class", "layers"); | |
var layers = layersArea.selectAll(".layer").data(data) | |
.enter().append("g") | |
.attr("class", layerClass); | |
layers.selectAll("rect").data(function (d) { return d.values; }) | |
.enter().append("rect") | |
.attr("x", stackedBarX) | |
.attr("y", height) | |
.attr("width", stackedBarWidth) | |
.attr("height", 0) | |
.call(transitionStackedBars); | |
mainArea.append("g") | |
.attr("class", "x axis") | |
.attr("transform", "translate(0," + height + ")") | |
.call(xAxis); | |
mainArea.append("g") | |
.attr("class", "y axis") | |
.call(yAxis); | |
svg.append("rect") | |
.attr("class", "overlay") | |
.attr("transform", "translate(" + margin.left + "," + margin.top + ")") | |
.attr("x", 0) | |
.attr("y", 0) | |
.attr("width", width) | |
.attr("height", height) | |
.on("mousemove", showTooltip) | |
.on("mouseout", hideTooltip); | |
var legendSeries = svg.append("g") | |
.attr("class", "legend") | |
.attr("transform", "translate(" + legendX + "," + legendY + ")") | |
.selectAll("g").data(seriesNames.reverse()) | |
.enter().append("g") | |
.attr("class", legendSeriesClass) | |
.attr("transform", function (d, i) { return "translate(0," + (i * legendSeriesHeight) + ")"; }) | |
.on("click", toggleSeries); | |
legendSeries.append("rect") | |
.attr("class", "series-box") | |
.attr("x", legendSeriesBoxX) | |
.attr("y", legendSeriesBoxY) | |
.attr("width", legendSeriesBoxWidth) | |
.attr("height", legendSeriesBoxHeight); | |
legendSeries.append("text") | |
.attr("class", "series-label") | |
.attr("x", legendSeriesLabelX) | |
.attr("y", legendSeriesLabelY) | |
.text(String); | |
d3.selectAll(".js-stacked-chart-container input").on("change", changeChartMode); | |
/** | |
* Toggles a certain series. | |
* @param {String} seriesName The name of the series to be toggled | |
*/ | |
function toggleSeries (seriesName) { | |
var series, | |
isDisabling, | |
newData; | |
series = _.findWhere(data, { name: seriesName }); | |
isDisabling = !series.disabled; | |
if (isDisabling === true && numEnabledSeries === 1) { | |
return; | |
} | |
d3.select(this).classed("disabled", isDisabling); | |
series.disabled = isDisabling; | |
newData = stack(enabledSeries()); | |
numEnabledSeries = newData.length; | |
layers = layers.data(newData, joinKey); | |
if (isDisabling === true) { | |
removeSeries(); | |
} | |
else { | |
addSeries(); | |
} | |
} | |
/** | |
* Removes a certain series. | |
*/ | |
function removeSeries () { | |
var layerToBeRemoved; | |
layerToBeRemoved = layers.exit(); | |
if (chartMode === "stacked") { | |
removeStackedSeries(layerToBeRemoved); | |
} | |
else { | |
removeGroupedSeries(layerToBeRemoved); | |
} | |
} | |
/** | |
* Smoothly transitions and then removes a certain series when the chart is in `stacked` mode. | |
* @param {d3.selection} layerToBeRemoved The layer that contains the series' bars | |
*/ | |
function removeStackedSeries (layerToBeRemoved) { | |
layerToBeRemoved.selectAll("rect").transition() | |
.duration(animationDuration) | |
.delay(barDelay) | |
.attr("y", stackedBarBaseY) | |
.attr("height", 0) | |
.call(endAll, function () { | |
layerToBeRemoved.remove(); | |
}); | |
transitionStackedBars(layers.selectAll("rect")); | |
} | |
/** | |
* Smoothly transitions and then removes a certain series when the chart is in `grouped` mode. | |
* @param {d3.selection} layerToBeRemoved The layer that contains the series' bars | |
*/ | |
function removeGroupedSeries (layerToBeRemoved) { | |
layerToBeRemoved.selectAll("rect").transition() | |
.duration(animationDuration) | |
.delay(barDelay) | |
.attr("y", groupedBarBaseY) | |
.attr("height", 0) | |
.call(endAll, function () { | |
layerToBeRemoved.remove(); | |
layers.selectAll("rect").transition() | |
.duration(animationDuration) | |
.delay(barDelay) | |
.attr("x", groupedBarX) | |
.attr("width", groupedBarWidth); | |
}); | |
} | |
/** | |
* Adds a certain series. | |
*/ | |
function addSeries () { | |
var newLayer; | |
newLayer = layers.enter().append("g") | |
.attr("class", layerClass); | |
if (chartMode === "stacked") { | |
addStackedSeries(newLayer); | |
} | |
else { | |
addGroupedSeries(newLayer); | |
} | |
} | |
/** | |
* Smoothly transitions and adds a certain series when the chart is in `stacked` mode. | |
* @param {d3.selection} newLayer The new layer to be added | |
*/ | |
function addStackedSeries (newLayer) { | |
newLayer.selectAll("rect").data(function (d) { return d.values; }) | |
.enter().append("rect") | |
.attr("x", stackedBarX) | |
.attr("y", stackedBarBaseY) | |
.attr("width", stackedBarWidth) | |
.attr("height", 0); | |
transitionStackedBars(layers.selectAll("rect")); | |
} | |
/** | |
* Smoothly transitions and adds a certain series when the chart is in `grouped` mode. | |
* @param {d3.selection} newLayer The new layer to be added | |
*/ | |
function addGroupedSeries (newLayer) { | |
var newBars; | |
layers.selectAll("rect").transition() | |
.duration(animationDuration) | |
.delay(barDelay) | |
.attr("x", groupedBarX) | |
.attr("width", groupedBarWidth) | |
.call(endAll, function () { | |
newBars = newLayer.selectAll("rect").data(function (d) { return d.values; }) | |
.enter().append("rect") | |
.attr("y", groupedBarBaseY) | |
.attr("width", groupedBarWidth) | |
.attr("height", 0); | |
layers.selectAll("rect").attr("x", groupedBarX); | |
newBars.transition() | |
.duration(animationDuration) | |
.delay(barDelay) | |
.attr("y", groupedBarY) | |
.attr("height", barHeight); | |
}); | |
} | |
/** | |
* Changes the chart to the selected mode: `stacked` or `grouped`. | |
* In `stacked` mode, the bars of each bin are stacked together. | |
* In `grouped` mode, the bars of each bin are placed side by side. | |
*/ | |
function changeChartMode() { | |
chartMode = this.value; | |
if (chartMode === "stacked") { | |
stackBars(); | |
} | |
else { | |
groupBars(); | |
} | |
} | |
/** | |
* Smoothly transitions the chart to `stacked` mode. | |
* In this mode, the bars of each bin are stacked together. | |
*/ | |
function stackBars() { | |
layers.selectAll("rect").transition() | |
.duration(animationDuration) | |
.delay(barDelay) | |
.attr("y", stackedBarY) | |
.transition() | |
.duration(animationDuration) | |
.attr("x", stackedBarX) | |
.attr("width", stackedBarWidth); | |
} | |
/** | |
* Smoothly transitions the chart to `grouped` mode. | |
* In this mode, the bars of each bin are placed side by side. | |
*/ | |
function groupBars() { | |
layers.selectAll("rect").transition() | |
.duration(animationDuration) | |
.delay(barDelay) | |
.attr("x", groupedBarX) | |
.attr("width", groupedBarWidth) | |
.transition() | |
.duration(animationDuration) | |
.attr("y", groupedBarY); | |
} | |
/** | |
* Shows the tooltip. | |
*/ | |
function showTooltip() { | |
var hoveredBarIndex, | |
tooltip; | |
hoveredBarIndex = (d3.mouse(this)[0] / widthPerStack) | 0; | |
if (hoveredBarIndex === lastHoveredBarIndex) { | |
return; | |
} | |
lastHoveredBarIndex = hoveredBarIndex; | |
layers.selectAll("rect").classed("highlighted", function (d, i) { return (i === hoveredBarIndex); }); | |
tooltip = $(".js-tooltip"); | |
tooltip.find(".js-tooltip-table").html(tooltipContent()); | |
tooltip.css({ | |
top: margin.top + highestBinBarHeight() - tooltip.outerHeight() - tooltipBottomMargin, | |
left: margin.left + (hoveredBarIndex * widthPerStack) + (widthPerStack / 2) - (tooltip.outerWidth() / 2), | |
}).fadeIn(); | |
} | |
function tooltipContent () { | |
var bars; | |
bars = []; | |
layers.each(function (d) { | |
bars.unshift({ name: d.name, value: d.values[lastHoveredBarIndex].y.toFixed(4) }); | |
}); | |
return tooltipTemplate({ bars: bars }); | |
} | |
/** | |
* Hides the tooltip. | |
*/ | |
function hideTooltip () { | |
$(".js-tooltip").stop().hide(); | |
layers.selectAll("rect") | |
.filter(function (d, i) { return (i === lastHoveredBarIndex); }) | |
.classed("highlighted", false); | |
lastHoveredBarIndex = undefined; | |
} | |
/** | |
* Calculates the height of the highest (not tallest) bar within a certain bin. | |
* @return {Number} The height, in pixels, of the highest bar within a certain bin | |
*/ | |
function highestBinBarHeight() { | |
var bars, | |
highestGroupBar; | |
if (chartMode === "stacked") { | |
highestGroupBar = _.last(layers.data()).values[lastHoveredBarIndex]; | |
return yScale(highestGroupBar.y0 + highestGroupBar.y); | |
} | |
else { | |
bars = _.map(layers.data(), function (series) { return series.values[lastHoveredBarIndex]; }); | |
highestGroupBar = _.max(bars, function (bar) { return bar.y; }); | |
return yScale(highestGroupBar.y); | |
} | |
} | |
/** | |
* Calls a function at the end of **all** transitions. | |
* @param {d3.transition} transition A D3 transition | |
* @param {Function} callback The function to be called at the end of **all** transitions | |
*/ | |
function endAll (transition, callback) { | |
var n; | |
if (transition.empty()) { | |
callback(); | |
} | |
else { | |
n = transition.size(); | |
transition.each("end", function () { | |
n--; | |
if (n === 0) { | |
callback(); | |
} | |
}); | |
} | |
} | |
// 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 {x: i, y: Math.max(0, d)}; }); | |
} | |
</script> | |
</body> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment