ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于antv G6封装网络拓扑图插件:自定义节点与动态连线实现

基于antv G6封装网络拓扑图插件:自定义节点与动态连线实现 简介基于 AntV G6 二次封装的拓扑图可视化插件面向网络管理员、系统架构师及前端开发者用于网络架构展示、运维监控和复杂关系图谱搭建。插件内置缩放节点、警告节点、播放警告节点等多种自定义节点以及流动边、信息流边、警告流边、二次标签边等动态连线类型能直观呈现数据流向与告警状态同时支持自定义右键菜单方便按业务需求配置操作提升交互效率。资源包共二十九个文件以源码文件为主配有动态演示、配置示意和说明文档压缩包约一点二兆便于对照源码查看节点/连线效果并理解实现逻辑。随包附有说明文件与附赠资源涵盖安装指引、接口调用和二次开发思路可降低接入门槛顺利集成到现有网络运维或管理系统。目前已有八十人学习下载是构建高定制化网络拓扑可视化界面的实用参考。1. 为什么网络拓扑图要基于antv G6自己封装插件运维人员和网络工程师手里最不缺的就是一张设备清单和一堆告警事件。真正缺的是把这些信息画成一张能看懂、能察觉异常的网络拓扑图。antv G6 恰好是图可视化领域渲染能力和交互完整性都很均衡的引擎但它只是绘图引擎不是拓扑产品节点样式、连线的动态效果、右键菜单的语义全部要自己组织。这就是把 G6 封装成拓扑图插件的原因让使用方只传数据不关心 canvas 绘制细节同时把缩放节点、警告节点、播放警告节点、流动边、信息流边、警告流边、二次标签边这些网管场景里反复出现的能力沉淀成配置项。这篇文章面向的是一线前端和运维开发按“插件架构 → 数据模型 → 节点类型 → 连线类型 → 菜单与排错”的顺序把能抄的代码和容易踩的坑一起讲清楚。2. 拓扑图插件的架构设计与数据模型怎么定2.1 插件与 G6 的边界哪些交给 Graph哪些交给注册器封装 antv G6 之前先要分清两类职责。G6.Graph 负责画布、视口、缩放拖拽和命中拾取G6.registerNode 与 G6.registerEdge 负责具体图形单元“长什么样、状态怎么变”。插件类只做两件事把注册器批量挂载进去再把使用方的数据转换成 G6 的 data 格式。import G6 from antv/g6; export class TopoPlugin { constructor(container, options {}) { this.container container; this.options options; this.graph null; this._registerTypes(options); } mount(data) { this.graph new G6.Graph({ container: this.container, width: this.container.clientWidth || 800, height: this.container.clientHeight || 600, fitView: true, groupByTypes: false, defaultNode: { type: this.options.defaultNodeType || scale-node, }, defaultEdge: { type: this.options.defaultEdgeType || flow-edge, }, modes: { default: [drag-canvas, zoom-canvas, drag-node, click-select], }, }); this.graph.data(data); this.graph.render(); } updateNode(id, cfg) { const item this.graph.findById(id); if (item) this.graph.updateItem(item, cfg); } setAllNodesState(stateName, value) { const nodes this.graph.findAllByState(node, stateName); nodes.forEach((node) this.graph.setItemState(node, stateName, value)); } }这个骨架解决了拓扑图插件最常见的需求外部只调用 mount、updateNode、setAllNodesState完全不触碰 G6 实例。参数说明container 是 DOM 容器建议在 mount 前确认它有实际宽高defaultNodeType 与 defaultEdgeType 决定数据里没有显式 type 时用什么兜底groupByTypes 关闭后节点和边按数据顺序绘制避免新增节点永远盖在边上。2.2 节点与边的数据模型一阵见血的字段约定拓扑数据结构如果设计得不好后面写节点类型和连线类型时就要到处判断字段。我一般把业务字段全部放进 data 字段里node.type 和 edge.type 只承担“选型”职责形状参数、状态值、颜色、标签都放 data。对象字段示例说明节点id / label / typen_sw01 / 核心交换机 / scale-nodetype 对应 registerNode 的名字节点data.scale1.5缩放系数控制节点显示半径的倍率节点data.warningLevel0 / 1 / 2告警等级正常 / 警告 / 严重节点data.playingtrue是否播放闪烁动画边source / target / typen_sw01 / n_fw01 / flow-edge边的两端必须存在节点 id边data.flowRate120信息流速率单位为 Mbps映射线宽边data.severity0 / 1 / 2链路告警等级映射颜色与流动速度边data.labels[主链路, 备用]二次标签边的两个文案这套模型在真正的网络拓扑图里对应的场景很直接根节点是核心交换机叶子节点是接入设备和服务器链路就是节点之间的连线。告警推送服务更新 warningLevel 后前端调 updateNode 或 setItemState图形立刻变化不需要重建整张图。3. 三种自定义节点类型缩放节点、警告节点、播放警告节点怎么画3.1 自定义节点的最小实现与两个生命周期antv G6 的 registerNode 最少要实现 draw返回一个绘制好的分组。更完整的做法是实现 update 和 setStateupdate 在外部调用 graph.updateItem 时触发setState 在 graph.setItemState 时触发。区分两者的意义是缩放系数属于业务数据变化走 update告警等级属于交互状态切换走 setState。混用会让脚本在排查状态时找不到数据来源。3.2 缩放节点改变半径而不是重画整个图形缩放节点用于表达设备性能水位或链路负载程度比如 CPU 使用率高的设备画得更大。这里的核心技巧是 update 里只 setAttr不要重新 addShape否则拖拽时图形会闪烁而且旧图形残留。G6.registerNode( scale-node, { draw(cfg, group) { const size Array.isArray(cfg.size) ? cfg.size[0] : 40; const r (size / 2) * (cfg.data?.scale || 1); const circle group.addShape(circle, { name: scale-circle, attrs: { x: 0, y: 0, r, fill: cfg.data?.fill || #1890ff, stroke: #096dd9, lineWidth: 2, }, }); group.addShape(text, { name: scale-label, attrs: { x: 0, y: r 18, text: cfg.label || cfg.id, textAlign: center, fill: #666, fontSize: 12, }, }); return group; }, update(cfg, node) { const group node.get(group); const size Array.isArray(cfg.size) ? cfg.size[0] : 40; const r (size / 2) * (cfg.data?.scale || 1); const circle group.findById(scale-circle); if (circle) circle.setAttr(r, r); const label group.findById(scale-label); if (label) { label.setAttr(text, cfg.label || cfg.id); label.setAttr(y, r 18); } }, }, circle );逻辑说明registerNode 的第三个参数是继承的基类这里继承 circle因此 draw 里手工画的 circle 完全替代默认圆。update 的关键是拿到 get(group) 后用 findById 定位子图形按比例改半径和文本位置。参数说明cfg.size 可以是数字或数组数组时取第一个值做直径基准cfg.data.scale 小于 1 时节点缩小大于 1 时放大。3.3 警告节点路径三角加等级映射警告节点是网管插件里最常见的告警载体。我用一个三角形加感叹号来表达severity 为 0 时显示绿色无告警1 显示橙色2 显示红色。避免直接改 fill 值散落在业务代码里颜色映射表固定在注册函数内部。G6.registerNode( warning-node, { draw(cfg, group) { const level cfg.data?.warningLevel ?? 0; const colorMap [#52c41a, #faad14, #f5222d]; const fill colorMap[level] || #faad14; group.addShape(path, { name: warning-triangle, attrs: { path: [[M, 0, -22], [L, 20, 14], [L, -20, 14], [Z]], fill, stroke: rgba(0,0,0,0.15), lineWidth: 1, }, }); group.addShape(text, { name: warning-mark, attrs: { x: 0, y: 9, text: !, fontSize: 18, fontWeight: bold, fill: #fff, textAlign: center, }, }); group.addShape(text, { name: warning-label, attrs: { x: 0, y: 36, text: cfg.label || cfg.id, fontSize: 12, fill: #555, textAlign: center, }, }); return group; }, setState(name, value, item) { const group item.get(group); if (name ! warning) return; const shape group.findById(warning-triangle); if (!shape) return; const colorMap [#52c41a, #faad14, #f5222d]; shape.setAttr(fill, colorMap[value] || #faad14); }, }, single-node );提示setState 的第三个参数 item 就是 G6 的节点实例。外部触发方式是graph.setItemState(nodeItem, warning, 2)大量节点批量切换时用一个循环调用 setItemState比逐条 updateItem 更轻。颜色映射表建议按告警等级固定不要把十六进制颜色直接写进业务数据。3.4 播放警告节点光晕闪烁与轮询暂停播放警告节点在警告节点之上增加“动态感”用于播放中的设备或正在演练的告警链路。我在三角形外围加一个半透明光晕并让光晕的透明度循环变化。G6.registerNode( play-warning-node, { draw(cfg, group) { const level cfg.data?.warningLevel ?? 1; const colorMap [#52c41a, #faad14, #f5222d]; const fill colorMap[level] || #faad14; group.addShape(circle, { name: warning-halo, attrs: { x: 0, y: 0, r: 26, fill, opacity: 0.15, }, }); group.addShape(path, { name: warning-triangle, attrs: { path: [[M, 0, -20], [L, 18, 14], [L, -18, 14], [Z]], fill, }, }); group.addShape(text, { name: warning-mark, attrs: { x: 0, y: 9, text: !, fontSize: 16, fill: #fff, textAlign: center }, }); const label group.addShape(text, { name: warning-label, attrs: { x: 0, y: 36, text: cfg.label || , fontSize: 12, fill: #555, textAlign: center }, }); if (cfg.data?.playing) { const halo group.findById(warning-halo); halo.animate( (ratio) ({ opacity: 0.08 0.12 * ratio }), { duration: 800, repeat: true, easing: easeInOutQuad } ); } return group; }, setState(name, value, item) { const group item.get(group); if (name playing) { const halo group.findById(warning-halo); if (!halo) return; if (value) { halo.animate( (ratio) ({ opacity: 0.08 0.12 * ratio }), { duration: 800, repeat: true, easing: easeInOutQuad } ); } else { halo.stopAnimate(); halo.setAttr(opacity, 0.15); } } }, }, single-node );播放警告节点的应用场景是模拟演练或故障播放外部点击“开始播放”后遍历全部节点调用setItemState(node, playing, true)画面立刻出现统一的闪烁节奏。注意 animate 的 ratio 回调里G6 会按 duration 自动推进 0 到 1 的参数repeat 为 true 时循环播放。性能上同时播放几十个动画没有压力超过两百个时建议把光晕动画改为在插件层定时切换 opacity。4. 四种动态连线类型流动边、信息流边、警告流边、二次标签边实现4.1 动态连线的共同基础注册边与动画生命周期边和节点的注册模式不同。节点用 draw 画形状边必须读取 cfg.startPoint 与 cfg.endPoint 这两个坐标再画连线图形。注册边的生命周期里有一个关键的 afterDraw 钩子可以拿到 group 并启动动画这样图形绘制完成后动画自动开始。动态连线的通用做法是给 line 设置一个 lineDash 数组再对 lineDashOffset 做线性插值形成虚线向前流动的视觉。4.2 流动边lineDashOffset 驱动的数据链路指示流动边是网络拓扑里最常用的连线类型表达两者之间有持续的数据通过。以下代码注册 flow-edge。G6.registerEdge( flow-edge, { draw(cfg, group) { const { startPoint: s, endPoint: e, data {} } cfg; const line group.addShape(line, { name: flow-line, attrs: { x1: s.x, y1: s.y, x2: e.x, y2: e.y, stroke: data.color || #5b8ff9, lineWidth: data.width || 3, lineDash: data.dash || [12, 6], lineDashOffset: 0, endArrow: data.arrow false ? false : { path: G6.Arrow.triangle(6, 8, 0) }, }, }); return group; }, afterDraw(cfg, group) { const line group.findById(flow-line); if (!line) return; const dash cfg.data?.dash || [12, 6]; const duration cfg.data?.duration || 1200; line.animate( (ratio) ({ lineDashOffset: -ratio * dash[1] }), { duration, repeat: true, easing: linear } ); }, }, line );逻辑说明lineDashOffset 从 0 渐变到 -dash[1]正好走完一个虚线单元的长度循环后视觉上看起来是虚线在连续流动。参数说明dash 数组的第一项是实线长度第二项是空白长度duration 越小流动越快建议设置在 800 到 2000 之间。endArrow 使用 G6.Arrow.triangle 绘制实心箭头当 data.arrow 为 false 时关掉箭头适合双向链路。4.3 信息流边与警告流边同一个工厂函数改三组参数信息流边要体现流量带宽警告流边要体现链路故障两者和流动边的差异集中在样式参数和动画节奏上。为了不让注册代码重复三份我抽了一个 createDynamicEdge 工厂函数。function createDynamicEdge(typeName, style {}) { G6.registerEdge( typeName, { draw(cfg, group) { const { startPoint: s, endPoint: e, data {} } cfg; const line group.addShape(line, { name: dynamic-line, attrs: { x1: s.x, y1: s.y, x2: e.x, y2: e.y, stroke: data.color || style.color || #5b8ff9, lineWidth: data.flowRate ? Math.min(2 data.flowRate / 100, 8) : style.lineWidth || 2, lineDash: data.dash || style.dash || [8, 4], lineDashOffset: 0, endArrow: style.arrow false ? false : true, }, }); return group; }, afterDraw(cfg, group) { const line group.findById(dynamic-line); if (!line) return; const dash cfg.data?.dash || style.dash || [8, 4]; const duration cfg.data?.duration || style.duration || 900; line.animate( (ratio) ({ lineDashOffset: -ratio * dash[1] }), { duration, repeat: true, easing: linear } ); }, }, line ); } createDynamicEdge(info-flow-edge, { color: #36cfc9, dash: [8, 6], duration: 1400, lineWidth: 2, }); createDynamicEdge(warning-flow-edge, { color: #ff4d4f, dash: [10, 4], duration: 500, lineWidth: 4, arrow: false, });边类型视觉特征典型参数组合适用场景flow-edge蓝色中等虚线匀速流动dash [12,6]duration 1200日常数据链路info-flow-edge青色细线慢速流动dash [8,6]duration 1400信息同步、低频请求warning-flow-edge红色粗线快速流动dash [10,4]duration 500链路拥塞、故障告警参数说明info-flow-edge 的 lineWidth 可以通过传入 data.flowRate 动态映射避免所有信息流边粗细一样warning-flow-edge 刻意把 duration 调小让流动频率更快人眼在监控大屏上能第一时间注意到。这里把工厂函数暴露在插件类的_registerTypes里使用方只需在 options 里声明需要哪些边类型。4.4 二次标签边原生单标签限制下的多标签绘制方案antv G6 的 labelCfg 只支持一条边一个标签但网络拓扑里一条链路经常要同时显示“主用/备用”“电信/联通”两类语义。二次标签边的做法是放弃 labelCfg在 draw 里手动加两段 text各自定位在边的三分之一和三分之二处。G6.registerEdge( double-label-edge, { draw(cfg, group) { const { startPoint: s, endPoint: e, data {} } cfg; const labels data.labels || []; const line group.addShape(line, { name: dbl-line, attrs: { x1: s.x, y1: s.y, x2: e.x, y2: e.y, stroke: data.color || #999, lineWidth: 2, }, }); if (labels[0]) { group.addShape(text, { name: dbl-label-0, attrs: { x: s.x (e.x - s.x) * 0.32, y: s.y (e.y - s.y) * 0.32 - 8, text: labels[0], fontSize: 12, fill: #333, textAlign: center, }, }); } if (labels[1]) { group.addShape(text, { name: dbl-label-1, attrs: { x: s.x (e.x - s.x) * 0.68, y: s.y (e.y - s.y) * 0.68 - 8, text: labels[1], fontSize: 12, fill: #888, textAlign: center, }, }); } return group; }, update(cfg, edge) { const group edge.get(group); const { startPoint: s, endPoint: e, data {} } cfg; const labels data.labels || []; const first group.findById(dbl-label-0); const second group.findById(dbl-label-1); if (first) { first.setAttr(text, labels[0] || ); first.setAttr(x, s.x (e.x - s.x) * 0.32); first.setAttr(y, s.y (e.y - s.y) * 0.32 - 8); } if (second) { second.setAttr(text, labels[1] || ); second.setAttr(x, s.x (e.x - s.x) * 0.68); second.setAttr(y, s.y (e.y - s.y) * 0.68 - 8); } }, }, line );提示手动绘制多标签后不要再配 labelCfg否则会出现双份文本。update 方法里的坐标重新计算必不可少节点拖拽后如果只是更新 text标签位置会留在旧坐标上视觉上就错位了。若边是曲线边建议改用 getPoint(ratio) 取曲线上的点直线边用比例插值即可。5. 右键菜单行为与拓扑图落地的排错技巧5.1 用 G6.Menu 接入右键菜单的最小配置G6 自带 Menu 插件菜单内容是普通 DOM不需要在 canvas 里绘制样式和事件处理用前端习惯的方式即可。const menu new G6.Menu({ offsetX: 12, offsetY: 12, itemTypes: [node, edge], getContent(e) { const itemType e.item.getType(); if (itemType node) { const model e.item.getModel(); return [ div classtopo-context-menu, div style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />
返回列表