给 ECharts 6 graph 节点叠加角标:从原理到性能优化

给 ECharts 6 graph 节点叠加角标:从原理到性能优化

在关系图谱里给节点挂角标(比如左上角的根节点标识、右上角的分支标识)是个常见需求,但在 ECharts 6 的 graph 系列 + force 布局下,这件事比想象中麻烦。本文记录 BadgeGraph.vue 里这套方案的落地过程:为什么不能走配置项、怎么把图标真正画进 canvas、怎么让它在力引导和缩放里实时跟随,以及节点变多后怎么不让它卡。

需求与约束

先把约束列清楚,因为它们直接决定了技术选型:

  • 角标要画进 canvasgetDataURL() 导出时必须可见------这排除了用 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.jsafterStep 里用 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 + 末尾全量清理残留)会明显卡顿。核心优化是把工作分成 rebuildupdatePositions 两层。

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 })

sizedxdyoffset 这些在 force 期间都不变,rebuild 时算一次就够了。

updatePositionsrendered 驱动,每帧只做增量定位:遍历池子里已有 的元素,读位置、算像素、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:节点数量多 且 大多数都有角标时 还是会有性能问题。

相关推荐
weixin_382395231 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__1 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.2 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~3 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华4 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子6 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅6 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni7 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学8 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端