实现两个节点之间多条关系线的显示,我们需要使用二次贝塞尔曲线来画图,主要是对两个节点之间的关系线进行编号,首先看一下数据
nodes: [
{id: 1, name: '刘备', type: '皇上'},
{id: 2, name: '关羽', type: '将军'},
{id: 3, name: '张飞', type: '将军'},
{id: 4, name: '诸葛亮', type: '丞相'},
{id: 5, name: '小兵1', type: '士兵'},
{id: 6, name: '小兵2', type: '士兵'},
],
links: [
{source: 1, target: 1, relate: '皇上'},
{source: 1, target: 2, relate: '将军'},
{source: 1, target: 2, relate: '异性兄弟'},
{source: 1, target: 3, relate: '将军'},
{source: 1, target: 4, relate: '丞相'},
{source: 2, target: 5, relate: '下属'},
{source: 2, target: 6, relate: '下属'},
{source: 3, target: 5, relate: '下属'},
],
先看一下实现的效果
先对所有关系进行分组
//关系分组
const linkGroup = {};
//对连接线进行统计和分组,不区分连接线的方向,只要属于同两个实体,即认为是同一组
var linkmap = {}
for (var i = 0; i < links.length; i++) {
var key = links[i].source < links[i].target ? links[i].source + ':' + links[i].target : links[i].target + ':' + links[i].source;
if (!linkmap.hasOwnProperty(key)) {
linkmap[key] = 0;
}
linkmap[key] += 1;
if (!linkGroup.hasOwnProperty(key)) {
linkGroup[key] = [];
}
linkGroup[key].push(links[i]);
}
//为每一条连接线分配size属性,同时对每一组连接线进行编号
for (var i = 0; i < links.length; i++) {
var key = links[i].source < links[i].target ? links[i].source + ':' + links[i].target : links[i].target + ':' + links[i].source;
links[i].size = linkmap[key];
//同一组的关系进行编号
var group = linkGroup[key];
var keyPair = key.split(':');
var type = 'noself';//标示该组关系是指向两个不同实体还是同一个实体
if (keyPair[0] == keyPair[1]) {
type = 'self';
}
//给节点分配编号
setLinkNumber(group, type);
}
给节点分配编号
function setLinkNumber(group, type) {
if (group.length == 0) return;
//对该分组内的关系按照方向进行分类,此处根据连接的实体ASCII值大小分成两部分
var linksA = [], linksB = [];
for (var i = 0; i < group.length; i++) {
var tempLink = group[i];
if (tempLink.source < tempLink.target) {
linksA.push(tempLink);
} else {
linksB.push(tempLink);
}
}
//确定关系最大编号。为了使得连接两个实体的关系曲线呈现对称,根据关系数量奇偶性进行平分。
//特殊情况:当关系都是连接到同一个实体时,不平分
var 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) {
var startLinkNumber = 1;
for (var i = 0; i < linksA.length; i++) {
linksA[i].linknum = startLinkNumber++;
}
startLinkNumber = 1;
for (var i = 0; i < linksB.length; i++) {
linksB[i].linknum = startLinkNumber++;
}
} else {//当两个方向的关系数量不对等时,先对数量少的那组关系从最大编号值进行逆序编号,然后在对另一组数量多的关系从编号1一直编号到最大编号,再对剩余关系进行负编号
//如果抛开负号,可以发现,最终所有关系的编号序列一定是对称的(对称是为了保证后续绘图时曲线的弯曲程度也是对称的)
var biggerLinks, smallerLinks;
if (linksA.length > linksB.length) {
biggerLinks = linksA;
smallerLinks = linksB;
} else {
biggerLinks = linksB;
smallerLinks = linksA;
}
var startLinkNumber = maxLinkNumber;
for (var i = 0; i < smallerLinks.length; i++) {
smallerLinks[i].linknum = startLinkNumber--;
}
var tmpNumber = startLinkNumber;
startLinkNumber = 1;
var p = 0;
while (startLinkNumber <= maxLinkNumber) {
biggerLinks[p++].linknum = startLinkNumber++;
}
//开始负编号
startLinkNumber = 0 - tmpNumber;
for (var i = p; i < biggerLinks.length; i++) {
biggerLinks[i].linknum = startLinkNumber++;
}
}
}
创建关系,注意曲线要用path
// 创建关系
const link = g.selectAll('path') //path是曲线,line是直线
.data(data.links, d => d.id) // 假设d.id是唯一的link标识符,用于绑定数据
.enter()
.append('g');
// 添加关系线的路径
const paths = link.append('path')
.attr('fill', 'none')
.attr('stroke', '#999') // 设置关系线的颜色
.attr('stroke-width', 2)
.attr('marker-end', 'url(#arrow)')
.attr('id', (d, i) => `linkPath${i}`);
添加关系的描述
// 添加关系线的描述文字
const linkText = link.append('text')
.attr('class', 'linktext')
.style('fill', 'black')
.style('font-size', 10)
.style('text-anchor', 'middle')
.style('pointer-events', 'none');
// 每条边都有各自的路径
linkText.append('textPath')
.attr('href', (d, i) => `#linkPath${i}`)
.attr('startOffset', '50%')
.text(d => d.relate);
更新节点和边的位置
const linkArc = d => {
// 计算方向向量
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const length = Math.sqrt(dx * dx + dy * dy);
const unitX = dx / length;
const unitY = dy / length;
// 调整后的起始和终止点
const startX = d.source.x + unitX * this.nodeRadius;
const startY = d.source.y + unitY * this.nodeRadius;
const endX = d.target.x - unitX * this.nodeRadius;
const endY = d.target.y - unitY * this.nodeRadius;
// 如果链接连接相同的节点,相应地调整路径
if (d.target === d.source) {
const dr = 20 / d.linknum;
return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 1,1 " + d.target.x + "," + (d.target.y + 3);
} else if (d.size % 2 !== 0 && d.linknum === 1) {
return `M${startX} ${startY}L${endX} ${endY}`;
}
// 计算曲线半径
const curve = 1.5;
const homogeneous = 3;
const dr = length * (d.linknum + homogeneous) / (curve * homogeneous);
// 根据链接编号调整路径
if (d.linknum < 0) {
const adjustedDr = length * (-1 * d.linknum + homogeneous) / (curve * homogeneous);
return `M${startX},${startY}A${adjustedDr},${adjustedDr} 0 0,0 ${endX},${endY}`;
}
return `M${startX},${startY}A${dr},${dr} 0 0,1 ${endX},${endY}`;
};
// 在模拟完成后调用updatePositions函数
simulation.on('tick', updatePositions);
// 定义更新位置的逻辑
function updatePositions() {
paths.attr('d', d => linkArc(d));
node.attr('transform', d => `translate(${d.x},${d.y})`);
}
最后附上完整代码
<template>
<div ref="chart" className="ggraph"></div>
</template>
<script>
import * as d3 from 'd3';
export default {
data() {
return {
nodes: [
{id: 1, name: '刘备', type: '皇上'},
{id: 2, name: '关羽', type: '将军'},
{id: 3, name: '张飞', type: '将军'},
{id: 4, name: '诸葛亮', type: '丞相'},
{id: 5, name: '小兵1', type: '士兵'},
{id: 6, name: '小兵2', type: '士兵'},
],
links: [
{source: 1, target: 1, relate: '皇上'},
{source: 1, target: 2, relate: '将军'},
{source: 1, target: 2, relate: '异性兄弟'},
{source: 1, target: 3, relate: '将军'},
{source: 1, target: 4, relate: '丞相'},
{source: 2, target: 5, relate: '下属'},
{source: 2, target: 6, relate: '下属'},
{source: 3, target: 5, relate: '下属'},
],
//节点颜色
colorScale: undefined,
//节点半径
nodeRadius: 18,
};
},
mounted() {
this.drawChart();
},
methods: {
drawChart() {
// 清除图表重新绘图
d3.select(this.$refs.chart).selectAll('*').remove();
const data = {
nodes: this.nodes,
links: this.links
};
const height = 600;
const width = 900;
this.colorScale = d3.scaleOrdinal(d3.schemeCategory10)
.domain(this.nodes.map(d => d.type));
// 创建SVG容器
const svg = d3.select(this.$refs.chart)
.append('svg')
.attr('width', width)
.attr('height', height);
const links = data.links;
//关系分组
const linkGroup = {};
//对连接线进行统计和分组,不区分连接线的方向,只要属于同两个实体,即认为是同一组
var linkmap = {}
for (var i = 0; i < links.length; i++) {
var key = links[i].source < links[i].target ? links[i].source + ':' + links[i].target : links[i].target + ':' + links[i].source;
if (!linkmap.hasOwnProperty(key)) {
linkmap[key] = 0;
}
linkmap[key] += 1;
if (!linkGroup.hasOwnProperty(key)) {
linkGroup[key] = [];
}
linkGroup[key].push(links[i]);
}
//为每一条连接线分配size属性,同时对每一组连接线进行编号
for (var i = 0; i < links.length; i++) {
var key = links[i].source < links[i].target ? links[i].source + ':' + links[i].target : links[i].target + ':' + links[i].source;
links[i].size = linkmap[key];
//同一组的关系进行编号
var group = linkGroup[key];
var keyPair = key.split(':');
var type = 'noself';//标示该组关系是指向两个不同实体还是同一个实体
if (keyPair[0] == keyPair[1]) {
type = 'self';
}
//给节点分配编号
setLinkNumber(group, type);
}
// 添加箭头定义
svg.append("defs")
.append("marker")
.attr("id", "arrow")
.attr("viewBox", "0 -5 10 10")
.attr("refX", this.nodeRadius - 8)
.attr("refY", 0)
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5");
const g = svg.append('g'); // 将 g 元素放在 svg 元素内部
//新建一个力导向图
const simulation = d3.forceSimulation(data.nodes)
.force('link', d3.forceLink(data.links).id(d => d.id).distance(200)) // 增大节点间距
.force('charge', d3.forceManyBody().strength(-100)) // 增大节点间斥力
.force('center', d3.forceCenter(width / 2, height / 2));
//创建节点
const node = g.selectAll('.node')
.data(data.nodes)
.enter()
.append('g')
.attr('class', 'node')
.style('fill', 'black')
.style('fill', d => {
this.colorScale(d)
})
node.append('circle')
.attr('r', 18)
.attr('fill', 'steelblue')
.style('fill', d => this.colorScale(d))
node.append('text')
.text(d => d.name)
.attr('text-anchor', 'middle')
.attr('dy', 4)
.attr('font-size', d => Math.min(2 * d.radius, 20))
.attr('fill', 'black')
.style('pointer-events', 'none');
const linkArc = d => {
// 计算方向向量
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const length = Math.sqrt(dx * dx + dy * dy);
const unitX = dx / length;
const unitY = dy / length;
// 调整后的起始和终止点
const startX = d.source.x + unitX * this.nodeRadius;
const startY = d.source.y + unitY * this.nodeRadius;
const endX = d.target.x - unitX * this.nodeRadius;
const endY = d.target.y - unitY * this.nodeRadius;
// 如果链接连接相同的节点,相应地调整路径
if (d.target === d.source) {
const dr = 20 / d.linknum;
return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 1,1 " + d.target.x + "," + (d.target.y + 3);
} else if (d.size % 2 !== 0 && d.linknum === 1) {
return `M${startX} ${startY}L${endX} ${endY}`;
}
// 计算曲线半径
const curve = 1.5;
const homogeneous = 3;
const dr = length * (d.linknum + homogeneous) / (curve * homogeneous);
// 根据链接编号调整路径
if (d.linknum < 0) {
const adjustedDr = length * (-1 * d.linknum + homogeneous) / (curve * homogeneous);
return `M${startX},${startY}A${adjustedDr},${adjustedDr} 0 0,0 ${endX},${endY}`;
}
return `M${startX},${startY}A${dr},${dr} 0 0,1 ${endX},${endY}`;
};
// 创建关系线
const link = g.selectAll('path') //path是曲线,line是直线
.data(data.links, d => d.id) // 假设d.id是唯一的link标识符,用于绑定数据
.enter()
.append('g');
// 添加关系线的路径
const paths = link.append('path')
.attr('fill', 'none')
.attr('stroke', '#999') // 设置关系线的颜色
.attr('stroke-width', 2)
.attr('marker-end', 'url(#arrow)')
.attr('id', (d, i) => `linkPath${i}`);
// 添加关系线的描述文字
const linkText = link.append('text')
.attr('class', 'linktext')
.style('fill', 'black')
.style('font-size', 10)
.style('text-anchor', 'middle')
.style('pointer-events', 'none');
// 每条边都有各自的路径
linkText.append('textPath')
.attr('href', (d, i) => `#linkPath${i}`)
.attr('startOffset', '50%')
.text(d => d.relate);
// 在模拟完成后调用updatePositions函数
simulation.on('tick', updatePositions);
// 定义更新位置的逻辑
function updatePositions() {
paths.attr('d', d => linkArc(d));
node.attr('transform', d => `translate(${d.x},${d.y})`);
}
function setLinkNumber(group, type) {
if (group.length == 0) return;
//对该分组内的关系按照方向进行分类,此处根据连接的实体ASCII值大小分成两部分
var linksA = [], linksB = [];
for (var i = 0; i < group.length; i++) {
var tempLink = group[i];
if (tempLink.source < tempLink.target) {
linksA.push(tempLink);
} else {
linksB.push(tempLink);
}
}
//确定关系最大编号。为了使得连接两个实体的关系曲线呈现对称,根据关系数量奇偶性进行平分。
//特殊情况:当关系都是连接到同一个实体时,不平分
var 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) {
var startLinkNumber = 1;
for (var i = 0; i < linksA.length; i++) {
linksA[i].linknum = startLinkNumber++;
}
startLinkNumber = 1;
for (var i = 0; i < linksB.length; i++) {
linksB[i].linknum = startLinkNumber++;
}
} else {//当两个方向的关系数量不对等时,先对数量少的那组关系从最大编号值进行逆序编号,然后在对另一组数量多的关系从编号1一直编号到最大编号,再对剩余关系进行负编号
//如果抛开负号,可以发现,最终所有关系的编号序列一定是对称的(对称是为了保证后续绘图时曲线的弯曲程度也是对称的)
var biggerLinks, smallerLinks;
if (linksA.length > linksB.length) {
biggerLinks = linksA;
smallerLinks = linksB;
} else {
biggerLinks = linksB;
smallerLinks = linksA;
}
var startLinkNumber = maxLinkNumber;
for (var i = 0; i < smallerLinks.length; i++) {
smallerLinks[i].linknum = startLinkNumber--;
}
var tmpNumber = startLinkNumber;
startLinkNumber = 1;
var p = 0;
while (startLinkNumber <= maxLinkNumber) {
biggerLinks[p++].linknum = startLinkNumber++;
}
//开始负编号
startLinkNumber = 0 - tmpNumber;
for (var i = p; i < biggerLinks.length; i++) {
biggerLinks[i].linknum = startLinkNumber++;
}
}
}
}
}
};
</script>
<style>
svg {
background-color: #d1e9ff;
}
</style>