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
- D3 JS (Data-Driven Documents)
- 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.
- What is a spider chart
- Creating radial axes from data attributes
- Calculating angle in slices from data attributes
- Calculating coordinates from angles
- Drawing lines from D3 line
- Adding interactivity to the spider chart
- Adding legends to the spider chart
Set up the project
-Install React JS
npm create vite@latest spider -- --template react
cd spider
npm install
npm run dev
-Install D3 JS
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.
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.
import { select } from 'd3';
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.
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.
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.
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.
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.
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.
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:
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';
//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.
//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:
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);
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;
}
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';
//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})`)
}
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;
}
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')
Finally, this is how the chart should look like:
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;
Top comments (0)