Creating a Beeswarm chart with Svelte and D3

A beeswarm plot is used to arrange one-dimensional data points into a two-dimensional space, making the data more readable by preventing overlap.

When visualizing the Global AI Index scores for different countries, we saw that many countries have similar scores, this overlap made it difficult to clearly distinguish countries with similar scores. Csv data for today.


This is where the Beeswarm Algorithm becomes an ideal solution. By adjusting the y-positions of countries based on their scores, the algorithm prevents overlap, enabling us to clearly see the distribution of nations along the AI Index spectrum.

Here is our class for applying the Beeswarm algorithm, we will import it to our Beeswarm component:

export class AccurateBeeswarm {
  constructor(items, radiusFun, xFun, seed, randomness1, randomness2) {
    this.items = items;
    this.radiusFun = radiusFun;
    this.xFun = xFun;
    this.seed = seed;
    this.randomness1 = randomness1;
    this.randomness2 = randomness2;
    this.tieBreakFn = this._sfc32(0x9e3779b9, 0x243f6a88, 0xb7e15162, seed);
    this.maxR = Math.max(...items.map((d) => radiusFun(d)));
    this.rng = this._sfc32(1, 2, 3, seed);
  }

  calculateYPositions() {
    let all = this.items
      .map((d, i) => ({
        datum: d,
        originalIndex: i,
        x: this.xFun(d),
        y: null,
        placed: false
      }))
      .sort((a, b) => a.x - b.x);

    // Using arrow function to ensure `this` context
    all.forEach((d, i) => {
      d.index = i;
    });

    let tieBreakFn = this.tieBreakFn;
    all.forEach((d) => {
      d.tieBreaker = tieBreakFn(d.x);
    });

    let allSortedByPriority = [...all].sort((a, b) => {
      let key_a = this.radiusFun(a.datum) + a.tieBreaker * this.randomness1;
      let key_b = this.radiusFun(b.datum) + b.tieBreaker * this.randomness2;
      return key_b - key_a;
    });

    for (let item of allSortedByPriority) {
      item.placed = true;
      item.y = this._getBestYPosition(item, all);
    }

    all.sort((a, b) => a.originalIndex - b.originalIndex);
    return all.map((d) => ({ datum: d.datum, x: d.x, y: d.y }));
  }

  _sfc32(a, b, c, d) {
    let rng = function () {
      a >>>= 0;
      b >>>= 0;
      c >>>= 0;
      d >>>= 0;
      var t = (a + b) | 0;
      a = b ^ (b >>> 9);
      b = (c + (c << 3)) | 0;
      c = (c << 21) | (c >>> 11);
      d = (d + 1) | 0;
      t = (t + d) | 0;
      c = (c + t) | 0;
      return (t >>> 0) / 4294967296;
    };
    for (let i = 0; i < 10; i++) {
      rng();
    }
    return rng;
  }

  _getBestYPosition(item, all) {
    let forbiddenIntervals = [];
    for (let step of [-1, 1]) {
      let xDist;
      let r = this.radiusFun(item.datum);
      for (
        let i = item.index + step;
        i >= 0 &&
        i < all.length &&
        (xDist = Math.abs(item.x - all[i].x)) < r + this.maxR;
        i += step
      ) {
        let other = all[i];
        if (!other.placed) continue;
        let sumOfRadii = r + this.radiusFun(other.datum);
        if (xDist >= r + this.radiusFun(other.datum)) continue;
        let yDist = Math.sqrt(sumOfRadii * sumOfRadii - xDist * xDist);
        let forbiddenInterval = [other.y - yDist, other.y + yDist];
        forbiddenIntervals.push(forbiddenInterval);
      }
    }
    if (forbiddenIntervals.length == 0) {
      return this.radiusFun(item.datum) * (this.rng() - 0.5) * this.randomness2;
    }
    let candidatePositions = forbiddenIntervals.flat();
    candidatePositions.push(0);
    candidatePositions.sort((a, b) => {
      let abs_a = Math.abs(a);
      let abs_b = Math.abs(b);
      if (abs_a < abs_b) return -1;
      if (abs_a > abs_b) return 1;
      return a - b;
    });
    for (let i = 0; i < candidatePositions.length; i++) {
      let position = candidatePositions[i];
      if (
        forbiddenIntervals.every(
          (interval) => position <= interval[0] || position >= interval[1]
        )
      ) {
        return position;
      }
    }
    return 0; // Fallback if no position is found
  }
}

Let’s walk through how we create the Beeswarm Chart. We’ll start by building a Circle.svelte component, which is an SVG <circle> element enhanced with animation functionality. This component allows us to animate the cy attribute, which controls the vertical positioning of each data point on the chart. We will use Svelte’s built-in tweened function, which interpolates smoothly between a starting and an ending value.

We’ll use the animate variable to control the animation state from the parent component, allowing us to switch between different views, such as a one-dimensional layout or a beeswarm layout.

<!-- Circle.svelte -->
<script>
  import { tweened } from 'svelte/motion';
  import { cubicOut } from 'svelte/easing';

  export let xValue;
  export let yValue;

  export let r = 10;
  export let fill = 'white';
  export let stroke;
  export let strokeWidth;
  export let opacity;

  // you can animate differentially each item by using index `i` value
  export let i: number;
  // control animation state from the parent component via bind:animate
  export let animate = 'one-dimensional';

  const tweenParams = {
    delay: 300 + 10 * i,
    duration: 250,
    easing: cubicOut
  };

  let tY = tweened(0, tweenParams);

  $: if (animate === 'beeswarm') {
    tY.set(+yValue);
  } else if (animate === 'one-dimensional') {
    tY.set(0);
  }
</script>

<circle
  pointer-events="none"
  cx={xValue}
  cy={$tY}
  {opacity}
  {fill}
  {stroke}
  stroke-width={strokeWidth}
  {r} />

And here is how our Beeswarm component looks like:

<!-- Beeswarm.svelte -->
<script>
  import { onMount, onDestroy } from 'svelte';
  import { csv } from 'd3-fetch';
  import { scaleLinear } from 'd3-scale';
  import Axis from './Basics/Axis.svelte';
  import { AccurateBeeswarm } from './AccurateBeeswarm';
  import Circle from './Basics/Circle.svelte';
  import Labels from './Basics/Labels.svelte';

  let countriesData = [];
  let xScale, yScale;
  let width; // width will be set by clientWidth, no need to initialize
  const height = 350;
  const margin = { top: 40, right: 30, bottom: 20, left: 50 };
  let animate = 'one-dimensional'; // states: 'one-dimensional' | 'beeswarm'

  // Toggle between 'beeswarm' and 'one-dimensional'
  const switchCharts = () => {
    animate = animate === 'beeswarm' ? 'one-dimensional' : 'beeswarm';
  };

  // you can download the data on: https://datavisualizationwithsvelte.com/data/countries.csv
  onMount(async () => {
    try {
      const data = await csv('/data/countries_data.csv'); // Adjust the path as needed
      countriesData = data.sort((a, b) => a.score_Total - b.score_Total);
    } catch (error) {
      console.error('Error loading CSV file:', error);
    }
  });

  // Reactively calculate domain and scales
  $: xDomainData = countriesData.map((d) => +d.score_Total);

  $: xScale = scaleLinear()
    .domain([Math.min(...xDomainData), Math.max(...xDomainData)])
    .range([margin.left, width - margin.right]);

  $: yScale = scaleLinear()
    .range([margin.top + height, margin.bottom])
    .domain([0, 2]);

  $: positionedData = new AccurateBeeswarm(
    countriesData,
    (d) => 10,
    (d) => xScale(d.score_Total),
    1,
    0,
    0
  ).calculateYPositions();

  let timeoutId = setTimeout(() => {
    animate = 'beeswarm';
  }, 600);

  onDestroy(() => {
    if (timeoutId) clearTimeout(timeoutId);
  });
</script>

<main bind:clientWidth={width} role="presentation">
  {#if width}
    <div class="relative">
      {#if countriesData.length > 0}
        <svg {width} {height} class="chart">
          <g transform="translate(0,0)">
            <Axis {width} {height} {margin} {xScale} ticksNumber={10} />
          </g>
          <Labels
            labelforx={true}
            fillColor={'#fcd34d'}
            {width}
            {height}
            {margin}
            yoffset={-300}
            xoffset={-20}
            label={'Total AI Score →'} />

          <g transform="translate(0,{height / 2})">
            {#each positionedData as country, i}
              <Circle
                {i}
                {yScale}
                stroke={'red'}
                strokeWidth="2"
                opacity="0.3"
                xValue={country.x.toFixed(1)}
                yValue={country.y.toFixed(1)}
                r={10}
                bind:animate />
            {/each}
          </g>
        </svg>
      {/if}
    </div>
    <div class="flex justify-center">
      <button class="variant-filled btn mt-4" on:click={switchCharts}>
        Show {animate === 'one-dimensional' ? 'Beeswarm' : '1D'}
      </button>
    </div>
  {/if}
</main>

Most important part of our code is here, this code is responsible for calculating the exact x and y positions of the data points on the beeswarm plot, ensuring clarity and non-overlapping display of the data.

$: positionedData = new AccurateBeeswarm( countriesData, (d) => 10, (d) =>
xScale(d.score_Total), 1, 0, 0 ).calculateYPositions();

Then we are binding animate variable to the animate prop of the child Circle.svelte compoment so we can animate the vertical circle positions from the button in the parent component.

{#each positionedData as country, i}
  <Circle
    {i}
    {yScale}
    stroke={'red'}
    strokeWidth="2"
    opacity="0.3"
    xValue={country.x.toFixed(1)}
    yValue={country.y.toFixed(1)}
    r={10}
    bind:animate />
{/each}

In a one-dimensional scatter plot, countries with similar scores can stack on top of each other, hiding important insights. The Beeswarm Algorithm solves this by spreading out the points without losing accuracy, letting us spot clusters or outliers.

Plus, by separating the data points, it enables the use of mouse events, letting us interactively view more information via tooltips about individual countries.

I’m enhancing the beeswarm chart by adding a Voronoi diagram to improve interaction. The Voronoi diagram allows us to capture mouse events over areas near data points, rather than requiring the user to hover directly on a point. This way, we can highlight the nearest point when the mouse hovers close by and display a tooltip with details about the closest data point.

A Voronoi diagram divides the chart into regions around each point, where every location within a region is closer to its corresponding point than to any other. This approach ensures that we can detect proximity to data points more easily, providing a smoother user experience when interacting with the chart. It effectively makes each point more “clickable” or “hoverable” by extending the area around it.


Here is our full code:
<script>
  import { onMount, onDestroy } from 'svelte';
  import { csv } from 'd3-fetch';
  import { scaleLinear } from 'd3-scale';
  import Axis from './Basics/Axis.svelte';
  import { AccurateBeeswarm } from './AccurateBeeswarm';
  import Circle from './Basics/CircleVoronoi.svelte';
  import Labels from './Basics/Labels.svelte';
  import { Delaunay } from 'd3-delaunay';

  export let voronoiColor = 'none';
  export let voronoiStroke = 0;
  export let voronoiStrokeColor = 'none';
  let countriesData = [];
  // $: console.log(<countriesData>.sort((a, b) => a.score_Total - b.score_Total));
  let xScale, yScale;
  let width = 600; // width will be set by the clientWidth
  const height = 350;
  const margin = { top: 40, right: 30, bottom: 20, left: 50 };

  onMount(async () => {
    try {
      const data = await csv('/data/countries_data.csv'); // Adjust the path based on where your file is in the static folder
      countriesData = data.sort((a, b) => a.score_Total - b.score_Total);
    } catch (error) {
      console.error('Error loading CSV file:', error);
    }
  });

  $: xDomainData = countriesData.map((d) => +d.score_Total);

  $: xScale = scaleLinear()
    .domain([Math.min(...xDomainData), Math.max(...xDomainData)])
    .range([margin.left, width - margin.right]);

  $: console.log(xScale.domain());

  // Setup the accurate beeswarm algorithm
  $: yScale = scaleLinear()
    .range([margin.top + height, margin.bottom])
    .domain([0, 2]);

  $: positionedData = new AccurateBeeswarm(
    countriesData,
    (d) => 10,
    (d) => xScale(d.score_Total),
    1,
    0,
    0
  ).calculateYPositions();

  let voronoi;

  $: if (positionedData) {
    voronoi = Delaunay.from(
      positionedData,
      (d) => +d.x,
      (d) => +d.y + height / 2
    ).voronoi([
      margin.left,
      margin.top,
      width - margin.right,
      height - margin.bottom
    ]);
  }

  let pointIndex;

  function handleMouseOver(e) {
    const { offsetX, offsetY } = e;
    pointIndex = voronoi.delaunay.find(offsetX, offsetY);
    found = positionedData[pointIndex];
  }

  function handleMouseOut() {
    pointIndex = null;
    found = null;
  }

  $: found = positionedData[pointIndex] || null;
</script>

<main class="relative" bind:clientWidth={width}>
  <!-- Tooltip element -->
  <div
    class="tooltip"
    style="top:{found ? found.y + 175 + 'px' : 'auto'}; 
           left:{found ? found.x + 10 + 'px' : 'auto'}; 
           opacity: {found ? 1 : 0}; 
           ">
    {#if found}
      {found.datum.Country}: {found.datum.score_Total}
    {/if}
  </div>

  {#if width}
    <div class="relative">
      {#if countriesData.length > 0}
        <svg {width} {height} class="chart">
          <g transform="translate(0,0)">
            <Axis {width} {height} {margin} {xScale} ticksNumber={10} />
            <Labels
              labelforx={true}
              fillColor={'#fcd34d'}
              {width}
              {height}
              {margin}
              yoffset={-300}
              xoffset={-20}
              label={'Total AI Score →'} />
          </g>

          {#each positionedData as data, i}
            <path
              role="presentation"
              cursor="pointer"
              tabindex="-1"
              on:mousemove={handleMouseOver}
              on:mouseleave={handleMouseOut}
              pointer-events="all"
              fill={voronoiColor}
              stroke={voronoiStrokeColor}
              stroke-width={voronoiStroke}
              opacity="0.1"
              d={voronoi.renderCell(i)} />
          {/each}

          <g transform="translate(0,{height / 2})">
            {#each positionedData as country, i}
              <Circle
                classX={country.datum.Country}
                {i}
                {yScale}
                opacity={pointIndex === i ? 0.7 : 0.3}
                stroke={pointIndex === i ? '#fcd34d' : 'red'}
                strokeWidth={pointIndex === i ? '3' : '2'}
                xValue={country.x.toFixed(1)}
                yValue={country.y.toFixed(1)}
                r={10} />
            {/each}
          </g>
        </svg>
      {/if}
    </div>
  {/if}
</main>

<style>
  .tooltip {
    position: absolute;
    pointer-events: none;
    font-family: 'Poppins', sans-serif;
    min-width: 8em;
    line-height: 1.2;
    font-size: 0.875rem;
    z-index: 1;
    padding: 6px;
    color: #111827;
    background-color: #f9fafb;
    border: 1px solid #ddd;
    border-radius: 4px;
    opacity: 0; /* Initially hidden */
  }
</style>

We are defining the Voronoi object here and using voronoi.delaunay.find() method to find the closest point the mouse position:

<script>
  //
  //
  let voronoi;

  $: if (positionedData) {
    voronoi = Delaunay.from(
      positionedData,
      (d) => +d.x,
      (d) => +d.y + height / 2
    ).voronoi([
      margin.left,
      margin.top,
      width - margin.right,
      height - margin.bottom
    ]);
  }
  let pointIndex;

  function handleMouseOver(e) {
    const { offsetX, offsetY } = e;
    pointIndex = voronoi.delaunay.find(offsetX, offsetY);
    found = positionedData[pointIndex];
  }

  function handleMouseOut() {
    pointIndex = null;
    found = null;
  }

  $: found = positionedData[pointIndex] || null;
</script>

{#each positionedData as data, i}
  <path
    role="presentation"
    cursor="pointer"
    tabindex="-1"
    on:mousemove={handleMouseOver}
    on:mouseleave={handleMouseOut}
    pointer-events="all"
    fill={voronoiColor}
    stroke={voronoiStrokeColor}
    stroke-width={voronoiStroke}
    opacity="0.1"
    d={voronoi.renderCell(i)} />
{/each}

This is it for today! Here is how our final chart looks like, mouseover the chart and see how voronoi works.

Circle.svelte is slightly modified for the voronoi version since we dont need animation anymore.

<!-- CircleVoronoi.svelte -->
<script lang="ts">import { tweened } from "svelte/motion";
import { cubicOut } from "svelte/easing";
export let xValue;
export let yValue;
export let r = 10;
export let fill = "white";
export let stroke;
export let strokeWidth;
export let opacity;
export let i;
export let animate = "one-dimensional";
const tweenParams = {
  delay: 300 + 10 * i,
  duration: 250,
  easing: cubicOut
};
let tY = tweened(+yValue, tweenParams);
</script>

<circle
  tabindex="-1"
  pointer-events="none"
  cx={xValue}
  cy={$tY}
  {opacity}
  {fill}
  {stroke}
  stroke-width={strokeWidth}
  {r} />

Thank you!

Thanks for reading! In case you have a question or comment please join us on Discord!