A Gentle Introduction to D3: How to Build a Reusable Bubble Chart

D3.js is a powerful JavaScript library for creating interactive data visualizations in the browser. With D3, you can bind arbitrary data to the Document Object Model (DOM) and then apply data-driven transformations to create engaging, informative graphics.

One of the key benefits of D3 is the ability to create reusable chart templates that can be repurposed across projects and datasets. In this post, we‘ll walk through building a reusable bubble chart in D3 to visualize data on U.S. state populations. We‘ll cover the core concepts of D3 and implement the essential components for a functioning, reusable chart.

Why Make D3 Charts Reusable?

Before diving into the code, let‘s discuss the importance of building reusable charts in D3. Modularity is a best practice in any programming paradigm and D3 is no exception. Some key benefits:

  • Reusability – Obviously, a reusable chart can be dropped into any project with a new dataset and just work, saving development time.
  • Configurability – Reusable charts can expose an API of configuration options, allowing the chart to be customized for different use cases without modifying the core code.
  • Testability – Modular code is inherently more testable, and reusable charts can be unit tested in isolation.
  • Sharability – Reusable chart templates can be open sourced and shared with the D3 community, or even wrapped into a library.

D3 creator Mike Bostock summed it up nicely, "To sum up: implement charts as closures with getter-setter methods."

The Basic Pattern

We‘ll structure our code according to the reusable chart pattern:

function myChart() {  

  // Default configuration  
  var width = 600;
  var height = 400;

  function chart(selection) {    
    // Generate chart here, using `width` and `height`
  }

  // Getter/setter methods
  chart.width = function(value) {
    if (!arguments.length) return width;
    width = value;
    return chart;
  };

  chart.height = function(value) {
    if (!arguments.length) return height;  
    height = value;
    return chart;
  };

  return chart;
}

The basic idea is to wrap everything in a closure, exposing getter/setter methods for configuration and returning the chart function itself. This allows us to instantiate a chart like:

var chart = myChart().width(500).height(300);

The chart function takes a D3 selection as input and will generate the chart within that selected DOM element. More on selections in a bit.

Loading Data

To generate our bubble chart, we first need some data. We‘ll create a simple dataset of U.S. state populations:

var stateData = [
  { name: "California", population: 39536653, region: "West" },
  { name: "Texas", population: 28304596, region: "South" },
  { name: "Florida", population: 20984400, region: "South" },
  { name: "New York", population: 19849399, region: "Northeast" },
  ...
];

To use external data with D3 (CSV, JSON, etc.), use one of the convenient file loading methods like d3.csv() or d3.json().

Binding Data to DOM Elements

The core concept of D3 is binding data to DOM elements, then using those data-bindings to drive the visual properties of the elements.

Let‘s select the element where we want to generate our chart and bind the stateData to it:

var svg = d3.select("#chart")
  .append("svg")
    .attr("width", width)
    .attr("height", height);

var circles = svg.selectAll("circle")
  .data(stateData);    

Here circles is now a D3 "selection" object representing all the circles we want to create, one for each state in our data. We haven‘t actually created those circles yet, for that we use…

Enter, Update, Exit

D3 has a system for synchronizing data with visual elements called Enter, Update, Exit:

  • Enter – New data elements that don‘t yet have a corresponding DOM element
  • Update – Existing DOM elements that need to be updated with new data
  • Exit – Existing DOM elements whose corresponding data has been removed

Since we are creating our chart for the first time, all of our data falls into the "Enter" selection. We use enter() and append() to create a new circle for each new data point:

circles.enter()
  .append("circle")
    .attr("r", 10)
    .attr("fill", "blue");

Simulating Physics

To create an interactive bubble chart, we want the circles to spread out and "bounce" off each other based on some physics model. D3 provides a "force" submodule (d3-force) for exactly this.

We‘ll create a force simulation and apply it to our circles:

var simulation = d3.forceSimulation(stateData)
    .force("charge", d3.forceManyBody().strength([-50]))
    .force("x", d3.forceX())  
    .force("y", d3.forceY())
    .on("tick", ticked);

function ticked() {
  circles
    .attr("cx", function(d) { return d.x; })
    .attr("cy", function(d) { return d.y; });
}  

Here we create a simulation acting on our state data, apply a negative charge force to make the circles repel each other, and x/y centering forces to keep them in the middle of the SVG. Each "tick" of the simulation, we update the positions of the circles.

Scaling the Circles

To encode the state population in our visualization, we‘ll scale the radius of each circle based on its state‘s population. D3 scales are functions that map from data space (domain) to display space (range).

var radiusScale = d3.scaleSqrt()  
    .domain([0, d3.max(stateData, function(d) { return d.population; })])
    .range([0, 50]);  

circles.attr("r", function(d) { 
  return radiusScale(d.population); 
});

We use a square root scale since we want to scale the circles‘ areas, not their radii. We set the domain to be from 0 to the maximum state population, and the range to be from 0 to 50 pixels.

Adding Color

Let‘s color code the states by region while we‘re at it, using a D3 ordinal scale:

var colorScale = d3.scaleOrdinal(d3.schemeCategory10);

circles.attr("fill", function(d) {
  return colorScale(d.region);  
});

Ordinal scales are a good fit for categorical data. We‘ll use the d3.schemeCategory10 color scheme which provides 10 distinct colors out of the box.

Tooltips on Hover

Finally, let‘s add a tooltip when you hover over each state bubble to show the state name and exact population. We‘ll add a title element to each circle:

circles.append("title")
  .text(function(d) { 
    return d.name + "\nPopulation: " + d.population.toLocaleString();
  });  

Putting it all together, here‘s the full code for our reusable D3 bubble chart:

function bubbleChart() {
  var width = 600;
  var height = 400;

  function chart(selection) {
    var svg = selection.append(‘svg‘)
        .attr("width", width)
        .attr("height", height);

    var circles = svg.selectAll("circle")
      .data(selection.datum());

    var radiusScale = d3.scaleSqrt()
        .domain([0, d3.max(selection.datum(), function(d) { return d.population; })])
        .range([0, 50]);

    var colorScale = d3.scaleOrdinal(d3.schemeCategory10);  

    var simulation = d3.forceSimulation(selection.datum())
        .force("charge", d3.forceManyBody().strength([-50]))
        .force("x", d3.forceX())
        .force("y", d3.forceY())  
        .on("tick", ticked);

    function ticked() {
      circles
          .attr("cx", function(d) { return d.x; })
          .attr("cy", function(d) { return d.y; });
    }

    circles = circles.enter()
      .append("circle")
        .attr("r", function(d) { return radiusScale(d.population); })
        .attr("fill", function(d) { return colorScale(d.region); })
        .call(d3.drag()
            .on("start", dragstarted)
            .on("drag", dragged)
            .on("end", dragended));

    circles.append("title")
      .text(function(d) {
        return d.name + "\nPopulation: " + d.population.toLocaleString();
      });

    function dragstarted(d) {
      if (!d3.event.active) simulation.alphaTarget(0.3).restart();
      d.fx = d.x;
      d.fy = d.y;
    }

    function dragged(d) {
      d.fx = d3.event.x;
      d.fy = d3.event.y;
    }

    function dragended(d) {
      if (!d3.event.active) simulation.alphaTarget(0);
      d.fx = null;
      d.fy = null;
    }
  }

  chart.width = function(value) {
    if (!arguments.length) return width;
    width = value;
    return chart;
  };

  chart.height = function(value) {
    if (!arguments.length) return height;
    height = value;
    return chart;
  };

  return chart;
}

To use it:

var chart = bubbleChart().width(500).height(300);

d3.select("#chart")
    .datum(stateData)  
    .call(chart);

And there you have it! A fully reusable D3 bubble chart. The chart function takes a selection and expects the bound data to have name, population, and region properties. It can be easily reused with different datasets, and its dimensions configured via the getter/setter methods.

I hope this has been a gentle yet useful introduction to the concept of reusable charts in D3. While we covered a lot of ground, there are many more features of D3 to explore, like axes, scales, transitions, zooming, and brushing. But understanding data binding, enter-update-exit, and the reusable chart pattern provides a solid foundation for creating your own expressive, interactive visualizations with this powerful library.

Similar Posts