数据可视化之 Sankey 桑基图的实现

原文地址:https://geekplux.com/2018/08/28/how-to-implement-sankey-diagram.html什么是桑基图Google 搜索桑基图,可以搜到一大堆定义。简而言之,桑基图是一种数据流图,展示了数据是如何从左到右流向最后的节点,每条边代表一条数据流,宽度代表数据流的大小。桑基图常用于流量分析,可以很清楚的看出数据是如何渐渐分流的。本文着重讲解如何实现,理论方面的东西各位可以自行了解。实现桑基图的关键点关键点有两个:1. 坐标计算桑基图要展现的数据流,算是图(拓扑类、网络型或关系型)数据的一种。实现一个数据可视化图,最重要的就是拆解元素。而实现一个图数据可视化,则最重要的是分清“节点”和“边”。拆解元素之后,最重要的便是坐标的计算,这里包括点和边的坐标。而图形中,任何的元素都可以看作是点连线而成,所以元素坐标的计算实际上就成了点坐标的计算。比如桑基图中,节点是一个矩形,那么只需计算两个点(左上和右下)的坐标(x0, y0),(x1, y1)便可确定;边是一个带形,需要计算四个端点才能确定,带形的弧度则可由简单的三次贝塞尔曲线计算得来。由此观之,实现桑基图的核心在于计算出以上的这些点坐标。其实实现任意一种可视化都是计算点的坐标。2. 减少边交叉当数据量到一定程度的时候, 桑基图中的边会出现重叠现象,造成一定的视觉混乱。如何减少可以阅读本文第二节。一、坐标计算的实现准备工作设计数据结构经典的图数据结构一般是邻接矩阵和邻接表,我们也可以自己设计。我在做拓扑数据可视化的时候,会先和后端或数据同学商定好我需要拿到的数据结构,通常是这个样子:{ nodes: [ { foo: bar }, { foo: baz }, … ], links: [ { source: 0, target: 1, value: 100 }, { source: 1, target: 2, value: 10 }, … ]}而我拿到之后要做的第一步就是先把 nodes 和 links 串联起来,这里每个 link 的 source 和 target 分别是 nodes 的下标,当然你也可以设置其他的引用(指针),总之通过引用讲两者串联起来,变成:{ nodes: [ { foo: bar, column: 0, // 节点所在第几列 row: 0, // 节点所在第几行 value: 100, // 节点数据流大小 sourceLinks: [ { source: 0, target: 1, … } ], targetLinks: [ … ] }, … ], links: [ { source: 0, target: 1, value: 100, sourceNode: { foo: bar, column: 0, row: 0, … }, targetNode: { … } }, … ]}这样,对于某个节点来说,可以直接用 O(1) 的时间复杂度访问到它的任意相邻节点。计算节点数据流大小这里的计算方法可自己定,通常是取该节点入边和出边的数据流大小之和的最小值。计算节点所在行列在桑基图的计算中,我们还需要进行一个关键的计算——计算节点在桑基图中的第几行第几列。第几列的计算,即为节点在图中的深度计算:入度为 0 的节点深度为 0,在第一列出度为 0 的节点深度最大,在最后一列其余节点的深度为他相连源节点的最大深度加 1第几行的计算,涉及到排序的问题,通常某一列中的节点都是按节点数据流大小,从大到小排序。节点坐标计算刚才我们说过,坐标计算可以分为两部分:节点和边。其中,边的坐标位置依赖于节点的坐标,所以应该先计算节点坐标。但在计算坐标之前,首先要明确一个问题:是否限定视图的宽高。这个问题引申出两种节点坐标的计算方式。不限定视图宽高如果不限定宽高,那么节点坐标的计算步骤很简单:设置一个节点的宽度设置节点的水平间距从左至右,根据刚才计算出的节点所在第几列,计算出节点的横坐标(x0, x1),初始的 x0 为 0设定一个比例尺函数(多大的数据流对应屏幕上的多少像素,通常是首先设定一个节点最小高度和一个节点最大高度,然后找出所有节点数据流的最小和最大值,映射成一个定义域为节点数据流大小,值域为节点高度的函数)通过比例尺计算出节点高度设置一个节点垂直间距从上至下,根据刚才计算出的节点所在第几行,计算出节点的纵坐标 (y0, y1),初始的 y0 为 0大致是这个思路,横坐标的计算取决于两个值,节点宽度和 节点水平间距;纵坐标的计算取决于 节点的数据流大小 和 节点垂直间距。具体的计算代码,可根据你自己的数据结构来调整。限定视图宽高如果限定宽高,那么计算步骤需要换个思路:节点的宽度和节点的水平间距需要根据节点的列数和视图宽度来计算,你可以自己手动调整也可以设计个算法来算从左只有,根据节点宽度和节点水平间距,计算出节点横坐标设定一个比例尺函数,计算出节点的高度设置一个节点垂直间距通过高斯-赛德尔迭代(Gauss–Seidel method)计算出纵坐标(大致的思路是,先根据前两步的数值算出一个初始节点坐标,如果总体布局超出视图的下界,则节点高度和节点垂直间距都按比例缩小(如 0.95),并同时上移 n 个像素,如果总体布局超出视图上界,则节点高度和垂直间距都按比例缩小,并同时下移 n 个像素,直到总体的桑基图布局适应一开始限定的视图宽高)这个思路是 d3-sankey 的实现思路。如果你有限定视图宽高的需求,那么可以直接使用 d3-sankey。边的坐标计算只要确定了节点坐标,边的坐标可以根据它源节点和目标节点的坐标来算出:对于一个节点,将它的出边和入边进行排序(排序方法通常是根据相连节点在第几行从上到下排,也可以通过一些其他排序方法减少边的交叉,具体在第二节介绍)计算每个节点中单位数据流占节点高度的比例根据出边入边的数据流大小,乘上一步计算出的比例,则可得到每条边左右两边的高度从上到下,计算每条边的纵坐标每条边四个端点的横坐标分别对应源节点和目标节点的横坐标以上操作可以通过遍历每个 node 的 sourceLinks 和 targetLinks 来计算。得到边的四个端点以后,就可以算出三次贝塞尔曲线的控制点了:二、如何减少交叉通常要减少边的交叉,可以采用下面两种方法:均值排序sugiyama 算法均值排序这个名字是我自己起的。。不过这个方法很实用有效。对于每个源节点来说,都有相连的目标节点。这里的“均值”指的是所有相连目标节点所在行数的平均值(所有目标节点的行数相加,除以目标节点个数),这个平均值可以大致描述该节点每个出边的位置。每条出边都有这样一个值,这个值越小,则说明该出边要连接的目标节点的位置越靠上,反之越靠下。所以可根据这个值,从小到大排出出边在该节点上从上到下的位置。三、具体项目中的交互我参与的 UBA (User Behavior Analytics 内部项目) 项目中,正好用到了桑基图。除了上述的图形绘制之外,主要复杂的是交互。如图所示,除了基本的 hover 交互之外,项目中主要还有minimap 拖拽和刷选主视图的拖拽和缩放左下角的过滤器点击交互,高亮只经过选中节点的路径,并且边上高亮的部分由最后一个选中节点懈怠的数据流值确定,其余部分半透明整个桑基图实现下来发现绘制只是一些计算,交互才是更难抽象和处理的部分。综上,桑基图是一个 展现数据流非常好用的视图,感兴趣的同学可以自己实现一个试试。除了我文章中这些基本的桑基图布局,你还可以试试其他变种,另外交互方面也可以突破刚才我提到的那些,比如我之前实现过点击节点进行折叠/展开的交互。总体来说可视化还是一个比较有意思的方向。本作品采用知识共享 署名-非商业性使用-禁止演绎 4.0 国际 许可协议进行许可。 ...

December 24, 2018 · 1 min · jiezi

d3入门篇(四):绘制完整柱状图添加过渡效果

这篇学习笔记是入门篇的最后一部分,将前几篇的内容整合到一起,绘制带过渡效果的柱状图,这次先给大家看一下结果图。结果前言先放结果图是想反馈一下在整合基础知识绘制完整柱状图遇到的几个问题:整个柱状图的布局,比如哪些元素包在一个<g>标签下,哪些元素是嵌套关系;如果不采用translate,transform 翻转height属性的值,如何让矩形正常方向显示;如何绘制文字;如何为柱状图添加过渡效果;坐标轴的位置如何确定,x轴如何划分刻度,如何让刻度显示在矩形的正下方;!!!! 接下来将逐个解决上述出现的问题!!!!Problem1:柱状图的整体布局Solution(1)为了绘制时,图形四周留有空白区域,我们首先设置一个padding值;var padding={top:40,bottom:40,left:40,right:40};//定义间隔(2)我们考虑在svg画布上进行绘制,采用如下的结构进行绘图: <svg> //将x轴包裹在一个g标签下 <g></g> //将y轴包裹在一个g标签下 <g></g> //将整个柱状图的矩形及文字包裹在一个g标签下 <g> //将柱形图的每个矩形与它相应的值包裹在一个g标签下 <g> <rect> <text> </g> </g> </svg>Problem2:如何按照垂直向上的方向显示矩形Solution之前的几篇文章我都是通过transform变换实现了矩形的翻转,这篇文章介绍一个新的思路。 首先确定一个矩形需要四要素(x,y,width,height),同时我们需要注意,画布的坐标轴方向为水平向右和垂直向下。height是我们数据可视化的展示部分,即数据的绑定部分,x,y确定了绘制矩形的左上角坐标。这里提供一个思路: 如果按照正常垂直向下的方向绘制矩形时,要求矩形的bottom处在同一水平线上,y+height==固定值;也就是数据(height)大的部分,我们希望矩形的绘制起始点(y)的值较小,数据小(height)的部分,我们希望矩形的绘制起始点(y)的值较大。 因此我们可以通过定义比例尺完成这个功能,将dataset中大的数值,映射出range中小的数值。//定义y方向比例尺var yScale=d3.scaleLinear().domain([0,d3.max(dataset)]).range([height-padding.top-padding.bottom,0]);//定义y的值.attr(“y”,function (d,i) { return yScale(d)})//定义height.attr(“height”,function (d,i) { return height-padding.top-padding.bottom-yScale(d);})可以看出来‘y’+‘height’==height-padding.top-padding.bottom(这是一个固定的值)Problem3:如何绘制文字Solution在Problem1中已经解决的布局方案问题,我们的方法是将矩形与文字包在一个g标签下,所以绘制文字与绘制矩形的方法相同,在<g>标签下添加<text>标签,同时需要设定: (1)文字的显示位置:x,y (2)文本信息:text (3)文字位置的偏移值:dx,dygraph.append(“text”).style(“fill”,“pink”).attr(“x”,function(d,i){ return xScale(i);}).text(function (d) { return d}).attr(“y”,function (d,i) { return yScale(d);})Problem4:如何为柱状图添加过渡效果Solution为柱状图添加过渡效果,我们需要调用以下API:.transition():为这个元素添加过渡;.duration():设定元素从起始状态到终止状态的过渡时间;.delay():设定元素执行过渡效果的时间间隔;.ease():设定过渡的动画效果;在为元素添加过渡效果时,初始状态,终止状态尤为重要,柱状图为例分析一下元素的两个状态:明确柱形图为每个矩形添加过渡时,只有两个属性值需要改变,一个是y的值,一个是height的值;起始状态:柱状图的起始状态非常好理解,就是矩形不显示的状态,即y值设定为前文提到的固定值,height设定为0;终止状态:柱状图的终止状态应该是矩形元素和文字都可视化固定显示出来,即为正常绑定元素时设定的相关属性值。//为矩形添加过渡效果.attr(“y”,function (d) { var min=yScale.domain()[0]; return yScale(min);}).attr(“height”,function(d,i){ return 0;}).transition().duration(2000).delay(function(d,i){ return i400;}).ease(d3.easeBackOut).attr(“y”,function (d,i) { return yScale(d)}).attr(“height”,function (d,i) { return height-padding.top-padding.bottom-yScale(d);})Problem5:格式化显示坐标轴Solution在开始学习坐标轴的时候,只实现了添加y轴,在这次完整柱状图实现中,尝试添加x轴却遇到了问题。在这个例子中我们一共绑定了8个数据,那么如何让x轴的刻度均匀的显示在每个矩形的下方呢?在定义x轴的时候我用了ScaleBand()这个方法://在range返回等差数列var xScale=d3.scaleBand().domain(d3.range(dataset.length)).rangeRound([0,dataset.length(rectWidth+(rectPadding/2))]);var xAxis=d3.axisBottom(xScale).ticks(5);既然比例尺返回一个等差数列,所以我们要求在柱状图区域,每个矩形和空白间隔这个整体是相同的,所以我的实现是每个矩形左右是半个rectPadding。先设置x的值,然后width设置成矩形宽度减去半个间隔。(不理解的可以自己画一张图就可以了).attr(“x”,function (d,i) { return (irectWidth)+(i+1)(rectPadding/2);}).attr(“width”,rectWidth-rectPadding/2)代码部分import * as d3 from “d3”;var dataset = [45, 70, 12, 79, 4, 127, 33, 150];var width = 600;//svg画布宽var height = 600;//svg画布高var rectWidth = 50;//每个矩形的默认宽度var rectPadding=10;//每个矩形间的间隔var padding={top:40,bottom:40,left:40,right:40};//定义间隔//定义画布var svg = d3.select(“body”).append(“svg”).attr(“width”, width).attr(“height”, height).style(“background-color”, “yellow”);//定义矩形比例尺var yScale=d3.scaleLinear().domain([0,d3.max(dataset)]).range([height-padding.top-padding.bottom,0]);var yAxis=d3.axisLeft(yScale).ticks(5);svg.append(“g”).attr(“transform”,translate(${padding.top},${padding.left})).call(yAxis);var xScale=d3.scaleBand().domain(d3.range(dataset.length)).rangeRound([0,dataset.length*(rectWidth+(rectPadding/2))]);var xAxis=d3.axisBottom(xScale).ticks(5);svg.append(“g”).attr(“transform”,translate(${padding.left},${height-padding.top})).call(xAxis);//定义矩形var g=d3.selectAll(“svg”).append(“g”).attr(“transform”,translate(${padding.top},${padding.left}));var graph=g.selectAll(“rect”).data(dataset).enter().append(“g”);graph.append(“rect”).style(“fill”,“blue”).attr(“x”,function (d,i) { return (irectWidth)+(i+1)(rectPadding/2);}).attr(“width”,rectWidth-rectPadding/2).attr(“y”,function (d) { var min=yScale.domain()[0]; return yScale(min);}).attr(“height”,function(d,i){ return 0;}).transition().duration(2000).delay(function(d,i){ return i400;})//.ease(d3.easeBackOut).attr(“y”,function (d,i) { return yScale(d)}).attr(“height”,function (d,i) { return height-padding.top-padding.bottom-yScale(d);})graph.append(“text”).style(“fill”,“pink”).attr(“x”,function(d,i){ return xScale(i);}).attr(“y”,function (d) { var min=d3.min(dataset); return yScale(min)}).text(function (d) { return d}).transition().duration(2000).delay(function(d,i){ return i400;}).attr(“y”,function (d,i) { return yScale(d);})附录接下来会写进阶篇的学习笔记 ...

December 18, 2018 · 1 min · jiezi

d3比例尺坐标轴

这篇文章继续介绍d3的基础知识比例尺在绘制柱状图时,我们往往会定义很大的画布,然而我们要可视化的数据确很小,这时会出现很多留白的情况。为了根据显示刻度灵活变化宽高,而不是定死,特别是数据差异性很大的时候,我们希望图表显示范围都在画布里面,这时就会引入比例尺的概念来进行缩放(scale)。我们在数学里有函数的概念y=f(x),定义域为x(输入)的取值范围,值域为y(输出)的取值范围。输入x,根据函数规则会输出一个y。d3中的比例尺与数学中的函数类似,首先我们需要指定函数类别,即比例尺的类别,如scaleLinear,scalePow, scaleQuantise, scaleOrdinal, scaleSqrt, scaleLog, scaleSequential等等。然后我们需要给定函数的定义域domain([]),最后需要给定函数的值域range([])。根据这些规则,d3会为我们返回一个比例尺函数。示例var linearScale=d3.scaleLinear().domain([0,d3.max(dataset,function (d) { return d;})]).range([0,height]);//这里linearScale为一个函数,我们可直接执行linearScale(d)完整示例import * as d3 from “d3”;var width=300;//svg画布宽var height=200;//svg画布高var rectWidth=30;//每个矩形的默认宽度var dataset=[45,70,12,79,4,127,33,90];//定义画布var svg=d3.select(“body”).append(“svg”).attr(“width”,width).attr(“height”,height).style(“background-color”,“yellow”);//定义比例尺var linearScale=d3.scaleLinear().domain([0,d3.max(dataset,function (d) { return d;})]).range([0,height]);//绑定数据集 绘制柱形图svg.selectAll(“rect”).data(dataset).enter().append(“rect”).attr(“width”,rectWidth-2).attr(“height”,function (d,i) { return linearScale(d)}).attr(“x”,function (d,i) { return rectWidth*i}).attr(“y”,200).attr(“transform”,function (d,i) { return translate(0,${linearScale(-d)})}).attr(“fill”,“blue”)结果 结果显示,数据集被比例尺进行缩放(scale)可视化出来。 d3还提供很多数据处理的方法,max min extent sum median mean shuffle等等。坐标轴要定义坐标轴,主要两个步骤:(1)定义坐标轴的比例尺;(2)定义坐标轴朝向,刻度;示例import * as d3 from “d3”;var width = 300;//svg画布宽var height = 200;//svg画布高var dataset = [45, 70, 12, 79, 4, 127, 33, 150];var testDataset = [4.5, 7.0, 1.2, 7.9, 0.4, 12.7, 3.3, 9.0];var rectWidth = 30;//每个矩形的默认宽度//定义画布var svg = d3.select(“body”).append(“svg”).attr(“width”, width).attr(“height”, height).style(“background-color”, “yellow”);//定义矩形比例尺var linearScale = d3.scaleLinear().domain([0, d3.max(dataset, function (d) { return d;})]).range([0, 200]);var g = svg.append(“g”).attr(“transform”, “translate(40,0)”);g.selectAll(“rect”).data(dataset).enter().append(“rect”).attr(“width”, rectWidth - 2).attr(“height”, function (d, i) { return linearScale(d)}).attr(“x”, function (d, i) { return rectWidth * i}).attr(“y”, 200).attr(“transform”, function (d, i) { return translate(0,${linearScale(-d)})}).attr(“fill”, “blue”);//定义坐标轴比例尺var yScale = d3.scaleLinear().domain([0, d3.max(dataset, function (d) { return d;})]).range([200,0]);//定义坐标轴var yAxis = d3.axisLeft(yScale).ticks(5);//调用坐标轴g.append(“g”).attr(“transform”, translate(0,0)).call(yAxis);结果补充知识点1.“g”标签g标签是svg的常用标签,相当于一个容器标签,把相关元素进行组合。通过g标签组合在一起的元素,可以通过g标签设置属性等,进行坐标变换等操作,如attr(“transform”,“translate()”),进行元素平移;2.定义坐标轴 var yAxis = d3.axisLeft(yScale) .ticks(5);首先需要设置坐标轴的朝向,这里是向左,将坐标轴的比例尺作为参数传入axisLeft中;通过ticks设置刻度的数目(不过好像并没有什么用处)3.call() g.append(“g”) .attr(“transform”, translate(0,0)) .call(yAxis);yAxis是我们定义的一个坐标轴,其实它本身也是一个函数,将新建的分组<g>传给yAxis()函数,用以绘制,所以这句代码等价于yAixs (g.append(“g”) ) ; ...

December 14, 2018 · 1 min · jiezi

转型为一名数据科学家的正确打开方式

摘要: 如果你是一名初级入门者,或者是一名软件工程师,亦或者是一名数学物理系毕业生,想要转型为一名数据科学家,按照我的建议一步一个脚印的去做,你会事半功倍。维基百科是你在数据科学领域解惑最佳的方式之一,但它所提供的信息要么就是特别简单,要么就是特别复杂。同样的,在数据科学职业建议这方面也一样:有些帖子针对的是初学者,有些则针对的是软件工程师,亦或者是针对入门后希望有所提升的入门级群体。因此,对于那些想要进入该领域的数据科学家来说,真的不知道去哪里寻找建议。没有完美的建议可以适合所有人,在这里我就初级入门者、软件工程师以及数学物理系毕业生分别提出一些正确进入数据科学领域的建议,按照我所说的去做,你会事半功倍。一、假如你是一名初级入门者……如果你是一个初级入门人员,那么请你一定要牢记:这个领域的发展速度令人咋舌,我在给你的任何建议基本上都是过时的。2017年的数据科学工作者在今天就不一定能在这个领域工作下去,同样的,今天的数据科学招聘标准在两年以后同样派不上多少用场。但假如你现在还没有编码或STEM背景,下面这些建议或许对你有所裨益:1、一定要有一个相对开阔的思路。如果你什么都不会,那么你可能并不了解什么是数据科学,所以数据科学这一领域并不一定是你特别想要接触的领域。你可以在LinkedIn上关注一些数据科学家,或者关注一些相关的博客。了解这个领域的人究竟都在做什么。成为一个真正的数据科学家需要你付出大量的时间和精力,因此,纯粹因为无人驾驶看起来很酷并不是你想成为数据科学家的理由。你要了解的是,数据科学并不是表面上那么光鲜,枯燥繁琐的数据整理、构建数据通道等占据了数据科学家们大部分时间和精力。2、了解到这些以后,如果你依然决定进入数据科学领域,那么你即将进入数据科学领域的学习!首先,学习Python和慕课网上的课程,然后尽快创建一个基础性的项目。在你熟悉Python的基本技能后,还得学习如何使用Jupyter笔记本。3、作为一个初学者,将目标一步定位到数据科学家并不明智。相反,适当的降低目标会更加有助于你成功,比如数据可视化或数据分析。这些专业的技术人员需求量很大,并且很容易找到工作,在这些领域工作后,你就会有机会经常和数据科学家一起工作。在积累了一定的经验后,你就可以在数据科学的各个领域横向发展了!二、假如你是一名软件工程师……以我的应验来看,20%的数据科学家可能都是软件工程师。一方面来说,作为一名软件工程师,将代码部署到生产环境中,他们有着相当丰富的经验,除此以外,他们还有强大的团队合作能力,这很重要。另一方面,全栈工程师的需求量很大,因此很多公司都会向倾向于招聘软件工程师,即便是他们在招聘时写的是“招聘数据科学家”,这也是虚的。因此,你要避免成为一名软件工程师!另外,我还有一些建议:1.如果你是一名软件工程师,你可以先熟悉数据管道,这能够帮助你学习核心的数据操作技巧。2.机器学习工程可能是最接近数据科学家的一个角色,如果你能成为一名机器学习工程师,那么再进阶为一名数据科学家对你来说就很容易了。机器学习重点在于部署模型或者是将现有的模型部署到应用程序中,可以有效的利用你所学到的技能。3.你可以创建机器学习或者数据科学项目来打动招聘主管,利用你所学的软件工程技能,向招聘人员展示你的应用程序,证明你是一个全栈数据科学家的潜力股。4.你要知道,在转型期间,你的薪水肯定会有所降低。即便是高级软件工程师,在转型到数据科学领域的时候,也是一个初级角色,也会面临薪水降级。很多人没有意识到这点,以至于面对薪资降低会感到特别失望。三、假如你是一名数学或物理系毕业生……如果你是数学或物理系的本科、硕士或博士毕业生,那么你一定已经在统计学和数学方面打下了坚实的基础。但是遗憾的是,你并没有工作经验,而且也不知道如何准备面试。即便是你在读书期间一直在学习编程,也不可能写出一个简洁、结构完整的代码。下面是我的一些建议供你参考:1.你在学校所学到的R语言、MATLAB或Mathematica远远不够,建议你学习下Python语言。除此之外,你还要学习协作版本控制(即如何与其他人一起使用GitHub)、容器化(即如何使用Docker)、Devops(即如何部署模型)和SQL。2.学习Python中的测试开发,并了解如何使用docstrings。除此之外,了解下如何将代码模块化。如果你还没准备好学习这些,那么你可以先学习如何使用Jupyter笔记本。3.如果你是一个数学系毕业生,那么深度学习可能是比较适合你的方向。如果想进入深度学习领域,可以从传统的““Scikit-Learn”开始,然后就可以很轻松的进入深度学习领域。最重要的是,你要进入这个行业,然后尽可能快的开始写代码。本文作者:【方向】阅读原文本文为云栖社区原创内容,未经允许不得转载。

December 5, 2018 · 1 min · jiezi

G2 定制仪表盘实践

我们打开 G2 看到的仪表盘如下图左边的样子,但是这个仪表盘看起来有点生硬。下图中右边的仪表盘是不是样子更炫酷了一些呢?那么如何绘制一个这样的仪表盘呢?接下来我们就来剖析一下这样的一个仪表盘是怎样做出来的,并在文章最后附上全部代码供大家参考。指针的绘制分析 G2 仪表盘核心代码 源代码请看这里:var Shape = G2.Shape;// 自定义Shape 部分Shape.registerShape(‘point’, ‘pointer’, { drawShape: function drawShape(cfg, group) { var center = this.parsePoint({ // 获取极坐标系下画布中心点 x: 0, y: 0 }); // 绘制指针 group.addShape(’line’, { attrs: { x1: center.x, y1: center.y, x2: cfg.x, y2: cfg.y, stroke: cfg.color, lineWidth: 5, lineCap: ‘round’ } }); return group.addShape(‘circle’, { attrs: { x: center.x, y: center.y, r: 9.75, stroke: cfg.color, lineWidth: 4.5, fill: ‘#fff’ } }); }});该部分自定义了仪表盘的指针形状,由一条线和一个圆圈组合而成。在 drawShape 函数中第一个参数 cfg 中携带了数据当前点坐标和颜色等信息,从而指针的指向会随数据指标而变化。那么重写该部分,就可以将 G2 原生的指针变成我们想要的样子了。但是不变的是指针的指向需要随数据指标而变化。如此一来,绘制一个箭头型指针就变成了一个数学问题:已知圆心 (xc, yc),圆上任意一点 (x, y),绘制一个箭头型指针从 (xc, yc) 指向 (x,y) 。解题步骤以仪表盘的原心坐标为原心建立坐标系如图,求出关键点 (x0, y0) 、 (x1, y1)、 (x2, y2)、 (x3, y3)的位置。(1)首先 (x0, y0)、(x2, y2) 两个点都在由 (xc, yc)、(x, y) 两点组成的线段上,所以 :(x0-xc, y0-yc) = 1(x-xc, y-yc) => (x0, y0) = (1*(x-xc) + xc, 1*(y-yc) + yc)(x2-xc, y2-yc) = 2(x-xc, y-yc) => (x2, y2) = (2*(x-xc) + xc, 2*(y-yc) + yc) 其中 1,2 都是可调节的参数在0到1范围内,它们决定了箭头的起始和终止位置。(2)然后计算 (x1, y1)。 从 (x1, y1) 向 (xc, yc) 到 (x,y) 组成的线段做一条垂直线段,设该线段的长度是 d(如图红色部分),垂直点为 (xd, yd)。 与步骤一同理,(xd, yd) 是由 (xc, yc)、(x, y) 两点组成的线段上的点,所以: (xd-xc, yd-yc) = d(x-xc, y-yc) => (xd, yd) = (d*(x-xc) + xc, d*(y-yc) + yc) 线段 d 的倾斜角度与向量 (x ,y) 存在某种关系,冥冥中我们能感觉到,通过 (xd, yd) 和 d 我们能计算出 (x1, y1) 的位置,那么如何计算呢? 我们以 (xd, yd) 为原心,再次建立坐标系,如下图: 放大该新的坐标系,并建立辅助线:从 (x1, y1) 向横坐标轴做一条垂直线段如下图: 图中出现了三个相等的角,如图蓝色的部分,所以采用三角函数可得:计算x1 sin 1 = sin 3 => (xd - x1) / d = (y-yc) / √( (x-xc)² + (y-yc)²) 把 xd = d * (x-xc) + xc、 √( (x-xc)² + (y-yc)²) = r 带入上式得到: (d * (x-xc) + xc - x1) / d = (y-yc) / r => x1 = d (x-xc) + xc - (d/r) ( y - yc) 那么 x1 就通过 (x, y)、(xc, yc) 表达出来了。计算 y1: cos 1 = cos 3 => (y1 - yd) / d = (x-xc) / √( (x-xc)² + (y-yc)²) 把 yd = d * (y-yc) + yc、 √( (x-xc)² + (y-yc)²) = r 带入上式得到: (y1-(d * (y-yc) + yc )) / d = (x-xc) / r => y1 = d (y-yc) + yc + (d/r) ( x - xc)(3)计算 (x3, y3) 与(2)中同理,从 (x3, y3) 向 (xc, yc) 到 (x,y) 组成的线段做一条垂直线段,设该线段的长度是 d(如图红色部分),垂直点为 (xd, yd)。如下图: 由于箭头型指针是一个对称图形,由数学知识可知 改(xd, yd) 点即为(2)中的 (xd, yd) 点: (xd-xc, yd-yc) = d(x-xc, y-yc) => (xd, yd) = (d*(x-xc) + xc, d*(y-yc) + yc)此时,三个相等的角 1、 2、 3的位置变为上图蓝色所示,计算 x3: sin 1 = sin 3 => (x3 - xd) / d = (y-yc) / √( (x-xc)² + (y-yc)²) 把 xd = d * (x-xc) + xc、 √( (x-xc)² + (y-yc)²) = r 带入上式得到: (x3 - (d * (x-xc) + xc)) / d = (y - yc) / r => x3 = d (x-xc) + xc + d/r (y - yc)计算 y3: cos 1 = cos 3 => (yd - y3) / d = (x-xc) / √( (x-xc)² + (y-yc)²) 把 yd = d * (y-yc) + yc、 √( (x-xc)² + (y-yc)²) = r 带入上式得到: (d * (y-yc) + yc - y3) / d = (x-xc) / r => y3 = d (y-yc) + yc - d/r (x-xc) 至此,(x0, y0)、(x1, y1)、(x2, y2)、(x3, y3) 四个关键点的位置已经全部计算出来:x0 = 1 * (x-xc) + xc;y0 = 1 * (y-yc) + yc;x1 = d * (x-xc) + xc - (d/r) * (y - yc);y1 = d * (y-yc) + yc + (d/r) * (x - xc);x2 = 2 * (x-xc) + xc;y2 = 2 * (y-yc) + yc;x3 = d * (x-xc) + xc + d/r * (y - yc);y3 = d * (y-yc) + yc - d/r * (x - xc);上列算式中:1、2 分别是决定指针的起点、终点位置,应介于0、1之间,分别取 0.44、0.55; d 是 (xd, yd) 的位置,决定箭头的折角位置,该值应介于1、2之间,靠近 1, 取0.46; (d/r) 决定指针的胖瘦,取 0.012; x、y在接下来的代码中应为 cfg.x、cfg.y; 圆心(xc, yc) 中的 xc 、yc 在接下来的代码中分别为 center.x、 center.y。应用到代码中去:Shape.registerShape(‘point’, ‘pointer’, { drawShape: function drawShape(cfg, group) { const center = this.parsePoint({ // 获取极坐标系下画布中心点 x: 0, y: 0, }); // 绘制指针 const x0 = (cfg.x - center.x) * 0.44 + center.x; const y0 = (cfg.y - center.y) * 0.44 + center.y; const x1 = (cfg.x - center.x) * 0.46 + center.x - (cfg.y - center.y) * 0.012; const y1 = (cfg.y - center.y) * 0.46 + center.y + (cfg.x - center.x) * 0.012; const x2 = (cfg.x - center.x) * 0.55 + center.x; const y2 = (cfg.y - center.y) * 0.55 + center.y; const x3 = (cfg.x - center.x) * 0.46 + center.x + (cfg.y - center.y) * 0.012; const y3 = (cfg.y - center.y) * 0.46 + center.y - (cfg.x - center.x) * 0.012; group.addShape(‘path’, { attrs: { path: M ${x0} ${y0} L ${x1} ${y1} L ${x2} ${y2} L ${x3} ${y3} Z, lineWidth: 10, lineJoin: ‘dot’, stroke: ‘#5571F7’, }, }); return group.addShape(‘circle’, { attrs: { x: center.x, y: center.y, r: 3, stroke: ‘#5571F7’, lineWidth: 4.5, fill: ‘#5571F7’, }, }); },});测试办法将上述代码,替换掉 G2 测试代码中的相应部分,运行查看效果。改造前后的样子如下:形状的改变观察文章开篇两个图表起始弧度与截止弧度也有差异:chart.coord(‘polar’, { startAngle: -9 / 8 * Math.PI, endAngle: 1 / 8 * Math.PI, radius: 0.75});修改为:chart.coord(‘polar’, { startAngle: -10 / 8 * Math.PI, endAngle: 2 / 8 * Math.PI, radius: 0.75});数据改变从 0 到 9 改变到 0 到 100chart.scale(‘value’, { min: 0, max: 9, tickInterval: 1, nice: false});修改为:chart.scale(‘value’, { min: 0, max: 100, tickInterval: 1, nice: false});但是你会发现一个问题,仪表盘从下图中左边的样子变为右边的样子,背景颜色少了一大半,究其原因是什么呢?我们接下来看圆弧的绘制。圆弧的绘制分析 G2 中圆弧的绘制部分,分为两步:仪表盘灰色背景的绘制,指标数据的绘制。// 绘制仪表盘背景chart.guide().arc({ zIndex: 0, top: false, start: [0, 0.945], end: [9, 0.945], style: { // 底灰色 stroke: ‘#CBCBCB’, lineWidth: 18 }});// 绘制指标chart.guide().arc({ zIndex: 1, start: [0, 0.945], end: [data[0].value, 0.945], style: { stroke: ‘#1890FF’, lineWidth: 18 }});采用绘制辅助弧线的方式绘制圆弧,start、end 分别表示圆弧的起始位置。其中 end: [9, 0.945],数组中第一项表示 value 维度,的二项表示半径维度。所以在 value 从 0 到 9,变为 0 到 100 时,灰色背景圆弧的截止位置应变为 end: [100, 0.945]。// 绘制仪表盘背景chart.guide().arc({ zIndex: 0, top: false, start: [0, 0.945], end: [100, 0.945], style: { // 底灰色 stroke: ‘#CBCBCB’, lineWidth: 18 }});// 绘制指标chart.guide().arc({ zIndex: 1, start: [0, 0.945], end: [data[0].value, 0.945], style: { stroke: ‘#1890FF’, lineWidth: 18 }});仪表盘背景色已经绘制完成,再观察指标绘制就是另一段圆弧的叠加。start、end 分别为圆弧的起始、截止位置,style 中的 lineWidth 为圆弧的厚度。绘制阴影和弧线以上绘制背景和指标的方式,即为圆弧叠加,绘制外圈的阴影和弧线同样可用此方式。追加一段圆弧,用来表示外圈浅灰色阴影:chart.guide().arc({ zIndex: 1, start: [0, 1.15], end: [100, 1.15], style: { stroke: ‘#F5F7FB’, lineWidth: 18 }});分段绘制外圈4段弧线:// 绘制第一段弧线 value 从 2 到 23 空出 2 个value 的位置显示 labelchart.guide().arc({ zIndex: 1, start: [2, 1.5], end: [23, 1.5], style: { stroke: ‘#F5F7FB’, lineWidth: 2 }});// 绘制第二段弧线 value 从 27 到 48 空出 23到27 之间的位置显示 label chart.guide().arc({ zIndex: 1, start: [27, 1.5], end: [48, 1.5], style: { stroke: ‘#F5F7FB’, lineWidth: 2 }});// 绘制第三段弧线chart.guide().arc({ zIndex: 1, start: [52, 1.5], end: [73, 1.5], style: { stroke: ‘#F5F7FB’, lineWidth: 2 }});// 绘制第四段弧线chart.guide().arc({ zIndex: 1, start: [77, 1.5], end: [97, 1.5], style: { stroke: ‘#F5F7FB’, lineWidth: 2 }});通过上述阴影绘制,加上弧线绘制,再把 label 的 offset 做调整,可以将仪表盘从下图左边的样子变为下图中右边的样子。绘制色彩分段与渐变色彩的分段与弧形绘制的原理一致,弧形的分段绘制。这一部分在 G2 的分段仪表盘里也已经有所介绍。不过在这里我仍然想要梳理一下:为了将仪表盘分为 4 段颜色展示,我们找到3个 value 的分割点,25、50、75。数据所在区间之前的区间拼接规则如下,顺序不能改变:当 value >= 25 时,[0, 25] 区间段颜色涂满为 color[0];当 value >= 50 时,[25, 50] 区间段颜色涂满为 color[1];当 value >= 75 时,[50, 75] 区间颜色段涂满为 color[2];数据所在区间涂色规则如下:当 value < 25 时,[0, value] 区间颜色涂成 color[0];当 value > 25 && value < 50 时, [25, value] 区间颜色图为 color[1];当 value > 50 && value < 75 时, [50, value] 区间颜色涂成 color[2];当 value > 75 时,[75, value] 区间的颜色涂成 color[3]; var color = [ ’l(0) 0:#69B4FA 1:#5AA9FC’, ’l(0) 0:#5AA9FC 1:#546DF6’, ’l(0) 0:#546DF6 1:#5461F7’, ’l(0) 0:#5461F7 1:#474DE2’,];var value = data[0].value;value >= 25 && chart.guide().arc({ zIndex: 1, start: [0, 0.945], end: [25, 0.945], style: { stroke: color[0], lineWidth: 18 }});value >= 50 && chart.guide().arc({ zIndex: 1, start: [25, 0.945], end: [50, 0.945], style: { stroke: color[1], lineWidth: 18 }});value >= 75 && chart.guide().arc({ zIndex: 1, start: [50, 0.945], end: [75, 0.945], style: { stroke: color[2], lineWidth: 18 }});value < 25 && chart.guide().arc({ zIndex: 1, start: [0, 0.945], end: [value, 0.945], style: { stroke: color[0], lineWidth: 18 }});value < 50 && value > 25 && chart.guide().arc({ zIndex: 1, start: [25, 0.945], end: [value, 0.945], style: { stroke: color[1], lineWidth: 18 }});value < 75 && value > 50 && chart.guide().arc({ zIndex: 1, start: [50, 0.945], end: [value, 0.945], style: { stroke: color[2], lineWidth: 18 }});value > 75 && chart.guide().arc({ zIndex: 1, start: [75, 0.945], end: [value, 0.945], style: { stroke: color[3], lineWidth: 18 }});将指标绘制的部分由上述代码代替分段,其中 color 为4个渐变色组成的数组。即可得到分段渐变的仪表盘了。小结其实仪表盘是一个极为简单的图表,其数据一般只有一个,表达这个数据的在其波动区间内的占比。那么自定义它的样式的难点就转化到绘制上,通过上述分析和实践,掌握两个要点就能绘制自己的仪表盘了:仪表盘的圆弧是叠加画出来,指针的形状可以自定义。附完整代码文章可随意转载,但请保留此 原文链接。非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj(at)alibaba-inc.com 。 ...

November 16, 2018 · 6 min · jiezi

炫酷粒子表白,双十一脱单靠它了!

双十一光棍节又要来临了,每年这个时候都是本人最苦闷的时刻。日渐消瘦的钱包,愈发干涸的双手,虽然变强了,头却变凉了。今年一定要搞点事情!<img src=“https://img.alicdn.com/tfs/TB...; alt=“fxxking things” width=“160”> 最近听女神说想谈恋爱了,✧(≖ ◡ ≖) 嘿嘿,一定不能放过这个机会,给她来个不一样的表白。<img src=“https://img.alicdn.com/tfs/TB...; alt=“我老婆” width=“360”>作为整天搞可视化的前端攻城狮,最先想到的就是常玩的各种粒子。那么咱们就一起来把这个粒子系统玩出花来吧。演示地址用粒子组成文字首先,咱们想下要如何将一系列的粒子组成一句表白呢?实现原理其实很简单,Canvas 中有个 getImageData 的方法,可以得到一个矩形范围所有像素点数据。那么我们就试试来获取一个文字的形状吧。第一步,用 measureText 的方法来计算出文字适当的尺寸和位置。// 创建一个跟画布等比例的 canvasconst width = 100;const height = ~~(width * this.height / this.width); // this.width , this.height 说整个画布的尺寸const offscreenCanvas = document.createElement(‘canvas’);const offscreenCanvasCtx = offscreenCanvas.getContext(‘2d’);offscreenCanvas.setAttribute(‘width’, width);offscreenCanvas.setAttribute(‘height’, height);// 在这离屏 canvas 中将我们想要的文字 textAll 绘制出来后,再计算它合适的尺寸offscreenCanvasCtx.fillStyle = ‘#000’;offscreenCanvasCtx.font = ‘bold 10px Arial’;const measure = offscreenCanvasCtx.measureText(textAll); // 测量文字,用来获取宽度const size = 0.8;// 宽高分别达到屏幕0.8时的sizeconst fSize = Math.min(height * size * 10 / lineHeight, width * size * 10 / measure.width); // 10像素字体行高 lineHeight=7 magicoffscreenCanvasCtx.font = bold ${fSize}px Arial;// 根据计算后的字体大小,在将文字摆放到适合的位置,文字的坐标起始位置在左下方const measureResize = offscreenCanvasCtx.measureText(textAll);// 文字起始位置在左下方let left = (width - measureResize.width) / 2;const bottom = (height + fSize / 10 * lineHeight) / 2;offscreenCanvasCtx.fillText(textAll, left, bottom);咱们可以 appendChild 到 body 里看眼好的。同学们注意,我要开始变形了 [推眼镜] 。getImageData 获取的像素数据是一个 Uint8ClampedArray (值是 0 - 255 的数组),4 个数一组分别对应一个像素点的 R G B A 值。我们只需要判断 i * 4 + 3 不为 0 就可以得到需要的字体形状数据了。// texts 所有的单词分别获取 data ,上文的 textAll 是 texts 加一起Object.values(texts).forEach(item => { offscreenCanvasCtx.clearRect(0, 0, width, height); offscreenCanvasCtx.fillText(item.text, left, bottom); left += offscreenCanvasCtx.measureText(item.text).width; const data = offscreenCanvasCtx.getImageData(0, 0, width, height); const points = []; // 判断第 i * 4 + 3 位是否为0,获得相对的 x,y 坐标(使用时需乘画布的实际长宽, y 坐标也需要取反向) for (let i = 0, max = data.width * data.height; i < max; i++) { if (data.data[i * 4 + 3]) { points.push({ x: (i % data.width) / data.width, y: (i / data.width) / data.height }); } } // 保存到一个对象,用于后面的绘制 geometry.push({ color: item.hsla, points });})制定场景,绘制图形文字图形的获取方式以及搞定了,那么咱们就可以把内容整体输出了。咱们定义一个简单的脚本格式。// hsla 格式方便以后做色彩变化的扩展const color1 = {h:197,s:‘100%’,l:‘50%’,a:‘80%’};const color2 = {h:197,s:‘100%’,l:‘50%’,a:‘80%’};// lifeTime 祯数const Actions = [ {lifeTime:60,text:[{text:3,hsla:color1}]}, {lifeTime:60,text:[{text:2,hsla:color1}]}, {lifeTime:60,text:[{text:1,hsla:color1}]}, {lifeTime:120,text:[ {text:‘I’,hsla:color1}, {text:’❤️’,hsla:color2}, {text:‘Y’,hsla:color1}, {text:‘O’,hsla:color1}, {text:‘U’,hsla:color1} ]},];根据预设的脚本解析出每个场景的图形,加一个 tick 判断是否到了 lifeTime 切换到下一个图形重新绘制图形。function draw() { this.tick++; if (this.tick >= this.actions[this.actionIndex].lifeTime) { this.nextAction(); } this.clear(); this.renderParticles(); // 绘制点 this.raf = requestAnimationFrame(this.draw);}function nextAction() { ….//切换场景 balabala.. this.setParticle(); // 随机将点设置到之前得到的 action.geometry.points 上}这样咱们基本的功能已经完成了。能不能再给力一点说好的粒子系统,现在只是 context.arc 简单的画了一点。那咱们就来加个粒子系统吧。class PARTICLE { // x,y,z 为当前的坐标,vx,vy,vz 则是3个方向的速度 constructor(center) { this.center = center; this.x = 0; this.y = 0; this.z = 0; this.vx = 0; this.vy = 0; this.vz = 0; } // 设置这些粒子需要运动到的终点(下一个位置) setAxis(axis) { this.nextX = axis.x; this.nextY = axis.y; this.nextZ = axis.z; this.color = axis.color; } step() { // 弹力模型 距离目标越远速度越快 this.vx += (this.nextX - this.x) * SPRING; this.vy += (this.nextY - this.y) * SPRING; this.vz += (this.nextZ - this.z) * SPRING; // 摩擦系数 让粒子可以趋向稳定 this.vx *= FRICTION; this.vy *= FRICTION; this.vz *= FRICTION; this.x += this.vx; this.y += this.vy; this.z += this.vz; } getAxis2D() { this.step(); // 3D 坐标下的 2D 偏移,暂且只考虑位置,不考虑大小变化 const scale = FOCUS_POSITION / (FOCUS_POSITION + this.z); return { x: this.center.x + (this.x * scale), y: this.center.y - (this.y * scale), }; }}大功告成!既然是 3D 的粒子,其实这上面还有不是文章可做,同学们可以发挥想象力来点更酷炫的。还有什么好玩的上面是将粒子摆成文字。那咱们当然也可以直接写公式摆出个造型。// Actions 中用 func 代替 texts{ lifeTime: 100, func: (radius) => { const i = Math.random() * 1200; let x = (i - 1200 / 2) / 300; let y = Math.sqrt(Math.abs(x)) - Math.sqrt(Math.cos(x)) * Math.cos(30 * x); return { x: x * radius / 2, y: y * radius / 2, z: (Math.random() * 30), color: color3 }; }}再把刚才文字转换形状的方法用一下{ lifeTime: Infinity, func: (width, height) => { if(!points.length){ const img = document.getElementById(“tulip”); const offscreenCanvas = document.createElement(‘canvas’); const offscreenCanvasCtx = offscreenCanvas.getContext(‘2d’); const imgWidth = 200; const imgHeight = 200; offscreenCanvas.setAttribute(‘width’, imgWidth); offscreenCanvas.setAttribute(‘height’, imgHeight); offscreenCanvasCtx.drawImage(img, 0, 0, imgWidth, imgHeight); let imgData = offscreenCanvasCtx.getImageData(0, 0, imgWidth, imgHeight); for (let i = 0, max = imgData.width * imgData.height; i < max; i++) { if (imgData.data[i * 4 + 3]) { points.push({ x: (i % imgData.width) / imgData.width, y: (i / imgData.width) / imgData.height }); } } } const p = points[(Math.random() * points.length)] const radius = Math.min(width * 0.8, height * 0.8); return { x: p.x * radius - radius / 2, y: (1 - p.y) * radius - radius / 2, z: ~~(Math.random() * 30), color: color3 }; }}完美 ????。然后咱也可以用 drawImage 绘制图片来代替 arc 画点。<img src=“https://img.alicdn.com/tfs/TB...; width=“160”>等等!!前面的效果总觉得哪里不对劲,好像有些卡 。优化小提示分层。如果还需要增加一些其他的内容到 Canvas 中的话,可以考虑拆出多个 Canvas 来做。减少属性设置。包括 lineWidth、fillStyle 等等。Canvas 上下文是很复杂的一个对象,当你调它的一些属性设置时消耗的性能还是不少的。arc 之类的画图方法也要减少。离屏绘制。不用 arc 画点,要怎么办。上面的延迟其实就是每次画点时都调用了一遍 fillStyle、arc。但是当我们绘制图片 drawImage 时,性能明显会好上很多。drawImage 除了直接绘图片外,还能绘制另一个 Canvas,所以我们提前将这些点画到一个不在屏幕上的 Canvas 里就可以了。减少 js 计算,避免堵塞进程,可以使用 web worker。当然咱们目前的计算完全用不上这个。我这使用了 3000 个粒子,对比下使用离屏绘制前后的帧率。总结现在唯一限制你的就是想象力了。大家一起去征服老板,征服女神!这个双十一脱贫脱单不脱发!好了不说了,女神喊我修电脑去了。参考deformable particlesheart.pngCanvas 参考手册y=sqrt(abs(x))-sqrt(cos(x))*cos(40x)文章可随意转载,但请保留此 原文链接。非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj(at)alibaba-inc.com 。 ...

November 5, 2018 · 3 min · jiezi