简介:这是一份基于ECharts深度定制的交互式关系图可视化解决方案,面向前端开发者、IT运维工程师及数据可视化学习者,聚焦网络拓扑建模与资源依赖分析等真实场景。资源支持节点动态添加、关键词搜索(含高亮定位)、拖拽排序及一键删除,显著提升复杂关系结构的可操作性与可维护性。压缩包共10个文件(4个JS核心脚本含echarts.min.js与hashmap.js、2个HTML主页面、3个PNG图标素材、1个备份文件),总大小493KB,轻量易集成,适合嵌入现有管理后台或快速搭建原型。目前已有5426人学习下载,提供开箱即用的完整功能链:从基础关系图渲染、自定义节点样式(pc/ren等图标)、连线逻辑控制,到搜索高亮交互与DOM级节点管理逻辑,代码结构清晰、注释充分,便于二次开发与教学演示。
1. ECharts 升级版关系图:不是换皮肤,而是把「拓扑图」从静态示意图变成可交互、可运维的资源调度看板
你有没有遇到过这种场景:画了一张漂亮的网络拓扑图,导出 PNG 发给同事,结果对方问“这个交换机连了哪几台服务器?点开能看 CPU 使用率吗?”——你只能回一句“那是 PPT 里的图,不是系统”。这恰恰暴露了传统 ECharts 关系图(graph类型)最深的痛点:它本质是一次渲染的可视化快照,不承载状态、不响应操作、不联动后端、不支持动态增删节点。而标题里说的“升级版”,不是指换个主题色或加个动画,而是用 ECharts 原生能力+合理封装,把graph图表从「展示层」推到「操作层」:点击添加新设备节点、输入关键词实时高亮匹配的资源、拖拽调整布局后自动保存拓扑结构、按延迟/负载/所属区域对连线排序、右键删除失效链路——所有动作都闭环在图表内部,且不依赖任何第三方图谱库(如 AntV G6、Cytoscape)。这不是炫技,而是某高校实验室在构建国产化IT资源监控平台时的真实需求:运维人员需要一张图完成「发现→定位→处置→验证」全链路,而不是在五个不同页面间反复跳转。如果你正卡在“ECharts 能不能做真拓扑图”的认知边界上,这篇就是为你写的落地笔记。
2. 从原生 graph 配置出发:为什么必须重写 series.nodes 和 series.links 的数据管理逻辑
ECharts 的graph图表默认只接受静态nodes数组和links数组,一旦初始化完成,直接修改数组内容不会触发重绘——这是所有“升级失败”项目的共同起点。很多人试图用setOption({ series: [...] })强刷,结果要么节点错位、要么连线断裂、要么高亮失效。根本原因在于:ECharts 内部对graph类型做了深度缓存优化,它会为每个节点生成唯一id并建立索引,而setOption时若未显式维护id一致性,就会导致视图与数据脱钩。所以真正的升级,始于对数据流的彻底接管。
2.1 构建可响应的数据容器:用 Map 替代原始数组管理节点
我们不直接操作option.series[0].nodes,而是创建一个NodeMap实例,用Map<string, NodeItem>存储所有节点,并提供原子化方法:
// NodeMap.ts class NodeMap { private data = new Map<string, NodeItem>(); // 添加节点:自动生成唯一 id,支持传入初始坐标 add(node: Omit<NodeItem, 'id'> & { x?: number; y?: number }): string { const id = `node_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; this.data.set(id, { id, name: node.name, value: node.value || 0, symbolSize: node.symbolSize || 30, itemStyle: node.itemStyle || { color: '#5470C6' }, // 关键:保留物理坐标,避免 forceLayout 后位置丢失 x: node.x, y: node.y }); return id; } // 删除节点:同时清理关联的连线 delete(id: string): void { this.data.delete(id); // 清理 links 中涉及该节点的边(后续章节详述) } // 获取所有节点数组(供 ECharts 消费) toArray(): NodeItem[] { return Array.from(this.data.values()); } }提示:
x/y字段必须显式传入,否则 ECharts 在forceLayout: 'none'模式下会重置所有节点位置。这是踩坑血泪经验——某次上线后所有设备图标挤在左上角,排查三天才发现toArray()返回的节点缺了坐标字段。
2.2 连线(links)的双向依赖管理:避免“删节点后连线残留”的玄学 bug
links不是独立存在,它必须严格对应nodes中的id。如果直接用数组存储links,删除节点时需遍历整个links数组并filter,效率低且易漏。我们采用LinkMap,以sourceId + targetId为 key 建立索引:
// LinkMap.ts class LinkMap { private data = new Map<string, LinkItem>(); // 生成 link key,确保 source→target 和 target→source 不重复 private getKey(source: string, target: string): string { return source < target ? `${source}-${target}` : `${target}-${source}`; } add(link: Omit<LinkItem, 'id'>): void { const key = this.getKey(link.source, link.target); this.data.set(key, { ...link, id: `link_${key}_${Date.now()}` }); } // 删除某节点关联的所有边 removeByNodeId(id: string): void { for (const [key, link] of this.data.entries()) { if (link.source === id || link.target === id) { this.data.delete(key); } } } toArray(): LinkItem[] { return Array.from(this.data.values()); } }2.3 初始化图表时的关键配置:关闭 forceLayout,启用 draggable
原生graph默认开启力导向布局(forceLayout),这会导致节点位置不可控。而拓扑图的核心诉求是人工排布+物理位置保真,因此必须禁用:
const option = { series: [{ type: 'graph', layout: 'none', // 关键!禁用力导向 symbolSize: 30, draggable: true, // 允许拖拽节点 roam: true, // 支持缩放和平移 label: { show: true, fontSize: 12 }, edgeSymbol: ['circle', 'arrow'], // 箭头表示方向 edgeLabel: { show: true, formatter: '{c}' // 显示连线上的数值(如带宽、延迟) }, // 数据由 NodeMap 和 LinkMap 动态提供 nodes: nodeMap.toArray(), links: linkMap.toArray(), // 重点:启用节点点击事件 emphasis: { focus: 'adjacency', // 高亮相邻节点和连线 itemStyle: { shadowBlur: 10, shadowColor: '#333' } } }] };注意:
draggable: true是实现“拖拽即保存”的前提,但 ECharts 不会自动触发onDragEnd事件。我们必须监听mousedown/mousemove/mouseup原生事件,结合chart.convertFromPixel反算坐标——这部分在第 4 章详解。
3. 实现四大核心交互:添加、搜索高亮、排序、删除的完整链路
标题中“可添加、搜索(支持高亮显示)、排序、删除”不是并列功能点,而是一个有先后依赖的操作闭环。比如“删除”必须先通过“搜索”定位目标,“排序”结果会影响“添加”时的新节点默认位置。本章按真实用户动线展开,每步给出可粘贴的代码块及参数说明。
3.1 添加节点:弹窗表单 + 坐标锚定 + 自动连线建议
添加不是简单 push 到数组,而是要解决三个问题:① 新节点放在哪?② 是否需要自动连到最近设备?③ 表单如何与图表联动?
// 触发添加的按钮事件 document.getElementById('add-node-btn').addEventListener('click', () => { // 1. 弹窗表单(此处简化为 prompt,实际用 Modal) const name = prompt('请输入设备名称(如:server-01)'); if (!name) return; // 2. 计算新节点坐标:放在视口中心偏右,避免遮挡 const rect = chart.getDom().getBoundingClientRect(); const centerX = rect.width / 2 + 200; // 右偏 200px const centerY = rect.height / 2; const pos = chart.convertToPixel({ seriesIndex: 0 }, [centerX, centerY]); // 3. 添加节点(调用 NodeMap.add) const nodeId = nodeMap.add({ name, symbolSize: 35, itemStyle: { color: '#EE6666' }, x: pos[0], // 重要:存像素坐标 y: pos[1] }); // 4. (可选)自动推荐连线:找距离最近的 3 个节点 const nearest = findNearestNodes(pos[0], pos[1], 3); nearest.forEach(targetId => { linkMap.add({ source: nodeId, target: targetId, value: 100, // 默认带宽 100Mbps lineStyle: { width: 2, curveness: 0.2 } }); }); // 5. 刷新图表 chart.setOption({ series: [{ nodes: nodeMap.toArray(), links: linkMap.toArray() }] }); }); // 辅助函数:找最近的 N 个节点(欧氏距离) function findNearestNodes(x: number, y: number, n: number): string[] { const nodes = nodeMap.toArray(); return nodes .map(node => ({ id: node.id, dist: Math.sqrt(Math.pow(node.x - x, 2) + Math.pow(node.y - y, 2)) })) .sort((a, b) => a.dist - b.dist) .slice(0, n) .map(item => item.id); }参数说明:
convertToPixel将视口坐标转为图表坐标系,curveness: 0.2让连线带轻微弧度,避免多条线完全重叠。value字段用于后续排序和 edgeLabel 显示。
3.2 搜索高亮:实时过滤 + 多字段匹配 + 高亮扩散
搜索不是简单indexOf,而是要支持:① 设备名、IP、所属区域多字段模糊匹配;② 匹配节点高亮,其相连的边和邻居节点也高亮;③ 输入过程实时响应(防抖)。
let searchTimer: ReturnType<typeof setTimeout>; const searchInput = document.getElementById('search-input') as HTMLInputElement; searchInput.addEventListener('input', (e) => { clearTimeout(searchTimer); const keyword = (e.target as HTMLInputElement).value.trim(); if (!keyword) { // 清空高亮 chart.dispatchAction({ type: 'downplay', seriesIndex: 0 }); return; } searchTimer = setTimeout(() => { const nodes = nodeMap.toArray(); const matchedIds = new Set<string>(); // 多字段匹配:name、value(可存 IP)、category(区域) nodes.forEach(node => { const matchName = node.name?.toLowerCase().includes(keyword.toLowerCase()); const matchValue = String(node.value)?.includes(keyword); const matchCategory = node.category?.toLowerCase().includes(keyword.toLowerCase()); if (matchName || matchValue || matchCategory) { matchedIds.add(node.id); // 扩散高亮:添加相邻节点和连线 linkMap.toArray().forEach(link => { if (link.source === node.id || link.target === node.id) { matchedIds.add(link.source); matchedIds.add(link.target); } }); } }); // 执行高亮(ECharts 原生 action) chart.dispatchAction({ type: 'highlight', seriesIndex: 0, dataIndex: Array.from(matchedIds).map(id => nodes.findIndex(n => n.id === id) ).filter(i => i !== -1) // 过滤掉找不到的索引 }); }, 300); // 300ms 防抖 });提示:“高亮扩散”是用户体验分水岭。只高亮关键词节点会让运维人员忽略关联风险,而扩散到邻居节点后,一眼就能看出“这台故障服务器连着数据库和缓存集群”。
3.3 排序连线:按数值降序重排 links 数组,影响渲染顺序和视觉权重
ECharts 渲染links时按数组顺序,靠后的连线会覆盖靠前的。利用这点,我们可以让高价值链路(如主干网、低延迟专线)视觉上更突出:
// 按连线 value 降序排列(value 可存延迟 ms、带宽 Mbps、错误率 %) function sortLinksByValue(ascending = false) { const links = linkMap.toArray(); links.sort((a, b) => { const diff = (b.value || 0) - (a.value || 0); return ascending ? -diff : diff; }); // 重新设置 links(注意:必须重置整个数组,不能 splice) linkMap.data.clear(); links.forEach(link => linkMap.add(link)); chart.setOption({ series: [{ links: linkMap.toArray() }] }); } // 绑定到排序按钮 document.getElementById('sort-by-value-btn').addEventListener('click', () => { sortLinksByValue(false); // 降序:高 value 在前,视觉更重 });注意:
sortLinksByValue不改变节点位置,只调整连线绘制顺序。若需按节点属性排序(如按区域分组),则需在nodes数组中增加category字段,并用series.categories配置颜色映射。
3.4 删除节点:右键菜单 + 级联清理 + 撤销机制
删除是最危险操作,必须有确认和后悔药:
// 1. 监听右键事件(ECharts 不支持原生右键菜单,需自己实现) chart.on('click', (params) => { if (params.event.event.button !== 2) return; // 只响应右键 const node = params.data; if (!node || !node.id) return; // 2. 弹出确认框(实际用 antd Modal 更专业) if (!confirm(`确定删除设备 ${node.name}?将同时删除其所有连接`)) return; // 3. 级联删除:节点 + 关联连线 nodeMap.delete(node.id); linkMap.removeByNodeId(node.id); // 4. 刷新图表 chart.setOption({ series: [{ nodes: nodeMap.toArray(), links: linkMap.toArray() }] }); }); // 5. 撤销机制(简易版):记录上一次操作 let lastOperation: { type: 'add' | 'delete'; payload: any } | null = null; function recordOperation(type: 'add' | 'delete', payload: any) { lastOperation = { type, payload }; } // 绑定撤销按钮 document.getElementById('undo-btn').addEventListener('click', () => { if (!lastOperation) return; if (lastOperation.type === 'delete') { // 恢复被删节点和连线(需在 delete 前备份) nodeMap.add(lastOperation.payload.node); lastOperation.payload.links.forEach((link: LinkItem) => { linkMap.add(link); }); } chart.setOption({ series: [{ nodes: nodeMap.toArray(), links: linkMap.toArray() }] }); lastOperation = null; });血泪经验:
params.event.event.button !== 2是判断右键的可靠方式,params.data在点击空白处为undefined,必须判空。线上曾因未判空导致Cannot read property 'id' of undefined报错。
4. 避坑指南:ECharts 关系图升级中 4 个高频翻车现场
升级不是平滑过渡,而是踩坑集训营。以下是某跨平台系统在灰度发布时,前端团队整理的 4 个真实翻车案例,按现象→原因→解法结构呈现,每一条都附带可验证的修复代码。
4.1 现象:拖拽节点后位置复位,松手瞬间跳回原位
原因:未监听mouseup事件保存坐标,ECharts 在render周期重置了x/y。draggable: true只控制拖拽行为,不负责持久化。
解法:监听mouseup,用convertFromPixel反算坐标并更新NodeMap:
let isDragging = false; let draggedNodeId: string | null = null; chart.on('mousedown', (params) => { if (params.dataType === 'node') { isDragging = true; draggedNodeId = params.data.id; } }); chart.on('mouseup', () => { if (isDragging && draggedNodeId) { // 获取当前鼠标位置(相对于图表容器) const pixelPos = chart.getPointerPosition(); // 转为图表坐标系 const coord = chart.convertFromPixel({ seriesIndex: 0 }, pixelPos); // 更新 NodeMap 中的坐标 const node = nodeMap.toArray().find(n => n.id === draggedNodeId); if (node) { node.x = coord[0]; node.y = coord[1]; } isDragging = false; draggedNodeId = null; } });4.2 现象:搜索高亮后,再次点击节点,高亮消失且无法恢复
原因:dispatchAction({ type: 'highlight' })是瞬时高亮,ECharts 不维护高亮状态。当用户点击节点触发emphasis时,原高亮被覆盖。
解法:改用setOption设置selectedMode和selected属性,让高亮成为数据状态的一部分:
// 搜索匹配后,不 dispatchAction,而是: const highlightedNodes = Array.from(matchedIds); chart.setOption({ series: [{ selectedMode: 'single', data: nodeMap.toArray().map(node => ({ ...node, selected: highlightedNodes.includes(node.id) })) }] });4.3 现象:添加大量节点(>500)后,图表卡顿,拖拽延迟超 500ms
原因:setOption全量刷新触发 DOM 重排,且graph类型对大数据量无优化。
解法:启用progressive: 0关闭渐进渲染,改用appendData增量更新:
// 初始化时设置 const option = { series: [{ progressive: 0, // 关键!禁用渐进渲染 progressiveThreshold: 1000 }] }; // 添加节点时,不用 setOption,改用 appendData chart.appendData({ seriesIndex: 0, data: [newNode] // newNode 是单个节点对象 });4.4 现象:导出 PNG 时,高亮边框、箭头、文字全部变粗或模糊
原因:ECharts 导出使用 canvas,而shadowBlur、lineWidth等 CSS 属性在 canvas 中渲染精度丢失。
解法:导出前临时关闭高亮样式,导出后再恢复:
function exportAsPNG() { // 1. 保存原高亮样式 const originalEmphasis = chart.getOption().series[0].emphasis; // 2. 关闭阴影和描边 chart.setOption({ series: [{ emphasis: { itemStyle: { shadowBlur: 0, borderWidth: 0 } } }] }); // 3. 导出 const url = chart.getDataURL({ type: 'png', pixelRatio: 2 }); const link = document.createElement('a'); link.href = url; link.download = 'topology.png'; link.click(); // 4. 恢复原样式 chart.setOption({ series: [{ emphasis: originalEmphasis }] }); }5. 进阶技巧:让关系图真正成为运维决策入口——联动指标、注入告警、导出结构化数据
做到“可添加、搜索、排序、删除”,只是把图变成了可编辑的白板。真正的升级,在于让它成为数据枢纽:点击节点弹出实时监控面板、连线颜色随延迟阈值动态变化、一键导出符合 CMDB 标准的 JSON 结构。这些不是锦上添花,而是某实验室将拓扑图接入自动化巡检流程后的刚性需求。
5.1 节点点击联动:从图表跳转到指标详情页
ECharts 的click事件能拿到完整节点数据,我们利用value字段存储设备唯一标识(如 CMDB ID),再拼接跳转 URL:
chart.on('click', (params) => { if (params.dataType === 'node' && params.data.cmdbId) { // 假设 cmdbId 存在,跳转到监控详情页 const url = `/monitor/device/${params.data.cmdbId}`; window.open(url, '_blank'); } }); // 添加节点时,务必存 cmdbId nodeMap.add({ name: 'db-prod-01', cmdbId: 'CMDB-2023-8847', // 关键业务字段 value: '10.10.1.100' // 同时存 IP 供搜索 });提示:
cmdbId不是 ECharts 官方字段,但 ECharts 允许在data对象中存任意自定义属性,params.data会完整透出。这是打通运维系统的最轻量方式。
5.2 连线动态着色:根据 value 值映射颜色,直观反映链路健康度
用links的lineStyle.color实现条件着色,比图例更直接:
// 修改 links 数据生成逻辑 function getLinkColor(value: number): string { if (value > 1000) return '#67C23A'; // 带宽 >1G,绿色 if (value > 100) return '#E6A23C'; // 100M-1G,橙色 return '#F56C6C'; // <100M,红色 } // 添加连线时 linkMap.add({ source: 'n1', target: 'n2', value: 1500, lineStyle: { color: getLinkColor(1500), width: 3 } });5.3 导出结构化数据:生成 CMDB 兼容的 JSON,支持导入/迁移
运维团队需要将手工绘制的拓扑图同步到 CMDB 系统,我们提供一键导出按钮,生成标准 JSON:
function exportToCMDB() { const nodes = nodeMap.toArray().map(node => ({ id: node.id, name: node.name, ip: node.value?.toString() || '', category: node.category || 'unknown', position: { x: node.x, y: node.y } // 保留布局信息 })); const links = linkMap.toArray().map(link => ({ source_id: link.source, target_id: link.target, bandwidth_mbps: link.value || 0, description: link.name || '' })); const cmdbData = { version: '1.0', generated_at: new Date().toISOString(), nodes, links }; const blob = new Blob([JSON.stringify(cmdbData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'topology_cmdb_export.json'; a.click(); URL.revokeObjectURL(url); }这份 JSON 可被 CMDB 系统直接解析,字段名(
source_id,bandwidth_mbps)与主流 CMDB API 对齐。某次迁移中,500+ 设备拓扑图 10 分钟内完成导入,替代了原来 3 天的手工录入。
5.4 告警注入:当后端推送设备离线,图表自动标记节点为灰色并闪烁
真正的实时性,来自 WebSocket 推送。我们监听后端消息,动态更新节点样式:
// 假设已建立 ws 连接 ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'device_offline') { const node = nodeMap.toArray().find(n => n.id === msg.deviceId); if (node) { node.itemStyle = { color: '#909399' }; // 灰色 node.symbolSize = 25; // 缩小表示异常 // 添加闪烁动画(CSS 类) chart.dispatchAction({ type: 'highlight', seriesIndex: 0, dataIndex: nodeMap.toArray().findIndex(n => n.id === msg.deviceId) }); // 3 秒后停止闪烁 setTimeout(() => { chart.dispatchAction({ type: 'downplay', seriesIndex: 0 }); }, 3000); } } };我坚持在每个项目里把exportToCMDB和getLinkColor这类函数写成独立模块,而不是塞进图表初始化代码里。因为拓扑图从来不是终点,而是运维自动化流水线的第一个齿轮——它输出的数据要能被下游系统直接消费,它响应的事件要能触发真实操作。当某天你发现,运维同学不再截图发群里问“这个链路怎么了”,而是直接点开图表,看到红色连线就去查日志,那一刻你就知道,这个“升级版关系图”真的活了。希望帮到你。
本文还有配套的精品资源,点击获取