实现两个节点之间多条关系线的显示,我们需要使用二次贝塞尔曲线来画图,主要是对两个节点之间的关系线进行编号或者分类,另一种写法在这里:https://blog.csdn.net/m0_54479027/article/details/140517284?spm=1001.2014.3001.5501
首先看一下数据
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: 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: '下属'},
],
先看一下实现的效果
先计算每个节点的关系的数量,并设置关系线的弧线
if (links.length > 0) {
_.each(links, function (link) {
let same = _.filter(links, {
source: link.source,
target: link.target
});
let sameAlt = _.filter(links, {
source: link.target,
target: link.source
});
let sameAll = same.concat(sameAlt);
_.each(sameAll, function (s, i) {
s.sameIndex = i + 1; //当前关系线在相同的起始节点和目标节点组合中的索引(从1开始)。
s.sameTotal = sameAll.length; //相同的起始节点和目标节点组合中的关系线总数。
s.sameTotalHalf = s.sameTotal / 2; //相同的起始节点和目标节点组合中关系线总数的一半。
s.sameUneven = s.sameTotal % 2 !== 0; //判断相同的起始节点和目标节点组合中的关系线总数是否为奇数。
s.sameMiddleLink =
s.sameUneven === true &&
Math.ceil(s.sameTotalHalf) === s.sameIndex; //判断当前关系线是否处于奇数个关系线中的中间位置。
s.sameLowerHalf = s.sameIndex <= s.sameTotalHalf; //判断当前关系线是否处于关系线总数的前半部分。
s.sameArcDirection = 1; //弧线方向,通常为0或1。
s.sameArcDirection = s.sameLowerHalf ? 0 : 1;
s.sameIndexCorrected = s.sameLowerHalf
? s.sameIndex
: s.sameIndex - Math.ceil(s.sameTotalHalf);
}); //修正后的关系线索引,根据是否处于关系线总数的前半部分进行调整。
});
let maxSame = _.chain(links) //关系线总数的一半,用于计算弯曲路径时的参数。
.sortBy(function (x) {
return x.sameTotal;
})
.last()
.value().sameTotal;
_.each(links, function (link) {
link.maxSameHalf = Math.round(maxSame / 2);
});
}
创建关系,注意曲线用path,line是直线
// 创建关系线
const link = g.selectAll('path')
.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}`);
添加关系的描述
// 添加关系线的relate文字
const linkText = link.append('text')
.attr('class', 'linktext')
.style('fill', 'black')
.style('font-size', 8)
.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;
let dr = Math.sqrt(dx * dx + dy * dy),
unevenCorrection = d.sameUneven ? 0 : 0.5;
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) {
dr = 40 / d.sameTotal;
return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 1,1 " + d.target.x + "," + (d.target.y + 3);
}
let curvature = 2,
arc =
(1.0 / curvature) *
((dr * d.maxSameHalf) / (d.sameIndexCorrected - unevenCorrection));
if (d.sameMiddleLink) {
arc = 0;
}
let dd = "M" + startX + "," + startY + "A" + arc + "," + arc + " 0 0," + d.sameArcDirection + " " + endX + "," + endY;
return dd;
}
最后附上完整代码
<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: 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;
//关系定义
if (links.length > 0) {
_.each(links, function (link) {
let same = _.filter(links, {
source: link.source,
target: link.target
});
let sameAlt = _.filter(links, {
source: link.target,
target: link.source
});
let sameAll = same.concat(sameAlt);
_.each(sameAll, function (s, i) {
s.sameIndex = i + 1; //当前关系线在相同的起始节点和目标节点组合中的索引(从1开始)。
s.sameTotal = sameAll.length; //相同的起始节点和目标节点组合中的关系线总数。
s.sameTotalHalf = s.sameTotal / 2; //相同的起始节点和目标节点组合中关系线总数的一半。
s.sameUneven = s.sameTotal % 2 !== 0; //判断相同的起始节点和目标节点组合中的关系线总数是否为奇数。
s.sameMiddleLink =
s.sameUneven === true &&
Math.ceil(s.sameTotalHalf) === s.sameIndex; //判断当前关系线是否处于奇数个关系线中的中间位置。
s.sameLowerHalf = s.sameIndex <= s.sameTotalHalf; //判断当前关系线是否处于关系线总数的前半部分。
s.sameArcDirection = 1; //弧线方向,通常为0或1。
s.sameArcDirection = s.sameLowerHalf ? 0 : 1;
s.sameIndexCorrected = s.sameLowerHalf
? s.sameIndex
: s.sameIndex - Math.ceil(s.sameTotalHalf);
}); //修正后的关系线索引,根据是否处于关系线总数的前半部分进行调整。
});
let maxSame = _.chain(links) //关系线总数的一半,用于计算弯曲路径时的参数。
.sortBy(function (x) {
return x.sameTotal;
})
.last()
.value().sameTotal;
_.each(links, function (link) {
link.maxSameHalf = Math.round(maxSame / 2);
});
}
// 添加箭头定义
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(150)) // 增大节点间距
.force('charge', d3.forceManyBody().strength(-200)) // 增大节点间斥力
.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 link = g.selectAll('path')
.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}`);
// 添加关系线的relate文字
const linkText = link.append('text')
.attr('class', 'linktext')
.style('fill', 'black')
.style('font-size', 8)
.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;
let dr = Math.sqrt(dx * dx + dy * dy),
unevenCorrection = d.sameUneven ? 0 : 0.5;
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) {
dr = 40 / d.sameTotal;
return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 1,1 " + d.target.x + "," + (d.target.y + 3);
}
let curvature = 2,
arc =
(1.0 / curvature) *
((dr * d.maxSameHalf) / (d.sameIndexCorrected - unevenCorrection));
if (d.sameMiddleLink) {
arc = 0;
}
let dd = "M" + startX + "," + startY + "A" + arc + "," + arc + " 0 0," + d.sameArcDirection + " " + endX + "," + endY;
return dd;
}
// 定义更新位置的逻辑
function updatePositions() {
paths.attr('d', d => linkArc(d));
node.attr('transform', d => `translate(${d.x},${d.y})`);
}
// 在模拟完成后调用updatePositions函数
simulation.on('tick', updatePositions);
}
}
};
</script>
<style>
svg {
background-color: #d1e9ff;
}
</style>