一、前言
桑基图常用于流量分析,它可以很清楚的看出数据是如何渐渐分流的。今天讨论一下绘制一个类桑机图的基本方法,之所以说“类”是因为它不满足桑吉图能量守恒的基本定律。我们不考虑节点权重,分流大小,所以实现起来,要相对简单。
简单叙述这里实现方法原理:
二、数据结构
- 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();
};