DEV Community

Cover image for Building spider chart with D3 JS and React js
simbamkenya
simbamkenya

Posted on • Updated on

Building spider chart with D3 JS and React js

Introduction

A spider chart is a popular chart used in visualizing complex data. It is commonly used to visualize multivariate data in a two-dimensional manner. A spider chart's name comes from its appearance in displaying data in a circular grid.

One of the most prominent features of a spider chart is radial axes. The chart has multiple axes extending from the center. The provided data points are plotted along each axis and then drawn to connect all the provided data points.

Subtopics from the provided data tend to branch out from the center which resembles a spider. In our case, the spider chart will visualize two football players' attributes. It will focus on attributes such as pace, shooting, passing, dribbling, and physical scores falls between 0 and 1.

Technologies

The chart will be built using the following technologies

  1. D3 JS (Data-Driven Documents)
  2. React JS.

What we are going to learn

The project entails building a complex spider chart. The process is very involving and there is a lot to learn.

  1. What is a spider chart
  2. Creating radial axes from data attributes
  3. Calculating angle in slices from data attributes
  4. Calculating coordinates from angles
  5. Drawing lines from D3 line
  6. Adding interactivity to the spider chart
  7. Adding legends to the spider chart

d3 js radar chart

Set up the project

-Install React JS

npm create vite@latest spider -- --template react
cd spider
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

-Install D3 JS

npm install d3
Enter fullscreen mode Exit fullscreen mode

Create a Radar component

Create a components folder and create a Radar component in the folder. The Radar component will be imported into the App component. The Radar component will return the SVG element.

The useRef will have chartRef which stores a reference to the SVG element. This way the component will have a reference to the DOM element. The SVG element will have ref with the value of containerRef.

The SVG element will have a viewBox attribute to the chart responsive on different device sizes. Code for drawing the chart will be contained in the useEffect hook to prevent unnecessary re-rendering.

import React, { useRef, useEffect } from 'react'

function Radar() {
......
//reference to DOM element (svg)
   const containerRef = useRef(null)

    const margin = { top: 20, right: 10, bottom: 60, left: 10 },
            width = 760 - margin.left - margin.right,
            height = 450 - margin.top - margin.bottom;
.....
useEffect(() => { 
//code for the chart goes in here
},[])
.......  
//DOM Element
  return (
    <svg  viewBox={`0 0 ${width + 100} ${height + 100}`} ref={containerRef} >
    </svg>
  )
}

export default Radar
Enter fullscreen mode Exit fullscreen mode

Set up SVG element

We set up an SVG element, this is the canvas that D3 will paint the spider chart. We get access to the SVG element through useRef by containerRef.current. We import select from d3 js.

import { select } from 'd3';
Enter fullscreen mode Exit fullscreen mode
        var svg = select(containerRef.current)
            .attr('width', width + margin.left + margin.right)
            .attr('height', height + margin.top + margin.bottom)
            .append('g')
            .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')
            .attr('fill', 'gray');
Enter fullscreen mode Exit fullscreen mode

Bring in the data

Spider chart works on two-dimensional data. Our dataset has two objects representing two players and each object has five attributes. This chart makes it easy to compare various characteristics of players. The chart will have five axes to represent the five attributes of pace, shooting, dribbling, passing, and physical. The closed shape will touch on each scaled value of player attributes.

const data =  [
    {
      pace : 0.85,
      shooting : 0.92,
      passing : 0.91,
      dribbling: 0.95,
      physical: 0.65,
     },
     {
      pace : 0.89,
      shooting : 0.93,
      passing : 0.81,
      dribbling : 0.89,
      physical : 0.77,
     }
  ]
Enter fullscreen mode Exit fullscreen mode

Setting up axes

The radial chart axes extend from the center. It will have five axes that represent the five attributes in the data. They include passing, dribbling, physical, shooting, and pace. The axes will extend from the center. When setting up these axes, we need a way to calculate angles for each axis. We need to convert coordinates to angles.

In this case center of the chart will be width/2 and height/2. Each axis is represented by an SVG line element. The value of x2in axis line is x + width/2 and the y2 will be y + height/2. Note that they take into account the center position of the chart. We will also require a capitalize helper function to capitalize axes labels.

   const capitalize = (str) => {
        return str.charAt(0).toUpperCase() + str.slice(1)
    }

Enter fullscreen mode Exit fullscreen mode

We set up radius and tick values for the chart. These values will help calculate coordinates for axes and draw ticks.

 const radius = 200;
 const ticks = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
Enter fullscreen mode Exit fullscreen mode

We will calculate the angle as a slice for each axis line and use the radius value of the chart to calculate the coordinates of each axis line. We will use (Math.PI / 2) to offset the angles by 90 degrees. This way angles start at the top of the circle or first quadrant.

const slice = (Math.PI / 2) + (2 * Math.PI * i / attributes.length)
Enter fullscreen mode Exit fullscreen mode

We will revisit my trigonometry mathematics. These basics will help to calculate the x2 and y2 coordinates for each axis line.

Based on the trigonometry chart above when calculating for y coordinate, we are simply calculating for opposite (opp). Having an angle and hypotenuse (Hpy), we can calculate for y coordinate by multiplying sin θ with Hyp. Thus we get y coordinates by Math.sin(angle) * (len). Similarly, trigonometry dictates that the calculation of x coordinates utilizes Math.cos(angle) * (len).

We will use cordForAngle to calculate the coordinates for each axis representing the five attributes. We loop the five attributes, calculate coordinates, and draw an axis line.

  const cordForAngle = (angle, len) => {  
           let x=  Math.cos(angle) * (len);
           let y= Math.sin(angle) * (len);

          return {"x": x, "y": y};
        }
Enter fullscreen mode Exit fullscreen mode

Placing labels on the five axes was challenging. Setting values for dy and dx was more experimental. I tweaked the values till they felt right.

const attributes = Object.keys(data[0])

 const radius = 200;
 const ticks = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]

  const cordForAngle = (angle, len) => {  
           let x=  Math.cos(angle) * (len);
           let y= Math.sin(angle) * (len);

          return {"x": x, "y": y};
        }

        //round axis
        for (var i = 0; i < attributes.length; i++) {
          const slice = (Math.PI / 2) + (2 * Math.PI * i / attributes.length)
          const key = attributes[i]

         //axis values
         const {x, y} = cordForAngle(slice, radius)

         svg.append('line')
          .attr('x2', x + width /2)
          .attr('y2', y + height/2)
          .attr('x1', width/2)
          .attr('y1', height/2)
          .attr('stroke', 'white')
          .attr('stroke-width', 1.5)
          .style('opacity', '0.1')

          svg.append('text')
          .attr('x', x + width/2)
          .attr('y', y + height/2)
          .text(capitalize(key))
          .style('text-anchor', d => (i === 0 ? 'end': i === 1 ? 'end': i=== 2 ? 'end':  i=== 2 ? 'end' : null))
          .attr('dx', d => (i === 0 ? '0.7em' : i === 1 ? '-0.7em'  : i === 2 ? '-0.5em': i === 3 ? '0.3em' : '0.6em'))
          .attr('dy', d => (i === 0 ? '1.3em': i === 1 ? '0.4em': i === 2 ? '-0.5em': i === 3 ? '-0.5em' : '0.4em'))
          .attr('fill', 'white')
        }
Enter fullscreen mode Exit fullscreen mode

This is what we have at this stage:

spider axes

Add ticks to the chart

D3 JS provides scaleLinear that we will use to position ticks and labels. These axes represent the five attributes presented in the data. They will share the circle, the 360% equally. Ticks in this chart will come in the form of circles. Axis labels are offset by 0.85 to prevent overlapping with the axis ticks. To set up the scale we will import scaleLiner from D3.
The radAxis scale will help to plot an object attribute. The scale will take values from 0.1 and 1.0 and map between 0 and the radius of the chart. This will be helpful when placing ticks and labels on the axis.

import { select, scaleLinear } from 'd3';
Enter fullscreen mode Exit fullscreen mode
//radial scale
   const radAxis = scaleLinear()
          .domain([0.1, 1.0])
          .range([0, radius])

  //circle labels
      ticks.forEach(el => {
        svg.append('text')
        .attr('x', width/2)
        .attr('y', height/2 - radAxis(el) - 0.85)
        .text(el)
        .attr('fill', 'white')
        .attr('stroke', 'none')
        .attr('opacity', '0.5')
        .style('text-anchor', 'middle')
        .style('font-size', '0.825rem')
      })
Enter fullscreen mode Exit fullscreen mode

In spider charts axis ticks are circles. The radial nature of the charts requires the use of circles as ticks. The radius of these values are scaled value of ticks.

      //circes levels
       ticks.forEach(el => {
        svg.append('circle')
          .attr('cx', width/2)
          .attr('cy', height/2)
          .attr('fill', 'none')
          .attr('stroke', 'gray')
          .attr('stroke-width', 1.0)
          .attr('r', radAxis(el))
       })


Enter fullscreen mode Exit fullscreen mode

What we have at this point:

Image description

Drawing a closed shape

The chart will have a closed shape. This closed area allows the comparison of multiple entities in the provided dataset. Our dataset holds a series of two entities that represent the two players. Each player entity will have a closed shape area, that takes into account five variables based on player attributes.

Note that we are drawing a closed shape for each entity. Our data has two entities each represented by an object with five attributes. As a result, we will have two closed shapes, one for each object. This is the reason we are looping the data array. We use getCoordPath to create coordinates for each object or entity.

The closed shape on the chart touches the values player's attributes on each of the five axes. These axes will represent player attributes. These attributes include passing, dribbling, physical, shooting, and pace. For each axis such as pace, the player is rated from 0.1 to 1.0.

But what kind of magic do we use in the getCoordPath. There is no magic trust me, just well-thought-out logic. Each data point has five attributes: passing, dribbling, pace, physical, and shooting. We use the for loop to access each attribute's score and convert each score to coordinates for a closed shape path.

We use (Math.PI / 2) to offset the angles by 90 degrees. This way angles start at the top of the circle or first quadrant. To get the angle we use this logic, the five attributes get to share the entire circle 2 * Math.PI. Sharing of the entire 360 degrees is achieved by use a for loop index.

let angle = (Math.PI / 2) + (2 * Math.PI * i / attributes.length);
Enter fullscreen mode Exit fullscreen mode

We then create a helper function to convert each angle into coordinates. The angle is calculated based on the number of attributes from the data set. The getCoordPath function receives a datapoint, accesses each attribute, scales it, and passes it to cordForAngle. Thus we get coordinates for each entry that is then used to plot the closed shape.


//converting data point to coordinates
  const getCoordPath = (dataPoint) => {
        let coord = [];
        for(let i=0; i<attributes.length; i++){
          let attr = attributes[i]
          let angle = (Math.PI / 2) + (2 * Math.PI * i / attributes.length);
          coord.push(cordForAngle(angle, radAxis(dataPoint[attr])))
        }
        return coord;
       }

Enter fullscreen mode Exit fullscreen mode

A spider chart is characterized by an area around the center based on the coordinates on each axis score. D3 JS will convert coordates into an area. This is achieved by d3 line generator. On close inspection line generator requires a dataset with x and y coordinates. The enclosed areas by lines are filled with color.

Drawing the enclosed area will be based on the number of items in the dataset. In our case, we have two data points. we convert these data points into coordinates and then use a line generator. We will import line from D3. This helps to create a line generator.

import { select, scaleLinear, line } from 'd3'; 
Enter fullscreen mode Exit fullscreen mode
       //line generator 
       let lineGen = line()
        .x(d => d.x)
        .y(d => d.y)

        //drawing path
        for(let i=0; i<data.length; i++ ){
          let d = data[i]
          const cord = getCoordPath(d)

        //spider chart 
        svg.append('path')
        .datum(cord)
        .attr('class', 'areapath')
        .attr("d",lineGen)
        .attr("stroke-width",1.5)
        .attr("stroke", 'none')
        .attr("fill", () => i === 0 ? '#FFC4DD': '#B4FF9F')
        .attr("opacity", 0.1)
        .attr('transform', `translate(${width/2}, ${height/2})`)
        }
Enter fullscreen mode Exit fullscreen mode

What we have at this stage:
spider chart

Adding interactivity

Interactivity in this chart will be achieved through CSS. We will add a hover effect on the path element with className of areapath. On a hover effect, the area path opacity will increase from 0.1 to 0.5.

body{
  background-color: #242424;
}
.areapath:hover{
  opacity: 0.5;
}
Enter fullscreen mode Exit fullscreen mode

Add legends to the chart

The chart will need legends. We need a way to easily identify the enclosed areas by the spider chart. It should be easy to tell which attributes are for Messi and Cristiano. This is achieved by assigning different colors and setting up legends to identify data.

Appending legends to the chart
//legends
        svg.append("circle")
        .attr("cx",width/2 + 250)
        .attr("cy", height/2 + 150)
        .attr("r", 10)
        .style("fill", "#FFC4DD")
        .style("opacity", "0.5")

        svg.append("circle")
        .attr("cx", width/2 + 250)
        .attr("cy", height/2 + 180)
        .attr("r", 10)
        .style("fill", "#B4FF9F")
        .style("opacity", "0.7")

        svg.append('text')
        .attr('y', height/2 + 150)
        .attr('x', width/2 + 280)
        .html('Messi')
        .style('stroke', 'none')
        .style('fill', 'white')

        svg.append('text')
        .attr('y', height/2 + 185)
        .attr('x', width/2 + 280)
        .html('Cristiano')
        .style('stroke', 'none')
        .style('fill', 'white')
Enter fullscreen mode Exit fullscreen mode

Finally, this is how the chart should look like:
spider chart with legends

Conclusion

A spider chart is an effective type of chart to visualize two-dimensional data. In our case, the spider chart will visualize two players' attributes. It will help outline the difference in abilities between these two players.

Live Chart: https://messivscristiano-spider-chart.netlify.app/

I am open to freelancing. Contact me: pharesmuruthi@gmail.com or Twitter

The final code on Radar component:

import React, { useRef, useEffect } from "react";
import { select, scaleLinear, line, extent } from "d3";

function Radar(props) {
  const containerRef = useRef(null);
  const margin = { top: 20, right: 10, bottom: 60, left: 10 },
    width = 760 - margin.left - margin.right,
    height = 450 - margin.top - margin.bottom;

  const data = [
    {
      pace: 0.85,
      shooting: 0.92,
      passing: 0.91,
      dribbling: 0.95,
      physical: 0.65,
    },
    {
      pace: 0.89,
      shooting: 0.93,
      passing: 0.81,
      dribbling: 0.89,
      physical: 0.77,
    },
  ];

  const capitalize = (str) => {
    return str.charAt(0).toUpperCase() + str.slice(1);
  };



  useEffect(() => {
    var svg = select(containerRef.current)
      .attr("width", width + margin.left + margin.right)
      .attr("height", height + margin.top + margin.bottom)
      .append("g")
      .attr("transform", "translate(" + margin.left + "," + margin.top + ")")
      .attr("fill", "gray");

    const attributes = Object.keys(data[0]);

    const radius = 200;
    const ticks = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0];

      //radial scale
  const radAxis = scaleLinear().domain([0.1, 1.0]).range([0, radius]);

    const cordForAngle = (angle, len) => {
      let x = Math.cos(angle) * len;
      let y = Math.sin(angle) * len;

      return { x: x, y: y };
    };

    for (var i = 0; i < attributes.length; i++) {
      const slice = Math.PI / 2 + (2 * Math.PI * i) / attributes.length;
      const key = attributes[i];

      //axis values
      const { x, y } = cordForAngle(slice, radius);

      svg
        .append("line")
        .attr("x2", x + width / 2)
        .attr("y2", y + height / 2)
        .attr("x1", width / 2)
        .attr("y1", height / 2)
        .attr("stroke", "white")
        .attr("stroke-width", 1.5)
        .style("opacity", "0.1");

      svg
        .append("text")
        .attr("x", x + width / 2)
        .attr("y", y + height / 2)
        .text(capitalize(key))
        .style("text-anchor", (d) =>
          i === 0
            ? "end"
            : i === 1
            ? "end"
            : i === 2
            ? "end"
            : i === 2
            ? "end"
            : null
        )
        .attr("dx", (d) =>
          i === 0
            ? "0.7em"
            : i === 1
            ? "-0.7em"
            : i === 2
            ? "-0.5em"
            : i === 3
            ? "0.3em"
            : "0.6em"
        )
        .attr("dy", (d) =>
          i === 0
            ? "1.3em"
            : i === 1
            ? "0.4em"
            : i === 2
            ? "-0.5em"
            : i === 3
            ? "-0.5em"
            : "0.4em"
        )
        .attr("fill", "white");
    }

    //circle labels
    ticks.forEach((el) => {
      svg
        .append("text")
        .attr("x", width / 2)
        .attr("y", height / 2 - radAxis(el) - 0.85)
        .text(el)
        .attr("fill", "white")
        .attr("stroke", "none")
        .attr("opacity", "0.5")
        .style("text-anchor", "middle")
        .style("font-size", "0.825rem");
    });

    //circes levels
    ticks.forEach((el) => {
      svg
        .append("circle")
        .attr("cx", width / 2)
        .attr("cy", height / 2)
        .attr("fill", "none")
        .attr("stroke", "gray")
        .attr("stroke-width", 1.0)
        .attr("r", radAxis(el));
    });

    //line generator
    let lineGen = line()
      .x((d) => d.x)
      .y((d) => d.y);

    //converting data point to coordinates
    const getCoordPath = (dataPoint) => {
      let coord = [];
      for (let i = 0; i < attributes.length; i++) {
        let attr = attributes[i];
        let angle = Math.PI / 2 + (2 * Math.PI * i) / attributes.length;
        coord.push(cordForAngle(angle, radAxis(dataPoint[attr])));
      }
      return coord;
    };

    //drawing path
    for (let i = 0; i < data.length; i++) {
      let d = data[i];
      const cord = getCoordPath(d);

      //spider chart
      svg
        .append("path")
        .datum(cord)
        .attr("class", "areapath")
        .attr("d", lineGen)
        .attr("stroke-width", 1.5)
        .attr("stroke", "none")
        .attr("fill", () => (i === 0 ? "#FFC4DD" : "#B4FF9F"))
        .attr("opacity", 0.1)
        .attr("transform", `translate(${width / 2}, ${height / 2})`)


//legends
        svg.append("circle")
        .attr("cx",width/2 + 250)
        .attr("cy", height/2 + 150)
        .attr("r", 10)
        .style("fill", "#FFC4DD")
        .style("opacity", "0.5")

        svg.append("circle")
        .attr("cx", width/2 + 250)
        .attr("cy", height/2 + 180)
        .attr("r", 10)
        .style("fill", "#B4FF9F")
        .style("opacity", "0.7")

        svg.append('text')
        .attr('y', height/2 + 150)
        .attr('x', width/2 + 280)
        .html('Messi')
        .style('stroke', 'none')
        .style('fill', 'white')

        svg.append('text')
        .attr('y', height/2 + 185)
        .attr('x', width/2 + 280)
        .html('Cristiano')
        .style('stroke', 'none')
        .style('fill', 'white')
    }
  }, []);

  return (
    <svg
      viewBox={`0 0 ${width} ${height}`}
      ref={containerRef}
    ></svg>
  );
}

export default Radar;

Enter fullscreen mode Exit fullscreen mode

Top comments (0)