如何自己开发一款力导向图?
前言
力导向图大家都不陌生,力导向图缺少不了力,而在数据量很大的情况下初始化节点以及对节点进行拖动时会导致整个力导图都在一直在动,密集的情况会更加严重,并且本着可以对点更好,灵活的控制,满足不同的需求,所以打算自己实现一个简单的力导向图,并在过程中对碰撞检测进行一次探索。
前一小节讲开发过程中有问题的需求。后面主要是碰撞检测以及点的分配的功能。
内容包括
整体内容分为两个部分
使用 d3.js 开发力导向图出现的问题
两点之间多条边的处理 点的框选 点的删除 缩略图 主图的拖拽、缩放与缩略图
自己实现一个简单的拓扑图
碰撞检测 矩形与矩形的检测 圆形与圆形 圆形与矩形 点的分配 碰撞后点的移动 拖动
一、使用d3.js 开发力导向图出现的问题
两点之间多条边的处理
思路为 ,将两点之间的线进行分组,中间,左右分别为三组,分好组后,当tick 进行渲染时,通过分组内容的数量,对分组内容改变path 的弯曲程度。
点的框选
拖拽中创建一个矩形框,拖拽后判断中心点是否在矩形框中则为被框选中. 注: 位置需要与d3 缩放的scale 配合计算
删除
点的删除实际上 就是把 相关点与线全部删除, 并且清空画布后, 重新用删除后的数据重新绘制。
缩略图
缩略图目前的逻辑是主图的最大倍数作为背景,主图的宽高作为缩略图视野(蓝框)的宽高。因为缩略图的dom 的宽高是 css 定死的,viewbox 是实际宽高,所以给定主图(正常)的宽高 会自动缩放。在拖拽主图的点与相应操作时,对缩略图的点也进行相应的变动,实际上就是在缩略图中又画了一遍主图的内容
/**
* @params
* width 缩略图宽度
* height 缩略图高度
* mainWidth 主图的宽度
* mainHeight 主图的高度
* zoomMax 最大缩放比例
*
*/
thumbSvg.attr('width', width)
.attr('height', height).attr('viewBox', () => {
// 缩略图的宽高为 主图的 最大缩略比例
w = mainWidth * zoomMax;
h = mainHeight * zoomMax;
// 设置偏移 让背景图移至中心,缩略图与主图的差/ 2 就是需要移动的距离
x = -(w - mainWidth) / 2;
y = -(h - mainHeight) / 2;
return `${x} ${y} ${w} ${h}`;
});
dragThumb.attr('width', mainWidth)
.attr('height', mainHeight);
主图的拖拽、缩放与缩略图
调用主图的缩放时(zoom) 会得到缩放以及拖拽信息,缩略图使用拖拽的信息,因为viewbox 的原因,拖拽信息会自动缩放。但是需要注意主图的缩放会对translate 进行变化 所以需要自己去处理 缩放过程中产生的位移
因为缩放会造成 主图的 translate 发生变化 与手动拖拽造成的translate 会有差 所以 要扣除缩放造成的偏移
/**
* @params
* innerZoomInfo 缩略图的缩放信息
* mainTransform 主图的缩放信息
* mainWidth,mainHeight 主图的宽高
*/
const {
innerZoomInfo, mainWidth, mainHeight,
} = this;
// 如果传入的 缩放值与之前记录的缩放值不一致 则认为发生了缩放 记录发生缩放后偏移值
if (!innerZoomInfo || innerZoomInfo.k !== mainTransform.k) {
this.moveDiff = {
x: (mainWidth - innerZoomInfo.k * mainWidth) / 2, //缩放产生的 位移
y: (mainHeight - innerZoomInfo.k * mainHeight) / 2,
};
}
const { x: diffX, y: diffY } = this.moveDiff;
const { x, y, k } = mainTransform; // 主图偏移以及缩放数据
this.dragThumb
.attr('width', mainWidth / k)
.attr('height', mainHeight / k)
.attr('transform', () => setTransform({
x: -((x - diffX) / k), // 这个地方应该不能直接 除 k 这里的x,y 应该是放大后的x,y应该减去缩放的差值 再 除K
y: -((y - diffY) / k),
}));
自己实现一个简单的拓扑图
碰撞检测
矩形与矩形的检测
矩形与矩形的碰撞是最好检测的通过上面的图基本就涵盖了规则矩形相交的情况 图可以得知 A:红色矩形 B:绿色矩形 上下是通过Y,左右是通过X
A.x < B.x + B.width &&
A.x + A.width > B.x &&
A.y < B.y + B.h &&
A.h + A.y > B.y
但是如果内部是一个圆形的话,那么如果 紫色的区域则会被判定为碰撞则 则准确性有一定的偏差,需要有圆形的检测
圆形与圆形
圆形与圆形的逻辑也比较简单,就是两点之间的距离小于两点半径之和 则为碰撞 var a = dot2.x-dot1.x;
var b = dot2.y-dot1.y;
return Math.sqrt(a*a+b*b) < a.radius + b.radius;
圆形与矩形
首先来看 矩形与圆形相交是什么样,从图所知矩形与圆形相交,表现为圆点距离矩形最近的点小于圆点半径 则为相交 那么如何得到圆点距离矩形最近的点从下图就知道了 圆点的延伸是圆点边的一点。crashX = 如果 圆点位于矩形 左侧 矩形(rect).x; 右侧 = rect.x + rect.w 上下 圆点(circle).x
crashY = 如果 圆点位于矩形 左右 circle.y; 上 rect.y 上下 rect.y + h
那么两点有了,可以得出两点之间的距离套用圆与圆的公式
var a = crash.x-dot1.x;
var b = crash.y-dot1.y;
return Math.sqrt(a*a+b*b) < a.radius;
上面就是基本的碰撞逻辑,更复杂的逻辑可以看下面参考文章 [1]
点的分配
点的位置的分配 就是确定中心点后,将关系最多的点作为中心点,其关系点向四周分散,没有关系的同级点,则向中心点四周进行分散,其关系点以确定后位置的点的坐标向周围分散。
根据三角形的正弦、余弦来得值;假设一个圆的圆心坐标是(a,b),半径为r,角度为d 则圆上每个点的坐标可以通过下面的公式得到
/*
* @params
* d 角度
* r 半径长度
*/
X = a + Math.cos(((Math.PI * 2) / 360) * d) * r;
Y = b + Math.sin(((Math.PI * 2) / 360) * d) * r;
角度可以通过 关系边进行得到. d = 360/关系边的数量,确定第一圈点的角度。拿到角度后 ,维持一个所有点坐标的对象,再结合圆形与圆形碰撞检测,我们就可以遍历 获取所有点的坐标了
/*
* @params
* dotsLocations 所有点的坐标信息
*/
initNodes() {
const { x: centerX, y: centerY } = this.center;
const { distance } = this;
const getDeg = (all, now) => 360 / (all - (now || 0));
// 把中心点分配给线最多的点
const centerdot = this.dots[0];
centerdot.x = centerX;
centerdot.y = centerY;
this.dotsLocations[centerdot.id] = { x: centerX, y: centerY };
this.dots.forEach((dot) => {
const { x: outx, y: outy } = dot;
if (!outx && !outy) {
// 兄弟点 (无关系的点) 默认以中心店的10度进行遍历
dot = this.getLocation(dot, centerX, centerY,10, distance).dot;
}
const { x: cx, y: cy } = dot;
const dotsLength = dot.relationDots.length;
let { distance: innerDistance } = this;
// 获取剩余点的角度
let addDeg = getDeg(dotsLength);
dot.relationDots.forEach((relationId, index) => {
let relationDot = this.findDot(relationId);
if (!relationDot.x && !relationDot.y) {
const {
dot: resultDot,
isPlus,
outerR,
} = this.getLocation(relationDot, cx, cy, addDeg, innerDistance);
if (isPlus) {
// 如果第一圈遍历完毕,则开始以 半径 * 2 为第二圈开始遍历
innerDistance = outerR;
addDeg = getDeg(dotsLength, index);
addDeg += randomNumber(5, 9); //防止第一圈与第二圈的点所生成的角度一致 造成链接的线重叠在一起
}
relationDot = resultDot;
}
});
});
}
// 分配位置
getLocation(dot, cx, cy, addDeg, distance) {
// 由第一张图 得知 -90度为最上面 从最上面开始循环
let outerDeg = -90;
let outerR = distance;
const { distance: addDistance } = this;
let firsted; // 用于分布完后一周
while (Object.keys(this.checkDotLocation(dot)).length !== 0) {
outerDeg += addDeg;
if (outerDeg > 360) {
// 转完一圈 随机生成第二圈的角度再开始对当前点进行定位
addDeg = randomNumber(10, 35);
outerDeg = addDeg;
if (firsted) {
outerR += addDistance;
}
firsted = true;
}
const innerLocation = getDegXy(cx, cy, outerDeg, outerR);
dot = Object.assign(dot, innerLocation);
}
this.dotsLocations[dot.id] = { x: dot.x, y: dot.y };
return {
dot,
isPlus: firsted,
outerR,
};
}
// 碰撞检测
checkDotLocation(circleA) {
let repeat = false;
if (!circleA.x || !circleA.y) return true;
const { forceCollide } = this;
console.log(this.dotsLocations)
Object.keys(this.dotsLocations).forEach((key) => {
if (key === circleA.id) {
return;
}
const circleB = this.dotsLocations[key];
let isRepeat = Math.sqrt(Math.pow(circleA.x - circleB.x, 2) + Math.pow(circleA.y - circleB.y, 2)) < forceCollide * 2;
if(isRepeat)repeat = true;
});
return repeat;
}
}
生成时间与D3 的差不多
碰撞后点的移动 (力?)
碰撞后的逻辑呢 简单的就是已拖动点为圆点,计算碰撞点与圆点的夹角,再通过角度与距离得出碰撞后被碰撞点的x,y的坐标
changeLocation(data, x, y, eliminate) {
// 先对原来的点进行赋值
data.x = x;
data.y = y;
// 对点的坐标进行赋值,使之后的碰撞使用新值进行计算
this.dotsLocations[data.id] = { x, y };
let crashDots = this.checkDotLocation(data);
// 获得所有被碰撞的点
Object.keys(crashDots).forEach((crashId) => {
if (eliminate === crashId) return; // 碰撞后的碰撞防止 更改当前拖拽元素
const crashDot = this.findDot(crashId);
// 获取被碰撞的x,y 值
const { x: crashX, y: crashY } = crashDot;
// 此处的角度是要移动的方向的角度
let deg = getDeg(crashDot.x,crashDot.y,data.x,data.y);
// - 180 的目的是为了 与上面的黑图角度一致
// 2是碰撞后 移动2个像素的半径
const {x:endX,y:endY} = getDegXy(crashDot.x, crashDot.y, deg - 180, 2);
// 讲被碰撞的点作为圆点 改变值 并进行碰撞点的碰撞的碰撞检测(禁止套娃 )
this.changeLocation(crashDot, endX, endY, data.id);
});
}
获取夹角角度
function getDeg(x1,y1,x2,y2){
//中心点
let cx = x1;
let cy = y1;
//2个点之间的角度获取
let c1 = Math.atan2(y1 - cy, x1 - cx) * 180 / (Math.PI);
let c2 = Math.atan2(y2 - cy, x2 - cx) * 180 / (Math.PI);
let angle;
c1 = c1 <= -90 ? (360 + c1) : c1;
c2 = c2 <= -90 ? (360 + c2) : c2;
//夹角获取
angle = Math.floor(c2 - c1);
angle = angle < 0 ? angle + 360 : angle;
return angle;
}
到此实现一个简单的拓扑图就搞定了。使用我们自己的force 代替 d3.js 的效果,后期想要什么效果就可以自己再加了 如 拖动主点相关点动,其他关联点不动的需求。tick方法需要自己手动去调用了
let force = new Force({
x: svgW / 2,
y: svgH / 2,
distance: 200,
forceCollide:30,
});
force.nodes(dot);
force.initLines(line);
拖动
这边的tick 是当 点的xy 发生变化的时候 自己去重新构建点和线。在实际项目中每一次拖动就会构建,会比较卡,可以丢到requestAnimationFrame 去调用
dotDoms.on("mousedown", function (d) {
dragDom = {
data: d,
dom: this,
};
});
d3.select("svg").on("mousemove", function (d) {
if (!dragDom) return;
const { offsetX: x, offsetY: y } = d3.event;
if (x < -1 || y < -1 || x >= svgH - 10 || y >= svgH - 10) {
//边界
dragDom = null;
return;
}
force.changeLocation(dragDom.data, x, y);
tick();
});
d3.select("svg").on("mouseup", function (d) {
dragDom = null;
});
小结
本章主要讲述了使用d3 对力导向图进行开发过程中,出现的问题。以及以碰撞为基础开发的简单的力导向图
参考资料
“等一下,我碰!”——常见的2D碰撞检测 : https://aotu.io/notes/2017/02/16/2d-collision-detection/index.html
[2]d3.js force api: https://github.com/d3/d3/blob/master/API.md#forces-d3-force