Building a Better Beeswarm

Note: This was originally published as an Observable notebook.

Beeswarm plots are a fun, visually pleasing alternative to histograms that can be used when data is sufficiently small and you want to plot every point.

This notebook contains an algorithm for accurate beeswarm plots with circles that vary in radius, using a simple implementation of this algorithm by James Trimble.

In addition to being faster to compute than a force-directed layout, it has the advantage of improved accuracy since it preserves the precise x position of each data point. Visualizations of the distortion induced by force-directed layouts can be found here.

Plot.plot({
  marks: [
    Plot.dot(circles, {
      r: 'r',
      x: d => pad + d.x,
      y: d => d.y + chartHeight / 2,
      fill: 'priority'
    }),
    Plot.text(circles, {
      x: d => pad + d.x,
      y: d => d.y + chartHeight / 2,
      fontSize: 'r',
      fill: '#fffd',
      text: d => d.order + 1,
      fillOpacity: 0
    })
  ],
  x: { type: 'identity', axis: null },
  y: { type: 'identity', axis: null },
  r: { type: 'identity' },
  color: {
    interpolate: t => d3.interpolatePuRd(0.15 + 0.8 * t),
    reverse: true
  },
  width: chartWidth,
  height: chartHeight
})

Circles are colored by their placement priority, with darker circles placed first.


Beeswarm Implementation

function swarm(data, x, r, priority, symmetric = true) {
  let circles = data
    .map((d, index) => ({
      datum: d,
      x: x(d),
      y: Infinity,
      r: r(d),
      priority: priority(d),
      index
    }))
    .sort((a, b) => d3.ascending(a.x, b.x));
  let indices = d3
    .range(0, circles.length)
    .sort((a, b) => d3.ascending(circles[a].priority, circles[b].priority));
  indices.forEach((index, order) => (circles[index].order = order));

  for (let d of circles) {
    if (d.x == undefined)
      throw new Error('x is undefined for datum at index ' + d.index);
    if (d.r == undefined)
      throw new Error('r is undefined for datum at index ' + d.index);
    if (d.priority == undefined)
      throw new Error('priority is undefined for datum at index ' + d.index);
  }
  let { sqrt, abs, min } = Math;
  let maxRadius = d3.max(circles, d => d.r);
  for (let index of indices) {
    let intervals = [];
    let circle = circles[index];
    // scan adjacent circles to the left and right
    for (let step of [-1, 1])
      for (let i = index + step; i > -1 && i < circles.length; i += step) {
        let other = circles[i];
        let dist = abs(circle.x - other.x);
        let radiusSum = circle.r + other.r;
        // stop once it becomes clear that no circle can overlap us
        if (dist > circle.r + maxRadius) break;
        // don't pay attention to this specific circle if
        // it hasn't been placed yet or doesn't overlap us
        if (other.y === Infinity || dist > radiusSum) continue;
        // compute the distance by which one would need to offset the circle
        // so that it just touches the other circle
        let offset = sqrt(radiusSum * radiusSum - dist * dist);
        // use that offset to create an interval in which this circle is forbidden
        intervals.push([other.y - offset, other.y + offset]);
      }
    // We're going to find a y coordinate for this circle by finding
    // the lowest point at the edge of any interval where it can fit.
    // This is quadratic in the number of intervals, but runs fast in practice due to
    // fact that we stop once the first acceptable candidate is found.
    let y = 0;
    if (intervals.length) {
      let candidates = intervals
        .flat()
        .sort((a, b) => d3.ascending(abs(a), abs(b)));
      for (let candidate of candidates) {
        if (!symmetric && candidate > 0) continue;
        if (intervals.every(([lo, hi]) => candidate <= lo || candidate >= hi)) {
          y = candidate;
          break;
        }
      }
    }
    circles[index].y = y;
  }
  return circles;
}