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 lang="ts">import { onMount } 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 = [];
let xScale, yScale;
let width = 600;
const height = 350;
const margin = {
top: 40,
right: 30,
bottom: 20,
left: 50
};
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);
}
});
$: 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());
$: 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"
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"
on:mouseleave={handleMouseOut}
on:blur
on:focus
role="presentation">
{#if countriesData.length > 0}
<svg {width} {height} class="chart">
<g
transform="translate(0,0)"
role="presentation"
on:mousemove={handleMouseOver}
on:mouseleave={handleMouseOut}>
<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}
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 */
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 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!
Join our Discord to share your charts, ask questions, or collaborate with fellow developers!