发布于 2026-01-06 3 阅读
0

利用 D3 和 React 数据创建篮球统计数据环形图

通过 D3 和 React 进行篮球数据统计

数据

创建甜甜圈图

制作一个环形图来可视化 2018-19 赛季洛杉矶湖人篮球队的得分总数。

数据

创建数据可视化的第一步,当然也是数据本身。这篇写得很好的文章解释了网络爬虫的一些法律和伦理问题。这个资源库提供了免费公共数据的链接。Dev.to网站本身也有很多关于数据、网络爬虫和可视化的文章。我个人认为,对于简单的数据可视化项目,Chrome 开发者工具本身就足以收集和处理数据。不妨看看这个过于简化的示例。

姓名 年龄
勒布朗·詹姆斯 34
锡安·威廉姆森 18
迈克尔·乔丹 56

根据上表,以下是数据处理步骤:

  1. 打开Chrome 开发者工具
  2. 隔离所有表格行
  3. 将NodeList中的结果转换数组,并舍弃标题行。
  4. 从每个表格数据单元格中提取文本,并将结果映射到一个新的对象数组。
  5. 输入c变量名并按下回车键Enter,你的新数组将显示在控制台中。
  6. 右键单击数组并选择Store as Global Variable。您将temp1在控制台中看到结果。
  7. 使用内置copy函数将临时变量复制到剪贴板 -copy(temp1)
  8. 将数据粘贴到JavaScriptJSON文件中。
  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

请注意,每个场景都不同,此示例为了便于解释流程而进行了简化。此外,上述所有逻辑都可以放入一个函数中以简化流程。请记住,您可以使用 `\n` 函数在控制台中创建Shift+Enter新行来创建多行函数。这种方法相当于JavaScript入门级的手动网页抓取。在访问并抓取您不应该抓取的数据之前,请务必阅读网站的服务条款。willy-nilly

创建甜甜圈图

D3React协同工作其实并不复杂。通常,只需要一个 DOM 入口点和一些在页面加载时初始化可视化的逻辑即可。要开始我们的示例项目,我们需要先create-react-app安装好 D3。第一步是创建一个新项目。我通常会先清空目录src,只保留`<project_name>`App.js和`<project_name>` 文件index.js。别忘了删除任何旧的 ` import<project_name>` 语句。在编写任何代码之前,我们需要安装一些依赖项。

1- 下载D3Styled Components



npm i d3 styled-components


Enter fullscreen mode Exit fullscreen mode

2- 创建一个新文件whatever-you-want.js,甚至可以data.jssrc目录中创建。示例中使用的数据可以在这个 gist 中找到。

3. 创建一些基本样板代码,可用于各种采用此配置的项目——即D3 + React + Styled Components。我鼓励你根据自己的需要进行调整,因为像大多数开发者一样,我也有自己的习惯和模式。例如,我不喜欢#000000黑色,所以我使用`<span>` 标签#333333;我喜欢 `<span>` 字体等等。如果你之前Raleway没有使用过HooksuseEffect ,那么带有空依赖数组的 Hook 与React类组件中的` []<span>` 类似。编号的注释对应于后续步骤,你需要将这些步骤中的代码插入到注释中。componentDidMount



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- 我们需要为甜甜圈图确定配色方案和一些尺寸。

我们糕点的半径。



const radius = Math.min(width, height) / 2


Enter fullscreen mode Exit fullscreen mode

使用湖人队的配色方案是理所当然的。



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


Enter fullscreen mode Exit fullscreen mode

D3 函数pie会将我们的数据映射到饼图的各个扇形中。它通过在后台添加诸如 ` startAngleis_strips` 和 ` is_strips` 之类的字段来实现这一点。我们使用了一个可选函数来打乱扇形的顺序。您可以尝试调整此函数,传递它,甚至省略它,以获得不同的排列方式。最后,我们使用该函数告诉D3使用 ` is_strips` 属性来分割饼图。将变量输出到控制台,有助于理解D3饼图函数对数据所做的操作。endAnglesortnullvaluepointspie



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

现在我们需要使用该arc函数创建圆形布局。变量arc用于我们的环形图outerArc稍后将用作标签的参考。getMidAngle是一个辅助函数,稍后也会用到。



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- 结构搭建完毕,几乎就要在屏幕上看到一些东西了。

将以下内容链接到我们最初的svg变量声明中。



   .append('g')
   .attr('transform', `translate(${width / 2}, ${height / 2})`)


Enter fullscreen mode Exit fullscreen mode

pie现在,当我们把反馈传递给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

接下来,我们需要从每个切片绘制线条,最终指向一个标签。这个命名恰当的centroid函数返回一个数组,其中包含[x,y]切片中心点pie(在本例中为d)在 内的所有坐标arc。最终,我们返回一个包含三个坐标数组的数组,分别对应于屏幕上显示的每条线的起点、折点和终点。 有助于midAngle确定线条尾部的方向。



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

现在,我们的线条已经准备好添加标签了。通过根据标签在图表中的位置互换其顺序name,增加一些对称性,标签看起来更美观。请注意,该函数已将原始标签移动到名为 `<key>` 的键中。对象的顶级键包含函数中使用的角度测量值pointspiedatadatapiegetMidAngle



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. 为了给标签添加一些样式,我们只需要在Visualization样式组件中添加几行代码。使用D3在Reactclass Hook中添加属性,然后使用Styled Components定义该类,似乎已经完成了库的集成。 useEffect



.label {
  font-size: 12px;
  font-weight: 600;
}


Enter fullscreen mode Exit fullscreen mode

7. 目前效果不错,但为什么不添加一些互动元素,让用户更有参与感呢?我们可以使用D3sum中的函数快速获取总得分



var total = d3.sum(data, d => d.points)


Enter fullscreen mode Exit fullscreen mode

8. 该showTotal函数会简单地添加一个text显示总金额的节点。text-anchor样式属性middle应该使文本在甜甜圈形状的圆孔内居中显示。该hideTotal函数稍后会发挥作用。请注意,我们调用该showTotal函数是为了确保页面加载时文本能够正常显示。



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

我们应该在步骤 6 的课程total旁边再增加一个课程。label



.total {
  font-size: 20px;
  font-weight: 600;
}


Enter fullscreen mode Exit fullscreen mode

9- 目前的编号注释系统有点复杂,但如果你已经看到这里,说明你足够聪明,能够跟上。接下来的函数可以放在下面hideTotal。这些是我们将应用于每个切片的监听器。



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

当鼠标悬停在切片上时,描边和填充会更加醒目,稍微放大还能增加炫酷效果。总分文本也会切换显示,这样我们就可以在切片上方添加一个包含更多信息的工具提示。首先,我们需要创建一个组件state,毕竟React应用怎么能少了它呢?



const [player, setPlayer] = useState(null)


Enter fullscreen mode Exit fullscreen mode

细心的观察者可能已经注意到这里提到的内容this,并想知道发生了什么。以下监听器需要添加到slices D3链的末尾。



   .attr('class', 'slice')
   .on('mouseover', onMouseOver)
   .on('mouseout', onMouseOut)


Enter fullscreen mode Exit fullscreen mode

既然我们transformslice类中使用了它,那就让我们通过 styled 组件中的另外几行代码来控制它Visualization



.slice {
  transition: transform 0.5s ease-in;
}


Enter fullscreen mode Exit fullscreen mode

10- 现在我们可以创建工具提示,以显示player鼠标悬停在各个切片上时发生变化的状态。



{
  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

就新信息而言,用户目前只能看到当前球员得分占全队总得分的百分比。然而,居中显示与移动相结合,营造出一种不错的视觉效果和交互感。如果能显示更多信息,或者我的设计思路更清晰,类似的模式或许能发挥更大的作用。看来最后还需要添加一个组件Tooltip,使其与其他样式组件相匹配。



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

最终,我们的代码应该类似于下面这样。



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

2018-19赛季NBA球员薪资及表现(复赛阶段图表)

灵感来源,例如甜甜圈图

文章来源:https://dev.to/benjaminadk/basketball-stats-through-d3-react-4m10