实现圆形关系图

225 阅读6分钟

项目背景

在项目中需要实现以下的关系图,因此将实现的过程记录下来。 image.png

页面剖析

这是部门关联图,部门平均分布于圆形圆周上,不同的部门之间通过箭头的移动来表明有关联关系。 把这个页面中的元素分一下类,有部门元素,连接线元素,箭头元素。 其中,部门元素通过dom处理,只需要计算每个元素的位置,再分别旋转相应的角度即可。 连接线是曲线,不同于直线,曲线有不同的类型,这里只是稍微弯曲的曲线,所以选用二阶贝塞尔曲线即可。 箭头是沿着曲线运动的,而曲线是贝塞尔曲线,所以我们只需计算出每一时刻曲线上的点,并旋转相应的角度。 有了总的思路,下面我们就开始实现功能。

展示部门元素

部门元素需要的数据很简单,就是部门数组,其中有name字段,代表部门名称。考虑到是在vue中做的,利用vue的数据特点,定义一个不断变化的部门数组,当需要旋转时,就不断改变这个数组中的数据,以及位置,这样页面就能自动实现变化效果,所以这里的关键在于改变数组。

生成动态变化的数组

设置一个不断变化的索引selectIndex,选中部门的索引值,每个元素的旋转角度和位置都是根据这个值不断变化,从而使得页面上的部门元素也不断旋转。 关键代码如下

// selectIndex为当前高亮部门的索引
renderlist() {                
	// 每个元素的夹角
	let angle = 360 / (this.datalist.length + this.actCount - 1);
	let rawlist = JSON.parse(JSON.stringify(this.datalist));
	let result = [];
	// 第一个元素的开始角度,90度+选中元素索引*每个元素夹角, 每次selectIndex变化,这个角度就变化,selectIndex可能为小数
	let sAngle = this.defaultStartAngle + this.selectIndex * angle;
	for (let i = 0; i < rawlist.length; i++) {
		if (i === this.selectIndex) {
			result.push(Object.assign(rawlist[i], {
				active: true,
				startAngle: sAngle,
				rotateAngle: 0, // 选中元素固定在正下方,所以角度固定0
				left: Math.cos(this.angleToRadian(startAngle)) * this.radius + this.center[0], // 位置横坐标
				top: Math.sin(this.angleToRadian(startAngle)) * this.radius + this.center[1] // 位置纵坐标
			}));
		} else {
			result.push(Object.assign(rawlist[i], {
				active: false,
				// angle: angle,
				startAngle: sAngle,
				rotateAngle: startAngle - this.defaultStartAngle + 180, // 其他元素的旋转角度,试出来的
				left: Math.cos(this.angleToRadian(startAngle)) * this.radius + this.center[0],
				top: Math.sin(this.angleToRadian(startAngle)) * this.radius + this.center[1]
			}));
		}
		// 往下遍历一,角度就减去一个平均角
		sAngle -= angle;
	}
	return result;
},

不断改变selectIndex的值

因为要实现动画效果,这里用tween.js来生成从当前高亮索引-->下一个高亮索引的变化回调。 image.png 关键代码

// 更新选中的部门索引, 通过tween,从当前索引渐渐增加到选中索引,实现旋转动画
updateSelectIndex: function (index) {
	if (this.selectIndex !== index) {
		// 设置此次转动动画的开始和结束索引
		let startIndex = this.selectIndex;
		let endIndex = index;
		if (index < this.selectIndex) {
			// 如果结束索引小于开始索引,结束索引加上总数据长度, 保证从开始到结束过程中方向正确
			endIndex += this.datalist.length;
		}
		let time = this.transitionInterval;
		if (endIndex - startIndex > this.datalist.length / 2) {
			// 在左侧
			// 以上图做一个例子,当前高亮为selectIndex = 2, 点击索引为20
			// 总数据条数this.datalist.length = 22
			// endIndex = 20
			// startIndex = 2
      // count = this.datalist.length - (endIndex - startIndex) = 4  
			// count左侧点击元素距离高亮元素的个数
			// endIndex = startIndex - count = 2 - 4 = -2;
			let count = this.datalist.length - (endIndex - startIndex);
			endIndex = startIndex - count;
		}
		// 经过上面的计算,结束索引实际是可能>=this.datalist.length或<0的
		new TWEEN.Tween({
			r: startIndex
		})
			.to(
			{
				r: endIndex
			},
			time,
			TWEEN.Easing.Bounce.InOut
		)
			.onUpdate(u => {
			// tween更新时,u是0~1范围的数字,根据比例计算出当前的的索引,可能为小数
			// let r = startIndex + u.r * (endIndex - startIndex);
			let r = u.r;
			if (r >= this.datalist.length) {
				// 如果当前索引大于总数据长度,则减去总数据长度
				r -= this.datalist.length;
			}
			if(r < 0){
				r += this.datalist.length;
			}
			// 更新当前的选中索引
			this.selectIndex = r;
		})
			.start();
	}
},

展示连接线

在创建部门元素的数据时,已经计算出来了每个元素的位置,因为连接线的端点位于每个部门元素的内部一些距离,也就能顺便计算出每个部门的端点位置。

生成连接线数据数组

因为连接线也是一个数组,所以在选中过程中这个数组就会变化,因此需要处理下,比如: 开始时,默认高亮的索引是0,那么连接线数组顺序为 2,3,4,5...... 当点击了部门4,索引为3,那么连接线数组顺序就变为4,5,6......20,21,1,2 关键代码

for (let i = 0; i < this.renderlist.length; i++) {
	let item = this.renderlist[i];
	let radius = 310; // 普通部门的点的半径
	if (item.active) {
		radius = 294; // 选中部门的点的半径
	}
	// 计算部门的点的坐标
	let left = Math.cos(this.angleToRadian(item.startAngle)) * radius + this.center[0];
	let top = Math.sin(this.angleToRadian(item.startAngle)) * radius + this.center[1];
	// 把选中部门和没选中部门的数据分别存放到activeLine和normalLines
	if (item.active) {
		actIndex = i;
		activeLine = {
			show: item.active,
			index: i + 1,
			xy: [left, top],
			id: item.id
		};
	} else {
		let newitem = {
			show: item.active,
			index: i + 1,
			xy: [left, top],
			id: item.id
		};
		// 非选中部门的数据从选中部门的后一个开始顺序排放,一直循环到选中部门的前一个
		if (!(actIndex >= 0)) {
			normalLines.push(newitem);
		} else if (i > actIndex) {
			normalLines.splice(i - actIndex - 1, 0, newitem);
		}
	}
}

计算曲线控制点

因为使用二阶贝塞尔曲线,所以需要一个控制点,那么这个控制点怎么选取呢?经过观察发现,每个曲线的弯曲方向是向着平分线来靠的,所以一开始考虑把控制点设置为中线的中点,也就是最上边和最下边端点线段的中点。但是太弯曲了。 image.png 所以考虑设置为每个线端的垂直平分线与临近线段的交点。经过试验,这样的效果不错。 image.png 关键代码,其中需要注意点地方是通过斜率来计算交点,斜率可能有不存在或者非常大(等同于不存在)的情况,需要特殊考虑

for (let i = 0; i < normalLines.length; i++) {
	let normal = normalLines[i];	// 结束端点
	let start = activeLine.xy;		// 开始端点
	let end = normal.xy;
	// 赋值给简单变量,容易书写
	let x1 = start[0];
	let y1 = start[1];
	let x2 = end[0];
	let y2 = end[1];
	let x3, y3;
	// 选中点与每个非选中点的连接线斜率
	let k2 = 0 + (normalLines[i].xy[1] - activeLine.xy[1]) / (normalLines[i].xy[0] - activeLine.xy[0]);
	// 开始点与控制点连线的斜率
	let k1;
	// 控制点是否特殊处理的变量
	let ss = false;
	if (normalSize % 2 === 1 && Math.abs(Math.floor(normalSize / 2) - i) === 0) {
		// 如果非选中点个数为奇数,并且是正中间的点,直接特殊处理
		ss = true;
	}
	if (k2 === Infinity || k2 === -Infinity || ss) {
		// 这三种情况下,控制点直接设置为起始点的中点
		x3 = (x1 + x2) / 2;
		y3 = (y1 + y2) / 2;
	} else {
		let sss = false;
		if (Math.abs(Math.floor(normalSize / 2) - i) <= 1) {
			// 当点与正中间的个数不大于1时,特殊处理2
			sss = true;
		}
		if (i < Math.floor(normalSize / 2)) {
			// 把开始点和控制点的连线斜率设置为当前点的下一个或者上一个点的连线斜率
			k1 = 0 + (normalLines[i + 1].xy[1] - activeLine.xy[1]) / (normalLines[i + 1].xy[0] - activeLine.xy[0]);
		} else {
			k1 = 0 + (normalLines[i - 1].xy[1] - activeLine.xy[1]) / (normalLines[i - 1].xy[0] - activeLine.xy[0]);
		}
		if (k1 === Infinity || k1 === -Infinity || sss) {
			// 控制点特殊处理2,此时开始点和结束点的x左边相同,通过下面的公式计算出的斜率是无穷大或者是一个非常大的数,无法正常处理,所以直接设x3 = x1 = x2
			x3 = x1;
			y3 = (y1 + y2) / 2 + (0 + 1 / k2) * (x3 - (x1 + x2) / 2);
		} else {
			// 解二元一次方程组
			// 开始点与控制点所在直线,与开始点和结束点线段上的垂直平分线的交点,就是控制点
			x3 = ((y1 + y2) / 2 + (x1 + x2) / (k2 * 2) + k1 * x1 - y1) / (k1 + 1 / k2);
			y3 = y1 + k1 * (x3 - x1);
		}
	}
}

展示动画箭头

因为箭头是在连接线上的,计算箭头在某一时刻的位置,就是要计算二阶贝塞尔曲线在某一时刻的坐标。

什么是二阶贝塞尔曲线

20190912152934657.gif

计算坐标

给定一个t∈[0,1],然后1. 计算端点为开始点与控制点的线段中进度为t的点P3, 计算端点为控制点与终点的线段中进度为t的点P4,计算端点为P3与P4的线段中进度为t的点P5。 关键代码

// start:开始点坐标 end:终点坐标 control:控制点坐标
let p1 = [start[0] * (1 - t) + control[0] * t, start[1] * (1 - t) + control[1] * t];
let p2 = [control[0] * (1 - t) + end[0] * t, control[1] * (1 - t) + end[1] * t];
let xy = [p1[0] * (1 - t) + p2[0] * t, p1[1] * (1 - t) + p2[1] * t];

计算箭头倾斜角度

  1. 列出二阶贝塞尔曲线的参数方程,根据上面的代码,容易列出为

x=(x1(1t)+x2t)(1t)+(x2(1t)+x3t)tx = (x_1*(1-t) + x_2*t)*(1-t)+(x_2*(1-t)+x_3*t)*t y=(y1(1t)+y2t)(1t)+(y2(1t)+y3t)ty=(y_1*(1-t)+y_2*t)*(1-t)+(y_2*(1-t)+y_3*t)*t

  1. 根据参数方程求导公式计算出每个点的斜率

tanα=x(t)y(t)tanα = \frac{x'(t)}{y'(t)} 关键代码

let dxdt = 2 * t * (start[0] - 2 * control[0] + end[0]) + 2 * (control[0] - start[0]);
let dydt = 2 * t * (start[1] - 2 * control[1] + end[1]) + 2 * (control[1] - start[1]);
let rotate = this.radianToAngle(Math.atan(dydt / dxdt));