简介:这是一份面向数据可视化开发者与前端工程师的 AntV G6 多边缘实战示例资源,聚焦图可视化中同一对节点存在多种连接关系的建模难题,适合已掌握 G6 基础、希望进阶复杂关系网络的读者。压缩包共 25 个文件,约 4.89MB,以 12 张 png 效果截图、6 个 js 示例脚本、2 个 json 配置、2 个 html 页面及 2 个 md 说明文档为主,另含 gitignore 等辅助文件,覆盖从数据转换、图数据构造到页面渲染的完整链路。资源围绕自定义 Edge 类型与 EdgeGroup 两种多边缘实现方式展开,示例代码展示了不同关系边的样式区分、分组管理与批量操作思路,并配有多种布局下的效果图便于对照理解。目前已有 756 人学习下载,读者可据此快速搭建可运行的多边缘演示环境,并在此基础上二次定制边样式与交互行为。
1. 多边图渲染的痛点:为什么你的 G6 图里两条边永远叠在一起
做过关系图谱的同行大概率都遇到过这个场景:两个节点之间存在两条以上的业务关系,比如 A 对 B 既有「投资」又有「担保」,结果渲染出来只有一条线,另一条被压在底下,用户点都点不到。这不是 G6 的 bug,而是默认布局和边路由策略的必然结果——G6 的边默认走两点之间的直线,多条边共享同一对起点终点时,几何位置完全重合。antv-g6-multi-edges 这个方向要解决的就是这件事:让同一对节点之间的多条边各自可见、可交互、可区分。它适合正在用 G6 做风控图谱、社交关系、知识图谱、供应链链路的前端和可视化工程师,尤其是那些已经跑通了单边渲染、现在被多边重叠卡住的人。核心思路不复杂,但参数和边界条件不少,下面从选型到落地一步步拆。
2. 多边渲染的三条技术路线:弧线、偏移、自定义边
2.1 先搞清楚 G6 的边是怎么画出来的
G6 里每一条边最终都对应一个图形路径。默认的line边类型走的是M x1 y1 L x2 y2,也就是一条直线。当同一对节点之间有 N 条边时,如果都用line,这 N 条路径的坐标完全一致,视觉上就是一条。要打破这个重合,本质上是让每条边在两点之间走不同的路径。
G6 提供了几个层面的干预点:一是边的type,内置了line、quadratic、cubic、arc等;二是边的style,可以控制curveOffset、lineWidth、stroke等;三是自定义边,通过继承或注册registerEdge完全接管路径计算。理解这三个层面,后面的选型就不会乱。
还有一个容易被忽略的点:边的绘制顺序。G6 按数据数组顺序绘制,后画的盖住先画的。所以即使你用了弧线,如果曲率方向一致,视觉上仍然可能重叠。多边渲染必须同时考虑「路径差异化」和「绘制顺序」两件事。
2.2 三条路线的适用场景对比
| 路线 | 实现方式 | 适用边数 | 交互友好度 | 实现成本 |
|---|---|---|---|---|
| 弧线法 | quadratic+curveOffset | 2~5 条 | 中 | 低 |
| 偏移法 | 自定义边,按索引平移控制点 | 3~8 条 | 高 | 中 |
| 自定义边 | registerEdge完全接管 | 不限 | 高 | 高 |
弧线法最省事,G6 内置的quadratic边支持curveOffset参数,给每条边设不同的偏移量就能错开。但它的局限是:偏移量是相对两点中点的垂直方向,边数多了之后外侧边的曲率会非常大,视觉上像一把扇子,超过 5 条就不好看。
偏移法是在弧线基础上做改进,不单纯靠增大曲率,而是把控制点沿垂直方向等距平移,同时限制最大曲率。这样 6~8 条边也能保持可读。实现上需要自定义边的draw方法,自己算控制点坐标。
自定义边最灵活,可以做出「多条边聚合成一条粗线、hover 时展开」这种高级效果,但代码量大,调试成本高。我一般建议先用弧线法跑通,边数超过 5 条再上偏移法,确实有聚合需求才考虑自定义边。
2.3 用内置 quadratic 边跑通最小可复现示例
先看一个能直接跑的最小例子。假设有一组节点和边数据,同一对节点之间有 3 条边:
import G6 from '@antv/g6'; // 构造测试数据:A-B 之间三条边,B-C 之间两条边 const data = { nodes: [ { id: 'A', x: 100, y: 200 }, { id: 'B', x: 400, y: 200 }, { id: 'C', x: 700, y: 200 }, ], edges: [ { source: 'A', target: 'B', label: '投资' }, { source: 'A', target: 'B', label: '担保' }, { source: 'A', target: 'B', label: '任职' }, { source: 'B', target: 'C', label: '供应' }, { source: 'B', target: 'C', label: '采购' }, ], }; // 关键:按 source-target 分组,给同组边分配不同的 curveOffset const edgeGroupMap = {}; data.edges.forEach((edge) => { const key = `${edge.source}__${edge.target}`; if (!edgeGroupMap[key]) edgeGroupMap[key] = []; edgeGroupMap[key].push(edge); }); Object.values(edgeGroupMap).forEach((group) => { const count = group.length; group.forEach((edge, index) => { // 以组中心为 0,向两侧对称分布偏移量 const offset = (index - (count - 1) / 2) * 30; edge.type = 'quadratic'; edge.curveOffset = offset; // 曲率为 0 时退化成直线,给一个最小绝对值避免重叠 if (Math.abs(edge.curveOffset) < 1) edge.curveOffset = 1; }); }); const graph = new G6.Graph({ container: 'mountNode', width: 800, height: 400, defaultEdge: { style: { endArrow: true, lineWidth: 1.5 }, labelCfg: { autoRotate: true, style: { fontSize: 12 } }, }, }); graph.data(data); graph.render();这段代码的核心逻辑是「分组 + 对称偏移」。先按source__target把边分组,每组内根据索引计算偏移量。(index - (count - 1) / 2)这个公式让偏移量以 0 为中心对称分布,比如 3 条边得到 -30、0、30,2 条边得到 -15、15。这样视觉上是对称的,不会所有边都往一侧弯。
参数说明:curveOffset的单位是像素,正值和负值代表弧线向两侧弯曲。30 这个值不是固定的,节点间距越大可以适当增大,一般取节点间距的 5%~10%。如果边数超过 5 条,30 会导致最外侧边曲率过大,需要改用偏移法。
labelCfg.autoRotate让标签跟随边的方向旋转,多边场景下这个必须开,否则标签会横七竖八。endArrow是箭头,多边时箭头位置也会随弧线偏移,这是符合预期的。
2.4 边数超过 5 条时改用偏移法
弧线法在边数多的时候会翻车,因为曲率随偏移量线性增长,最外侧边会弯成一个半圆。偏移法的思路是:控制点不沿垂直方向无限平移,而是把多条边在垂直方向等距排布,同时把曲率限制在一个合理范围内。
实现方式是注册一个自定义边,在draw里自己算路径。下面是一个简化版:
import G6 from '@antv/g6'; G6.registerEdge('multi-edge', { draw(cfg, group) { const startPoint = cfg.startPoint; const endPoint = cfg.endPoint; const index = cfg.edgeIndex || 0; // 当前边在组内的索引 const total = cfg.edgeTotal || 1; // 组内总边数 const gap = 12; // 每条边的垂直间距 // 计算两点连线的垂直方向单位向量 const dx = endPoint.x - startPoint.x; const dy = endPoint.y - startPoint.y; const len = Math.sqrt(dx * dx + dy * dy) || 1; const nx = -dy / len; const ny = dx / len; // 以组中心为 0 对称分布 const offset = (index - (total - 1) / 2) * gap; // 控制点取两点中点,再沿垂直方向平移 const cx = (startPoint.x + endPoint.x) / 2 + nx * offset * 2; const cy = (startPoint.y + endPoint.y) / 2 + ny * offset * 2; const path = [ ['M', startPoint.x, startPoint.y], ['Q', cx, cy, endPoint.x, endPoint.y], ]; const shape = group.addShape('path', { attrs: { path, stroke: cfg.style?.stroke || '#99add1', lineWidth: cfg.style?.lineWidth || 1.5, endArrow: true, }, name: 'multi-edge-path', }); return shape; }, });逻辑说明:先算两点连线的垂直单位向量(nx, ny),这是偏移的方向。控制点取中点再沿垂直方向平移offset * 2,乘 2 是因为二次贝塞尔曲线的实际弯曲程度约为控制点偏移量的一半。gap控制每条边的间距,12 像素在大多数场景下够用,节点密集时可以降到 8。
参数说明:edgeIndex和edgeTotal需要你在数据预处理阶段写进每条边的数据里,G6 不会自动算。gap是唯一需要调的参数,它决定了多条边的视觉密度。如果边数超过 8 条,gap要降到 6 以下,否则最外侧边会超出节点包围盒。
用这个自定义边时,数据预处理和前面类似,只是把type换成multi-edge,并写入edgeIndex和edgeTotal:
Object.values(edgeGroupMap).forEach((group) => { group.forEach((edge, index) => { edge.type = 'multi-edge'; edge.edgeIndex = index; edge.edgeTotal = group.length; }); });3. 多边场景下的交互与标签避坑
3.1 点击命中区域为什么总是选错边
多边渲染跑通后,第一个冒出来的问题往往是点击。用户点最上面那条边,结果选中的是底下那条。原因是 G6 的点击命中基于图形路径的包围盒或实际路径,弧线边的包围盒是矩形,多条边的包围盒高度重叠,命中判定就会乱。
解决办法是给每条边加一个透明的加粗路径作为命中区域。在自定义边的draw里,除了画可见路径,再画一条lineWidth为 10~15、stroke为透明的路径,放在可见路径下面:
// 命中区域:透明加粗路径,放在可见路径之前绘制 group.addShape('path', { attrs: { path, stroke: 'transparent', lineWidth: 12, lineAppendWidth: 4, }, name: 'multi-edge-hit', }); // 可见路径 const shape = group.addShape('path', { attrs: { path, stroke: '#99add1', lineWidth: 1.5, endArrow: true }, name: 'multi-edge-path', });lineAppendWidth是 G6 特有的参数,在原有线宽基础上再增加命中宽度,对细边特别有用。注意命中路径要先画,这样它在图层下方,不会遮挡可见路径的视觉。
3.2 标签重叠的三种处理策略
多边场景下标签重叠几乎是必然的。三条边的标签如果都放在弧线中点,会挤在一起。常见做法有三种:
第一种是标签跟随弧线偏移,每条边的标签放在各自弧线的中点,这样标签天然错开。G6 的labelCfg默认放在边的中点,弧线边的中点会随曲率偏移,所以只要curveOffset不同,标签位置就不同。这是最省事的。
第二种是标签沿边方向错位,通过labelCfg.offset把标签沿边的切线方向平移。适合边数多、弧线偏移量小的情况。
第三种是标签聚合,多条边的标签合并成一个,hover 时展开。这个需要自定义交互,成本最高,但边数超过 8 条时几乎是唯一选择。
我一般先用第一种,标签还挤就上第二种,边数超过 8 条才考虑聚合。labelCfg的配置示例:
defaultEdge: { labelCfg: { autoRotate: true, offset: 8, style: { fontSize: 12, fill: '#333' }, }, },offset是标签相对边中点的偏移距离,配合autoRotate使用。注意autoRotate在弧线边上会让标签沿弧线切线方向旋转,视觉上更自然,但边接近水平时标签可能倒置,需要额外处理。
3.3 边状态切换时的重绘开销
多边场景下,如果每条边都绑定了 hover 高亮、点击选中这类状态切换,重绘开销会明显上升。因为 G6 的状态切换会触发边的重绘,而自定义边的draw里做了路径计算,边数多时会有卡顿。
优化思路有两个:一是把路径计算的结果缓存起来,状态切换时只改stroke和lineWidth,不重算路径;二是用graph.setItemState的批量模式,避免逐条触发重绘。
缓存路径的做法是在draw里把算好的path存到cfg上,下次重绘时直接取:
draw(cfg, group) { let path = cfg._cachedPath; if (!path) { // ... 计算 path cfg._cachedPath = path; } // 用 path 画图 }注意cfg是 G6 内部对象,直接挂属性不是官方推荐做法,但在实际项目里很有效。更规范的做法是用graph.get('edgeCache')这类外部 Map 来存。
4. 避坑与排查:多边渲染最容易翻车的五个点
4.1 边数据顺序变了,偏移量全乱
现象:刷新页面后,同一条边的弧线方向变了,或者两条边交换了位置。
原因:分组后按数组顺序分配edgeIndex,如果后端返回的边数据顺序不稳定,索引就会变,偏移量跟着变。
解决:在数据预处理阶段对每组边做稳定排序,比如按边的某个业务字段(关系类型、创建时间)排序,保证每次渲染顺序一致。不要依赖后端返回顺序。
4.2 自环边和多边混在一起时路径异常
现象:节点有自环边(source 等于 target),同时又有普通多边,自环边的路径画到了节点外面。
原因:自环边的起点终点相同,垂直方向向量算出来是零向量,偏移计算失效。
解决:在自定义边的draw里先判断startPoint和endPoint是否重合,重合时走自环边的专用路径(用arc或cubic画一个环),不要走多边逻辑。G6 内置的loop边类型可以直接用。
4.3 curveOffset 设成 0 导致边退化成直线
现象:三条边里中间那条和另外两条中的一条完全重叠。
原因:对称偏移公式在边数为奇数时,中间那条边的偏移量是 0,退化成直线,和默认边重叠。
解决:给偏移量设一个最小绝对值,比如if (Math.abs(offset) < 1) offset = 1。或者干脆不用对称分布,所有边都往同一侧偏,但这样视觉上不对称,一般不推荐。
4.4 缩放后边宽和偏移量不协调
现象:图放大后,多条边挤在一起;缩小后,边又散得太开。
原因:curveOffset和gap是像素值,不随缩放变化,但节点间距随缩放变化,比例就失调了。
解决:监听graph.on('viewportchange'),根据当前缩放比例动态调整curveOffset或gap。或者用 G6 的graph.getZoom()拿到缩放比,在重绘时按比例缩放偏移量。这个坑在需要缩放的场景里几乎必踩。
4.5 边数动态变化时索引没更新
现象:新增一条边后,原有的边偏移量没变,新边和某条旧边重叠。
原因:edgeIndex和edgeTotal是在数据预处理时写死的,数据变了但没重新计算。
解决:把分组和索引分配逻辑封装成一个函数,每次graph.changeData之前重新跑一遍。不要只在初始化时算一次。
5. 进阶:用边捆绑做大规模多边图的降噪
边数超过 10 条时,无论怎么偏移,视觉上都是一团乱麻。这时候要考虑边捆绑(edge bundling),把同一对节点之间的多条边聚合成一条粗线,用颜色或粗细表示边数,hover 或点击时再展开成多条。
G6 本身没有内置边捆绑,但可以用自定义边 + 交互实现。核心思路是:默认渲染一条聚合边,lineWidth按边数加权,颜色按关系类型混合;监听边的 hover 事件,hover 时把聚合边替换成多条展开边。
实现上分两步。第一步是数据聚合,把同一对节点的多条边合并成一条,记录原始边列表:
const bundledEdges = Object.entries(edgeGroupMap).map(([key, group]) => { const [source, target] = key.split('__'); return { id: `bundle_${key}`, source, target, type: 'bundle-edge', bundleCount: group.length, bundleEdges: group, // 保留原始边,展开时用 }; });第二步是注册bundle-edge自定义边,lineWidth按bundleCount映射,比如Math.min(2 + bundleCount * 1.5, 12)。然后监听 hover:
graph.on('edge:mouseenter', (e) => { const edge = e.item; const model = edge.getModel(); if (model.type !== 'bundle-edge') return; // 隐藏聚合边,渲染展开边 graph.hideItem(edge); model.bundleEdges.forEach((rawEdge, index) => { graph.addItem('edge', { ...rawEdge, type: 'multi-edge', edgeIndex: index, edgeTotal: model.bundleEdges.length, }); }); }); graph.on('edge:mouseleave', (e) => { // 反向操作:移除展开边,显示聚合边 });这个方案的坑在于展开和收起的时机控制。如果鼠标快速划过,mouseenter和mouseleave会频繁触发,导致边反复增删,性能急剧下降。我一般会加一个 100ms 的防抖,或者用graph.get('edgeExpanded')标记当前展开状态,避免重复操作。
另一个坑是展开边的位置。展开边是基于原始节点位置算的,如果聚合边在渲染时做了偏移,展开边要和聚合边的位置对齐,否则视觉上会跳。解决办法是聚合边不做偏移,展开边以聚合边为中心对称分布。
验证多边渲染是否正确的办法很简单:在控制台打印每条边的path,检查同一对节点的边路径是否互不相同。如果两条边的path字符串完全一致,说明偏移没生效。这个检查在调试阶段比肉眼看图靠谱得多。
我自己做这类图的时候,习惯先把边数控制在 5 条以内用弧线法跑通,确认交互和标签没问题,再逐步加边数、换偏移法。一上来就上边捆绑,调试成本太高,容易在路径计算和交互状态里绕不出来。多边渲染这件事,本质是几何计算和交互命中的平衡,参数没有万能值,得根据节点间距和边数现场调。希望帮到你。
本文还有配套的精品资源,点击获取