Chartfleau

How to Make a Swarm Chart

Javascript and D3.js Tutorial

In this tutorial you'll learn the fundamentals of making a swarm chart using javascript and D3.js. If you're not familiar with D3, it would help to get a basic understanding of how it works first. D3 is widely used on the web, and well-documented. A good starting point is the D3 homepage, and this introductory guide at D3 Observable.

Why a Swarm?

A swarm, or "beeswarm", chart visualizes data at a very granular level. There's a one-to-one mapping between the number of elements in your dataset and the circles representing them in the chart. In addition to conventional x and y dimensions, it also enables us to visualize extra dimensions through the application of size and color. Optionally, animation can add information from a fifth dimension (time), making swarms a very informationally dense visualization alternative. Here's an animated example done entirely in D3:

Getting Started

Let's begin with an empty web page with an <svg> element that will contain our chart. Note that you will need a local server to host this page in order to see it in your browser. I use Visual Studio Code with a plugin called Live Server for my development environment, but you can use a dev environment of your own choosing. In this example we'll bring in the D3 library via a <script> tag referencing the d3js.org link as per the code below.

The code and data for this project are available on Github.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script src="https://d3js.org/d3.v5.min.js"></script>
    <title>Swarm Plot</title>
  </head>
  <body></body>
</html>
<script>
    const width = 1920;
    const height = 1080;
    let svg = d3
        .select("body")
        .append("svg")
        .attr("height", height)
        .attr("width", width);

    d3.csv("data.csv").then((data) => {
        // the rest of our d3 code will go here
    });
</script>

Our Data Structure

In this example, I'm using a list of about 500 companies, including their stock tickers, market capitalizations (ie. size), sector assignments, and their year-to-date (YTD) returns as of May 2020. In this tutorial we'll build a static swarm plot, but if you have time series data you could use Javascript's setInterval() function to animate your chart.

TickerSectorMarket CapReturn
MSFTTechnology1381.230.1637
AMZNTechnology998.090.3188
AAPLTechnology1326.700.086
etc.etc.etc.etc.

Note that we're pulling in this data from a csv file using the asynchronous d3.csv() function, so the rest of our code will be in the callback function that runs after the data is loaded.

Creating X, Y, Color, and Size Scales

Firstly, we'll use an ordinal (ie. categorical) scale to map the 11 sectors in our dataset to 11 x-coordinates on the screen as follows:

// a Set is a convenient way to remove duplicates
let sectors = Array.from(new Set(data.map((d) => d.Sector)));
// we'll separate our parallel swarms by 150 pixels each
let xCoords = sectors.map((d, i) => 150 + i * 150);
let xScale = d3.scaleOrdinal().domain(sectors).range(xCoords);

For our y-scale we'll map each company's return to a vertical position on the chart. Because coordinates on a computer screen begin with (0,0) at the top left corner, the bottom of our chart will have the maximum y-value and the top of the chart will have the minimum.

let yScale = d3
    .scaleLinear()
    .domain(d3.extent(data.map((d) => +d["Return"])))
    .range([height - 50, 50]); // using 50 just to provide some margin at the top and bottom

Next we'll make a color scale so each of our 11 sectors gets its own color. This is another example of an ordinal scale similar to our x-axis, but instead of mapping to a range of 11 x-coordinates we'll map to a range of 11 colors. D3 has some color scales built in, but you could specify your own array of colors here. For example, instead of d3.schemePaired you could use your own array ["red", "yellow", "orange", etc.]. Specifying colors as hex codes or rgba values is also fine.

let color = d3.scaleOrdinal().domain(sectors).range(d3.schemePaired);

Finally we'll map market capitalization to a radius for our circles. It's up to you how to size your elements, but you should consider your data's domain and the range of circle sizes you want to see on the chart. Since our data has a very wide range of company sizes (from a few billion dollars to over a trillion for the big tech firms), I'll use the square root of market cap as our domain, and remember to pass in the square root of market cap when we define the radius of our circles in the next section.

let marketcapDomain = d3.extent(data.map((d) => d["Market Cap"]));
// we'll use the square root of market cap for a better scale
marketcapDomain = marketcapDomain.map((d) => Math.sqrt(d));
// our circle radii will range from 5-30px
let size = d3.scaleLinear().domain(marketcapDomain).range([5, 30]);

Joining Data With Circle Elements

Now let's join our dataset to svg circle elements. This part is essentially no different from any other D3 visualization. Basically the d3.selectAll().data().enter() pattern creates an element in the DOM (in this case a <circle>) for each item in our dataset. If you're unfamiliar with D3, this would be better explained by the D3 homepage

svg.selectAll(".circ")
    .data(data)
    .enter()
    .append("circle")
    .attr("class", "circ")
    .attr("stroke", "black")
    .attr("fill", (d) => color(d.Sector))
    .attr("r", (d) => size(Math.sqrt(d["Market Cap"])))
    .attr("cx", (d) => xScale(d.Sector))
    .attr("cy", (d) => yScale(d.Return));

At this point we already have something nice to look at:

The Interesting Part - Force Simulation

The key to building a swarm plot is to understand the three forces acting upon each element. Controlling these forces opens up all sorts of creative possibilities.

D3 will do most of the work here, we just need to tell it what to do.

  1. In this example we're orienting our clusters vertically, so every element within a sector should be attracted to the same x-coordinate (the same coordinates defined by our xScale above). Think of the attractive force as a vertical line with the same x-coordinate for all points. Any elements to the left of the line will be pulled towards it to the right, and vice versa from the opposite side.
  2. The y-force is specific to each element within a category (unlike the x-force, which applies the same force to every element in its sector). Each element's y-force pulls its associated circle to the vertical location (y-coordinate) that's mapped from that element's value (in this example the company's YTD return) via the scaling function defined previously.
  3. Finally, to prevent the circles from completely overlapping eachother, D3 provides a helpful collision force that makes our elements push against eachother when they touch. D3 does all the hard work here. We can just specify the radius around which the force will be exerted. Since our circles have unique radii (representing each company's size) we want to set the radius of the collision force equal to the radius of the circle it's linked to in the same way that we defined the radius above.
let simulation = d3.forceSimulation(data)
    
    .force("x", d3.forceX((d) => {
        return xScale(d.Sector);
        }).strength(0.2))
    
    .force("y", d3.forceY((d) => {
        return yScale(d.Return);
        }).strength(1))
    
    .force("collide", d3.forceCollide((d) => {
        return size(Math.sqrt(d["Market Cap"]));
        }))
    
    .alphaDecay(0)
    .alpha(0.3)
    .on("tick", tick);

There are a few parameters here that are best understood by tinkering with them to get a feel for their effects. Strength is a value between 0 and 1 defining how forcefully our elements will be pulled to their new locations. The alpha parameter is related to how quickly the simulation advances. With alphaDecay set to 0, the forces will continue exerting themselves indefinitely (we'll address that below).

Running the Simulation

In the code above, we referenced a function called "tick". A force simulation proceeds iteratively in ticks, which you can think of as frames of animation. There are functions for starting, stopping, and controlling how many ticks are processed, but by default the simulation will begin rapidly processing ticks to smoothly adjust the coordinates of our dataset frame-by-frame in accordance with the forces we've specified. The force simulation assigns additional properties to our data, including positions and velocities for all elements on each tick. All we need to do is update the location of our circle elements on each tick by referencing the simulated x and y positions as follows:

function tick() {
    d3.selectAll(".circ")
        .attr("cx", (d) => d.x)
        .attr("cy", (d) => d.y);
    }

Applying Force Decay

Our chart is basically working now, but as a final step you may want to assign a force decay that initializes a few seconds after your elements are positioned. Depending on the force parameters you're using, there may be some tension on your circles as the forces continue to interact, which may result in some overlapping elements. Applying a decay will smoothly switch off the forces after our elements are positioned.

let init_decay = setTimeout(function () {
    console.log("start alpha decay");
    simulation.alphaDecay(0.1);
    }, 3000); // start decay after 3 seconds

Now the simulation will converge on a nice stable layout.

What's Next?

From here, you may want to embellish your chart with the usual adornments like x and y axes, a legend, grid lines, a title and so on. This can all be done the same way as in any other D3 project, so you can refer to an introductory tutorial for further guidance.

Personally, I've been doing a lot of data visualization within React applications (like this website) and have found a library called Nivo very useful. It's built on top of D3 and implements swarm charts in a similar manner to what we've covered here, but also takes care of making all the tedious elements like axes and legends for you. In this tutorial I demonstrate how to use Nivo to quickly make a chart like this one in a React application.

Follow @chartfleau on Twitter