最近项目需要写一个d3的力导向图,之前没接触过d3.js 所以吧这次开发的经历写一下
友情提示:不要让设计设计的华丽呼哨,点多了很卡,而且svg 有些标签是不支持css 控制 某些样式的,也不是很好实现
如果之前没写过d3 的旁友 还不熟悉d3 的话。可以吧d3. js 理解为1个帮助你处理数据的库。
把点与线 给到d3, d3 会根据你传入的长宽 自动给你分配x,y 的位置,自己再通过 js 去点的位置想干嘛干嘛(画点)
###基本的展示
####基本配置
// 生成力
const force = d3
.forceSimulation()
.force('link',d3.forceLink().id((d) => d.id),)
.force('collide', d3.forceCollide(72).strength(0.1))
.force('charge',d3.forceManyBody().strength(-400),)
.force('center', d3.forceCenter());
分配点与线
处理一下线的数据, 两个点可能出现多条线的情况
export const setLinkNumber = (group, type) => {
if (group.length === 0) return;
const linksA = [];
const linksB = [];
for (let i = 0; i < group.length; i++) {
const link = group[i];
// 对该分组内的关系按照方向进行分类,此处根据连接的实体ASCII值大小分成两部分
if (link.source.id < link.target.id) {
linksA.push(link);
} else {
linksB.push(link);
}
}
// 确定关系最大编号。为了使得连接两个实体的关系曲线呈现对称,根据关系数量奇偶性进行平分。
// 特殊情况:当关系都是连接到同一个实体时,不平分
let maxLinkNumber = 0;
if (type === 'self') {
maxLinkNumber = group.length;
} else {
maxLinkNumber = group.length % 2 === 0 ? group.length / 2 : (group.length + 1) / 2;
}
// 如果两个方向的关系数量一样多,直接分别设置编号即可
if (linksA.length === linksB.length) {
let startLinkNumber = 1;
for (let i = 0; i < linksA.length; i++) {
linksA[i].linknum = startLinkNumber++;
}
startLinkNumber = 1;
for (let i = 0; i < linksB.length; i++) {
linksB[i].linknum = startLinkNumber++;
}
} else {
// 当两个方向的关系数量不对等时,先对数量少的那组关系从最大编号值进行逆序编号,然后在对另一组数量多的关系从编号1一直编号到最大编号,再对剩余关系进行负编号
// 如果抛开负号,可以发现,最终所有关系的编号序列一定是对称的(对称是为了保证后续绘图时曲线的弯曲程度也是对称的)
let biggerLinks;
let smallerLinks;
if (linksA.length > linksB.length) {
biggerLinks = linksA;
smallerLinks = linksB;
} else {
biggerLinks = linksB;
smallerLinks = linksA;
}
let startLinkNumber = maxLinkNumber;
for (let i = 0; i < smallerLinks.length; i++) {
smallerLinks[i].linknum = startLinkNumber--;
}
const tmpNumber = startLinkNumber;
startLinkNumber = 1;
let p = 0;
while (startLinkNumber <= maxLinkNumber) {
biggerLinks[p++].linknum = startLinkNumber++;
}
// 开始负编号
startLinkNumber = 0 - tmpNumber;
for (let i = p; i < biggerLinks.length; i++) {
biggerLinks[i].linknum = startLinkNumber++;
}
}
};
function getKey(target, source) {
const result = target > source ? `${target}:${source}` : `${source}:${target}`;
return result;
}
export const operationData = (chartData, clickType) => {
const linkmap = {};
const linkGroup = {};
const { links, dots } = chartData;
for (let i = 0; i < links.length; i++) {
const link = links[i];
const { target, source } = link;
const key = getKey(target, source);
if (linkGroup[key]) {
linkGroup[key].push(link);
linkmap[key] += 1;
} else {
linkGroup[key] = [links[i]];
}
}
Object.keys(linkGroup).forEach((groupKey) => {
linkmap[groupKey] = linkGroup[groupKey].length;
});
// 关联线与点
JSON.parse(JSON.stringify(links)).forEach((e) => {
const sourceNode = dots.filter((n) => n.id === e.source)[0];
const targetNode = dots.filter((n) => n.id === e.target)[0];
const nowIndex = links.findIndex(
(n) => n.source === e.source && n.target === e.target,
);
if (!sourceNode || !targetNode) {
links.splice(nowIndex, 1);
} else {
links[nowIndex].source = sourceNode;
links[nowIndex].target = targetNode;
}
});
for (let i = 0; i < links.length; i++) {
let { target, source } = links[i];
target = target.id;
source = source.id;
const link = links[i];
const key = getKey(target, source);
link.size = linkmap[key];
const group = linkGroup[key];
const type = 'noself'; // 标示该组关系是指向两个不同实体还是同一个实体
setLinkNumber(group, type);
}
return { links, dots };
};
处理好的数据丢到d3里面去
// tick 渲染时执行的方法
force.nodes(dots).alpha(0.01).on('tick', this.tick).restart();
force.force('link').links(links).distance(150);
丢进去 会吐出来有x,y 的数据 如:
数据处理完了 接下来创建dom
创建dom
线
线: 线是一个 g 标签包含着N 条线(a)
线内包含着2条线以及线相关的箭头
两条线的目的是因为1条线很细的情况下会很不好hover 到。所以1条粗线 一条细线 ,直接把透明度(opacity) 属性 设置为0 即可
所有箭头状态的属性,因为箭头和线不是"一体"的,所以当hover 的时候,圆点(dot) 的大小会发生变化,箭头的refX,refY,也会发生变化,甚至 箭头的大小变化 refX 和refY也得改变。
export const styleSize = {
normal: {
refX: 30,
markerHeight: 8,
markerWidth: 8,
},
hover: {
refX: 28,
markerHeight: 10,
markerWidth: 10,
},
click: {
refX: 19,
markerHeight: 17.5,
markerWidth: 17.5,
},
dotnormal: {
refX: 35,
markerHeight: 8,
markerWidth: 8,
},
dothover: {
refX: 43,
markerHeight: 10,
markerWidth: 10,
},
dotclick: {
refX: 48,
markerHeight: 10,
markerWidth: 10,
},
dotlineclick: {
refX: 28,
markerHeight: 25,
markerWidth: 20,
},
};
// isThumb 是否是缩略图
export const drawLine = (svg, type, links) => {
const isThumb = type === 'thumb';
const warp = isThumb ? svg.insert('g', '.dragThumb') : svg.append('g');
const lineWarp = warp
.attr('class', `${isThumb ? 'thumbG' : 'forceLines forceMainG'}`)
.selectAll('g')
.data(links)
.enter()
.append('a')
const {
refX, markerWidth, markerHeight,
} = styleSize.normal;
const markerId = (d) => `marker-${(d.id)}`;
lineWarp
.append('marker')
.attr('id', markerId)
.attr('markerUnits', 'userSpaceOnUse')
.attr('viewBox', '0 -5 10 10') // 坐标系的区域
.attr('refX', refX) // 箭头坐标
.attr('markerWidth', markerWidth) // 标识的大小
.attr('markerHeight', markerHeight)
.attr('orient', 'auto') // 绘制方向,可设定为:auto(自动确认方向)和 角度值
.attr('stroke-width', 2) // 箭头宽度
.append('path')
.attr('d', 'M0,-5L10,0L0,5') // 箭头的路径
.attr('fill', 'inherit'); //箭头的颜色, 设置箭头的颜色 不可以直接找到箭头然后更改fill 因为真正有颜色的是 箭头里面的dom
// 展示的线
const line = lineWarp.append('path')
//实线虚线自己控制
.attr('stroke-dasharray', (d) => (虚线 ? '8,5' : ''))
.attr('marker-end', (d) => {
if (isThumb) return '';
return `url(#${(markerId(d))})`;
});
// 实际hover 以及 点击的线
const bkLine = lineWarp.append('path')
.attr('stroke-width', 10)
.attr('stroke', 'red')
.attr('fill', 'none')
.attr('opacity', '0')
return {
lineWarp,
line,
bkLine,
};
};
点
此处只创建1个圆点
export const drawCircle = (svg, nodes, type) => {
const dotWarp = svg
.append('g')
.attr('class', 'forceNodes forceMainG')
.selectAll('g')
.data(nodes)
.enter()
.append('a')
.attr('xlink:href', 'javascript:void(0)');
const circle = dotWarp.append('circle')
.attr('class', 'forceNode regionNode');
return {
circle, dotWarp
};
};
绘制线
在之前讲的tick 中去改变 path 的d属性 以及 点的 位置
this.paths.attr('d', function (data) {
return pathD(data, this);
});
this.bkLine.attr('d', function (data) {
return pathD(data, this);
});
this.dotWarp.attr('transform', setTransform); // 圆圈
export const pathD = (d, dom) => {
const { x: sx, y: sy } = d.source;
const { x: tx, y: ty } = d.target;
let dr;
// 如果连接线连接的是同一个实体,则对path属性进行调整,绘制的圆弧属于长圆弧,同时对终点坐标进行微调,避免因坐标一致导致弧无法绘制
if (d.target === d.source) {
dr = 30 / d.linknum;
return (
`M${
sx
},${
sy
}A${
dr
},${
dr
} 0 1,1 ${
tx
},${
ty + 1}`
);
} if (d.size % 2 !== 0 && d.linknum === 1) {
// 如果两个节点之间的连接线数量为奇数条,则设置编号为1的连接线为直线,其他连接线会均分在两边
return `M ${sx},${sy},L ${tx},${ty}`;
}
// 根据连接线编号值来动态确定该条椭圆弧线的长半轴和短半轴,当两者一致时绘制的是圆弧
// 注意A属性后面的参数,前两个为长半轴和短半轴,第三个默认为0,
// 第四个表示弧度大于180度则为1,小于则为0,这在绘制连接到相同节点的连接线时用到;
// 第五个参数,0表示正角,1表示负角,即用来控制弧形凹凸的方向。本文正是结合编号的正负情况来控制该条连接线的凹凸方向,从而达到连接线对称的效果
const curve = 1.5;
const homogeneous = 2;
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
dr = (Math.sqrt(dx * dx + dy * dy) * (d.linknum + homogeneous))
/ (curve * homogeneous);
// 当节点编号为负数时,对弧形进行反向凹凸,达到对称效果
if (d.linknum < 0) {
if (dom) {
d3.select(dom.previousElementSibling).attr('refY', 4).attr('oldRefY', 4);
}
dr = (Math.sqrt(dx * dx + dy * dy) * (-1 * d.linknum + homogeneous))
/ (curve * homogeneous);
return (
`M${sx},${sy}A${dr},${dr} 0 0,0 ${tx},${ty}`
);
}
if (dom) {
d3.select(dom.previousElementSibling).attr('refY', -4).attr('oldRefY', -4);
}
return `M${sx},${sy}A${dr},${dr} 0 0,1 ${tx},${ty}`;
};
export const setTransform = (node) => {
const { x, y, k } = node;
let result = '';
if (x && y)result += `translate(${x},${y})`;
if (k)result += ` scale(${k})`;
return result;
};
####点的拖拽
固定节点的方法就是 设置fx fy=null
// 创建完dotWarp的时候 可以直接绑定
dotWarp.call(
d3
.drag()
.on('start', this.started)
.on('drag', this.dragged)
.on('end', this.ended),
);
started(d) {
const { force } = this;
if (!d3.event.active) {
force.alphaTarget(0.2).restart(); // 设置衰减系数,对节点位置移动过程的模拟,数值越高移动越快,数值范围[0,1]
}
d.fx = d.x;
d.fy = d.y;
},
dragged(d) {
const { x, y } = d3.event;
if (this.inBoundaries(x, y).isIn) {
d.fx = x;
d.fy = y;
}
d.fx = d3.event.x;
d.fy = d3.event.y;
},
ended(d) {
const { force } = this;
if (!d3.event.active) {
force.alphaTarget(0);
}
d.fx = null;
d.fy = null;
},
####zoom 画布的拖拽 以及放大缩小
/**
* @params
* zoomMin 最小缩小倍数
* zoomMax 最大放大倍数
*/
const zoom = d3
.zoom()
.scaleExtent([zoomMin, zoomMax])
.on('zoom', () => {
const transInfo = d3.event.transform; //绘制框选的时候需要用到
mainSvg.selectAll('g').attr('transform', transInfo);
this.transInfo = transInfo;
this.$emit('zoom', transInfo); // 告诉外层 发生了拖拽
});
mainSvg.call(zoom).on('dblclick.zoom', null); // 注销双击缩放
放大 缩小
//svg.transition().duration(750).call(zoom.scaleBy,放大的倍数);
// 缩小 0.9倍直到 缩小到最小倍数
svg.transition().duration(750).call(zoom.scaleBy, 0.9);
// 放大 1.1倍直到 放大到最大倍数
svg.transition().duration(750).call(zoom.scaleBy, 1.1);
####点的框选
拖拽中创建一个矩形框,拖拽后判断中心点是否在矩形框中则为被框选中 (位置需要与缩放的scale 配合计算)
####删除
点的删除实际上 就是把 相关点与线全部删除, 并且清空画布后, 重新用删除后的数据重新绘制。
####缩略图
缩略图目前的逻辑是主图的最大倍数作为背景,主图的宽高作为缩略图视野(蓝框)的宽高。
因为缩略图的dom 的宽高是css 定死的,所以给定主图(正常)的宽高 会自动缩放。
主图的拖拽与缩略图背景图的关系会在下面一节说
/**
* @params
* width 缩略图宽度
* height 缩略图高度
* mainWidth 主图的宽度
* mainHeight 主图的高度
* zoomMax 最大缩放比例
*
*/
thumbSvg = d3
.select('#thumbWarp')
.append('svg');
dragThumb = thumbSvg.append('rect')
.attr('class', 'dragThumb')
.attr('fill', 'none');
let w; let h; let x = 0; let y = 0;
thumbSvg.attr('width', width)
.attr('height', height)
.attr('id', 'thumbSvg')
.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 的on zoom 方法
并将缩放以及拖拽的距离传给 缩略图
因为缩放会造成 主图的 translate 发生变化 与手动拖拽造成的translate 会有差 所以 要扣除缩放造成的偏移
if (!mainTransform.x && !mainTransform.y && mainTransform.k === 1) {
this.initSvg();
return;
}
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),
}));
###自己实现一个简单的拓扑图
####碰撞检测
一开始的逻辑,两个正方形任意正方形包裹住另外一个任意一点则为碰撞 如下图。如果画的真正是个圆形的话则存在精度不足的问题
但是这种情况不适于 两个长方形只相交,如:
最后还是需要改为两个圆进行检测,逻辑为任意两个圆形的圆心距离是否小于两圆半径之和,若小于则为碰撞。
Math.sqrt(Math.pow(circleA.x - circleB.x, 2) +
Math.pow(circleA.y - circleB.y, 2))
< circleA.radius + circleB.radius
详情见 aotu实验室 碰撞专栏
####点的分配
点的位置的分配 就是确定中心点后,将关系最多的点作为中心点,其关系点向四周分散,没有关系的同级点,则向中心点四周进行分散,其关系点以确定后位置的点的坐标向周围分散。
根据三角形的正玄、余弦来得值;
假设一个圆的圆心坐标是(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;
});