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 { Tween } from 'svelte/motion';
  import { cubicOut } from 'svelte/easing';

  let {
    xValue,
    yValue,
    r = 10,
    fill = 'white',
    stroke,
    strokeWidth,
    opacity,
    i,
    animate = 'one-dimensional'
  } = $props();

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

  let tY = new Tween(0, tweenParams);

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

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

And here is how our Beeswarm component looks like:

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

  // State variables
  let countriesData = $state([]);
  let width = $state(0);
  let animate = $state('one-dimensional');
  const height = 350;
  const margin = { top: 40, right: 30, bottom: 20, left: 50 };

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

  // Data loading with onMount
  onMount(async () => {
    try {
      const data = await csv('/data/countries_data.csv');
      countriesData = data.sort((a, b) => a.score_Total - b.score_Total);
    } catch (error) {
      console.error('Error loading CSV file:', error);
    }
  });

  // Computed values
  let xDomainData = $derived(countriesData.map((d) => +d.score_Total));

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

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

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

  // Animation timing
  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}
                {animate} />
            {/each}
          </g>
        </svg>
      {/if}
    </div>
    <div class="flex justify-center">
      <button class="variant-filled btn mt-4" onclick={switchCharts}>
        {animate === 'beeswarm' ? 'Show 1D' : 'Show Beeswarm'}
      </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.

<script>
  let positionedData = $derived(
    new AccurateBeeswarm(
      countriesData,
      (d) => 10,
      (d) => xScale(d.score_Total),
      1,
      0,
      0
    ).calculateYPositions()
  );
</script>

Then we are passing the 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}
    {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 { AccurateBeeswarm } from './AccurateBeeswarm.js';
  import Circle from './Basics/Circle.svelte';
  import Axis from './Basics/AxisBottomV5.svelte';
  import { Delaunay } from 'd3-delaunay';
  import Labels from './Basics/Labels.svelte';

  // Props
  let {
    voronoiColor = 'none',
    voronoiStroke = 0,
    voronoiStrokeColor = 'none'
  } = $props();

  // State variables
  let countriesData = $state([]);
  let width = $state(600);
  let pointIndex = $state(null);
  const height = 350;
  const margin = { top: 40, right: 30, bottom: 20, left: 50 };

  // Data loading
  onMount(async () => {
    try {
      const data = await csv('/data/countries_data.csv');
      countriesData = data.sort((a, b) => a.score_Total - b.score_Total);
    } catch (error) {
      console.error('Error loading CSV file:', error);
    }
  });

  // Computed values
  let xDomainData = $derived(countriesData.map((d) => +d.score_Total));

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

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

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

  let points = $derived(positionedData.map((d) => [d.x, d.y + height / 2]));

  let voronoi = $derived(
    points.length > 0
      ? Delaunay.from(points).voronoi([
          margin.left,
          margin.top,
          width - margin.right,
          height - margin.bottom
        ])
      : null
  );

  let found = $derived(positionedData?.[pointIndex] || null);

  function handleMouseOver(e) {
    const { offsetX, offsetY } = e;
    if (voronoi) {
      pointIndex = voronoi.delaunay.find(offsetX, offsetY);
    }
  }

  function handleMouseOut() {
    pointIndex = null;
  }

  // Cleanup
  onDestroy(() => {
    pointIndex = null;
  });
</script>

<main class="relative" bind:clientWidth={width}>
  <!-- Tooltip -->
  <div
    class="tooltip"
    role="tooltip"
    aria-live="polite"
    aria-hidden={!found}
    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
          role="presentation"
          {width}
          {height}
          class="chart"
          onmousemove={handleMouseOver}
          onmouseleave={handleMouseOut}>
          <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>

          <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>

          {#each positionedData as data, i}
            <path
              role="presentation"
              cursor="pointer"
              tabindex="-1"
              onmousemove={handleMouseOver}
              pointer-events="all"
              fill={voronoiColor}
              stroke={voronoiStrokeColor}
              stroke-width={voronoiStroke}
              opacity="0.1"
              d={voronoi.renderCell(i)} />
          {/each}
        </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;
    transition:
      left 300ms ease,
      top 300ms ease;
  }
</style>

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

<script>
  //
  let pointIndex = $state(null);
  //
  let voronoi = $derived(
    points.length > 0
      ? Delaunay.from(points).voronoi([
          margin.left,
          margin.top,
          width - margin.right,
          height - margin.bottom
        ])
      : null
  );

  let found = $derived(positionedData?.[pointIndex] || null);

  function handleMouseOver(e) {
    const { offsetX, offsetY } = e;
    if (voronoi) {
      pointIndex = voronoi.delaunay.find(offsetX, offsetY);
    }
  }

  function handleMouseOut() {
    pointIndex = null;
  }

  // Cleanup
  onDestroy(() => {
    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.

Here is how our Circle.svelte component looks like using the new Tween class in Svelte 5.

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

  let {
    xValue,
    yValue,
    r = 10,
    fill = 'white',
    stroke,
    strokeWidth,
    opacity,
    i,
    animate = 'beeswarm'
  } = $props();

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

  let tY = new Tween(0, tweenParams);

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

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

Thank you for reading!

Join our Discord to share your charts, ask questions, or collaborate with fellow developers!