“A picture says more than a thousand words,” goes a common saying, and indeed, there are plenty of situations in which drawing something is much faster to convey information than writing it out. Parsing information visually predates the invention of language by eons, and so it is no wonder that even today, humans are much faster in understanding an image than text. This is the reason scientists almost always include plots and figures in papers to complement the text.
What holds true for scientific papers also holds true in situations where you want to display information programmatically. Whenever you have some information you want to convey to someone else visually, you have to ask three questions:
- What data do you want to visualize? Usually, we have plenty of data, but not all of them is relevant in a specific context. Selecting the pieces of information most relevant to telling a story to someone else is thus the first important question you need to answer.
- How do you want to visualize this data? As always, many roads lead to Rome, and as such, the second question is to decide which type of visualization best conveys the intention of the data. Sometimes, a bar chart works, sometimes a line plot is better, and sometimes circles.
-
How do you implement this visualization? This is the big question I want to answer today in this guide. Just because you have a mental image in your head as to what the visualization should look like doesn’t mean you know how to implement this. The devil, as so often, is in the details. People who have had experience with Python’s
matplotlib
or R’sggplot2
probably can relate.
This guide focuses on a visualization of storage space as an example (question 1) using a segmented circle visualization (question 2) using programmatic SVG images (question 3). I want to share with you what I learned about drawing circles in SVG from implementing this use case, because it is at the same time self-evident as it is tricky to implement.
Exposé: Use-Case & Conventions
Before diving in, a few words on the use-case we’re going to focus on today, and some conventions that I will use throughout the text.
Use-Case: Visualizing Storage Space
The use-case this guide is derived from is simple to explain. In my free time, I am currently writing a server application with an emphasis on storage space. Think Dropbox, OneDrive, iCloud, or Google Drive. This naturally means that administrators of this server application should be able to quickly spot any issues with the storage, primarily: are we running out of space? This by itself would not merit a visualization, because even though it is just text, it is easy to realize that “5% free” is more urgent than “95% free.” But it isn’t as simple as that, as a quick example will show.
Let’s say you reserved 1 GB of space for this server. Then, say you have 1,000 users, and you allow each user to store up to 10 MB of data on this server. Very often, users don’t fill up their entire allowed space, so in reality, let’s assume the average user only fills 100 KB. An indicator that simply shows the free storage would be an egregious lie. Why? Let’s do the math.
In our average case, all users taken together would occupy 1,000 × 100 KB = 100,000 KB (or 100 MB) of space. In our example, this is perfectly fine, and a visualization would tell you that only 10 % of the space is occupied, leaving 90 % free space. That’s great, right? Well, not exactly. We also have to consider the case that each user suddenly decides to exhaust their quota. Then we would have 1,000 × 10 MB = 10 GB of occupied space. That’s 10 times greater than the actual storage space. If that happens, users who decide to fill up their space later would lose out and get weird errors from the server that it’s out of space, even though their quota indicates they should still have a few Megabyte free. So the goal here is to find a sweet spot where you can allow the theoretical quota to be a bit over the actually available storage, but still low enough that the chances of a user not being able to use their quota if they really want to, are negligible. How do you, the server administrator, decide on how much space everyone should be allowed to take? That’s where our visualization comes in.
To finish up this exposé, let us answer the three questions from above:
- What data do you want to visualize? We have 5 data points (total space, free space, used space, total reserved quota, used quota) that we can choose from. To make our visualization convey the correct information, we need to relate the used quota and the total reserved quota to the available free space of the server. In addition, to help administrators decide if they should start reducing their users’ quotas or if they should just add more storage space to their server, we want to show them a quick visualization of their disk. This gives them a better judgment of how quick the relation of the quota to free storage space might change if things to south.
- How do you want to visualize this data? Essentially, we have two ways of how to visualize this. One way is two progress bars next to each other, similar to how storage space is visualized in your computer’s Explorer, Finder, or the Linux file browser. The second way is essentially the same, but as a ring. A ring, or circle, has the benefit that it is more compact and thus lends itself more naturally to the dashboard, for which I designed this visualization.
- How do you implement this visualization? Now to the spicy part. On the web, there are essentially two ways of creating visualizations. Canvas are bad because they aren’t accessible, so I decided to use Scalable Vector Graphics, or SVGs.
Conventions
To finish up, here are the conventions I am going to use in the following text. I will show you HTML code that programmatically builds the SVG, and JavaScript code that calculates the corresponding values. I will also include results so that you can directly inspect what the code will look like. What I will not show is how to actually move the calculated values into the SVG code, because that is not part of this tutorial. I will share, at the end, a Vue.js component that implements all of that, and that you can use, if you wish. Adapting this to other frontend frameworks such as React is trivial, and converting this into a WebComponent for usage in no-framework settings should also be fairly trivial.
Defining the Data Structure
Before we even write a single line of code, we have to decide on the data structure we want to use. But in order to know what additional information we need, we should start by sketching out a concept of what the result should look like:
You will see a few things in this visualization:
- We can immediately see that the entire disk is about 75% filled.
- We can immediately see that all the allocated but unused quota is much lower than the total available free space. This means that even if all our users decide to use up their quotas, we would still be in the green.
- If the allocated quota exceeds the free space, we would use a segment to display by how much the theoretical quota exceeds the available free space, giving a quick visualization as to how dire the problem is. Again, it is not an issue if the reserved quota exceeds the free disk space, it is only an issue if that margin is too small. Remember that most users will never use up all their quota, meaning that there is some leeway.
Now that we have an idea of what we want to design, we need to define the data structure. Again, the visualization tells us what we need:
- For the entire SVG, we need to know:
- Its size (because it is a square, we only need one side)
- The ring thickness
- Where the ring should start (you will see why we need this in a second)
- The gap at the bottom that we should carve out.1
- For each ring, we further need to know:
- A base color (in case the segments do not fill up the entire ring; this is not the case here, but I like things to be configurable)
- A label (this is useful for accessibility reasons so that screen readers can tell their users what the ring itself represents)
- A list of segments to be added for this ring
- For each segment, we lastly need to know:
- Which color to draw the segment in
- A label (in which we can explicate the percentage/absolute value of the segment and explain what it represents)
- The ratio the segment should fill (this should be a number between
0.0
and1.0
)
All of this leads us to the following data (and structure) that I will use throughout the examples here:
const ringData = [
// Outer ring
{
label: 'Available quota and free disk space',
baseRingColor: '#dddddd',
segments: [
{ ratio: 0.1, color: '#c6314d', label: 'Used Quota' },
{ ratio: 0.2, color: '#e19739', label: 'Allocated unused quota' },
{ ratio: 0.7, color: '#3cd59b', label: 'Free, unreserved space' }
]
},
// Inner ring
{
label: 'Disk Space',
baseRingColor: '#dddddd',
segments: [
{ ratio: 0.7, color: '#c6314d', label: 'Used Disk Space' },
{ ratio: 0.3, color: '#3cd59b', label: 'Free Disk Space' },
]
}
]
const circleSize = 160 // Size in pixels
const lineWidth = 30 // Size in pixels
Basics: Drawing a Circle in SVG
Now let us first explore how we can actually draw a circle in SVG. SVG is very powerful, but it manages to achieve all of this with surprisingly few directives. Drawing a simple circle is as simple as using <circle>
:
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="50" />
</svg>
The result:
For now, the circle is pretty black. If you want to only draw the outline, this is as simple as defining the fill
, stroke
(the line), and stroke-width
properties:
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="50" fill="none" stroke="black" stroke-width="10" />
</svg>
The result:
Oh, what happened here? This is a first thing to note: The outline’s center will be at the coordinates of the radius, and it spreads evenly out both inward and outward as you increase the stroke width. In the example above, the radius of the circle is 50
, the SVG size is 100
, and the stroke width is 10
, meaning that the circle is in total 5
too large on each side to fit inside the SVG’s view box. In this toy example, this is easily corrected by reducing the radius by half the stroke width:
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="45" fill="none" stroke="black" stroke-width="10" />
</svg>
The result:
Drawing Segments of a Circle
Now we have a circle. But how do we draw individual segments of this circle? Easy-peasy: Simply manipulate the stroke to only draw a part of the circle. This is easily achieved with the property stroke-dasharray
, which is a list that defines when the stroke will be drawn vs. when the stroke will have gaps. This is how I was convinced I needed to do this for a long time, because this is what the internet™ will tell you when you google for how to do that.
The math for doing so is pretty straight forward:
- You need to know the total circle circumference, which is $2 \pi r$.
- Then you, multiply this with the ratio of the circle you want to have filled to get the actual length that should be drawn.
- For each segment, you then also calculate the offset which is dictated by the amount of the circle the previous segments already fill (this way you can “chain up” multiple segments).
- Generate the
stroke-dasharray
from that.
But hold on! There is a pressing issue with this approach: Essentially, you draw one entire circle for each segment on top of each other. What you modify is only where the circle’s outline is drawn. The rest of the circle is still “there,” it is only not visible because the fill of the circle is transparent, and the outline’s (stroke) color is transparent where it isn’t drawn. This is an issue for technical accessibility (think about showing a title when the user hovers with their mouse over a specific segment). Also, I don’t think it’s clean to draw ten circles over each other, even though it achieves the job.
Luckily, SVG offers a remedy for this: An arc. Instead of using <circle>
to draw a circle and setting the stroke-dasharray
appropriately, we are going to draw a <path>
and use its d
-parameter to draw an arc – one arc for each segment. However, the syntax for drawing an arc is extremely unintuitive for the uninitiated (including me), and this is the primary reason for why I am sharing this knowledge with you. Here’s the syntax for drawing an arc, taken from the Mozilla MDN:
A rx ry x-axis-rotation large-arc-flag sweep-flag x y
This is quite a lot, and despite Mozilla taking a good stab at explaining this mess of parameters, it will be difficult. So we first need to understand circle math a bit better. What you need to know for now is that the arc-command fundamentally expects two coordinates from between which to draw the arc! You can ignore all other parameters for now, just remember that we need two coordinates.
Excursus: Circle Math
Welcome back to high school! At least that’s how I felt revisiting all of this. But circle math (at least the stuff we need for today) is pretty quick to explain, so here’s a mild refresher. Remember that the arc command requires us to provide two points. Since we want to draw segments of a circle, we need to realize that the two points we want to provide to the arc command are points on a circle. How do we find points on a circle, however? With the sine and cosine functions. Those are defined on the unit circle to get any point on the circle.
The unit circle is simply defined as a circle of radius one, centered around the origin of our coordinate system. That means that the point to the right is $(1,0)$, the point at the top is $(0,1)$, left is $(-1,0)$, and bottom is $(0,-1)$. As you can see, each of these points requires two coordinates, one for the x-axis, and one for the y-axis. These are what we can get using sine and cosine. Wikipedia has a pretty good visualization that shows this fact, and that also tells us that the x-coordinate is described by the cosine, and the y-coordinate is described by the sine:
The only thing that we need for this is the angle, $\theta$ (theta). Then, calculating a point is trivial:
const [x, y] = [Math.cos(theta), Math.sin(theta)]
But what is $\theta$, exactly? My first intuition was that a full circle has 360°. And you would be true that, to get around the circle ones, you would pass 360 degrees. However that is not the theta that cosine and sine expect. No, what they expect are radians. Interestingly, the Wikipedia article never explicates that (at least not that I have seen it, but I may have overlooked it), it merely states that “If $\theta = \pi$, the point is at the circle’s halfway. If $\theta = 2 \pi$, the point returned to its origin.” You may remember $2 \pi$ from above: This is also the circumference of the unit circle (since $r = 1$). I was always bad at conceptualizing radians, but I feel this exercise finally hammered home the point (pun maybe intended).
Pulling this together, to get the two required points that we need to draw an arc, we simply have to calculate two $\theta$ values, one for the beginning of the segment, and one for the end:
const MAX_RADIANS = 2 * Math.PI
const thetaStart = 0
const segmentRadians = MAX_RADIANS * segmentRatio // The radians used up by this segment
const thetaEnd = thetaStart + segmentRadians
We only have to adjust thetaStart
depending on how many segments are before the one we are currently drawing. With this information we can calculate the two points we have to provide to the arc command:
const [x1, y1] = [Math.cos(thetaStart), Math.sin(thetaStart)]
const [x2, y2] = [Math.cos(thetaEnd), Math.sin(thetaEnd)]
However, this only gives us points on a unit circle. Since we want to also make the circles any size we want, we have to scale those points accordingly. How do we do that? Well, simple vector math tells us that, if we multiply a vector with a scalar (a single number), we only change the length of the vector, but not its direction. So we can simply multiply the coordinates with the target radius of the circle, and we have the points on our actual, bigger circle:
const [x1, y1] = [Math.cos(thetaStart) * radius, Math.sin(thetaStart) * radius]
const [x2, y2] = [Math.cos(thetaEnd) * radius, Math.sin(thetaEnd) * radius]
Voilà! … right? Not quite. There are three missing pieces to consider. The first is the origin of the circle based on this math. By definition, the coordinates that are described by $\theta = 0$ are actually the point to the right of the circle. Secondly, sine and cosine move around the unit circle in a counter-clockwise fashion, but (at least in Western cultures) we associate the “start” of something with the left side, and its “end” with the right side, so we would want the points to move clockwise, instead. Lastly, remember that we wanted to have a gap in our circle to make it simpler to identify where it starts and where it ends.
First, the gap, since that is trivial now. If we specify the gap in a ratio of a full circle, calculating a, e.g., gap of 10% in terms of the radians of a full circle. The available radians that we can use to fill up with our segments is simply the remainder, and we start with the first point at half the gap:
const circleGap = 0.1 * MAX_RADIANS
const availableTheta = MAX_RADIANS - circleGap
const startOffset = circleGap / 2
The second problem can then be solved by simply reversing thetaStart
and thetaEnd
:
const thetaStart = MAX_RADIANS - startOffset // Move from the right point clockwise to the start
const thetaEnd = thetaStart - segmentRadians // Move further clockwise to the end
Finally, with this out of the way, the first problem is now also trivial to solve. Since the unit circle starts at the right, and we want to start at the bottom, and we are moving backwards from the full radians, we have to move the start 25% of the full circle forward, or 75% of the max radians:
const circleOrigin = MAX_RADIANS * 0.75
const thetaStart = circleOrigin - startOffset
If we wanted the circle to start to the left, we would multiply the radians with $0.5$, and if we wanted the circle to start at the top, we would multiply them with $0.25$.
Finally, for any segment but the first, we have to move thetaStart
by whichever amount of radians those other segments already cover so that the segment starts at the correct place:
const previousSegments = [0.1, 0.2] // Assume two previous segments that fill 30% of the circle
const previousRatio = previousSegments.reduce((prev, cur) => prev + cur, 0)
const previousTheta = previousRatio * availableTheta
// thetaStart for our current segment is then:
const thetaStart = circleOrigin - startOffset - previousTheta
Armed with this information, we can now return to the SVG world and implement this.
Drawing a Circle Segment with SVG
To get start, let us first draw a full segment that fills all the way from start to finish. To do so, we have to plug in the correct numbers for the following SVG template:
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="M x1 y1 A radius radius 0 1 1 x2 y2" />
</svg>
Note that we “move” the imaginary cursor of the path to our start point first, which means we don’t have to provide the point $(x_1,y_1)$ to the arc command anymore and can directly start with the radius. This means we directly start with the radius. The radius, for us, is the same for x and y, because we want to draw a circle. If you wanted to draw an elliptical arc instead, you would have to provide different radii. The next value 0
describes the “x-axis rotation.” Again, this is irrelevant for us because both radii are equal, which means that even if we rotate this around, this won’t make any difference. However, the next two values are important. The first one is the “large-arc-flag” and should indicate if the arc should take the “long” or the “short” route between the two points.
What?! Let me explain. If you only specify two points and task the engine to draw an arc between them, there are strictly speaking two paths available under the condition that the arc should follow a specific radius. Let me demonstrate this with my initial sketch:
Calculating whether we need to tell the engine to draw the arc around the long way or the short way is as simple as looking at the amount of radians that the particular segment spans. If it is more than half a circle, that is, if $\theta_{start} - \theta_{end} \gt \frac{2 \pi}{2}$ (the amount of radians is larger than half the circle circumference, or $\pi$), we need to pass the large arc flag. If the amount of radians equals $\pi$, then it doesn’t matter because both paths are equally long, so I let you be the judge of whether you want to use the >=
or the >
operator.
The final parameter is the so-called “sweep-flag.” To understand this, you need to remember that, by providing only two points and a radius, the arc can theoretically follow the path of two circles. With this sweep flag, you simply select which one of the circles to follow. This means that, for our purposes of drawing along a single circle, we need to fix it. The inner circle is selected by setting the sweep flag simply to 1
.
Now we have everything in place to draw a circle! Using the radius from the initial example, and using a “gap” of 10% of the circle, we can calculate the numbers quickly and generate an arc-path to use:
function arcForCircle () {
const MAX_RADIANS = 2 * Math.PI
const gap = 0.1 * MAX_RADIANS
const radius = 45
const startOffset = gap / 2
const availableTheta = MAX_RADIANS - gap
const largeArc = 1 // The full circle always goes around the large path
const thetaStart = MAX_RADIANS * 0.75 - startOffset
const thetaEnd = thetaStart - availableTheta
const [x1, y1] = [Math.cos(thetaStart) * radius, Math.sin(thetaStart) * radius]
const [x2, y2] = [Math.cos(thetaEnd) * radius, Math.sin(thetaEnd) * radius]
return `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}`
}
Plugging in our numbers produces the following SVG code:
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="M -13.90576474687264 -42.79754323328191 A 45 45 0 1 1 13.905764746872634 -42.79754323328191" />
</svg>
And this is the result:
Wait… what?! Where is our circle?! If you open up your developer tools, and inspect the SVG, you can find where the path is. It turns out, the circle has been drawn about 50% off to the left, and 100% off to the top. What has happened here? Simple: There is a mismatch between the coordinate systems. Remember, the unit circle is defined to be centered at the coordinate system. The SVG coordinate system, however, starts at the top-left. In addition, the normal coordinate system has positive y-values towards the top, whereas the SVG coordinate system has positive y-values towards the bottom.
This means that we have to apply two final transformations. First, we have to transpose the center of the circle that will be drawn 50% to the left and 50% to the bottom. We can achieve this by simply taking the image’s total size (in our example 100
), and moving both points by half of it (50
). Second, since the y-axis of SVGs is mirrored, we have to flip the sign of the y-coordinates. Adding these two final improvements, we arrive at a function to calculate the correct values in this context:
function arcForCircle () {
const svgSize = 100
const translate = svgSize / 2
const MAX_RADIANS = 2 * Math.PI
const gap = 0.1 * MAX_RADIANS
const radius = 45
const startOffset = gap / 2
const availableTheta = MAX_RADIANS - gap
const largeArc = 1 // The full circle always goes around the large path
const thetaStart = MAX_RADIANS * 0.75 - startOffset
const thetaEnd = thetaStart - availableTheta
const [x1, y1] = [Math.cos(thetaStart) * radius + translate, -Math.sin(thetaStart) * radius + translate]
const [x2, y2] = [Math.cos(thetaEnd) * radius + translate, -Math.sin(thetaEnd) * radius + translate]
return `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}`
}
Using this improved function yields the following SVG (note that I have now added the necessary attributes to remove the filling, make the outline of the circle 10 large, and used a more pleasing color than pitch black):
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path
d="M 36.09423525312736 92.79754323328191 A 45 45 0 1 1 63.905764746872634 92.79754323328191"
fill="none" stroke="#dddddd" stroke-width="10"
/>
</svg>
And this is the result:
Drawing a First Segmented Circle
Now let’s combine everything we have learned today to make a function that allows us to draw several segments. To do so, first we must make the function general so that it can yield not just a single, full circle, but any parts of that circle. We have to change the function a bit:
- Pass a
ratio
parameter that can range from0
to1.0
. For the segments, this is usually below 1, and to draw a full circle, we simply pass 1. To do so, we simply have to multiply theavailableTheta
inthetaEnd
with the ratio. - We need to actually calculate the
largeArc
flag now. This is trivial with our knowledge now — simply check if the radians covered by this segment surpass half a circumference, or $\pi$. - Adjust the start offset for each segment based on the preceding segments. For this, we pass a second parameter to the function that describes the ratio already covered by all previous segments. Then, we subtract the radians corresponding to this
offsetRatio
fromthetaStart
to move the beginning of the segment accordingly.
function arcForSegment (ratio, offsetRatio) {
const svgSize = 100
const translate = svgSize / 2
const MAX_RADIANS = 2 * Math.PI
const gap = 0.1 * MAX_RADIANS
const radius = 45
const startOffset = gap / 2
const availableTheta = MAX_RADIANS - gap
const offsetTheta = availableTheta * offsetRatio // CHANGED
const thetaStart = MAX_RADIANS * 0.75 - startOffset - offsetTheta // CHANGED
const thetaEnd = thetaStart - availableTheta * ratio // CHANGED
const largeArc = thetaStart - thetaEnd > Math.PI ? 1 : 0 // CHANGED
const [x1, y1] = [Math.cos(thetaStart) * radius + translate, -Math.sin(thetaStart) * radius + translate]
const [x2, y2] = [Math.cos(thetaEnd) * radius + translate, -Math.sin(thetaEnd) * radius + translate]
return `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}`
}
With this, we only need a way to generate the corresponding SVG elements. Remember the data structure of our segments, which includes a ratio, a color, and a label. For the segment drawing function we only need the ratio. To calculate the offset ratio, we simply use the reduce
function, which allows us to calculate what computer scientists call the “cumsum” (cumulative sum) of ratios of all elements before the current one:
function drawSegmentedCircle (segments) {
const pathElements = [
// Calculate the arc path for the full circle. By drawing this first,
// we ensure that the other segments cover the circle.
arcForSegment(1, 0)
]
for (let i = 0; i < segments.length; i++) {
// Note that "prev" is a number, while "cur" is still a segment object. Refer to
// the documentation on the reduce function for more information.
const offsetRatio = segments.slice(0, i).reduce((prev, cur) => prev + cur.ratio, 0)
const segmentPath = arcForSegment(segments[i].ratio, offsetRatio)
pathElements.push(segmentPath)
}
return pathElements
}
Using these two functions, we can now turn our data structure into a list of path segments that we can draw:
const segments = [
{ ratio: 0.1, color: '#c6314d', label: 'Used Quota' },
{ ratio: 0.2, color: '#e19739', label: 'Allocated unused quota' },
{ ratio: 0.7, color: '#3cd59b', label: 'Free, unreserved space' }
]
const paths = drawSegmentedCircle(segments)
let svgContent = ''
// First, add our circle with a neutral base color
svgContent += `<path d="${paths[0]}" fill="none" stroke="#dddddd" stroke-width="10" />`
for (let i = 1; i < paths.length; i++) {
svgContent += `<path d="${paths[1]}" fill="none" stroke="${segments[i - 1].color}" stroke-width="10" />`
}
This yields the following SVG code (I added the usual SVG wrapper, which remains unchanged):
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path
d="M 36.09423525312736 92.79754323328191 A 45 45 0 1 1 63.905764746872634 92.79754323328191"
fill="none" stroke="#dddddd" stroke-width="10"
/>
<path
d="M 36.09423525312736 92.79754323328191 A 45 45 0 0 1 15.32690407508948 78.68407953869104"
fill="none" stroke="#c6314d" stroke-width="10"
/>
<path
d="M 15.32690407508948 78.68407953869104 A 45 45 0 0 1 9.28278263902913 30.839931879571722"
fill="none" stroke="#e19739" stroke-width="10"
/>
<path
d="M 9.28278263902913 30.839931879571722 A 45 45 0 1 1 63.905764746872634 92.79754323328191"
fill="none" stroke="#3cd59b" stroke-width="10"
/>
</svg>
And this is the final result:
Note that the base circle is not visible, because the segments fill the entire available space. However, keeping this “base circle,” even if it is not visible allows you to dynamically change the sizes of these ratios in such a way that the total amount of the segments will be less than 100%. This means that you can see the underlying circle, and it highlights that it is not completely filled.
The Final Step: Drawing Multiple Concentric Circles
One final puzzle piece concerns the drawing of several circles. As indicated in the beginning, being able to draw two, or maybe even more, concentric circles at the same time allows to visually compare multiple ratios – in our use-case, disk space and quota space. The only thing we have to do for this is to dynamically calculate the circle radius. Instead of using a fixed radius, we can simply calculate the radius based on the index of the ring in question. Because math is always beautiful, we do not have to change anything else, because all the rest is wholly independent of the radius size.
We follow a simple algorithm here:
- The total available space for all of our rings is half the size of the entire SVG. This way, the first ring fills the entire available space, and all other rings are evenly spaced out inside of it.
- Given that we want to evenly space out all rings, the space available to each ring is a simple fraction of the total available space.
- The radius for the ring is then the maximum radius minus one space per ring index (since indices start at 0, the first ring’s radius is equal to the maximum radius)
- Lastly, we have to reduce the radius by half the width of the lines that we want to have. This is because the stroke spreads out evenly around the actual circumference of the circle, as described earlier. For example, for our example of
stroke-width
of10
, we need to reduce the radius by 5.
function circleRadius (ringIdx, totalRingCount) {
const lineWidth = 10 // The thickness of our circles
const svgSize = 100 // Taken from the other function
// The total available space (for our radius) is the SVG size by half.
const totalAvailableSpace = svgSize / 2
// Each ring has this proportion available (to space them out equally with
// space in between).
const spacePerRing = totalAvailableSpace / totalRingCount
// The ring in question has the radius determined by index * space, and then
// minus half a line width (to place the center of the ring centrally inside
// the available space).
const radiusForRing = totalAvailableSpace - (ringIdx * spacePerRing) - (lineWidth / 2)
return radiusForRing
}
Then, instead of statically setting the radius to 45
, we simply call the function to get the radius. Everything else remains unchanged. To demonstrate to you how beautifully simple all of this is in the end, here is an interactive example, preset to the data that I presented earlier.
Conclusion
Explaining all of this took a surprising amount of text, but in the end I believe this to be a very satisfying ability. Being able to create all kinds of concentric circles and dynamically change them accordingly will help you visualize even the most complicated data very quickly. In addition, using modern, accessible SVGs instead of having to fall back to raster images means that your visualization remains future-proof. I hope that you could learn something today.
If you are interested in using this for yourself, click this link to view a Vue component that implements precisely what I outlined today. In addition, this component also accounts for the labels that were not part of this guide, and it groups all elements together logically.
As a bonus round, here is what happens if you drive this to the extreme. The following SVG contains 5 concentric circles, all shoulder to shoulder, each with a randomly generated assortment of segments using a pastell color palette.
You can generate this monster of a circle with just a few lines of code. Here’s the snippet in case you want to recreate and try it for yourself! Simply pass the DEMODATA
variable to the Ring indicator component:
const DEMODATA: RingData[] = []
const PALETTE_COLORS = [
'#FBF9EA', '#F8D5C5', '#F5B3A1', '#DB8B8B', '#6973D1',
'#88D1D6', '#A8E6C8', '#E5EBB7', '#FBE3BD', '#FBE6C5'
]
// Generate 5 rings. Together with circle size and line width they will completely fill the thing
for (let i = 0; i < 5; i++) {
const ring: RingData = { segments: [], baseRingColor: '#dddddd' }
while (ring.segments.reduce((prev, cur) => prev + cur.ratio, 0) < 1.0) {
const remainingRatio = 1 - ring.segments.reduce((prev, cur) => prev + cur.ratio, 0)
const newRatio = Math.random() * 0.1 // Ensure that we have AT LEAST 10 segments per ring
const randomColor = PALETTE_COLORS[Math.floor(Math.random() * PALETTE_COLORS.length)]
ring.segments.push({ ratio: Math.min(newRatio, remainingRatio), color: randomColor })
}
DEMODATA.push(ring)
}
I hope you liked today’s… surprisingly length article! If you did, let me know.
1 I didn’t talk about this gap before, but this gap is pretty useful. If you were to just fill an entire circle, it would be much more difficult to identify where the data starts, and where it ends. By leaving a gap, it becomes much simpler to parse the start and end of the circle.