Build a radar diagram with d3.js
By: Babette Landmesser
Tags: Diagrams, Radar, Data Visualization, JavaScript, D3js

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:

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