Make a Donut Chart to visualize the scoring totals of the 2018-19 Los Angeles Lakers basketball team.
Data
The first thing we need to create our data visualization is, not coincidentally, data. This well written article explains some of the legal and ethical ramifications of web scraping. This repository offers links to free public data. Dev.to itself has many articles on data, web scrapers, and visualizations. My two cents is that for simple data visualizations projects, good old Chrome Devtools on its own is more than enough to gather and shape data. Check out this over-simplified example.
Name | Age |
---|---|
LeBron James | 34 |
Zion Williamson | 18 |
Micheal Jordan | 56 |
Given the above table here are the steps to massage the data:
- Open Chrome Devtools
- Isolate all table rows
- Convert results from a NodeList to an Array and ditch the title row
- Extract text from each table data cell and map the results to a new array of objects
- Type
c
(the variable name) and pressEnter
and your new array will be displayed in the console - Right click the array and chose
Store as Global Variable
. You will seetemp1
appear in the console. - Use the built in
copy
function to copy the temporary variable to the clipboard -copy(temp1)
- Paste your data into a JavaScript or JSON file.
- 🤯
var a = document.querySelectorAll('tr') // 2
var b = Array.from(a).slice(1) // 3
var c = b.map(el => {
// 4
var name = el.children[0].innerText
var age = el.children[1].innerText
return { name, age }
})
c // 5
// right click array
copy(temp1) // 7
Note that every scenario is different and this example is simplified to help explain the process. Also, all of the logic above can be put into a single function to streamline the process. Remember you can create multi-line functions in the console by using Shift+Enter
to create new lines. With this method we have what amounts to manual web scraping with JavaScript 101. Be sure to read a website's Terms of Service before going willy-nilly
and harvesting data where you aren't supposed to.
Create a Donut Chart
Getting D3 and React to work together is not really that complicated. Generally, all that is needed is an entry point to the DOM and some logic that initializes the visualization when the page loads. To get started with our example project we want to have create-react-app
installed. The first step is to create a new project. The first thing I like to do is clear out the src
directory, leaving only App.js
and index.js
. Don't forget to remove any old import
statements. Before we write any code we need to snag a couple dependencies.
1- Download D3 and Styled Components.
npm i d3 styled-components
2- Create a new file whatever-you-want.js
, or even data.js
in the src
directory. The data used in the example is available in this gist.
3- Create some basic boilerplate that can be used for a variety of projects with this configuration - aka D3 + React + Styled Components. I encourage you to tweak whatever you see fit as like most developers I have my own quircks and patterns. Case in point, I am bothered by #000000
black so I use #333333
, I like the font Raleway
, etc. If you haven't used Hooks before, the useEffect
hook with an empty []
dependency array is similar to componentDidMount
in a React class component. The numbered comments correspond to upcoming steps and are the place to insert the code from those steps.
import React, { useRef, useEffect, useState } from 'react'
import * as d3 from 'd3'
import styled, { createGlobalStyle } from 'styled-components'
import data from './data'
const width = 1000
const height = 600
const black = '#333333'
const title = 'My Data Visualization'
// 4
// 7
export const GlobalStyle = createGlobalStyle`
@import url('https://fonts.googleapis.com/css?family=Raleway:400,600&display=swap');
body {
font-family: 'Raleway', Arial, Helvetica, sans-serif;
color: ${black};
padding: 0;
margin: 0;
}
`
export const Container = styled.div`
display: grid;
grid-template-rows: 30px 1fr;
align-items: center;
.title {
font-size: 25px;
font-weight: 600;
padding-left: 20px;
}
`
export const Visualization = styled.div`
justify-self: center;
width: ${width}px;
height: ${height}px;
// 6
`
export default () => {
const visualization = useRef(null)
useEffect(() => {
var svg = d3
.select(visualization.current)
.append('svg')
.attr('width', width)
.attr('height', height)
// 5
// 8
}, [])
return (
<>
<GlobalStyle/>
<Container>
<div className='title'>{title}</div>
<Visualization ref={visualization} />
{/*10*/}
</Container>
<>
)
}
4- We need to establish a color scheme and some dimensions for our Donut Chart.
The radius of our pastry.
const radius = Math.min(width, height) / 2
It only makes sense to use a Lakers color theme.
var lakersColors = d3
.scaleLinear()
.domain([0, 1, 2, 3])
.range(['#7E1DAF', '#C08BDA', '#FEEBBD', '#FDBB21'])
The D3 pie
function will map our data into pie slices. It does this by adding fields such as startAngle
and endAngle
behind the scenes. We are using an optional sort
function just to shuffle the order of the slices. Play around with this, pass it null
or even leave it out to get different arrangements. Finally, we use the value
function to tell D3 to use the points
property to divide up the pie. Log the pie
variable to the console to help conceptualize what the D3 pie function did to our data.
var pie = d3
.pie()
.sort((a, b) => {
return a.name.length - b.name.length
})
.value(d => d.points)(data)
Now we need to create circular layouts using the arc
function. The variable arc
is for our Donut Chart and the outerArc
will be used as a guide for labels later. getMidAngle
is a helper function to be used at a later time also.
var arc = d3
.arc()
.outerRadius(radius * 0.7)
.innerRadius(radius * 0.4)
var outerArc = d3
.arc()
.outerRadius(radius * 0.9)
.innerRadius(radius * 0.9)
function getMidAngle(d) {
return d.startAngle + (d.endAngle - d.startAngle) / 2
}
5- With a structure in place are almost to the point of seeing something on the screen.
Chain the following to our original svg
variable declaration.
.append('g')
.attr('transform', `translate(${width / 2}, ${height / 2})`)
Now the magic happens when we feed our pie
back to D3.
svg
.selectAll('slices')
.data(pie)
.enter()
.append('path')
.attr('d', arc)
.attr('fill', (d, i) => lakersColors(i % 4))
.attr('stroke', black)
.attr('stroke-width', 1)
Next we need to draw lines from each slice that will eventually point to a label. The well named centroid
function returns an array with [x,y]
coordinates to the center point of the pie
slice (in this case d
) within the arc
. In the end we are returning an array of three coordinate arrays that correspond to the origin point, bend point, and termination point of each line the now appears on the screen. The midAngle
helps determine which direction to point the tail of our line.
svg
.selectAll('lines')
.data(pie)
.enter()
.append('polyline')
.attr('stroke', black)
.attr('stroke-width', 1)
.style('fill', 'none')
.attr('points', d => {
var posA = arc.centroid(d)
var posB = outerArc.centroid(d)
var posC = outerArc.centroid(d)
var midAngle = getMidAngle(d)
posC[0] = radius * 0.95 * (midAngle < Math.PI ? 1 : -1)
return [posA, posB, posC]
})
Now our lines are ready for labels. The label seems to look better by adding some symmetry by flip-flopping the order of name
and points
based on which side of the chart it appears on. Notice that the pie
function moved our original data
into a key named data
. The top level keys of pie
objects contain the angle measurements used in the getMidAngle
function.
svg
.selectAll('labels')
.data(pie)
.enter()
.append('text')
.text(d => {
var midAngle = getMidAngle(d)
return midAngle < Math.PI
? `${d.data.name} - ${d.data.points}`
: `${d.data.points} - ${d.data.name}`
})
.attr('class', 'label')
.attr('transform', d => {
var pos = outerArc.centroid(d)
var midAngle = getMidAngle(d)
pos[0] = radius * 0.99 * (midAngle < Math.PI ? 1 : -1)
return `translate(${pos})`
})
.style('text-anchor', d => {
var midAngle = getMidAngle(d)
return midAngle < Math.PI ? 'start' : 'end'
})
6- To polish off our labels with some style we just need to add a couple lines of code to the Visualization
styled component. Having used D3 to add a class
attribute inside a React useEffect
hook and then defining that class using Styled Components seems to check the boxes on integrating the libraries.
.label {
font-size: 12px;
font-weight: 600;
}
7- We are looking good but why not add a little more flavor to give the user an interactive feel. We can quickly grab the total amount of points scored using the sum
function from D3.
var total = d3.sum(data, d => d.points)
8- The showTotal
function will simply tack on a text
node displaying our total. The text-anchor
style property of middle
should center the text within our Donut hole. The hideTotal
function will come into play in a bit. Notice we are calling the showTotal
function to make sure the text is showing when the page loads.
function showTotal() {
svg
.append('text')
.text(`Total: ${total}`)
.attr('class', 'total')
.style('text-anchor', 'middle')
}
function hideTotal() {
svg.selectAll('.total').remove()
}
showTotal()
We should tack on another class for total
right next to our label
class from step 6.
.total {
font-size: 20px;
font-weight: 600;
}
9- The numbered comment system is a getting a little gnarly at this point, but if you have made it this far you are smart enough to follow along. These next functions can go below hideTotal
. These are listeners we will apply to each slice.
function onMouseOver(d, i) {
hideTotal()
setPlayer(d.data)
d3.select(this)
.attr('fill', d3.rgb(lakersColors(i % 4)).brighter(0.5))
.attr('stroke-width', 2)
.attr('transform', 'scale(1.1)')
}
function onMouseOut(d, i) {
setPlayer(null)
showTotal()
d3.select(this)
.attr('fill', lakersColors(i % 4))
.attr('stroke-width', 1)
.attr('transform', 'scale(1)')
}
When a slice is hovered the stroke and fill will be emphasized and a slight scale up will add a cool effect. The total points text will also be toggled so we can stick a tooltip with a little more information smack dab in the hole. First we need to create a piece of state
, what would a React app be without it.
const [player, setPlayer] = useState(null)
A keen observer may have noticed the reference to this
and wondered what was happening. The following listeners need to be tacked on to the end of the slices
D3 chain.
.attr('class', 'slice')
.on('mouseover', onMouseOver)
.on('mouseout', onMouseOut)
Since we are using a transform
on the slice
class let's control it through another couple lines in the Visualization
styled component.
.slice {
transition: transform 0.5s ease-in;
}
10- We can now create the tooltip to display the player
state that changes as individual slices are moused over.
{
player ? (
<Tooltip>
<div>
<span className='label'>Name: </span>
<span>{player.name}</span>
<br />
<span className='label'>Points: </span>
<span>{player.points}</span>
<br />
<span className='label'>Percent: </span>
<span>{Math.round((player.points / total) * 1000) / 10}%</span>
</div>
</Tooltip>
) : null
}
In terms of new information the user is only getting the percentage of the team's points the current player scored. However, with the centralized position combined with the movement a nice effect and a nice feeling of interactivity is created. A similar pattern could be used more effectively if there was more information to show or I was smarter. It seems the last thing needed is the Tooltip
component, which goes with the other styled components.
export const Tooltip = styled.div`
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: ${radius * 0.7}px;
height: ${radius * 0.7}px;
display: grid;
align-items: center;
justify-items: center;
border-radius: 50%;
margin-top: 10px;
font-size: 12px;
background: #ffffff;
.label {
font-weight: 600;
}
`
Alas, our final code should look something like the following.
import React, { useRef, useEffect, useState } from 'react'
import * as d3 from 'd3'
import data from './data'
import styled, { createGlobalStyle } from 'styled-components'
/**
* Constants
*/
const width = 1000
const height = 600
const radius = Math.min(width, height) / 2
const black = '#333333'
const title = 'Los Angeles Lakers Scoring 2018-19'
/**
* D3 Helpers
*/
// total points
var total = d3.sum(data, d => d.points)
// lakers colors
var lakersColors = d3
.scaleLinear()
.domain([0, 1, 2, 3])
.range(['#7E1DAF', '#C08BDA', '#FEEBBD', '#FDBB21'])
// pie transformation
var pie = d3
.pie()
.sort((a, b) => {
return a.name.length - b.name.length
})
.value(d => d.points)(data)
// inner arc used for pie chart
var arc = d3
.arc()
.outerRadius(radius * 0.7)
.innerRadius(radius * 0.4)
// outer arc used for labels
var outerArc = d3
.arc()
.outerRadius(radius * 0.9)
.innerRadius(radius * 0.9)
// midAngle helper function
function getMidAngle(d) {
return d.startAngle + (d.endAngle - d.startAngle) / 2
}
/**
* Global Style Sheet
*/
export const GlobalStyle = createGlobalStyle`
@import url('https://fonts.googleapis.com/css?family=Raleway:400,600&display=swap');
body {
font-family: 'Raleway', Arial, Helvetica, sans-serif;
color: ${black};
padding: 0;
margin: 0;
}
`
/**
* Styled Components
*/
export const Container = styled.div`
display: grid;
grid-template-rows: 30px 1fr;
align-items: center;
user-select: none;
.title {
font-size: 25px;
font-weight: 600;
padding-left: 20px;
}
`
export const Visualization = styled.div`
justify-self: center;
width: ${width}px;
height: ${height}px;
.slice {
transition: transform 0.5s ease-in;
}
.label {
font-size: 12px;
font-weight: 600;
}
.total {
font-size: 20px;
font-weight: 600;
}
`
export const Tooltip = styled.div`
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: ${radius * 0.7}px;
height: ${radius * 0.7}px;
display: grid;
align-items: center;
justify-items: center;
border-radius: 50%;
margin-top: 10px;
font-size: 12px;
background: #ffffff;
.label {
font-weight: 600;
}
`
export default () => {
const [player, setPlayer] = useState(null)
const visualization = useRef(null)
useEffect(() => {
var svg = d3
.select(visualization.current)
.append('svg')
.attr('width', width)
.attr('height', height)
.append('g')
.attr('transform', `translate(${width / 2}, ${height / 2})`)
svg
.selectAll('slices')
.data(pie)
.enter()
.append('path')
.attr('d', arc)
.attr('fill', (d, i) => lakersColors(i % 4))
.attr('stroke', black)
.attr('stroke-width', 1)
.attr('class', 'slice')
.on('mouseover', onMouseOver)
.on('mouseout', onMouseOut)
svg
.selectAll('lines')
.data(pie)
.enter()
.append('polyline')
.attr('stroke', black)
.attr('stroke-width', 1)
.style('fill', 'none')
.attr('points', d => {
var posA = arc.centroid(d)
var posB = outerArc.centroid(d)
var posC = outerArc.centroid(d)
var midAngle = getMidAngle(d)
posC[0] = radius * 0.95 * (midAngle < Math.PI ? 1 : -1)
return [posA, posB, posC]
})
svg
.selectAll('labels')
.data(pie)
.enter()
.append('text')
.text(d => {
var midAngle = getMidAngle(d)
return midAngle < Math.PI
? `${d.data.name} - ${d.data.points}`
: `${d.data.points} - ${d.data.name}`
})
.attr('class', 'label')
.attr('transform', d => {
var pos = outerArc.centroid(d)
var midAngle = getMidAngle(d)
pos[0] = radius * 0.99 * (midAngle < Math.PI ? 1 : -1)
return `translate(${pos})`
})
.style('text-anchor', d => {
var midAngle = getMidAngle(d)
return midAngle < Math.PI ? 'start' : 'end'
})
function showTotal() {
svg
.append('text')
.text(`Total: ${total}`)
.attr('class', 'total')
.style('text-anchor', 'middle')
}
function hideTotal() {
svg.selectAll('.total').remove()
}
function onMouseOver(d, i) {
hideTotal()
setPlayer(d.data)
d3.select(this)
.attr('fill', d3.rgb(lakersColors(i % 4)).brighter(0.5))
.attr('stroke-width', 2)
.attr('transform', 'scale(1.1)')
}
function onMouseOut(d, i) {
setPlayer(null)
showTotal()
d3.select(this)
.attr('fill', lakersColors(i % 4))
.attr('stroke-width', 1)
.attr('transform', 'scale(1)')
}
showTotal()
}, [])
return (
<>
<GlobalStyle />
<Container>
<div className='title'>{title}</div>
<Visualization ref={visualization} />
{player ? (
<Tooltip>
<div>
<span className='label'>Name: </span>
<span>{player.name}</span>
<br />
<span className='label'>Points: </span>
<span>{player.points}</span>
<br />
<span className='label'>Percent: </span>
<span>{Math.round((player.points / total) * 1000) / 10}%</span>
</div>
</Tooltip>
) : null}
</Container>
</>
)
}
Top comments (6)
I have wanted to do something like this for a while, but it seems so daunting! Nice work!
Thanks. Trying to follow D3 examples is the worst part. Many are out of date and looking up what each thing does is a pain. I'm looking into a map for international player origin countries and its driving me insane right now.
Recently found this which I thought was a nice collection: d3-graph-gallery.com/index.html
Otherwise....
How to DataViz w/d3:
Observable is pretty crazy. Yet another thing to learn, but after a couple days its fun. observablehq.com/@benjaminadk/rank...
Thanks for the heads up.
That amount of code for a pie chart. I think it's an overkill. Anyway thanks for sharing your knowledge.
That's d3.