通过 D3 和 React 进行篮球数据统计
数据
创建甜甜圈图
制作一个环形图来可视化 2018-19 赛季洛杉矶湖人篮球队的得分总数。
数据
创建数据可视化的第一步,当然也是数据本身。这篇写得很好的文章解释了网络爬虫的一些法律和伦理问题。这个资源库提供了免费公共数据的链接。Dev.to网站本身也有很多关于数据、网络爬虫和可视化的文章。我个人认为,对于简单的数据可视化项目,Chrome 开发者工具本身就足以收集和处理数据。不妨看看这个过于简化的示例。
| 姓名 | 年龄 |
|---|---|
| 勒布朗·詹姆斯 | 34 |
| 锡安·威廉姆森 | 18 |
| 迈克尔·乔丹 | 56 |
根据上表,以下是数据处理步骤:
- 打开Chrome 开发者工具
- 隔离所有表格行
- 将NodeList中的结果转换为数组,并舍弃标题行。
- 从每个表格数据单元格中提取文本,并将结果映射到一个新的对象数组。
- 输入
c变量名并按下回车键Enter,你的新数组将显示在控制台中。 - 右键单击数组并选择
Store as Global Variable。您将temp1在控制台中看到结果。 - 使用内置
copy函数将临时变量复制到剪贴板 -copy(temp1) - 将数据粘贴到JavaScript或JSON文件中。
- 🤯
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
请注意,每个场景都不同,此示例为了便于解释流程而进行了简化。此外,上述所有逻辑都可以放入一个函数中以简化流程。请记住,您可以使用 `\n` 函数在控制台中创建Shift+Enter新行来创建多行函数。这种方法相当于JavaScript入门级的手动网页抓取。在访问并抓取您不应该抓取的数据之前,请务必阅读网站的服务条款。willy-nilly
创建甜甜圈图
让D3和React协同工作其实并不复杂。通常,只需要一个 DOM 入口点和一些在页面加载时初始化可视化的逻辑即可。要开始我们的示例项目,我们需要先create-react-app安装好 D3。第一步是创建一个新项目。我通常会先清空目录src,只保留`<project_name>`App.js和`<project_name>` 文件index.js。别忘了删除任何旧的 ` import<project_name>` 语句。在编写任何代码之前,我们需要安装一些依赖项。
1- 下载D3和Styled Components。
npm i d3 styled-components
2- 创建一个新文件whatever-you-want.js,甚至可以data.js在src目录中创建。示例中使用的数据可以在这个 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>
<>
)
}
4- 我们需要为甜甜圈图确定配色方案和一些尺寸。
我们糕点的半径。
const radius = Math.min(width, height) / 2
使用湖人队的配色方案是理所当然的。
var lakersColors = d3
.scaleLinear()
.domain([0, 1, 2, 3])
.range(['#7E1DAF', '#C08BDA', '#FEEBBD', '#FDBB21'])
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)
现在我们需要使用该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
}
5- 结构搭建完毕,几乎就要在屏幕上看到一些东西了。
将以下内容链接到我们最初的svg变量声明中。
.append('g')
.attr('transform', `translate(${width / 2}, ${height / 2})`)
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)
接下来,我们需要从每个切片绘制线条,最终指向一个标签。这个命名恰当的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]
})
现在,我们的线条已经准备好添加标签了。通过根据标签在图表中的位置互换其顺序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'
})
6. 为了给标签添加一些样式,我们只需要在Visualization样式组件中添加几行代码。使用D3在Reactclass Hook中添加属性,然后使用Styled Components定义该类,似乎已经完成了库的集成。 useEffect
.label {
font-size: 12px;
font-weight: 600;
}
7. 目前效果不错,但为什么不添加一些互动元素,让用户更有参与感呢?我们可以使用D3sum中的函数快速获取总得分。
var total = d3.sum(data, d => d.points)
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()
我们应该在步骤 6 的课程total旁边再增加一个课程。label
.total {
font-size: 20px;
font-weight: 600;
}
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)')
}
当鼠标悬停在切片上时,描边和填充会更加醒目,稍微放大还能增加炫酷效果。总分文本也会切换显示,这样我们就可以在切片上方添加一个包含更多信息的工具提示。首先,我们需要创建一个组件state,毕竟React应用怎么能少了它呢?
const [player, setPlayer] = useState(null)
细心的观察者可能已经注意到这里提到的内容this,并想知道发生了什么。以下监听器需要添加到slices D3链的末尾。
.attr('class', 'slice')
.on('mouseover', onMouseOver)
.on('mouseout', onMouseOut)
既然我们transform在slice类中使用了它,那就让我们通过 styled 组件中的另外几行代码来控制它Visualization。
.slice {
transition: transform 0.5s ease-in;
}
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
}
就新信息而言,用户目前只能看到当前球员得分占全队总得分的百分比。然而,居中显示与移动相结合,营造出一种不错的视觉效果和交互感。如果能显示更多信息,或者我的设计思路更清晰,类似的模式或许能发挥更大的作用。看来最后还需要添加一个组件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;
}
`
最终,我们的代码应该类似于下面这样。
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>
</>
)
}

