给 ECharts 6 graph 节点叠加角标:从原理到性能优化
在关系图谱里给节点挂角标(比如左上角的根节点标识、右上角的分支标识)是个常见需求,但在 ECharts 6 的 graph 系列 + force 布局下,这件事比想象中麻烦。本文记录 BadgeGraph.vue 里这套方案的落地过程:为什么不能走配置项、怎么把图标真正画进 canvas、怎么让它在力引导和缩放里实时跟随,以及节点变多后怎么不让它卡。
需求与约束
先把约束列清楚,因为它们直接决定了技术选型:
- 角标要画进 canvas ,
getDataURL()导出时必须可见------这排除了用 HTML overlay 的方案。 - 数据驱动显隐 :节点
data上有某个属性(如iconLeft)才显示对应角标,没有就不显示。 - force 布局、拖动、缩放、平移时角标要实时跟随节点。
- 节点可能很多(80+ 节点、每节点最多两个角标,合计 160+ 个图标),不能因为角标拖垮帧率。
为什么不能用 graphic 配置项
ECharts 提供了 graphic 配置项来画自定义图形,直觉上把角标塞进 graphic 然后用 setOption 更新位置就行。但这在 force 布局下是个陷阱。
setOption 会重新跑 force 的 stage handler(createSimpleOverallStageHandler),它会把 friction 重置再 step 一次。也就是说,你每帧用 setOption 更新角标位置,力引导就永远无法收敛------force 死循环,CPU 直接打满。
结论:角标的位置更新必须完全绕开 setOption,直接操作 zrender 层。
用 zrender Image 把图标画进 canvas
ECharts 底层就是 zrender,chart.getZr() 拿到 zrender 实例后,可以直接往画布上 add 元素。这些元素和 series 画出的节点在同一个 canvas 里,getDataURL 自然可见。
构造图标元素用 echarts.graphic.Image:
js
const createIcon = (config) => {
const size = config.size || 20
const el = new echarts.graphic.Image({
style: { image: config.image, width: size, height: size, x: 0, y: 0 },
z: 10, // 浮在节点之上
silent: true // 不阻挡图表鼠标交互(拖拽/缩放)
})
el.hide()
if (zr.value) zr.value.add(el)
return el
}
这里有个坑值得单独说:不要 import 单独的 zrender 包去拿 Image。 在 pnpm 下 zrender 是 echarts 的间接依赖,顶层 node_modules/zrender 的符号链接不存在,import from 'zrender' 直接报模块找不到。echarts 6 通过 api/graphic.js 把 zrender 的 Image(ZRImage)re-export 了出来,所以 import * as echarts from 'echarts' 之后用 new echarts.graphic.Image(...) 就行。
style.image 接 URL,Vite import SVG/PNG 返回的 URL 可以直接用。silent: true 很关键------否则角标会挡住底层节点的拖拽和缩放事件。z: 10 保证它浮在节点之上。
定位用 el.attr('position', [x, y]),注意它设的是元素左上角 而不是中心。显隐用 el.show() / el.hide()。
用 rendered 事件驱动,而不是 rAF 轮询
角标要跟随 force 每一帧的位置变化,需要一个触发源。早期方案想过用 requestAnimationFrame 轮询,但有个问题:force 收敛后渲染停止,rAF 却还在跑,既浪费又可能在收敛后因为某个意外再次触发拖动时跟不上节奏。
最终选了 ECharts 的 rendered 事件:
js
chart.on('rendered', scheduleUpdate)
rendered 在 force 每个 step、拖动、缩放、平移、resize 后都会触发。force 收敛后不再有新帧,rendered 自然不再触发,角标自动停在最终位置------比 rAF 轮询干净得多。
需要注意的是:rendered 可能在同一帧内被多次触发(force step + 拖动 + 缩放叠加),所以用一个 rAF 做合并,而不是直接在回调里重算:
js
const scheduleUpdate = () => {
if (rafId != null) return
rafId = requestAnimationFrame(() => {
rafId = null
if (needRebuild) { rebuild(); needRebuild = false; return }
if ((frameSkip++ & 1) === 1) return // 跳过偶数帧
updatePositions()
})
}
这里 rAF 只是"合并同帧多次触发",触发源仍然是 rendered,不是纯轮询,两者不矛盾。
数据联动与位置读取
角标的显隐由节点 data 上的自定义属性决定。遍历数据时用 itemModel.get(field) 读属性:
js
data.each((idx) => {
const itemModel = data.getItemModel(idx)
for (const [field, config] of badgeEntries) {
if (!itemModel.get(field)) continue // 属性为 falsy 不画角标
// ...创建图标
}
})
读节点位置时要对齐 v6 force 布局的写入方式。v6 的 forceLayout.js 在 afterStep 里用 graph.getNodeByIndex(i).setLayout(nodes[i].p) 写位置,所以读的时候优先走 graph:
js
const node = graph && graph.getNodeByIndex(idx)
const layout = (node && node.getLayout()) || data.getItemLayout(idx)
这是 v6 相对 v5 的一个变化------v5 时代 data.getItemLayout() 能直接读到,v6 下 getData() 和 getGraph().data 可能不是同一个对象,只走 data.getItemLayout 会拿不到 force 写入的位置。
拿到数据坐标后,还要带上 roam(pan/zoom)变换才能落到屏幕像素:
js
const pixel = chart.convertToPixel({ seriesIndex: 0 }, [layout[0], layout[1]])
convertToPixel 会把当前的平移、缩放一并算进去,缩放平移时角标自动跟随。
四角定位
角标要贴在节点的四个角之一。用一个方向向量乘以偏移量得到角的位置:
js
const POS_VEC = {
topLeft: [-1, -1],
topRight: [1, -1],
bottomLeft: [-1, 1],
bottomRight: [1, 1]
}
偏移量取节点半径在 45° 方向上的投影,正好落到角上:
js
const symbolSize = data.getItemVisual(idx, 'symbolSize') || [50, 50]
const radius = Array.isArray(symbolSize) ? symbolSize[0] / 2 : symbolSize / 2
const offset = radius * 0.707 // √2/2
最终位置 = 节点中心像素 + 方向向量 × 偏移。place 里再做一次中心到左上角的换算:
js
const place = (el, cx, cy, size) => {
el.attr('position', [cx - size / 2, cy - size / 2])
el.show()
}
性能优化:把"建池"和"定位"拆开
节点一多,朴素方案(每帧 data.each + itemModel.get + 末尾全量清理残留)会明显卡顿。核心优化是把工作分成 rebuild 和 updatePositions 两层。
rebuild 只在 option / badges 变化时跑,它做一次性的重活:遍历所有节点,判断哪些需要角标,创建 zrender Image,并把几何参数预算好缓存进池子:
js
const iconPool = new Map() // dataIdx -> Map<field, { el, size, dx, dy, offset, lastPx }>
// rebuild 内
nodePool.set(field, { el, size, dx, dy, offset, lastPx: null })
size、dx、dy、offset 这些在 force 期间都不变,rebuild 时算一次就够了。
updatePositions 由 rendered 驱动,每帧只做增量定位:遍历池子里已有 的元素,读位置、算像素、place。不再读 itemModel,不再读 symbolSize,也不做全量清理:
js
for (const [idx, nodePool] of iconPool) {
const layout = (node && node.getLayout()) || data.getItemLayout(idx)
const pixel = chart.convertToPixel({ seriesIndex: 0 }, [layout[0], layout[1]])
for (const entry of nodePool.values()) {
const nx = pixel[0] + entry.dx * entry.offset
const ny = pixel[1] + entry.dy * entry.offset
if (entry.lastPx && Math.abs(nx - entry.lastPx[0]) < POS_EPSILON
&& Math.abs(ny - entry.lastPx[1]) < POS_EPSILON) continue
place(entry.el, nx, ny, entry.size)
entry.lastPx = [nx, ny]
}
}
这里有个关键的增量跳过:每个 entry 缓存上次的像素位置 lastPx,如果位置变化小于 POS_EPSILON(0.5px)就直接 continue,不调 attr。force 收敛后节点位置几乎不动,绝大多数角标都会命中这个跳过,每帧开销趋近于零。
跳帧 + finished 补偿
force 期间 rendered 每帧都触发,即使做了上面的优化,160+ 个图标的 zrender 重绘仍有成本。再加一层节流:每两帧更新一次角标:
js
if ((frameSkip++ & 1) === 1) return // 跳过偶数帧
代价是进场动画期间角标跟随会略顿一帧。为了补上这个滞后,监听 finished 事件------force 或动画收敛后绕过跳帧,强制精确定位一次:
js
const onFinished = () => {
if (rafId != null) { cancelAnimationFrame(rafId); rafId = null }
if (needRebuild) { rebuild(); needRebuild = false }
else updatePositions()
}
chart.on('finished', onFinished)
这样跳帧带来的"最后一帧滞后"会被 finished 补上,最坏也只差一帧,完全可接受。
顺带提一个 v6 的废弃
v6 里 focusNodeAdjacency: true 已经废弃,会打 deprecation warning,改成:
js
emphasis: { focus: 'adjacency' }
小结
整套方案的核心思路其实就两条:绕开 setOption 直接操作 zrender ,以及把一次性计算和每帧更新拆开。
第一点让 force 不会因为角标更新而死循环;第二点让角标在 force 期间只做最便宜的增量定位,收敛后近乎零开销。rendered 驱动 + rAF 合并保证实时跟随,finished 补偿跳帧滞后,四角定位用方向向量 + 45° 投影一行算出来。
最终使用方只需要关心两件事:传一个标准 ECharts option,再传一个 badges 配置({ 字段名: { image, position, size } }),组件自动处理画布绘制、数据联动、跟随和性能。把脏活藏在组件里,对外保持配置式,这是这种"框架能力补丁"类组件比较理想的封装边界。
ps:节点数量多 且 大多数都有角标时 还是会有性能问题。