block by guilhermesimoes 8913c15adf7dd2cab53a

D3.js: Animating Stacked-to-Grouped Bars

Full Screen

index.html

<!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>