用 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 就是最直接的出路。

相关推荐
卷福同学6 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
赵庆明老师6 小时前
Vben精讲:21-详解web-antd:tsconfig.json
前端·json·vim
wc887 小时前
微软EDGE浏览器功能学习
前端·学习·edge
Csvn7 小时前
🎯 原生 `<dialog>` 元素:终于可以扔掉一半的自定义弹窗组件了?
前端
Csvn7 小时前
🎨 CSS @layer:用「层叠优先级」终结样式打架的世纪难题
前端
濮水大叔8 小时前
不必把 Vue3 写成“麻花”:从状态碎片到对象协作,重新理解 Zova 的前端心智模型
前端·typescript·vue3·ioc·tsx·zova
Vuji9 小时前
MCP: 一条 tool 调用链路的旅程
前端·人工智能
万少9 小时前
DeepSeek-V4-Flash 正式版上线了,但这 3 个坑我帮你提前踩了
前端·javascript·后端
明月_清风9 小时前
🚀 Palantir Foundry 本体论实战:当 Ontology 从"知识图谱"进化为"企业操作系统"
前端·后端
驳是9 小时前
入坑 Nginx,看这一篇就够了
前端