Build a radar diagram with d3.js

In this tutorial I am going to show you how to create a radar diagram with random dots within 4 segments. Each dot will be assigned to a quadrant of the radar.

Babette Landmesser
Jan 8 · 11 min read

At mediaman, we recently launched the very first and still very rough MVP of our smart data radar. With this small application we are presenting tools and techniques around data science in quadrants. We rate the different possibilities by our day-to-day usage up to „we have heard of it but have not yet tried it“. For that, we divided each quadrant into four rings holding our evaluation.

Image for post
Image for post
mediaman’s Smart Data Radar

Now, I am going to explain how you are able to create your own radar based on this layout by using the JavaScript library D3.

I prefer using TypeScript over Vanilla JS simply because I am used to TS.

Step 1: Install dependencies

Of course we need to install our dependencies via npm (or yarn). Run

npm install — save d3

Next add the following devDependencies to your package.json and run

npm i 

afterwards.

"devDependencies": {
"@babel/core": "^7.4.5",
"@babel/preset-env": "^7.4.5",
"@types/d3": "^6.2.0",
"autoprefixer": "^9.1.3",
"babel-loader": "^8.0.6",
"copy-webpack-plugin": "^6.4.0",
"core-js": "^3.1.4",
"css-loader": "^3.0.0",
"extract-loader": "^2.0.1",
"file-loader": "^2.0.0",
"node-sass": "^4.9.3",
"postcss-cli": "^6.0.0",
"postcss-loader": "^3.0.0",
"regenerator-runtime": "^0.13.2",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"ts-loader": "^8.0.4",
"ts-node": "^9.0.0",
"typescript": "^4.0.3",
"webpack": "^4.35.0",
"webpack-cli": "^3.3.5",
"webpack-dev-server": "^3.7.2"
},

I’m going to compile the TypeScript via Babel and Webpack, including styles as well.

Step 2: Setup your project and implement webpack and TypeScript

My personal webpack file can be found here.

Create an index.html and a main.ts file. Paste the following minimum HTML code into your file:

<div class=”techradar__chart”>
<div class=”chart”>
<svg id=”radar”></svg>
</div>
</div>

Here, we have some containers for our individual use: In our .chart container we’re adding the SVG element for later extending it via D3.

Step 3: Extend the SVG with some basic stuff

In this part, we’re going to set our D3 domain, the axes and our range. Remember, we want to display 4 circles and always start in the middle.

const svgOuter = d3.select('#radar'),
margin = {top: 20, right: 20, bottom: 20, left: 20},
width = 900,
height = 900,
domainwidth = width - margin.left - margin.right,
domainheight = height - margin.top - margin.bottom,
tooltip = d3.select('.tooltip');

svgOuter.attr('viewBox', '0 0 ' + (width + margin.left + margin.right) + ' ' + (height + margin.top + margin.bottom));

const svg = svgOuter.append('g').style('transform', 'translate(' + margin.left + 'px, ' + margin.top + 'px)')

const x = d3.scaleLinear()
.domain([-4, 4])
.range([0, domainwidth]);
const y = d3.scaleLinear()
.domain([-4, 4])
.range([domainheight, 0]);

const xAxis = svg.append('g')
.attr('class', 'x-axis')
.attr('transform', 'translate(0,' + y.range()[0] / 2 + ')');

xAxis.call(d3.axisBottom(x).ticks(6))
.call(g => g.selectAll('.tick text').remove())
.call(g => g.selectAll('.tick line').remove())
.call(g => g.selectAll('.domain').remove());

svg.append('g')
.attr('class', 'y-axis')
.attr('transform', 'translate(' + x.range()[1] / 2 + ', 0)')
.call(d3.axisLeft(y).ticks(6))
.call(g => g.selectAll('.tick text').remove())
.call(g => g.selectAll('.tick line').remove())
.call(g => g.selectAll('.domain').remove());

The radar has a value range from -4 to 4 on both axes. That allows us to put four circles on it: The inner circle has a radius of 1, the second of 2, .. and so on.

In case you just copied the code above, you may see nothing now as I remove the complete axis texts and lines. But that doesn’t matter — we will create the circles shortly.

Excursion: Some theoretical maths stuff

Image for post
Image for post

Since we want to differentiate each quadrant with another color, we need to calculate the arc. This is done via the radius of this ring. The formula for this part is:

2*π*radius * (degree / 360)

In our case this is quite simple as the degree is always 90. As you will see later, D3 already has a function for this part.

For the dot calculation we need to know several parts about how to calculate with and within circles.

First of all, we should keep in mind that circles are usually calculated with degrees. But for our „coordinate system“ we need to calculate with radian measure:

Image for post
Image for post
Image for post
Image for post
Image for post
Image for post

1° = 1 * (π/ 180)
30° = 30 * (π/ 180)

Turning that around for getting our angle we calculate:

α = DEGREEVALUE * π / 180

To get the x- and y-coordinates in our system, sinus and cosine come into play.

x = cos(α) while y = sin(α)

Hence, for all dot calculation we can combine the radius with sinus and cosine to get the exact x and y values:

Image for post
Image for post
Image for post
Image for post

At last, remember that we want to fill all four quadrants. Therefore, it might be easy to calculate with some kind of shift or offset.

Remember, 1/4 — or 90deg — of a circle is 1/2π.

Well, that’s the basics.

Step 4: Creating the radar circles

First of all, we need to draw 4 arcs of 90 degree. Luckily, D3 already brings a function called arc(). With four chained methods we’re able to draw arcs for all four rings in all four quadrants:

const arcGen = (innerRadius, outerRadius, startAngle, endAngle) => d3.arc()
.innerRadius(innerRadius)
.outerRadius(outerRadius)
.startAngle(startAngle)
.endAngle(endAngle);

In the example from mediaman you can see that the outer circles are thinner than the inner ones. This is the reason for adding innerRadius and outerRadius here. StartAngle and endAngle will be multiples from 90: 0–90, 90–180, 180–270, 270–360 — for each quadrant.

Then, I also extracted the calculation for all four circles per quadrant into functions:

const innerFirstCircle = (startAngle: number, endAngle: number) => {
return arcGen((domainheight / 8) + 8, (domainheight / 8) - 8, startAngle, endAngle);
};
const innerSecondCircle = (startAngle: number, endAngle: number) => {
return arcGen((domainheight / 4) + 6, (domainheight / 4) - 6, startAngle, endAngle);
};
const innerThirdCircle = (startAngle: number, endAngle: number) => {
return arcGen((domainheight / 8 * 3) + 4, (domainheight / 8 * 3) - 4, startAngle, endAngle);
};
const innerFourthCircle = (startAngle: number, endAngle: number) => {
return arcGen((domainheight / 2) + 2, (domainheight / 2) - 2, startAngle, endAngle);
};

Here, the inner and outer radius of the arc are calculated by the height of our circle adding and subtracting some pixels for the circle thickness. Remember, our axis were -4 to 4 for x and y. So this means that the arc need to start and end on exactly these values. That’s why I calculated for all circles the height divided by 8 — for the smallest circle which has a radius of 1, 4 — for the 2nd circle which has a radius of 2, 3/8 — for the third circle which has a radius of 3, and by 2 for the outer circle which has a radius of 4.

Next, create all arcs with colors. Repeat the step below for all four colors.

const circleTransform = 'translate(' + (domainwidth / 2) + 'px, ' + (domainheight / 2) + 'px)';
const GREEN = '#84BFA4';
const green = svg.append('g')
.attr('class', 'green');

// green background gradient area
green.append('rect')
.attr('width', domainwidth / 2)
.attr('height', domainheight / 2)
.attr('x', 0)
.attr('y', 0)
.attr('fill', 'url(#green-radial-gradient)')
.style('opacity', .25);

// green circle borders
green.append('path')
.attr('fill', GREEN)
.attr('d', innerFirstCircle(3 * Math.PI / 2, 4 * Math.PI / 2))
.style('transform', circleTransform);
green.append('path')
.attr('fill', GREEN)
.attr('d', innerSecondCircle(3 * Math.PI / 2, 4 * Math.PI / 2))
.style('transform', circleTransform);
green.append('path')
.attr('fill', GREEN)
.attr('d', innerThirdCircle(3 * Math.PI / 2, 4 * Math.PI / 2))
.style('transform', circleTransform);
green.append('path')
.attr('fill', GREEN)
.attr('d', innerFourthCircle(3 * Math.PI / 2, 4 * Math.PI / 2))
.style('transform', circleTransform);

Let me explain the code. My green area is my first quadrant. It’s the upper left hand side of a circle. Think of our arc logic from the math part and remember that 90deg is 0,5π. As the green are starts at 270deg (clockwise counted) and ends at 360deg, we pass 3π/2 and 4π/2 to the arc function for all four circles.

Pay attention here to the function calls. The parameters for startAngle and endAngle passed to the “inner circle” functions are always the same as we are still in the same quadrant. For the other quadrants this would mean:

For the second quadrant — upper righthand side — this would mean to pass 0deg to 90deg: 0, π/2.
For the third quarter — lower righthand side — it is 90deg to 180deg: π/2 to π.
And the fourth quarter — lower lefthand side — it is 180deg to 270deg: π to 3π/2.

Then you should see all four circles and in case you changed the fill color of the path for each group, you should also see all four quadrants in different colors.

Step 5: Placing the dots inside the circles.

For the dots, you should add a JSON file with a structure similar to this one:

{
"text": "Dot-Name",
"quadrant": 1,
"circle": 2
}

The quadrant should be self-explainable. The circle number defines the ring in which the dot should be placed. For circle, it means somewhere between the inner and the second circle.

Now, we loop through all our values in the data JSON file (don’t forget to import the JSON file).

const values: Dots[] = (data as Data[]).reduce((list: Dots[], element: Data) => {
const entry: Dots = {
x: null,
y: null,
text: element.text,
quadrant: element.quadrant,
fillColor: ['green', 'blue', 'red', 'orange'][element.quadrant - 1]
};

let dot;
do {
dot = calculateDot(element, x, y);
// calculate this exact dot new until it has a distance to every other dot that is longer than 28 (2x dot radius)
} while (list.some(item => checkDistanceBetweenDots(dot.xValue, item.x, dot.yValue, item.y) < 20));

entry.x = dot.xValue;
entry.y = dot.yValue;

list.push(entry);

return list;
}, []);

First of all, we create a new object for each dot. Here we’re going to prepare the dot data for the D3 processing when we later add the dots to our SVG. But first we need to calculate x- and y-values for each dot.

As I want the dots to be positioned randomly, I also need a check against the distance between each dot. Otherwise a user might not see or perceive all dots.

So, how are the dots calculated?

const pi = Math.PI;
export const calculateDot = (element: Data, x: d3.ScaleLinear<number, number>, y: d3.ScaleLinear<number, number>) => {
// radian between 5 and 85
const randomDegree = ((Math.random() * 80 + 5) * pi) / 180;
const circle = element.circle - 0.2;
const r = Math.random() * 0.6 + (circle - 0.6);
// multiples of PI/2
// loops through every quadrant starting from top left, bottom left, bottom right, top right
const shift = pi * [1,4,3,2][element.quadrant - 1] / 2;

return {
xValue: x(Math.cos(randomDegree + shift) * r),
yValue: y(Math.sin(randomDegree + shift) * r)
};
};

After all, it’s pretty simple when recalling the mathematical formula and logic behind circles. First we create a random degree in radian measure, from 5 to 85 — to avoid dots overlapping quadrants: The „normal“ formula for a fixed degree value would be: DEG * π / 180. Here, I replace DEG by my Math.random function.

Next, we know in which circle (1–4) the dot should be placed. I subtract 0.2 here — again to avoid overlapping into another circle.

Then we calculate the radius (r). Don’t mess it up with the dots’ radius because here it is the „distance“ from the center of our „coordinate system“ to this dot. I call it radius here because it’s similar to a circle calculation.

The next part of „shift“ is a special one. Scroll back to my math section: an arc of exactly 90 degree — which is our quadrant size — is 1π/2. So, for calculating all dots for all circles and all quadrants, we simply multiply π with the quadrant number. Unfortunately, my quadrant number is in a different order than circle calculation which is why I added the array [1, 4, 3, 2] here to show that it’s the other way around this time (upper left, lower left, lower right, upper right). For example a dot has a quadrant number of 3, it will return index 2 of this array — which is 3. Another example for quadrant 4: This will return value 2 — so the lower righthand quadrant. This would be pretty wrong as quadrant 4 is the lower lefthand quadrant. But see in the return statement that the shift of one quarter is always added to the random degree. Then, the dot for quadrant 4 will be within the lower lefthand quadrant.

Next, we return an object for each dot, containing the exact x- and y-value. You may ask yourself what is this x() and y() function about: Check how I’ve set up the x-axis and y-axis at the beginning of the TypeScript and see that I created scaling functions using d3-scaleLinear for x and y values. Continue reading about d3-scales here.

And that’s it for the dot calculation. The distance between each dot which is checked in the do-while-loop is just using Pythagoras:

export const checkDistanceBetweenDots = (x1: number, x2: number, y1: number, y2: number) => {
const a = x2 - x1;
const b = y2 - y1;
return Math.sqrt((a * a) + (b * b));
};

Now, that you calculated all x- and y-values for the dots, you can implement the dots into the SVG using d3:

g.append('g')
.attr('class', 'circles')
.selectAll('circle')
.data(values)
.enter().append('circle')
.attr('class', d => 'dot is-' + d.fillColor)
.attr('r', 5)
.attr('data-value', d => d.text)
.attr('cx', d => d.x)
.attr('cy', d => d.y)

You should now see all the dots from your JSON file randomized positioned in your radar. Feel free to add texts or mouseover functions here.

🥳

Summary

This example shows pretty clear that D3 is a very powerful and extendible framework. I assume, it would be a lot more time consuming to create different arcs in different colors for creating circles with any other framework. Here, we highly benefit from the huge possibilities in terms of customization.

For beginners it might look very confusing and messy but once you understand how D3 expects your SVG to be created and all its children to be arranged in code, it is quite handy to create beautiful visualizations. Also, it becomes easier to extract parts of the code into functions for reusing them.

My personal struggle with a visualization like this was definitely the maths theory — as my last maths class had been years ago… 😁

Create & Code

UX & IT

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

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