Building spider chart with D3 JS and React js
Tags: d3js, datascience, javascript, dataviz
Published: 2023-12-01 11:28:21

## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/eidegy34f9agyrm38cvi.gif)
### Set up the project
-Install React JS

```javascript
npm create vite@latest spider -- --template react
cd spider
npm install
npm run dev
```
-Install D3 JS

```javascript
npm install d3
```


### 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. 

```javascript
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
```

### 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. 

```javascript
import { select } from 'd3';
```

```javascript
        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');
```

### 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.

```javascript
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,
     }
  ]
```

### 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 `x2`in 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.

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

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

```javascript
 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]
```
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.

```javascript
const slice = (Math.PI / 2) + (2 * Math.PI * i / attributes.length)
```
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.

```javascript
  const cordForAngle = (angle, len) => {  
           let x=  Math.cos(angle) * (len);
           let y= Math.sin(angle) * (len);
          
          return {"x": x, "y": y};
        }
```
   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. 

```javascript
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')
        }
```

This is what we have at this stage:

![spider axes](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y6tspwd8f649gll187cp.PNG)

### 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. 

```javascript
import { select, scaleLinear } from 'd3';
```

```javascript
//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')
      })
```

   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.

```javascript
      //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))
       })


```
What we have at this point:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/89erpx0ewsgbwa0jle80.PNG)
### 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. 

```javascript
let angle = (Math.PI / 2) + (2 * Math.PI * i / attributes.length);
```
   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.  

```javascript

//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;
       }

```
       
   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.

```javascript
import { select, scaleLinear, line } from 'd3'; 
```

```javascript
       //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})`)
        }
```
What we have at this stage: 
![spider chart](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/apmhumvsh42fahl5yavm.PNG)

### 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.

```CSS
body{
  background-color: #242424;
}
.areapath:hover{
  opacity: 0.5;
}
```

### 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.  

```javascript
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')
```

Finally, this is how the chart should look like:
![spider chart with legends](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j6wxd2qdhzozuno9kwi1.PNG)

### _**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](https://twitter.com/SimbaMkenya)

_**The final code on Radar component**_:

```javascript
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;

```