用 SVG image symbol 画 ECharts graph 圆形节点

用 SVG image symbol 画 ECharts graph 圆形节点

前两篇讲了给节点叠角标、以及用富文本 label 拼矩形卡片节点。矩形卡片的布局靠 ECharts 的 rich text 还算顺手,但要把节点画成圆形 + 十字分隔 + 四象限内容 (上半圆标题、左下企业、右下技术),rich text 就力不从心了--它画不了圆弧,也画不了穿过圆心的十字线。这篇记录 graph.vue 里圆形节点的实现:把整个节点烘焙成一张 SVG,作为节点的 symbol 传给 ECharts。

思路转变:从 label 拼布局到 SVG 烘焙

矩形卡片那套方案的核心是 symbol: 'rect' 画外壳 + label 的 rich text 画内容。它成立的前提是内容能用"块 + 边框"拼出来。

圆形节点要的是:一个圆、两条过圆心的分隔线把圆切成四块、标题在上方、两个指标在下方左右两格。这里有两个 rich text 做不到的事--画圆弧、画穿过圆心的直线。强行用 graphic 配置项去画圆和线,又会撞上前一篇说的 force 死循环问题(setOption 更新位置会重跑 force stage handler)。

于是换一个思路:既然 ECharts 的 symbol 可以接 image:// 图片,那就把整个节点(圆、十字、所有文字)画成一张 SVG,转成 data URL 当 symbol。 ECharts 只负责把这张图摆到 force 算出来的位置上,节点内部的所有视觉全在 SVG 里一次画定。

image:// SVG symbol 原理

ECharts 的 symbol 字段除了内置形状名('circle''rect' 等),还接受 image://URL 形式的图片。URL 可以是网络地址,也可以是 data URL。这里用 SVG data URL:

js 复制代码
return `image://data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`

encodeURIComponent 很关键--SVG 里有大量 <>#、空格,直接拼进 data URL 会破坏解析。编码后整张 SVG 变成一个合法的 URL 字符串,ECharts 把它当图片加载,画到节点位置。

buildNode 把这个字符串塞进 symbol,并关掉 label:

js 复制代码
function buildNode(node) {
  return {
    ...node,
    symbol: createCircleNodeSymbol(node),
    symbolSize: [node.symbolSize, node.symbolSize],
    z: 10,
    label: { show: false },
  }
}

label.show: false 是必须的--文字已经画进 SVG 了,再让 ECharts 的 label 显示一遍就是重复。symbolSize: [n, n] 用正方形尺寸,因为 SVG 的 viewBox 是正方形,正方形 symbolSize 能保证图不被压扁。

SVG 结构:圆 + 十字 + 四象限

整张 SVG 画在一个固定的 128×128 viewBox 里:

js 复制代码
const SVG_VIEWBOX_SIZE = 128
const SVG_CENTER = SVG_VIEWBOX_SIZE / 2   // 64
const SVG_RADIUS = 62

节点视觉由几个元素拼成:

js 复制代码
const svg = `
  <svg xmlns="http://www.w3.org/2000/svg" width="${SVG_VIEWBOX_SIZE}" height="${SVG_VIEWBOX_SIZE}" viewBox="0 0 ${SVG_VIEWBOX_SIZE} ${SVG_VIEWBOX_SIZE}">
    <circle cx="${SVG_CENTER}" cy="${SVG_CENTER}" r="${SVG_RADIUS}" fill="${theme.backgroundColor}" stroke="${theme.strokeColor}" stroke-width="2"/>
    <line x1="2" y1="${SVG_CENTER}" x2="${SVG_VIEWBOX_SIZE - 2}" y2="${SVG_CENTER}" stroke="${theme.strokeColor}" stroke-width="1.5"/>
    <line x1="${SVG_CENTER}" y1="${SVG_CENTER}" x2="${SVG_CENTER}" y2="${SVG_VIEWBOX_SIZE - 2}" stroke="${theme.strokeColor}" stroke-width="1.5"/>
    <text x="${SVG_CENTER}" text-anchor="middle" ...>${createTitleSvg(content.title)}</text>
    <text x="${METRIC_LEFT_X}" y="86" ...>${escapeXml(content.enterprise.label || '企业')}</text>
    <text x="${METRIC_LEFT_X}" y="105" ...>${escapeXml(getMetricValue(content.enterprise))}</text>
    <text x="${METRIC_RIGHT_X}" y="86" ...>${escapeXml(content.tech.label || '技术')}</text>
    <text x="${METRIC_RIGHT_X}" y="105" ...>${escapeXml(getMetricValue(content.tech))}</text>
  </svg>
`
  • <circle> 画圆,半径 62(留 2px 给描边,避免贴边裁切)。
  • 两条 <line> 一横一竖,都从圆心出发,把圆切成四块。横向那条贯穿整个 viewBox(x1="2"x2="126"),竖向那条只画下半段(从圆心到下沿),这样上半圆留给标题,下半圆被竖线分成左右两格放指标。
  • 标题 <text>text-anchor="middle" 居中,内容由 createTitleSvg 生成多个 <tspan> 实现多行。
  • 指标分标签和数值两行:y=86 放小字标签("企业"/"技术"),y=105 放大字数值(17px)。左格 x=40,右格 x=88,对应下半圆的两个象限中心。

这套布局在 SVG 里用绝对坐标摆,比在 ECharts rich text 里用 padding/width/borderWidth 拼要直观得多,尤其圆和十字这种 rich text 根本画不出来的元素。

viewBox 与 symbolSize 解耦

一个关键设计是 SVG 的"设计坐标"和实际渲染尺寸分离。

SVG 固定在 128×128 的 viewBox 里画,所有坐标(圆心 64、半径 62、文字位置 40/88/86/105)都基于这套抽象坐标系。实际渲染多大由 symbolSize 决定:

js 复制代码
const NODE_SIZE_MAP = { 1: 132, 2: 124, 3: 116, 4: 108, 5: 108 }
// buildNode 里
symbolSize: [node.symbolSize, node.symbolSize]

层级越高(level 越小)节点越大,根节点 132px,叶子 108px。ECharts 会把 128×128 的 SVG 缩放到 symbolSize 指定的像素尺寸。改大小不用动 SVG 内容,只改 NODE_SIZE_MAP 这张表就行。

这也意味着 force 布局算出的斥力、边长要匹配节点尺寸--节点整体比矩形卡片版本大了一圈(132 vs 240 宽,但圆形视觉占比不同),所以 force 参数也重新调过(见后文)。

标题自适应换行

标题长度不一,要在一行放不下时自动换两行、再长就截断。难点是中英文混排时不能用字符数判断--一个汉字占的视觉宽度差不多是两个字母。

getVisualLength 给每个字符一个权重:CJK 字符算 1,ASCII 算 0.55:

js 复制代码
function getVisualLength(text) {
  return Array.from(text).reduce((total, char) =>
    total + (/[\u0000-\u00ff]/.test(char) ? 0.55 : 1), 0)
}

splitTitleLines 按视觉长度切分,每行最多 8 个单位,最多 2 行,超出用省略号:

js 复制代码
function splitTitleLines(title, maxUnits = TITLE_MAX_VISUAL_UNITS) {
  // 逐字符累加,超过 maxUnits 就断行
  // ...
  const collapsedLines = lines.slice(0, 2)
  if (trimmed.length > collapsedLines.join('').length) {
    // 末行末尾换成 ...
  }
  return collapsedLines
}

字号也跟着行数和长度自适应:两行用 12px,单行但较长用 13px,短标题用 15px:

js 复制代码
function getTitleFontSize(titleLines) {
  if (titleLines.length > 1) return 12
  return getVisualLength(titleLines[0] ?? '') > 6 ? 13 : 15
}

createTitleSvg 把每行包成一个 <tspan>,按行高(字号 +4)纵向排列:

js 复制代码
function createTitleSvg(title) {
  const titleLines = splitTitleLines(title)
  const fontSize = getTitleFontSize(titleLines)
  const lineHeight = fontSize + 4
  const startY = titleLines.length > 1 ? 34 : 45
  return titleLines.map((line, index) =>
    `<tspan x="${SVG_CENTER}" y="${startY + index * lineHeight}">${escapeXml(line)}</tspan>`
  ).join('')
}

单行时 startY=45 让标题垂直靠上居中,两行时 startY=34 给两行腾出空间。这种基于视觉长度的换行比 ECharts label 的 overflow: 'truncate' 灵活--后者只能截断,不能换行,也不会区分中英文宽度。

指标解析与简化

节点的 name 是多行文本,第一行标题,后面几行是"企业 120 家"这种"标签 值 单位"格式。parseMetric 用正则拆出标签和值:

js 复制代码
const matched = trimmed.match(/^(.+?)\s+(.+)$/)
if (matched) {
  return { label: matched[1].trim(), value: matched[2].trim() }
}

数值里常带"家""项"这类量词,在圆形节点里显示太挤,simplifyMetricValue 把末尾的量词去掉:

js 复制代码
function simplifyMetricValue(value) {
  return value.replace(/[家项]\s*$/u, '').trim()
}

没有值时显示 --

js 复制代码
function getMetricValue(metric) {
  return metric.value ? simplifyMetricValue(metric.value) : '--'
}

XML 转义:data URL 里的隐形陷阱

SVG 要嵌进 data URL,里面任何原始的 &<>"' 都会让 XML 解析失败,节点直接画不出来。escapeXml 做五件事:

js 复制代码
function escapeXml(value) {
  return String(value)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, ''')
}

注意替换顺序--& 必须先替换,否则后面替换产生的 &lt; 里的 & 会被再次转义成 &amp;lt;。所有从数据里来的文本(标题、指标标签、数值)输出前都要过一遍 escapeXml。这是用 SVG image symbol 时最容易踩的坑:节点画不出、只显示个空圆,多半就是某个字段里有特殊字符没转义。

主题映射

配色按层级走 LEVEL_THEME_MAP,每级三个颜色:背景、文字、描边:

js 复制代码
const LEVEL_THEME_MAP = {
  1: { backgroundColor: '#070904', textColor: '#ffffff', strokeColor: 'rgba(255, 255, 255, 0.72)' },
  2: { backgroundColor: '#433B30', textColor: '#ffffff', strokeColor: 'rgba(255, 255, 255, 0.72)' },
  3: { backgroundColor: '#A38972', textColor: '#ffffff', strokeColor: 'rgba(255, 255, 255, 0.72)' },
  4: { backgroundColor: '#D2C2B5', textColor: '#1f2937', strokeColor: '#39424e' },
  5: { backgroundColor: '#C6BDB8', textColor: '#1f2937', strokeColor: '#39424e' },
  // ...
}

深色背景配白字 + 半透明白色描边,浅色背景配深字 + 深色描边。描边色同时用在圆的 stroke 和十字分隔线上,保证视觉统一。这套主题在 createCircleNodeSymbol 里读出来注入 SVG 的 fill/stroke

image symbol 的取舍

把节点烘焙成 SVG 图片这条路不是没代价,几个关键取舍要讲清楚。

缩放会模糊。 图片符号本质是位图(SVG 虽是矢量,但 ECharts 渲染 image symbol 时会光栅化),roam 放大时文字会糊。缓解方式是关掉 hover 缩放,避免鼠标移上去突然放大变糊:

js 复制代码
emphasis: {
  focus: 'none',
  scale: false,
}

scale: false 关掉 emphasis 的放大效果,focus: 'none' 关掉邻接高亮(圆形节点这套视觉不需要高亮联动)。

失去 per-element 交互。 整个节点是一张图,不能像 rich text 那样给某段文字单独加交互。点击还是有的(ECharts 的 click 事件按节点命中),但图内部分区点击做不到。这套场景不需要,所以可接受。

失去 ECharts label 能力。 截断、formatter、自适应这些都得自己在 SVG 里实现,前文的 splitTitleLines 就是补这个缺。好处是控制力更强,坏处是代码量更多。

收益是视觉自由度:圆、十字、任意象限的文本摆放,rich text 都做不到,SVG 随便画。对于这种"圆形 + 四象限"的节点形态,image symbol 是性价比最高的选择。

force 调参

圆形节点比矩形卡片视觉占比更大(132px 圆 vs 240×54 矩形,圆的有效面积更大),斥力和边长都要跟着调:

js 复制代码
force: {
  repulsion: 700,
  edgeLength: [100, 200],
  gravity: 0.06,
  friction: 0.9,
}

repulsion 从矩形版本的 500 提到 700,给大圆之间留出足够间距;gravity 从 0.1 降到 0.06,让图更舒展不被过度拉向中心;friction 0.9(介于默认 0.6 和矩形版本的 1 之间),收敛速度和稳定性折中。

数据流

和矩形版本一样的分层结构,只是 buildNode 的实现换了:

js 复制代码
function treeToGraph(root) {
  const data = []
  const links = []
  const walk = (node, parentId) => {
    const level = node.level ?? 5
    data.push({
      id: String(node.id),
      name: node.name,
      level,
      symbolSize: NODE_SIZE_MAP[level] || NODE_SIZE_MAP[5],
      // ...
    })
    if (parentId != null) {
      links.push({ source: String(parentId), target: String(node.id) })
    }
    node.children?.forEach(child => walk(child, node.id))
  }
  walk(root)
  return { data, links }
}

treeToGraph 产出的还是裸节点(只有 id/name/level/symbolSize),buildNode 再把每个节点包成 SVG image symbol:

js 复制代码
const graphData = computed(() => graphStructure.value.data.map(node => buildNode(node)))

最终进 option 的 series,symbol: 'circle' 是 series 级兜底,被每个节点的 image symbol 覆盖。BadgeGraph 拿到这个 option 照常渲染--对它来说节点是圆是方都一样,它只在节点上叠角标(这版 badges 是空对象,没启用角标)。

小结

圆形节点这套方案的核心是把节点视觉整体烘焙成 SVG,用 image:// data URL 当 symbol。它绕开了 rich text 画不了圆和直线的限制,用 SVG 的绝对坐标把圆、十字分隔、四象限文本一次画定,viewBox 和 symbolSize 解耦让尺寸调整只改一张表。

代价是放弃了 ECharts label 的便利(截断、formatter),相关能力(标题换行、指标解析)都得在 SVG 侧自己实现,以及 image symbol 缩放模糊需要靠关掉 hover 缩放来缓解。escapeXml 是这条路上必须盯紧的坑--data URL 里一个没转义的 & 就能让整个节点画不出来。

三种节点形态(角标、矩形卡片、圆形 image symbol)对应三种不同复杂度的需求:角标是"在已有节点上贴图标",矩形卡片是"用 rich text 拼规则布局",圆形 image symbol 是"任意视觉自由度"。选哪种取决于节点要画多复杂--能用 rich text 搞定的别上 SVG,一旦要画圆弧、斜线、非规则分区,image symbol 就是最直接的出路。

相关推荐
hxy06011 小时前
React-Call让你的React组件像异步函数一样调用
前端·react.js
Hilaku1 小时前
为什么 AI 写前端时,总是优先选择 React,而不是 Vue?
前端·javascript·程序员
京东云开发者1 小时前
GPT-5.6、ChatGPT Work 与 Codex 更新食用指南
前端
Cao_Ron1 小时前
用 ECharts graph 画出卡片式矩形节点
前端
skiyee1 小时前
换个底座的 Wot Starter 居然让 AI 更懂项目!
前端·ai编程
anyup1 小时前
这一套绝了!AI 大模型写故事,星云 SDK 驱动 3D 数字人实时演绎
前端·人工智能·aigc
耀耀切克闹灬2 小时前
短链跳转
前端
孟陬2 小时前
高质量全方位的 AI 工具 sse-stuntman — Mock SSE 接口
前端·node.js·openai
Cao_Ron2 小时前
给 ECharts 6 graph 节点叠加角标:从原理到性能优化
前端