DEV Community

benjaminadk
benjaminadk

Posted on

Basketball Stats Through D3 & React

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:

  1. Open Chrome Devtools
  2. Isolate all table rows
  3. Convert results from a NodeList to an Array and ditch the title row
  4. Extract text from each table data cell and map the results to a new array of objects
  5. Type c (the variable name) and press Enter and your new array will be displayed in the console
  6. Right click the array and chose Store as Global Variable. You will see temp1 appear in the console.
  7. Use the built in copy function to copy the temporary variable to the clipboard - copy(temp1)
  8. Paste your data into a JavaScript or JSON file.
  9. 🤯
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
    <>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It only makes sense to use a Lakers color theme.

var lakersColors = d3
  .scaleLinear()
  .domain([0, 1, 2, 3])
  .range(['#7E1DAF', '#C08BDA', '#FEEBBD', '#FDBB21'])
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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})`)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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]
  })
Enter fullscreen mode Exit fullscreen mode

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'
  })
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

We should tack on another class for total right next to our label class from step 6.

.total {
  font-size: 20px;
  font-weight: 600;
}
Enter fullscreen mode Exit fullscreen mode

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)')
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
`
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

NBA Player Salaries & Performance 2018-19 (Bubble Chart)

Inspiration for example Donut Chart

Top comments (6)

Collapse
 
jmdembe profile image
Jessica Dembe

I have wanted to do something like this for a while, but it seems so daunting! Nice work!

Collapse
 
benjaminadk profile image
benjaminadk

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.

Collapse
 
the_riz profile image
Rich Winter

Recently found this which I thought was a nice collection: d3-graph-gallery.com/index.html

Otherwise....
How to DataViz w/d3:

  1. Go to observablehq.com/collection/@obser... find something similar to what you want.
  2. Copy/Paste the code.
  3. Futz with it until it works or you until give up.
Thread Thread
 
benjaminadk profile image
benjaminadk • Edited

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.

Collapse
 
orisa profile image
orisa

That amount of code for a pie chart. I think it's an overkill. Anyway thanks for sharing your knowledge.

Collapse
 
the_riz profile image
Rich Winter

That's d3.