D3 Histogram with Animations

Intro

While SVG can be animated with CSS, there are properties on SVG that cannot. D3 has a transition library to assist with animations.

final histogram

Transitions

.transition

The transition library can animate any property, synchronize animations, and sequence animations, as well as interrupt them.

D3: transition

Transitions are quite easy to implement, all we have to do is chain the transition method into our selections. The position in the chain is important however, we need to add it after the data has been joined, but before the items that will be animated.

async function draw(el) {
  // Data
  const dataset = await d3.json("data.json");

  // Dimensions
  let dimensions = {
    width: 800,
    height: 400,
    margins: 50,
  };

  dimensions.containerWidth = dimensions.width - dimensions.margins * 2;
  dimensions.containerHeight = dimensions.height - dimensions.margins * 2;

  binPadding = 2;

  // Draw Image
  const svg = d3
    .select(el)
    .append("svg")
    .attr("width", dimensions.width)
    .attr("height", dimensions.height);

  const container = svg
    .append("g")
    .classed("container", true)
    .attr(
      "transform",
      `translate(${dimensions.margins}, ${dimensions.margins})`
    );

  // Element Groups
  const labelsGroup = container.append("g").classed("bar-labels", true);

  const xAxisGroup = container
    .append("g")
    .classed("axis", true)
    .style("transform", `translateY(${dimensions.containerHeight}px)`);

  const barsGroup = container.append("g").classed("bars", true);

  // Histogram Function
  // elements that rely on data go here
  function histogram(metric) {
    // Accessors
    const xAccessor = (d) => d.currently[metric];
    const yAccessor = (d) => d.length;

    // Scales
    const xScale = d3
      .scaleLinear()
      .domain(d3.extent(dataset, xAccessor))
      .range([0, dimensions.containerWidth])
      .nice();

    const bin = d3
      .bin()
      .domain(xScale.domain()) // data domain
      .value(xAccessor) // data values
      .thresholds(10); // number of buckets

    const binnedDataset = bin(dataset);
    console.log(binnedDataset);

    const yScale = d3
      .scaleLinear()
      .domain([0, d3.max(binnedDataset, yAccessor)])
      .range([dimensions.containerHeight, 0])
      .nice();

    // Draw Bars
    barsGroup
      .selectAll("rect")
      .data(binnedDataset)
      .join("rect")
      .transition()
      .attr(
        "width",
        (d) => d3.max([0, xScale(d.x1) - xScale(d.x0)]) - binPadding
      )
      .attr("height", (d) => dimensions.containerHeight - yScale(yAccessor(d)))
      .attr("x", (d) => xScale(d.x0))
      .attr("y", (d) => yScale(yAccessor(d)))
      .attr("fill", "#01c5c4");

    // Bar Labels
    labelsGroup
      .selectAll("text")
      .data(binnedDataset)
      .join("text")
      .transition()
      // add half the size of the bar to center the text
      .attr("x", (d) => xScale(d.x0) + (xScale(d.x1) - xScale(d.x0)) / 2)
      .attr("y", (d) => yScale(yAccessor(d)) - 10)
      .text(yAccessor);

    // Draw Axis
    const xAxis = d3.axisBottom(xScale);

    xAxisGroup.transition().call(xAxis);
  }

  // Select Handler
  d3.select("#metric").on("change", function (e) {
    e.preventDefault();

    histogram(this.value);
  });

  // default metric
  histogram("humidity");
}

draw("#chart");

And already all of our transitions are animated, however they are a bit funky. All of the animations are originating at the origin, instead of at the base of each item and moving upward.

_enter & _exit transitions

To solve this we will need to use what we learned in the D3 Fundamentals post about the _enter selection, which holds the data for items that have not been joined. We need to access that selection and apply the attributes that used before, but with a modified width and y-position so that the new bars start in their desired location and animate upwards.

// Draw Bars
    barsGroup
      .selectAll("rect")
      .data(binnedDataset)
      .join((enter) =>
        enter
          .append("rect")
          .attr(
            "width",
            (d) => d3.max([0, xScale(d.x1) - xScale(d.x0)]) - binPadding
          )
          .attr("height", 0)
          .attr("x", (d) => xScale(d.x0))
          .attr("y", dimensions.containerHeight)
          .attr("fill", "#01c5c4")
      )
      .transition()
      //.attr('width', ((dimensions.containerWidth / binnedDataset.length) - binPadding))
      .attr(
        "width",
        (d) => d3.max([0, xScale(d.x1) - xScale(d.x0)]) - binPadding
      )
      .attr("height", (d) => dimensions.containerHeight - yScale(yAccessor(d)))
      .attr("x", (d) => xScale(d.x0))
      .attr("y", (d) => yScale(yAccessor(d)))
      .attr("fill", "#01c5c4");

and now the bars are animating correctly, although the bar labels are still zooming in from the left.

correct bar animation

Before we do that we can round out the bars animations by also adding an animation for bars that are being removed with the _exit selection.

// Draw Bars
barsGroup
    .selectAll("rect")
    .data(binnedDataset)
    .join(
    (enter) =>
        enter
        .append("rect")
        .attr(
            "width",
            (d) => d3.max([0, xScale(d.x1) - xScale(d.x0)]) - binPadding
        )
        .attr("height", 0)
        .attr("x", (d) => xScale(d.x0))
        .attr("y", dimensions.containerHeight)
        .attr("fill", "#01c5c4"),
    (update) => update,
    (exit) =>
        exit
        .transition()
        .attr("y", dimensions.containerHeight)
        .attr("height", 0)
        .remove()
    )
    .transition()
    //.attr('width', ((dimensions.containerWidth / binnedDataset.length) - binPadding))
    .attr(
    "width",
    (d) => d3.max([0, xScale(d.x1) - xScale(d.x0)]) - binPadding
    )
    .attr("height", (d) => dimensions.containerHeight - yScale(yAccessor(d)))
    .attr("x", (d) => xScale(d.x0))
    .attr("y", (d) => yScale(yAccessor(d)))
    .attr("fill", "#01c5c4");

Transition Chaining

One of the other issues that is happing with our bars animation right now is that all the animations are happening at the same time. This results in the bars overlapping for a short period of time. We can avoid this by using transition chaining.

Essentially what we do is we take our transitions and assign them to variables, thus giving them an id. Then when we can chain one transition inside the definition of another transition, and D3 know that we want to wait for that transition to finish before we move on to the next one.

// Transitions
const exitTransition = d3.transition().duration(500)
const updateTransition = exitTransition.transition().duration(500)

// Draw Bars
barsGroup
    .selectAll("rect")
    .data(binnedDataset)
    .join(
    (enter) =>
        enter
        .append("rect")
        .attr(
            "width",
            (d) => d3.max([0, xScale(d.x1) - xScale(d.x0)]) - binPadding
        )
        .attr("height", 0)
        .attr("x", (d) => xScale(d.x0))
        .attr("y", dimensions.containerHeight)
        .attr("fill", "#01c5c4"),
    (update) => update,
    (exit) =>
        exit
        .transition(exitTransition)
        .attr("y", dimensions.containerHeight)
        .attr("height", 0)
        .remove()
    )
    .transition(updateTransition)
    //.attr('width', ((dimensions.containerWidth / binnedDataset.length) - binPadding))
    .attr(
    "width",
    (d) => d3.max([0, xScale(d.x1) - xScale(d.x0)]) - binPadding
    )
    .attr("height", (d) => dimensions.containerHeight - yScale(yAccessor(d)))
    .attr("x", (d) => xScale(d.x0))
    .attr("y", (d) => yScale(yAccessor(d)))
    .attr("fill", "#01c5c4");

transition chain

Transition Colors

Using the _enter and _exit state we can change the colors of elements that are being added and taken away.

// Transitions
const exitTransition = d3.transition().duration(1000);
const updateTransition = exitTransition.transition().duration(1000);

// Draw Bars
barsGroup
    .selectAll("rect")
    .data(binnedDataset)
    .join(
    (enter) =>
        enter
        .append("rect")

        .attr(
            "width",
            (d) => d3.max([0, xScale(d.x1) - xScale(d.x0)]) - binPadding
        )
        .attr("height", 0)
        .attr("x", (d) => xScale(d.x0))
        .attr("y", dimensions.containerHeight)
        .attr("fill", "#b8de6f"),
    (update) => update,
    (exit) =>
        exit
        .attr("fill", "#f39233")
        .transition(exitTransition)
        .attr("y", dimensions.containerHeight)
        .attr("height", 0)
        .remove()
    )
    .transition(updateTransition)
    //.attr('width', ((dimensions.containerWidth / binnedDataset.length) - binPadding))
    .attr(
    "width",
    (d) => d3.max([0, xScale(d.x1) - xScale(d.x0)]) - binPadding
    )
    .attr("height", (d) => dimensions.containerHeight - yScale(yAccessor(d)))
    .attr("x", (d) => xScale(d.x0))
    .attr("y", (d) => yScale(yAccessor(d)))
    .attr("fill", "#01c5c4");

color transitions

Transition Labels

Lastly we can add very similar transitions to our labels

// Transitions
    let transitionDuration = 1000;
    const exitTransition = d3.transition().duration(transitionDuration);
    const updateTransition = exitTransition
      .transition()
      .duration(transitionDuration);

    // Draw Bars
    barsGroup
      .selectAll("rect")
      .data(binnedDataset)
      .join(
        (enter) =>
          enter
            .append("rect")
            .attr(
              "width",
              (d) => d3.max([0, xScale(d.x1) - xScale(d.x0)]) - binPadding
            )
            .attr("height", 0)
            .attr("x", (d) => xScale(d.x0))
            .attr("y", dimensions.containerHeight)
            .attr("fill", "#b8de6f"),
        (update) => update,
        (exit) =>
          exit
            .attr("fill", "#f39233")
            .transition(exitTransition)
            .attr("y", dimensions.containerHeight)
            .attr("height", 0)
            .remove()
      )
      .transition(updateTransition)
      //.attr('width', ((dimensions.containerWidth / binnedDataset.length) - binPadding))
      .attr(
        "width",
        (d) => d3.max([0, xScale(d.x1) - xScale(d.x0)]) - binPadding
      )
      .attr("height", (d) => dimensions.containerHeight - yScale(yAccessor(d)))
      .attr("x", (d) => xScale(d.x0))
      .attr("y", (d) => yScale(yAccessor(d)))
      .attr("fill", "#01c5c4");

    // Bar Labels
    labelsGroup
      .selectAll("text")
      .data(binnedDataset)
      .join(
        (enter) =>
          enter
            .append("text")
            // add half the size of the bar to center the text
            .attr("x", (d) => xScale(d.x0) + (xScale(d.x1) - xScale(d.x0)) / 2)
            .attr("y", dimensions.containerHeight - 10)
            .text(yAccessor),
        (update) => update,
        (exit) =>
          exit
            .transition(exitTransition)
            .attr("y", dimensions.containerHeight)
            .attr("height", -10)
            .remove()
      )
      .transition(updateTransition)
      // add half the size of the bar to center the text
      .attr("x", (d) => xScale(d.x0) + (xScale(d.x1) - xScale(d.x0)) / 2)
      .attr("y", (d) => yScale(yAccessor(d)) - 10)
      .text(yAccessor);

final histogram

Final Code

async function draw(el) {
  // Data
  const dataset = await d3.json("data.json");

  // Dimensions
  let dimensions = {
    width: 800,
    height: 400,
    margins: 50,
  };

  dimensions.containerWidth = dimensions.width - dimensions.margins * 2;
  dimensions.containerHeight = dimensions.height - dimensions.margins * 2;

  binPadding = 2;

  // Draw Image
  const svg = d3
    .select(el)
    .append("svg")
    .attr("width", dimensions.width)
    .attr("height", dimensions.height);

  const container = svg
    .append("g")
    .classed("container", true)
    .attr(
      "transform",
      `translate(${dimensions.margins}, ${dimensions.margins})`
    );

  // Element Groups
  const labelsGroup = container.append("g").classed("bar-labels", true);

  const xAxisGroup = container
    .append("g")
    .classed("axis", true)
    .style("transform", `translateY(${dimensions.containerHeight}px)`);

  const barsGroup = container.append("g").classed("bars", true);

  // Histogram Function
  // elements that rely on data go here
  function histogram(metric) {
    // Accessors
    const xAccessor = (d) => d.currently[metric];
    const yAccessor = (d) => d.length;

    // Scales
    const xScale = d3
      .scaleLinear()
      .domain(d3.extent(dataset, xAccessor))
      .range([0, dimensions.containerWidth])
      .nice();

    const bin = d3
      .bin()
      .domain(xScale.domain()) // data domain
      .value(xAccessor) // data values
      .thresholds(10); // number of buckets

    const binnedDataset = bin(dataset);
    console.log(binnedDataset);

    const yScale = d3
      .scaleLinear()
      .domain([0, d3.max(binnedDataset, yAccessor)])
      .range([dimensions.containerHeight, 0])
      .nice();

    // Transitions
    let transitionDuration = 1000;
    const exitTransition = d3.transition().duration(transitionDuration);
    const updateTransition = exitTransition
      .transition()
      .duration(transitionDuration);

    // Draw Bars
    barsGroup
      .selectAll("rect")
      .data(binnedDataset)
      .join(
        (enter) =>
          enter
            .append("rect")
            .attr(
              "width",
              (d) => d3.max([0, xScale(d.x1) - xScale(d.x0)]) - binPadding
            )
            .attr("height", 0)
            .attr("x", (d) => xScale(d.x0))
            .attr("y", dimensions.containerHeight)
            .attr("fill", "#b8de6f"),
        (update) => update,
        (exit) =>
          exit
            .attr("fill", "#f39233")
            .transition(exitTransition)
            .attr("y", dimensions.containerHeight)
            .attr("height", 0)
            .remove()
      )
      .transition(updateTransition)
      //.attr('width', ((dimensions.containerWidth / binnedDataset.length) - binPadding))
      .attr(
        "width",
        (d) => d3.max([0, xScale(d.x1) - xScale(d.x0)]) - binPadding
      )
      .attr("height", (d) => dimensions.containerHeight - yScale(yAccessor(d)))
      .attr("x", (d) => xScale(d.x0))
      .attr("y", (d) => yScale(yAccessor(d)))
      .attr("fill", "#01c5c4");

    // Bar Labels
    labelsGroup
      .selectAll("text")
      .data(binnedDataset)
      .join(
        (enter) =>
          enter
            .append("text")
            // add half the size of the bar to center the text
            .attr("x", (d) => xScale(d.x0) + (xScale(d.x1) - xScale(d.x0)) / 2)
            .attr("y", dimensions.containerHeight - 10)
            .text(yAccessor),
        (update) => update,
        (exit) =>
          exit
            .transition(exitTransition)
            .attr("y", dimensions.containerHeight)
            .attr("height", -10)
            .remove()
      )
      .transition(updateTransition)
      // add half the size of the bar to center the text
      .attr("x", (d) => xScale(d.x0) + (xScale(d.x1) - xScale(d.x0)) / 2)
      .attr("y", (d) => yScale(yAccessor(d)) - 10)
      .text(yAccessor);

    // Draw Axis
    const xAxis = d3.axisBottom(xScale);

    xAxisGroup.transition().call(xAxis);
  }

  // Select Handler
  d3.select("#metric").on("change", function (e) {
    e.preventDefault();

    histogram(this.value);
  });

  // default metric
  histogram("humidity");
}

draw("#chart");