使用canvas画一个类桑吉图

1,460 阅读4分钟

一、前言

桑基图常用于流量分析,它可以很清楚的看出数据是如何渐渐分流的。今天讨论一下绘制一个类桑机图的基本方法,之所以说“类”是因为它不满足桑吉图能量守恒的基本定律。我们不考虑节点权重,分流大小,所以实现起来,要相对简单。

简单叙述这里实现方法原理:

二、数据结构

  • nodes:节点集合
  • id:节点ID
  • name:节点名称
  • properties:节点属性
  • level:节点所在列(这里是配置好的,也可以根具link关系计算)
  • links: 节点关系集合
  • from:起始节点ID
  • to:目标节点ID

三、实现原理

数据结构组织中,节点关联关系从前向后,从左向右,不会出现回流的现象。实现绘制时,可以按照列,逐列绘制节点。然后根据关联【from,to】绘制关联线。节点的绘制相对简单,将节点按照列划分,根据列数、节点大小和画布宽高均等拆分。关联线的绘制相对麻烦点,我们可以用1/2周期的正弦或余弦函数来模拟

四、实现步骤

1、按列提取

节点和关联曲线按列逐步绘制,所以需要对【nodes】进行案列分组,我的数据结构,节点【properties】中【level】属性记录了节点列好,所以直接根据该属性分组就可以。也可以根据【links】关联关系自新提取。

几行可以不贴出来的代码:

let datas = [];
nodes.forEach(function (node) {r datas = [];
let level = node.properties.level;
if (!datas[level]) {
   datas[level] = [];
}
// node.name = getRandomWorlds();// 随机繁体字,脱敏处理一下哈
node.index = datas[level].length;
datas[level].push(node);
// datasmap[node.id] = node;
});

图中,案例数据共9列,第一列14个节点,第二列32个节点。

2、关联列

相邻两列组成一个关联列。为了方便后续处理绘制,将连续两列组成一个关联列。九列共八个关联列。

怎么确定各个关联列中,前后两列各节点的相互关系呢?这里就需要从【links】中提取了。按照列提取的时候,在node节点中记录了一个【index】属性,这个属性值指的的该节点在当前列中的索引,对是当前列。链接索引关系就可以通过【links】中获取。结果是这样的;

例如:图中红框,该节点在当前列的索引是12,他是当前列的第13个节点。71,72,73是该节点对于下一列中地72,73,74号节点。

这种就可以提取出各个关联列中,前后两类索引关联喽,下图 【rows】数组中的每一项都对应一个关联关系,落到图上就是一条连线,曲线,用余弦函数模拟的一条曲线。

2、绘制

1)关联列的【X】坐标集

计算每一个关联列的【x】值,根据画布宽度划分:

let { width, height } = canvas;
width -= 50;
let middle_height = height / 2 + delta_top;
let xlimit = [((cx + 1) / (cols.length + 1)) * width, 
((cx + 2) / (cols.length + 1)) * width];

对每个关联列【x】值进行插值。对于同一个关联列,任何一条关联线,他们都有统一【x】坐标。这很好理解。因为我们是对同一个【x】区间进行相同数量点的插值。指定一个关联列区间,即上面代码中计算的【xlimit】,在这个区间中插120个点,得到该关联列的 【x】坐标集。计算公式是这样的:

对于每一个关联列的【x】坐标集合,就均等拆分就可以。

function createXdots  (fromx, tox, count) {
	if (!(count >= 2)) throw new Error('插值数量大于2');
	     let i = 0;
	     let dots = [];
	     while (i++ < count) {
		dots.push(fromx+ ((tox- fromx) / count) * i);
	     }
	return dots;
};

2)关联列的【Y】坐标集

每个关联列 的【X】坐标集是一样的。但y方向就不同了,因为每一个节点的y坐标都不一样。每一列节点都画布居中,如果这一列节点很多,就让他超出画布范围,不做处理。

let middle_height = height / 2 + delta_top;
let [y1,y2] = [(row[0] - c[0].length / 2) * 20 + middle_height,
(row[1] - c[1].length / 2) * 20 + middle_height ]

【y1,y2】对应一条关联线两端的的y坐标:可以这样描述:

节点y坐标 = 画布高度 / 2 + (节点所在列的索引 - 该列节点数量 / 2) * 节点高度;

获取两个关联节点的y坐标后,对y坐标值范围进行插值,获取关联列中每对关联节点的y坐标集。关联列的x坐标集和每对关联节点的y坐标集数量必须相同,所以对y方向区间进行插值的数量,我们也取120。插值公式是这样的:

function createShape (from, to, count) {
	var deltaPI = Math.PI / count;
	let { cos } = Math;
	if (!(count >= 2)) throw new Error('count必须大于2');
	var inc = 0;
	let i = 0;
	let dots = [];
	while (i++ < count) {
	let res = from + (to - from) / 2 - ((to - from) / 2) * cos((Math.PI / count) * i);
		dots.push(res);
	}
	return dots;
};
  • y0:关联线起点y坐标
  • y1:管理线终点y坐标
  • yn:待插值点y坐标
  • count:插值点数量
  • i:插值点索引

按照这个计算公式,就可以得到个关联列中,每一条关联线的【y】坐标集。遍历每个关联列,遍历每条关联线,结合【x】【y】坐标集合,就可以绘制出结果。

function drawRect  (rect, context) {
	context.beginPath();
	context.moveTo(rect[0], rect[1]);
	context.lineTo(rect[2], rect[1]);
	context.lineTo(rect[2], rect[3]);
	context.lineTo(rect[0], rect[3]);
	context.closePath();
	context.fill();
};
 function drawShapes  (shape, xdots, context) {
      if (shape.length !== xdots.length) {
       throw new Error('纵坐标数据和坚坐标数量必须相同!');
      }
       context.beginPath();
       context.moveTo(xdots[0], shape[0]);
       let inc = 0;
       while (++inc < shape.length) {
	   context.lineTo(xdots[inc], shape[inc]);
	}
	context.stroke();
};

五、结果