最近在做腾讯云大数据可视化项目,天天跟各种柱状图、饼图、面积图等打交道。
饼图能直观的表现一堆数据中各项所占比例,是非常常见的图表之一。本文尝试来讲讲如何在浏览器里绘制饼图。
准备工作
要展示的数据
这是某地扶贫项目的数据
-
雨露计划:21
-
金融扶贫:25
-
产业扶贫:70
-
基础设施:40
D3.js
这是一个优秀的JavaScript库,支持<svg>
和<canvas>
绘图,能简化绘图工作中涉及的大量计算、动画,可以称之为绘图引擎。
我们开始吧
首先把数据整理成代码友好的格式
const oriData = [
{"x": "雨露计划", "y": 20},
{"x": "金融扶贫", "y": 20},
{"x": "产业扶贫", "y": 70},
{"x": "基础设施", "y": 40}
];
复制代码
定义画布大小
// 画布大小
const [width, height] = [450, 350];
复制代码
然后用d3生成一个<svg>
元素并挂载到#main下面,这个<svg>
讲作为后续绘制的画布。
// 初始化一个svg元素
let svg = d3.select("#main")
.append("svg")
.attr("width", width)
.attr("height", height);
复制代码
简单提一下append("svg")
返回的是一个svg元素,确切说是d3封装的一个数据结构,所有后面的.attr("width", width)
和.attr("height", height)
是作用于这个<svg>
元素的,而非一开始选中的"#main"。
一个数据项用一个扇形来表示,多个数据项的和就是一个完整的圆。所以绘制饼图其实就是绘制与各数据项对应的扇形。每个扇形都有一个起始角度和结束角度。
我们需要算出每个扇形的起始和结束角度,D3.js打包了这一工作,称之为布局器。
// 准备一个pie布局器,此布局可根据原始数据计算出一段弧的开始和结束角度
let pie = d3.pie().value(d => d.y); //.sort(null);
// 将原始数据经过布局转换
let drawData = pie(oriData);
复制代码
d3.pie()创建布局器,
.value(d => d.y)
设置布局器的取值过滤器
典型的链式调用。
我们来看下经过布局器转换的数据长什么样子
console.log(drawData);
复制代码
data: 存放原始数据
index: 2排序索引,D3的pie布局器默认会按数据大小来倒排
startAngle: 4.607669225265029 表示弧的开始角度
endAngle: 5.445427266222308 表示弧的结束角度
最小角度为0,最大为2π(约等于6.283185307179586〒▽〒)。
默认情况下,索引为第0弧的起点在从12点方向,索引为末尾的弧终点在12点方向,这两个弧首尾相接闭环
value: 20 来自源数据的值,因为代码d => d.y设定了数据源是y属性
padAngle:0相邻弧间的夹角,这里未设置,所以是0
有了各个扇形的角度数据,可以绘制了,<svg>
里没有现成的”扇形”元素,所以需要借助<path>
标签来定义任意路径以绘制扇形。
用<path>
标签来定义路径是一个比较吃力的事情,需要根据路径的复杂度多次调用moveto、lineto、closepath等多种命令,路径的不在本文讨论范围内。
所以我们直接用D3.js提供的“弧生成器“来生成路径。
// 根据画布大小算一个合适的半径吧
let radius = Math.min(width, height) * 0.8 / 2;
// 准备一个弧生成器,用于根据角度生产弧路径
let arc = d3.arc().innerRadius(0).outerRadius(radius);
复制代码
同样链式调用,传入了内径和外径,内径传0最后得到饼图,传非0最后得到环形图,这很容易理解。
arc是一个柯里化返函数,它接受含有startAngle、endAngle、padAngle属性的对象,返回值为<path>
的d属性。
万事俱备,接下来,要把<path>
绘制上<svg>
了。
不过在这之前我们还得再准备一个<g>
元素来作为容器打包每个弧的<path>
,为什么这么做呢,后面揭晓。
let pathParent = svg.append("g");
复制代码
然后执行一长串操作
pathParent.selectAll("path")
.data(drawData)
.enter()
.append("path")
.attr("d", oneData => arc(oneData));// 调用弧生成器得到路径
复制代码
玄乎的来了
pathParent.selectAll("path")
暂时理解为容器上执行.selectAll("path")
返回了全部<path>
元素,实际上这些<path>
还不存在,也不知道会有多少个<path>
.data(drawData)
绑定预先准备好的经过布局器转换后的数据,既然绑定了数据,那data返回的对象内部就知道了需要多少个<path>
.enter()
前面两行已经说了这些<path>
元素还不存在,但是已经知道需要多少个<path>
,enter()
就是选择这些还不存在的空<path>
,所以enter用于根据数据的条数来选择元素,与它类似的还有update和exit,分别对应已经存在和需要删除的元素,当然这里用不到。具体参见这里。
.append("path")
前面选择了这些尚未存在的<path>
,那总得将他们补齐使之真的存在。
.attr("d", oneData => arc(oneData))
最后遍历这些<path>
元素,给他们赋上d属性,属性值是调用弧生成器的结果。
至此,组成饼图的各个扇形已经绘制好了。看看:
得到了这样一个结果,嗯。
各位看官应该也看出来了,前面我们并没有设置各个路径的坐标偏移量和填充颜色。
先解决坐标偏移量。
“位置是相对的”
观察<svg>
和各<path>
的在页面上的位置。
<path>
路径都绘制出来了,但是只显示出了饼图的第四象限。
原因是在一般的2d平面绘制时,我们都以(0,0)坐标为原点的,并且为了简化计算复杂度,都直接计算路径的相对坐标,刻意不将绝对坐标带进计算,然后通过位移画布或者容器来一步达成位移。
前面已经准备了一个所有<path>
的父级<g>
元素,来把它移动到画布中间。
pathParent.attr("transform", `translate(${width / 2}, ${height / 2})`);
复制代码
饼图已经正常展现了。
接下来着色
设定颜色比例尺,对于饼图来说,此比例尺的作用是根据饼上的某一节的序号得到一个对应的颜色值。
let colorScale = d3.scaleOrdinal().domain(d3.range(0, oriData.length)).range(d3.schemeCategory20c);
复制代码
在绘制<path>
处设置fill属性
pathParent
.selectAll("path")
.data(drawData)
.enter()
.append("path")
.attr("fill", function (d) {
// 设置填充颜色
return colorScale(d.index);
})
.attr("d", d => arc(d));
复制代码
饼图已经画好,接下来添加一些文字标签上去。
// 先算一个总数
let sum = d3.sum(oriData, d => d.y);
// 同样,搞一个g来承载文字标签
let textParent = svg.append("g");
textParent.attr("transform", `translate(${width / 2}, ${height / 2})`);
// 生产每一个文字标签的容器
let texts = textParent.selectAll("text")
.data(drawData)
.enter()
.append("text")
.attr("transform", function(d) {
// 将文字平移到弧的中心
return "translate(" + arc.centroid(d) + ")";
})
.attr("text-anchor", "middle")
.attr("font-size", "10px")
.text(function(d) {
// 显示百分比
return (d.data.y / sum * 100).toFixed(2) + "%";
});
复制代码
完成图
至此,基本饼图画好。
还有一些事情没做,
鼠标与扇形的交互,click、over等;
文字标签不友好,很多饼图都实现了在饼外用线连接的标签,单单是连接线就要涉及很多问题的处理,比如某些算法下实现的饼图对应不同数据的时候可能会导致线于扇相交,很难看或者是要求连接线只能在饼的两边等等;
这些问题将在后续送出。