Make aDonut Chart to visualize the scoring totals of the 2018-19Los 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 oldChrome 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:
- OpenChrome Devtools
- Isolate all table rows
- Convert results from aNodeList to anArray 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 aJavaScript orJSON file.
- 🤯
vara=document.querySelectorAll('tr')// 2varb=Array.from(a).slice(1)// 3varc=b.map(el=>{// 4varname=el.children[0].innerTextvarage=el.children[1].innerTextreturn{name,age}})c// 5// right click arraycopy(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 usingShift+Enter
to create new lines. With this method we have what amounts to manual web scraping withJavaScript 101. Be sure to read a website'sTerms of Service before goingwilly-nilly
and harvesting data where you aren't supposed to.
Create a Donut Chart
GettingD3 andReact 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 havecreate-react-app
installed. The first step is to create a new project. The first thing I like to do is clear out thesrc
directory, leaving onlyApp.js
andindex.js
. Don't forget to remove any oldimport
statements. Before we write any code we need to snag a couple dependencies.
1- DownloadD3 andStyled Components.
npm i d3 styled-components
2- Create a new filewhatever-you-want.js
, or evendata.js
in thesrc
directory. The data used in the example is availablein this gist.
3- Create some basic boilerplate that can be used for a variety of projects with this configuration - akaD3 +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 fontRaleway
, etc. If you haven't usedHooks before, theuseEffect
hook with an empty[]
dependency array is similar tocomponentDidMount
in aReact class component. The numbered comments correspond to upcoming steps and are the place to insert the code from those steps.
importReact,{useRef,useEffect,useState}from'react'import*asd3from'd3'importstyled,{createGlobalStyle}from'styled-components'importdatafrom'./data'constwidth=1000constheight=600constblack='#333333'consttitle='My Data Visualization'// 4// 7exportconstGlobalStyle=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;}`exportconstContainer=styled.div` display: grid; grid-template-rows: 30px 1fr; align-items: center; .title { font-size: 25px; font-weight: 600; padding-left: 20px; }`exportconstVisualization=styled.div` justify-self: center; width:${width}px; height:${height}px;// 6`exportdefault()=>{constvisualization=useRef(null)useEffect(()=>{varsvg=d3.select(visualization.current).append('svg').attr('width',width).attr('height',height)// 5// 8},[])return(<><GlobalStyle/><Container><divclassName='title'>{title}</div><Visualizationref={visualization}/>{/*10*/}</Container><> )}
4- We need to establish a color scheme and some dimensions for ourDonut Chart.
The radius of our pastry.
constradius=Math.min(width,height)/2
It only makes sense to use aLakers color theme.
varlakersColors=d3.scaleLinear().domain([0,1,2,3]).range(['#7E1DAF','#C08BDA','#FEEBBD','#FDBB21'])
TheD3pie
function will map our data into pie slices. It does this by adding fields such asstartAngle
andendAngle
behind the scenes. We are using an optionalsort
function just to shuffle the order of the slices. Play around with this, pass itnull
or even leave it out to get different arrangements. Finally, we use thevalue
function to tellD3 to use thepoints
property to divide up the pie. Log thepie
variable to the console to help conceptualize what theD3 pie function did to our data.
varpie=d3.pie().sort((a,b)=>{returna.name.length-b.name.length}).value(d=>d.points)(data)
Now we need to create circular layouts using thearc
function. The variablearc
is for ourDonut Chart and theouterArc
will be used as a guide for labels later.getMidAngle
is a helper function to be used at a later time also.
vararc=d3.arc().outerRadius(radius*0.7).innerRadius(radius*0.4)varouterArc=d3.arc().outerRadius(radius*0.9).innerRadius(radius*0.9)functiongetMidAngle(d){returnd.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 originalsvg
variable declaration.
.append('g').attr('transform',`translate(${width/2},${height/2})`)
Now the magic happens when we feed ourpie
back toD3.
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 namedcentroid
function returns an array with[x,y]
coordinates to the center point of thepie
slice (in this cased
) within thearc
. 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. ThemidAngle
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=>{varposA=arc.centroid(d)varposB=outerArc.centroid(d)varposC=outerArc.centroid(d)varmidAngle=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 ofname
andpoints
based on which side of the chart it appears on. Notice that thepie
function moved our originaldata
into a key nameddata
. The top level keys ofpie
objects contain the angle measurements used in thegetMidAngle
function.
svg.selectAll('labels').data(pie).enter().append('text').text(d=>{varmidAngle=getMidAngle(d)returnmidAngle<Math.PI?`${d.data.name} -${d.data.points}`:`${d.data.points} -${d.data.name}`}).attr('class','label').attr('transform',d=>{varpos=outerArc.centroid(d)varmidAngle=getMidAngle(d)pos[0]=radius*0.99*(midAngle<Math.PI?1:-1)return`translate(${pos})`}).style('text-anchor',d=>{varmidAngle=getMidAngle(d)returnmidAngle<Math.PI?'start':'end'})
6- To polish off our labels with some style we just need to add a couple lines of code to theVisualization
styled component. Having usedD3 to add aclass
attribute inside aReactuseEffect
hook and then defining that class usingStyled 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 thesum
function fromD3.
vartotal=d3.sum(data,d=>d.points)
8- TheshowTotal
function will simply tack on atext
node displaying our total. Thetext-anchor
style property ofmiddle
should center the text within ourDonut hole. ThehideTotal
function will come into play in a bit. Notice we are calling theshowTotal
function to make sure the text is showing when the page loads.
functionshowTotal(){svg.append('text').text(`Total:${total}`).attr('class','total').style('text-anchor','middle')}functionhideTotal(){svg.selectAll('.total').remove()}showTotal()
We should tack on another class fortotal
right next to ourlabel
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 belowhideTotal
. These are listeners we will apply to each slice.
functiononMouseOver(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)')}functiononMouseOut(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 ofstate
, what would aReact app be without it.
const[player,setPlayer]=useState(null)
A keen observer may have noticed the reference tothis
and wondered what was happening. The following listeners need to be tacked on to the end of theslices
D3 chain.
.attr('class','slice').on('mouseover',onMouseOver).on('mouseout',onMouseOut)
Since we are using atransform
on theslice
class let's control it through another couple lines in theVisualization
styled component.
.slice{transition:transform0.5sease-in;}
10- We can now create the tooltip to display theplayer
state that changes as individual slices are moused over.
{player?(<Tooltip><div><spanclassName='label'>Name:</span><span>{player.name}</span><br/><spanclassName='label'>Points:</span><span>{player.points}</span><br/><spanclassName='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 theTooltip
component, which goes with the other styled components.
exportconstTooltip=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.
importReact,{useRef,useEffect,useState}from'react'import*asd3from'd3'importdatafrom'./data'importstyled,{createGlobalStyle}from'styled-components'/** * Constants */constwidth=1000constheight=600constradius=Math.min(width,height)/2constblack='#333333'consttitle='Los Angeles Lakers Scoring 2018-19'/** * D3 Helpers */// total pointsvartotal=d3.sum(data,d=>d.points)// lakers colorsvarlakersColors=d3.scaleLinear().domain([0,1,2,3]).range(['#7E1DAF','#C08BDA','#FEEBBD','#FDBB21'])// pie transformationvarpie=d3.pie().sort((a,b)=>{returna.name.length-b.name.length}).value(d=>d.points)(data)// inner arc used for pie chartvararc=d3.arc().outerRadius(radius*0.7).innerRadius(radius*0.4)// outer arc used for labelsvarouterArc=d3.arc().outerRadius(radius*0.9).innerRadius(radius*0.9)// midAngle helper functionfunctiongetMidAngle(d){returnd.startAngle+(d.endAngle-d.startAngle)/2}/** * Global Style Sheet */exportconstGlobalStyle=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 */exportconstContainer=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; }`exportconstVisualization=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; }`exportconstTooltip=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; }`exportdefault()=>{const[player,setPlayer]=useState(null)constvisualization=useRef(null)useEffect(()=>{varsvg=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=>{varposA=arc.centroid(d)varposB=outerArc.centroid(d)varposC=outerArc.centroid(d)varmidAngle=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=>{varmidAngle=getMidAngle(d)returnmidAngle<Math.PI?`${d.data.name} -${d.data.points}`:`${d.data.points} -${d.data.name}`}).attr('class','label').attr('transform',d=>{varpos=outerArc.centroid(d)varmidAngle=getMidAngle(d)pos[0]=radius*0.99*(midAngle<Math.PI?1:-1)return`translate(${pos})`}).style('text-anchor',d=>{varmidAngle=getMidAngle(d)returnmidAngle<Math.PI?'start':'end'})functionshowTotal(){svg.append('text').text(`Total:${total}`).attr('class','total').style('text-anchor','middle')}functionhideTotal(){svg.selectAll('.total').remove()}functiononMouseOver(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)')}functiononMouseOut(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><divclassName='title'>{title}</div><Visualizationref={visualization}/>{player?(<Tooltip><div><spanclassName='label'>Name:</span><span>{player.name}</span><br/><spanclassName='label'>Points:</span><span>{player.points}</span><br/><spanclassName='label'>Percent:</span><span>{Math.round((player.points/total)*1000)/10}%</span></div></Tooltip>):null}</Container></>)}
Top comments(6)

- LocationWashington, D.C
- EducationUniversity of Maryland, College Park, The Iron Yard, Life
- Joined
I have wanted to do something like this for a while, but it seems so daunting! Nice work!

- LocationCalifornia
- EducationBS Software Development @ WGU
- WorkWeb Developer at L.A. Design Concepts
- Joined
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:
- Go toobservablehq.com/collection/@obser... find something similar to what you want.
- Copy/Paste the code.
- Futz with it until it works or you until give up.

- LocationCalifornia
- EducationBS Software Development @ WGU
- WorkWeb Developer at L.A. Design Concepts
- Joined
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.
For further actions, you may consider blocking this person and/orreporting abuse