Want Some Bubbles With Your Donut…Chart?

Donut charts are great to visualize the proportional value of each piece of data. On the other hand, bubble charts are great to compare individual weight of each data.

Why not putting bubbles inside a Donut Chart to get the best of both worlds to create, wait for it… a Bubble Donut Chart ? (demo here)

In this article I will explain how I managed to draw this type of chart based on a simple data set.

I’ve been largely inspired by Kris Van Bael from his contribution on stackoverflow

First step : define a data structure

We’ll use a very simple structure to model our dataset because we’ll be able to affect bubbles appearance with only two properties : weight and group.

Each bubble size will be directly based on their weight and their group will define in which donut section they will appear.

Let’s see what looks like our dataset in a JSON format:

[
{
"weight": 14,
"group": "group_1"
},
{
"weight": 32,
"group": "group_2"
},
{
"weight": 16,
"group": "group_2"
},
...
]

Let’s see how to draw this chart

So we need to define:

  1. the size of each bubble
  2. the exact position of the bubble in the donut (I wont lie to you, this is the hardest part)

Bubble size

In our Bubble Donut Chart, each bubble has a size proportional to their weight compared to the donut area.

So to be able to define each bubble size, let’s calculate our donut area.

A donut is composed of two disc, the outer one (the biggest) and the inner one (the smallest). We can calculate the area of both discs thanks to this formula:

Finally to get the donut area, we can just subtract the smallest disc area from the biggest one.

Here’s a little javascript sample:

const outerRadius = 400;
const innerRadius = 100;
const outerDiscArea = Math.PI * Math.pow(outerRadius, 2);
const innerDiscArea = Math.PI * Math.pow(innerRadius, 2);

const donutArea = outerDiscArea - innerDiscArea;

All of our bubbles will be a part of the donut area so we could imagine that the sum of all bubble weight is the equivalent of the donut area.
Given a bubble weight, we can easily know its ratio with the total of all bubble weight.

Then, if we apply this ratio to the donut area, we get the bubble area.
Now we’ve got the bubble area we can use this little formula to get its radius and we’ll be done !

And because a little javascript snippet is worth a thousand words:

const allBubbleWeightSum = bubbles.reduce((sum, bubble) => 
sum + bubble.weight
, 0);
bubbles.forEach(bubble => {
const bubbleArea = bubble.weight * donutArea / allBubbleWeightSum;
const bubbleRadius = Math.sqrt(bubbleArea / Math.PI);
});

Bubble positionning

This part is the hardest one. Bubbles of same group need to stick next to each other and we want them to sightly cover a section of our donut.

To do that, we’ll build a grid for each section of the donut, helping us to place every bubbles.

A grid for a simple rectangle would look like this:

But this would be too easy… Here how it looks like applied on our donut:

Donut divided in sections with points representing the grid of each sections
Donut divided in sections with points representing the grid of each sections
Each section of the donut has a unique color to better visualize every grids

Each section’s grid size is based on number of bubbles it will contain. The precision of the bubble positioning will be directly affected by the size of the grid (as performance of our algorithm).

We’ll store each section grid as an array and, to make things easier, we will generate it as we could do for a square.

Here is what the grid could look like for a 4x4 resolution:

const donutSectionGrid = 
[
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0]
];

And this is how you could generate it:

const gridSize = sectionBubbles.length;
const grid = [];
for (let x=0; x<gridSize; x++) {
for (let y=0; y<gridSize; y++) {
grid[y][x] = 0;
}
}

So, each grid point is an element of the array. With their index we can find what position on the grid they have.

We won’t replace their values (zero for now) with their real position in pixels (we’ll convert grid point to pixels later).
For now, we’ll store, for each grid point, the distance with one of the closest edge of the section…

…And this is the beginning of our positioning algorithm !

We’ll need a function to convert a grid point in pixels coordinates and some edges position of each section.

With some trigonometry, it’s not that hard to find a position from an angle (thanks to sinus and cosinus) and that’s why we’ll try first to determinate what is the angle of our grid point.

A donut section has a startAngle and an endAngle. The grid will help us because each section is divided proportionally by its grid size.
So, the angle of a grid point at position y is proportional to the section angle.

The section angle is not hard to found:

const sectionAngle = endAngle - startAngle;

It means that with a grid point at position y you can write this to get it’s angle:

const gridPointAngle = startAngle + sectionAngle / gridSize * y;

We’re now missing only one element to get the bubble position: the distance of the grid point from the center.

On sure thing, our distance will be equal or above the inner radius and below or equal of the outer radius of the donut.

const gridPointDistance = innerRadius + (outerRadius - innerRadius) / gridSize * x;

Now, we’ve got everything to get the real bubble position !

const bubblePosition = {
x: gridPointDistance * Math.cos(gridPointAngle),
y: gridPointDistance * Math.sin(gridPointAngle)
}

And here is how our grid coord converting function look like

const sectionAngle = endAngle - startAngle;
const gridPointAngle = startAngle + (sectionAngle * y) / gridLength;
const gridPointDistanceWithCenter = innerRadius + (outerRadius - innerRadius) * x / gridLength;

const bubblePosition = {
x: gridPointDistanceWithCenter * Math.cos(gridPointAngle),
y: gridPointDistanceWithCenter * Math.sin(gridPointAngle)
}

We can finally get back on our positioning algorithm but before moving on, let’s see every steps of it:

  1. loop through each grid point and store the distance with the closest edge in the grid
  2. sort bubble by their size
  3. loop though each bubble
  4. Iterate on bubble array, find the max distance in the grid: its coordinates is the position of the current bubble
  5. Regenerate the grid, storing for each grid point the closest distance with edges or already drawn bubbles
  6. go to 4 when all bubbles not already drawn
See the algorithm processing

Step 1. So let’s start and store every grid point distance with closest edge.

So to do this, we need the coordinates of some edges of the section like those which are darker in the image above. Once again, we can use the grid to get those edges:

const edges = [];

for (let i = 0; i <= gridSize; i++) {
edges.push(gridCoordToPos(0, i);
edges.push(gridCoordToPos(i, 0);
edges.push(gridCoordToPos(gridSize, i);
edges.push(gridCoordToPos(i, gridSize);
}

Great, we’ve got our edges, we can now start our positioning algorithm by looping every grid point and finding their closest edge.

for (let y = 0; y <= gridSize; y++) {
for (let x = 0; x <= gridSize; x++) {
const coords = gridPointPosition(x, y);
const closestEdgeDistance = getMinDistance(edges, coords);

grid[y][x] = closestEdgeDistance;
}
}

Step 2. Let’s sort bubbles by their size. This should be easy right ?

bubbles.sort((a, b) => {
if (a.weight === b.weight) return 0;
return b.weight > a.weight ? 1 : -1;
});

Step 3 & 4. Find the max distance

const drawnBubblesCoordinates = [];bubbles.forEach(bubble => {
const maxDistanceCoordinates = null;
const maxDistance = grid[0];
for (let y = 0; y <= gridSize; y++) {
for (let x = 1; x <= gridSize; x++) {
if (grid[y][x] > maxDistance) {
maxDistance = grid[y][x];
maxDistanceCoords = {x, y};
}
}
}

// use the tool of your choice to draw the bubble
// store drawn bubbles coordinates (see why on step 5)
drawnBubblesCoordinates.push(
gridPointPosition(maxDistanceCoords.x, maxDistanceCoords.y)
);
// Step 5. Regenerate the grid});

Step 5. Regenerate the grid. We’ve already done that before but only with distances from edges. Now we also need to take into account already drawn bubbles

for (let y = 0; y <= gridSize; y++) {
for (let x = 0; x <= gridSize; x++) {
const gridPointPosition = gridPointPosition(x, y);
const closestEdgeDistance = getMinDistance(edges, gridPointPosition);

const bubbleDistances = drawnBubblesCoordinates.map(
bubbleCoord => getDistance(
coords,
bubbleCoord) - bubbleCoord.r
);

grid[gridIndex] = Math.min(
closestEdgeDistance,
...bubbleDistances
);
}
}

That’s it ! You can do this for each donut section and you should get a Bubble Donut Chart !

Conclusion

That was a long road to get this result !

This article doesn’t cover how to draw bubbles but only how to calculate everything to be able to draw them. I hope you enjoyed the reading and don’t hesitate to send me your own Bubble Donut Charts !

You can find source code and a live demo on my github.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store