forked from guilhermesimoes's block: D3.js: Animating Stacked-to-Grouped Bars
forked from vikkya's block: D3.js: Animating Stacked-to-Grouped Bars
license: mit |
forked from guilhermesimoes's block: D3.js: Animating Stacked-to-Grouped Bars
forked from vikkya's block: D3.js: Animating Stacked-to-Grouped Bars
<!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> |