用 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
注意替换顺序--& 必须先替换,否则后面替换产生的 < 里的 & 会被再次转义成 &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 就是最直接的出路。