我正在参加「掘金·启航计划」
最近项目上有个功能,拓扑图,最开始用antV做了一版初版,但是被老大驳回,说不太好,然后就重新用d3做了一版,基于老版拓扑的结构和一些基础知识。整个组件当然也不是我独立完成,有靠于老大的润色和小伙伴的奠基。从这个过程学到很多关于d3的基础知识。
这是整个的结构
从index开始说
参考d3 的几种模型说明,使用力模型应该是最合适的。
详情可以参考github.com/d3/d3/wiki/…
直接放代码吧,我懒得拆分了。基本上的备注都写了
const nodeRefs = useRef(`topology_n_${uuid()}`);
const linkRefs = useRef(`topology_l_${uuid()}`);
const linkNodeRefs = useRef(`topology_l_n__${uuid()}`);
// 深拷贝并存储这个值
const dataClone = useCacheData(tData);
// d3 力模型
useEffect(() => {
const { nodes, calls } = dataClone;
if (!nodes) return;
try {
const nodesDom = D3
.selectAll(`.${nodeRefs.current}`)
.data(nodes);
const linksDom = D3.selectAll(`.${linkRefs.current}`).data(calls);
const linkNodeDom = D3.selectAll(`.${linkNodeRefs.current}`).data(calls);
const tick = () => {
nodesDom.attr('transform', (d) => `translate(${d.x - 25}, ${d.y - 25})`);
linksDom.attr('d', (d) => {
const { source, target } = d;
const x1 = source.x;
const y1 = source.y;
const x2 = target.x;
const y2 = target.y;
const distance = Math.sqrt(Math.pow(y2 - y1, 2) + Math.pow(x2 - x1, 2)) || 1;
const startDistance = 30;
const endDistance = 30;
// 利用相似三角行求出距离(x1, y1)、(x2, y2)一定距离的(x3, y3)、(x4, y4)
const x3 = (x2 - x1) * (startDistance / distance) + x1;
const y3 = (y2 - y1) * (startDistance / distance) + y1;
const x4 = (x2 - x1) * ((distance - endDistance) / distance) + x1;
const y4 = (y2 - y1) * ((distance - endDistance) / distance) + y1;
// 三角形三边分别是(x3,y3),(x4,y4),(x3,y4),取得三角形重心点坐标,作为控制点
// const x5 = (x4 + x3 + x3) / 3;
// const y5 = (y4 + y3 + y4) / 3;
// `M${x3} ${y3} Q ${x5} ${y5} ${x4} ${y4}` //二次贝塞尔曲线,重心离曲线不远,所以双向曲线时间距太近,如果是直线两个点还是会重合
// 选用圆弧线
const r = Math.sqrt((x4 - x3) * (x4 - x3) + (y4 - y3) * (y4 - y3)) * 2;
const properties = new SvgPathProperties(`M${x3} ${y3} A ${r} ${r} 0 0 1 ${x4} ${y4}`);
const length = properties.getTotalLength();
const point = properties.getPointAtLength(length / 2);
const a = linkNodeDom
?._groups[0]
?.find(
(val) => (
val?.__data__?.target?.id === target?.id
&& val?.__data__?.source?.id === source?.id
),
);
a?.setAttribute(
'transform',
`translate(${point.x} ${point.y})`,
);
return `M${x3} ${y3} A ${r} ${r} 0 0 1 ${x4} ${y4}`;
// return `M${x3} ${y3} Q ${(x4 + x3) / 2} ${(y4 + y3) / 2 - 20} ${x4} ${y4}`;
});
};
instanceRef.current = D3.forceSimulation(nodes)//力模型绑定节点
.force('link',
D3
.forceLink(calls)//绑定连线
.id((d) => d.id)
.distance(150))//添加一个距离
.force('yPos', D3.forceY().strength(0.5))//在y轴上施加一个力,让他压扁一点
.force('charge', D3.forceManyBody().strength())//创建一个名为change的多体力
.force(
'collision',
D3.forceCollide().radius(() => 100),//创建一个名为collision的圆碰撞力,半径为100
)
.force('center', D3.forceCenter())//创建一个名为center的定心力
.on('tick', tick);//绑定tick事件
// .stop();
// 暂时没看出来加了没加有什么变化
// // 手动调用 tick 使布局达到稳定状态
// D3.timeout(() => {
// const n = Math.ceil(Math.log(instanceRef.current.alphaMin())
// / Math.log(1 - instanceRef.current.alphaDecay()));
// for (let i = 0; i < n; i += 1) {
// instanceRef.current.restart();
// }
// });
const onDragStart = (event) => {
nodesDom._groups[0].forEach((e) => {
e.__data__.fx = e.__data__.x;
e.__data__.fy = e.__data__.y;
});
if (!event.active) {
instanceRef.current
.alphaTarget(0.01)
.restart();
}
event.sourceEvent.stopPropagation();
};
const onDrag = (event, e) => {
e.fx = event.x;
e.fy = event.y;
};
const onDragEnd = () => {
console.log('onDragEnd');
};
nodesDom.call(
D3
.drag()
.on('start', onDragStart)
.on('drag', onDrag)
.on('end', onDragEnd),
);
} catch {
message.error('数据异常');
setError(true);
}
}, [dataClone, extra]);
useEffect(() => {
const svg = D3.select(svgRef.current);
const transitionG = D3.select(transitionRef.current);
const dimensions = svgRef.current.getBoundingClientRect();
// d3 zoom
const zoom = D3.zoom()
.scaleExtent([0.5, 10])
.on('zoom', (e) => {
transitionG.attr('transform', e.transform);
});
// 居中
svg.call(zoom.transform, D3.zoomIdentity.translate(dimensions.width / 2.5, dimensions.height / 2).scale(0.8));
// zoom
svg.call(zoom);
}, [extra]);
const { nodes = [], calls: _calls = [] } = tData;
const calls = _calls
.map((v) => ({
...v,
id: uuid(),
}));
return (
error ? (
null
) : (
<div className="topology-container" style={style}>
<svg ref={svgRef} width="100%" height="100%">
<defs>
<marker
id="mark-arrow"
markerUnits="userSpaceOnUse"
viewBox="0 -5 10 10"
refX={8.4}
refY={0}
markerWidth={14}
markerHeight={14}
orient="auto"
strokeWidth="2"
>
<path d="M2,0 L0,-3 L9,0 L0,3 M2,0 L0,-3" fill="#217EF28f" />
</marker>
<g id="tooltip">rect</g>
</defs>
<g ref={transitionRef}>
<g>
{
calls.map((d) => (
<Link
key={d.id}
className={[
'topology-link',
linkRefs.current,
`${extra?.linkClassName?.(d)}`,
]
?.join(' ')}
href="/"
data={d}
/>
))
}
</g>
<g>
{
nodes.map((d) => (
<Node
key={d.id}
className={`topology-node ${nodeRefs.current}`}
data={d}
tooltips={extra?.nodeNameTip(d) ?? ''}
extra={extra}
/>
))
}
</g>
{
extra?.linkTip && (
<g>
{
calls.map((d) => (
<LinkNode
key={d.id}
className={`topology-link-node ${linkNodeRefs.current}`}
data={d}
tooltips={extra?.linkTip(d) ?? ''}
/>
))
}
</g>
)
}
</g>
</svg>
</div>
)
);
};
这里一直困扰的一个点是在线中心画圆点的问题,关键是计算问题。
最开始使用的是二次贝塞尔曲线,后面又尝试了椭圆的线,最后选择了圆弧线,相关的尝试都有放在注释中 其中困扰最大的就是线中间的点和线匹配不上的问题,总是差一些,最后发现,是自己吧线的计算和点的计算分开了,所以导致在线计算的时候,拿到的相关值和点计算拿到的相关值存在误差。
const a = linkNodeDom
?._groups[0]
?.find(
(val) => (
val?.__data__?.target?.id === target?.id
&& val?.__data__?.source?.id === source?.id
),
);
所以最后选择在线计算的时候计算点。 另外还有一个困扰点就是关于初始化的时候,topo图抖动了一会才稳定下来,遇到数据多的时候,那真是群魔乱舞,所以为此解决折腾了很久,主要是不了解他的原理,期间一直觉得是自己的力和一些距离设置的有问题,所以一直在这上面打转,后面发现是后面这段代码
D3.timeout(() => {
const n = Math.ceil(Math.log(instanceRef.current.alphaMin())
/ Math.log(1 - instanceRef.current.alphaDecay()));
for (let i = 0; i < n; i += 1) {
instanceRef.current.restart().tick();
}
}, 0);
重点就是restart()之后执行tick,以前一直觉得stop之后执行restart就可以,或者再手动执行tick,没想到是要绑在一起执行。 更新一个bug: 两条线相交的时候,点会重合的问题,还在解决中。 d3路漫漫其修远兮呀