我们来用 D3.js 画个饼图

最近在做腾讯云大数据可视化项目,天天跟各种柱状图、饼图、面积图等打交道。

饼图能直观的表现一堆数据中各项所占比例,是非常常见的图表之一。本文尝试来讲讲如何在浏览器里绘制饼图。

准备工作

要展示的数据

这是某地扶贫项目的数据

  • 雨露计划: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等;

文字标签不友好,很多饼图都实现了在饼外用线连接的标签,单单是连接线就要涉及很多问题的处理,比如某些算法下实现的饼图对应不同数据的时候可能会导致线于扇相交,很难看或者是要求连接线只能在饼的两边等等;

这些问题将在后续送出。

附件:

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

发表于

税国龙的专栏

1 篇文章1 人订阅

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏mathor

概率论与数理统计(一)

12640
来自专栏IMWeb前端团队

transform 的副作用

本文作者:IMWeb elvin 原文出处:IMWeb社区 未经同意,禁止转载 transform 想必大家都很熟悉,可以通过其转换(translat...

22490
来自专栏向治洪

React Native之TextInput组件实现联想输入

TextInput组件是最基本的组件,相关介绍请查看TextInput组件介绍 输入框组件属性 输入框组件的主要属性如下: autoCapitalize : ...

251100
来自专栏深度学习之tensorflow实战篇

绘制动态心形图案::R语言绘制心形图

原始方程源于此贴一楼:直通车 整理修改后: 被窝修改成这样: x<-seq(-1.1,1.1,length = 3000) rep<-30 y<-matri...

50670
来自专栏潇涧技术专栏

When Math meets Android Animation (2)

当数学遇上动画:讲述ValueAnimator、TypeEvaluator和TimeInterpolator之间的恩恩怨怨(2)

11810
来自专栏HTML5学堂

JavaScript | 动画显示比例的投票效果

HTML5学堂(码匠):一个简洁实用的投票效果如何使用原生JS来进行实现呢?同时动画显示比例的形式又需要依靠哪些技术来实现?是数学对象还是字符串操作,又或者是计...

37160
来自专栏全沾开发(huā)

使用JavaScript实现一个俄罗斯方块

使用JavaScript实现一个俄罗斯方块 清明假期期间,闲的无聊,就做了一个小游戏玩玩,目前游戏逻辑上暂未发现bug,只不过样子稍...

39960
来自专栏python3

scrapy选择器css

CSS是网页代码中非常重要的一环,即使不是专业的Web从业人员,也有必要认真学习一下

9720
来自专栏烙馅饼喽的技术分享

Silverlight像素着色器编写简明指南 附送文字描边效果

      在玩很多flash网页游戏的时候,看到它们都有非常清晰的宋体字,并且有漂亮的描边效果。如图,这是战将传奇的登录界面中的文字。 ? 对比之下,silv...

21170
来自专栏移动端开发

Swift 实现俄罗斯方块详细思路解析(附完整项目)

    俄罗斯方块,是一款我们小时候都玩过的小游戏,我自己也是看着书上的思路,学着用 Swift 来写这个小游戏,在写这个游戏的过程中,除了一些位置的计算,数据...

13720

扫码关注云+社区

领取腾讯云代金券