Radar charts with d3.js

Radar charts with d3.js

ยท

10 min read

Recently, I've been reading a lot of webcomics where character stats are shown on radar charts.

I've also been playing around with d3 for a while now, so I decided to see how I could create a radar chart from scratch using this awesome library.

As an additional bonus, I also decided that the chart had to be built with polygons. Could I have made it easier on myself by using circles only? Probably ๐Ÿคท๐Ÿฝโ€โ™€๏ธ.

In this tutorial, I used CSS to style my charts, but feel free to use javascript to add styles wherever possible.

This tutorial assumes basic knowledge of d3

HTML CODE

<!DOCTYPE html>
<html lang="en">

    <head>
        <meta charset="UTF-8">
        <meta name="viewport"
            content="width=device-width, initial-scale=1.0">
        <title>Radar Chart</title>
        <link rel="stylesheet"
            href="./style.css">
    </head>

    <body>
        <div class="chart">
            <div class="tooltip"></div>
        </div>

        <script src="./lib/d3/d3.min.js"></script>
        <script src="./index.js"></script>
    </body>

</html>

As you can see, the HTML is fairly straightforward, I linked the d3 library, my custom CSS and JS files. I also created a div with a class of chart and another with a class tooltip

The chart div is where our SVG code will be added, and the tooltip div will display some data when we add event listeners to the chart.

Styles to be found here. Nothing much going on there either, just basic styles for the chart

Coding the chart

I'm going to define some variables that will be useful to us later on.

const NUM_OF_SIDES = 5,
    NUM_OF_LEVEL = 4,
    size = Math.min( window.innerWidth, window.innerHeight, 400 ),
    offset = Math.PI,
    polyangle = ( Math.PI * 2 ) / NUM_OF_SIDES,
    r = 0.8 * size,
    r_0 = r / 2,
    center =
    {
        x: size / 2,
        y: size / 2
    };

const wrapper = d3.select( ".chart" )
    .append( "svg" )
    .attr( "width", size )
    .attr( "height", size );

const g = d3.select( "svg" ).append( "g" );

Explanation

NUM_OF_SIDES = The number of sides of our polygon.

NUM_OF_LEVEL = The number of levels we want.

size = The width and height of our SVG wrapper.

polyangle = size of the angle created at the center of the drawing area by each polygon side. The sum of angles at a point is 360 degrees or 2 * Math.PI radians. For a regular polygon, ( i.e the length of all sides of the polygon are equal ) each side would create an angle of size 2 * Math.PI / NUM_OF_SIDES radians .

offset = We want our polygon to start at the top, like a clock. So we need to shift our points by this angle.

r = Distance between center and edge of the drawing area.

r_0 = Maximum allowable distance from center to the edge of a polygon. We want to draw labels, so we need to give a little gap between our chart area and the edge of the drawing area

center = As you know, SVG coordinates start at the top left, we want to move that center to the middle of our drawing area.


To draw a polygon radar chart, we need polygons.

You need two things to draw a polygon

  1. length of each polygon side and,
  2. the coordinates of the polygon vertices

Generating the coordinates of the vertex requires us to know the angle that the vertex will make with the center of our polygon.

Let's create a function for getting points

const generatePoint = ( { length, angle } ) =>
{
    const point =
    {
        x: center.x + ( length * Math.sin( offset - angle ) ),
        y: center.y + ( length * Math.cos( offset - angle ) )
    };
    return point;
};

What's happening here?

trig.png

From the image above, using basic trigonometry, we get x and y as

x = length * Sin(angle)
y = length * Cos(angle)

Making sure that we start at the center of our drawing area, we add our center to x and y

We want our first point to start at the top so we offset our angles by 90 degrees { or (Math.PI /2 ) radians } which is defined as our offset variable.

Now that we can generate a point, we need to generate all the vertices of our polygon

 let points = [];  const length = 100;
        for ( let vertex = 0; vertex < sideCount; vertex++ ) 
        {
            const theta = vertex * polyangle;

            points.push( generatePoint( { length, angle: theta } ) );

        }

To actually draw these points, we need a drawing function.

Let's do that

const drawPath = ( points, parent ) =>
{
    const lineGenerator = d3.line()
        .x( d => d.x )
        .y( d => d.y );

    parent.append( "path" )
        .attr( "d", lineGenerator( points ) );
};

parent = SVG we want to attach our polygon to.

If we run this code, we get this

incomplete polygon

Why is this happening?

Remember, our drawPath function draws a path for us based on the points provided. Closing the path requires that we duplicate the first point and append it to our points array.

points = [...points,points[0]];
drawPath(points,g);

Now we have a polygon, ๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰ ๐ŸŽ‰

Pentagon


Drawing a chart requires data. We also want to include our axis with tick marks, so let's generate some data and ticks.

Our code base will look like so

const NUM_OF_SIDES = 5;
NUM_OF_LEVEL = 4,
    size = Math.min( window.innerWidth, window.innerHeight, 400 ),
    offset = Math.PI,
    polyangle = ( Math.PI * 2 ) / NUM_OF_SIDES,
    r = 0.8 * size,
    r_0 = r / 2,
    center =
    {
        x: size / 2,
        y: size / 2
    };


const tooltip = d3.select( ".tooltip" );

const generateData = ( length ) =>
{
    const data = [];
    const min = 25;
    const max = 100;

    for ( let i = 0; i < length; i++ ) 
    {
        data.push(
            {
                name: "Label",
                value: Math.round( min + ( ( max - min ) * Math.random() ) )
            }
        );
    }

    return data;
};

const genTicks = levels =>
{
    const ticks = [];
    const step = 100 / levels;
    for ( let i = 0; i <= levels; i++ ) 
    {
        const num = step * i;
        if ( Number.isInteger( step ) )
        {
            ticks.push( num );
        }
        else
        {
            ticks.push( num.toFixed( 2 ) );
        }
    }
    return ticks;
};

const ticks = genTicks( NUM_OF_LEVEL );
const dataset = generateData( NUM_OF_SIDES );

const wrapper = d3.select( ".chart" )
    .append( "svg" )
    .attr( "width", size )
    .attr( "height", size );

const g = d3.select( "svg" ).append( "g" );

const scale = d3.scaleLinear()
    .domain( [ 0, 100 ] )
    .range( [ 0, r_0 ] );

...

points = [...points,points[0]];
drawPath(points,g);

generateData will return a different collection every time, we run the code.

scale will help normalize our data so that it fits on our chart.

The length of the dataset will correspond to the number of sides of the polygon, which in our case is 5

Before we move on, let's take a sneak peek at the final chart.

Final radar chart


Let's list the things we need to do to draw the chart

  1. Generate and draw the polygon levels
  2. Generate and draw the lines extending from the center
  3. Generate and draw the chart axis
  4. Draw the data
  5. Draw the labels

We can create a function for each task

1. Generate and draw the polygon levels

...

const generateAndDrawLevels = ( levelsCount, sideCount ) =>
{

    for ( let level = 1; level <= levelsCount; level++ ) 
    {
        const hyp = ( level / levelsCount ) * r_0;

        const points = [];
        for ( let vertex = 0; vertex < sideCount; vertex++ ) 
        {
            const theta = vertex * polyangle;

            points.push( generatePoint( { length: hyp, angle: theta } ) );

        }
        const group = g.append( "g" ).attr( "class", "levels" );
        drawPath( [ ...points, points[ 0 ] ], group );
    }


};

generateAndDrawLevels( NUM_OF_LEVEL, NUM_OF_SIDES );

Using our code from before, when we drew a single polygon, we generate one polygon per level. The size of each polygon is calculated as the ratio of the current level to the total number of levels multiplied by r_0

Our output now looks like this

levels.PNG

2. Generate and draw the lines extending from the center

...

const generateAndDrawLines = ( sideCount ) =>
{

    const group = g.append( "g" ).attr( "class", "grid-lines" );
    for ( let vertex = 1; vertex <= sideCount; vertex++ ) 
    {
        const theta = vertex * polyangle;
        const point = generatePoint( { length: r_0, angle: theta } );

        drawPath( [ center, point ], group );
    }

};


generateAndDrawLevels( NUM_OF_LEVEL, NUM_OF_SIDES );
generateAndDrawLines( NUM_OF_SIDES );

theta is acting as the angle between the line and a vertical line drawn through the middle of our chart

We should have this now

lines.PNG

3. Generate and draw the chart axis

...

const drawText = ( text, point, isAxis, group ) =>
{
    if ( isAxis )
    {
        const xSpacing = text.toString().includes( "." ) ? 30 : 22;
        group.append( "text" )
            .attr( "x", point.x - xSpacing )
            .attr( "y", point.y + 5 )
            .html( text )
            .style( "text-anchor", "middle" )
            .attr( "fill", "darkgrey" )
            .style( "font-size", "12px" )
            .style( "font-family", "sans-serif" );
    }
    else
    {
        group.append( "text" )
            .attr( "x", point.x )
            .attr( "y", point.y )
            .html( text )
            .style( "text-anchor", "middle" )
            .attr( "fill", "darkgrey" )
            .style( "font-size", "12px" )
            .style( "font-family", "sans-serif" );
    }

};

const drawAxis = ( ticks, levelsCount ) =>
{
    const groupL = g.append( "g" ).attr( "class", "tick-lines" );
    const point = generatePoint( { length: r_0, angle: 0 } );
    drawPath( [ center, point ], groupL );

    const groupT = g.append( "g" ).attr( "class", "ticks" );

    ticks.forEach( ( d, i ) =>
    {
        const r = ( i / levelsCount ) * r_0;
        const p = generatePoint( { length: r, angle: 0 } );
        const points =
            [
                p,
                {
                    ...p,
                    x: p.x - 10
                }

            ];
        drawPath( points, groupL );
        drawText( d, p, true, groupT );
    } );
};

generateAndDrawLevels( NUM_OF_LEVEL, NUM_OF_SIDES );
generateAndDrawLines( NUM_OF_SIDES );
drawAxis( ticks, NUM_OF_LEVEL );

Phew, that's quite a bit of code, but it's fairly simple. Using our pointGenerator function we determine the position of out tick lines and tick texts, then we draw them using drawPath and drawText functions.

drawText will also be used to draw our labels later.

I hardcoded some numbers in the drawText function. You'll have to run the code and test to find values that match your use case

Our chart now looks like this

Chart with ticks

We've done well, why don't we take a break and congratulate ourselves on the progress we made so far.

...and we're back.

Let's actually draw our data

4. Draw the data

...
const drawCircles = points =>
{
    const mouseEnter = d =>
    {       
        tooltip.style( "opacity", 1 );
        const { x, y } = d3.event;
        tooltip.style( "top", `${ y - 20 }px` );
        tooltip.style( "left", `${ x + 15 }px` );
        tooltip.text( d.value );
    };

    const mouseLeave = d =>
    {
        tooltip.style( "opacity", 0 );
    };

    g.append( "g" )
        .attr( "class", "indic" )
        .selectAll( "circle" )
        .data( points )
        .enter()
        .append( "circle" )
        .attr( "cx", d => d.x )
        .attr( "cy", d => d.y )
        .attr( "r", 8 )
        .on( "mouseenter",  mouseEnter )
        .on( "mouseleave",  mouseLeave );
};

const drawData = ( dataset, n ) =>
{
    const points = [];
    dataset.forEach( ( d, i ) => 
    {
        const len = scale( d.value );
        const theta = i * ( 2 * Math.PI / n );

        points.push(
            {
                ...generatePoint( { length: len, angle: theta } ),
                value: d.value
            } );
    } );

    const group = g.append( "g" ).attr( "class", "shape" );

    drawPath( [ ...points, points[ 0 ] ], group );
    drawCircles( points );
};

generateAndDrawLevels( NUM_OF_LEVEL, NUM_OF_SIDES );
generateAndDrawLines( NUM_OF_SIDES );
drawAxis( ticks, NUM_OF_LEVEL );
drawData( dataset, NUM_OF_SIDES );

Explanation

Each item in our dataset has a value property.

We scale each value to fit our chart and then get a point. The collection of coordinates can then be used to draw a polygon.

I also added markers that show a tooltip, when you hover on them, using the drawCircles function.

Code output

chart and markers


  1. Draw the labels

We're almost done, all that's left is to label the lines of our chart. See code and output below

...
const drawLabels = ( dataset, sideCount ) =>
{
    const groupL = g.append( "g" ).attr( "class", "labels" );
    for ( let vertex = 0; vertex < sideCount; vertex++ ) 
    {

        const angle = vertex * polyangle;
        const label = dataset[ vertex ].name;
        const point = generatePoint( { length: 0.9 * ( size / 2 ), angle } );

        drawText( label, point, false, groupL );
    }
};

generateAndDrawLevels( NUM_OF_LEVEL, NUM_OF_SIDES );
generateAndDrawLines( NUM_OF_SIDES );
drawAxis( ticks, NUM_OF_LEVEL );
drawData( dataset, NUM_OF_SIDES );
drawLabels( dataset, NUM_OF_SIDES );

Final Chart

That's it.

Here's my output for different configurations

3 sides and 3 levels

 3 sides and 3 levels chart


10 sides and 4 levels

0 sides and 4 levels chart


6 sides and 10 levels

6 sides and 10 levels chart


For the full code see here

The demo is here

Hope you had fun? Until next time, happy coding.

Don't forget to like and share.

ย