用 ECharts graph 画出卡片式矩形节点
关系图谱里把节点画成一张带标题、分隔线、两栏指标的小卡片,是后台可视化里很常见的需求。ECharts 的 graph 系列默认节点是圆形 symbol,要变成"卡片"并没有现成组件,但靠 symbol: 'rect' + 富文本 label 可以拼出来。本文讲 graph.vue 里这套卡片节点的实现,以及它怎么经由 option 注入到 BadgeGraph.vue 里渲染。
先厘清一个边界:矩形不是 BadgeGraph 画的
BadgeGraph.vue 本身对节点形状一无所知--它只负责把角标画到节点上。矩形卡片节点的配置全部写在 option 里,option 在 graph.vue 的 buildNode 中构建,再通过 <BadgeGraph :option="option" /> 传进去。ECharts 拿到这个 option 后自己把矩形画出来,BadgeGraph 只是在画完的节点上叠角标。
所以"矩形怎么实现"的答案在 option 的 series.data 里,不在 BadgeGraph 的代码里。理解了这条数据流,后面的实现就清楚了:graph.vue 负责"画什么",BadgeGraph.vue 负责"在画好的东西上贴角标"。
symbol: 'rect' + symbolSize 定矩形
把节点从圆形变成矩形,靠的是 symbol 和 symbolSize:
js
function buildNode(node) {
const cardSize = CARD_SIZE_MAP[node.level] ?? CARD_SIZE_MAP[5]
// ...
return {
...node,
symbol: 'rect',
symbolSize: cardSize, // [width, height]
itemStyle: {
color: cardTheme.backgroundColor,
borderColor: cardTheme.borderColor,
borderWidth,
shadowBlur: isSelected ? 16 : 8,
shadowColor: 'rgba(0, 0, 0, 0.18)',
shadowOffsetY: isSelected ? 6 : 4,
borderRadius: 0,
},
// ...
}
}
symbol: 'rect' 让 ECharts 用矩形路径绘制节点,symbolSize 接受 [w, h] 二元数组。不同层级的节点尺寸不同,用一张映射表控制:
js
const CARD_SIZE_MAP = {
1: [240, 54],
2: [180, 48],
3: [150, 44],
4: [150, 40],
5: [150, 40],
}
itemStyle 里 borderRadius: 0 保持直角卡片观感,shadowBlur + shadowColor + shadowOffsetY 给卡片加投影,让它从背景里"浮"出来。选中态用更大的 shadowBlur(16 vs 8)和更深的 shadowOffsetY(6 vs 4)拉开视觉层级。
卡片内容:富文本 label 当布局引擎
矩形只是个外壳,真正让节点像"卡片"的是 label。ECharts 的富文本(rich text)允许在一段 label 里定义多个样式块,用 {块名|内容} 引用。这相当于把一个微型布局引擎塞进了 label。
js
label: {
show: true,
position: 'inside',
formatter: createSelectedFormatter(node),
rich: createCardLabelRich(cardSize[0], textColor, showDividerBorder),
}
position: 'inside' 让 label 居中画在矩形内部。formatter 决定"画什么文字、按什么结构排",rich 决定"每个块长什么样"。
formatter 拼结构
卡片分三行:标题、分隔线、两栏指标。formatter 把它们用换行拼起来:
js
function createSelectedFormatter(node) {
const content = parseNodeDisplayContent(node.name)
const title = escapeRichText(content.title)
if (!content.hasStats) {
return `{title|${title}}`
}
return [
`{title|${title}}`,
'{divider| }',
`{enterprise|${escapeRichText(getMetricText(content.enterprise))}}{tech|${escapeRichText(getMetricText(content.tech))}}`,
].join('\n')
}
注意第三行是 {enterprise|...}{tech|...} 紧挨着,中间没有换行--两个块在同一行左右排列,靠各自的 width 各占一半,拼成两栏。{divider| } 单独一行,它本身是个高度为 0 的块,靠 borderWidth 画出顶部分隔线。
rich 定义每块的样式
rich 是一个对象,key 对应 formatter 里的块名:
js
function createCardLabelRich(cardWidth, textColor, showDividerBorder) {
const contentWidth = Math.max(cardWidth - 18, 80)
const halfWidth = Math.floor(contentWidth / 2)
const dividerColor = showDividerBorder ? 'rgba(0, 0, 0, 0.35)' : 'transparent'
return {
title: {
width: contentWidth, align: 'center', color: textColor,
fontSize: 12, fontWeight: 700, lineHeight: 16,
padding: [6, 0, 4, 0], overflow: 'truncate',
},
divider: {
width: contentWidth, height: 0,
borderColor: dividerColor, borderWidth: [1, 0, 0, 0],
padding: [0, 0, 4, 0],
},
enterprise: {
width: halfWidth, align: 'center', color: textColor,
fontSize: 10, lineHeight: 14,
borderColor: dividerColor, borderWidth: [0, 1, 0, 0],
padding: [0, 2, 4, 0], overflow: 'truncate',
},
tech: {
width: contentWidth - halfWidth, align: 'center', color: textColor,
fontSize: 10, lineHeight: 14, padding: [0, 0, 4, 2],
},
}
}
这里有几个关键技巧:
用 borderWidth 画线。 ECharts 富文本块支持四边边框,borderWidth 接 [上, 右, 下, 左] 数组。divider 块 height: 0 + borderWidth: [1, 0, 0, 0] 就是一条只有上边框的线,正好当标题和指标之间的分隔线。enterprise 块 borderWidth: [0, 1, 0, 0] 只有右边框,自然成了两栏之间的竖向分隔线。不需要额外画 SVG,纯靠边框拼出来。
用 width 控制两栏宽度。 enterprise 宽 halfWidth,tech 宽 contentWidth - halfWidth,两者加起来正好等于 contentWidth,填满一行。contentWidth = cardWidth - 18 给左右留了点内边距。
overflow: 'truncate' 防溢出。 标题和指标栏文字过长时截断,不会撑破卡片。
dividerColor 可控。 showDividerBorder 为 false 时设成 transparent,分隔线消失但布局不变--这样选中态和某些层级才显示分隔线,其余隐去,视觉更干净。
名称解析:把多行字符串拆成结构化内容
节点的 name 实际是一段多行文本,第一行是标题,第二、三行是指标。parseNodeDisplayContent 把它拆开:
js
function parseNodeDisplayContent(rawName) {
const lines = String(rawName).split('\n').map(item => item.trim()).filter(Boolean)
const enterprise = parseMetric(lines[1], '企业')
const tech = parseMetric(lines[2], '技术')
return {
title: lines[0] ?? rawName,
enterprise, tech,
hasStats: Boolean(enterprise.value || tech.value),
}
}
指标行可能是"企业 120"这种"标签 值"格式,parseMetric 用正则拆成 { label, value }:
js
const matched = trimmed.match(/^(.+?)\s+(.+)$/)
if (matched) {
return { label: matched[1].trim(), value: matched[2].trim() }
}
hasStats 决定 formatter 走单行(只有标题)还是三行(标题 + 分隔 + 两栏)。这样没有指标的叶子节点就只显示一行标题,不会留出空的两栏。
富文本里 {、}、| 是语法字符,节点名里如果出现这几个字符会破坏解析,所以输出前要转义:
js
function escapeRichText(value) {
return String(value).replace(/[{}|]/g, '\\$&')
}
主题与选中态
卡片配色按层级走映射表,根节点深、叶子节点浅:
js
const CARD_THEME_MAP = {
1: { backgroundColor: '#070904', borderColor: '#070904', textColor: '#ffffff' },
2: { backgroundColor: '#433B30', borderColor: '#433B30', textColor: '#ffffff' },
3: { backgroundColor: '#A38972', borderColor: '#A38972', textColor: '#ffffff' },
4: { backgroundColor: '#D2C2B5', borderColor: '#D2C2B5', textColor: '#1f2937' },
5: { backgroundColor: '#C6BDB8', borderColor: '#C6BDB8', textColor: '#1f2937' },
}
选中态单独一套高对比配色(黄色底 + 黑边),并且把 z 提到 20、borderWidth 加粗到 2:
js
const isSelected = selectedNodeId.value === node.id
const cardTheme = isSelected ? SELECTED_THEME : (CARD_THEME_MAP[node.level] ?? CARD_THEME_MAP[5])
const borderWidth = isSelected ? 2 : 1
// ...
z: isSelected ? 20 : 10,
z 控制绘制层级,选中的节点浮在所有未选中节点之上,不会被遮挡。showDividerBorder 也跟选中态联动--选中或层级较深(level >= 4)时才显示分隔线:
js
const showDividerBorder = isSelected || node.level >= 4
点击切换选中态由 handleNodeClick 处理,再点一次取消:
js
function handleNodeClick(node) {
selectedNodeId.value = selectedNodeId.value === node.id ? undefined : node.id
}
因为 graphData 是 computed,selectedNodeId 一变,依赖它的 buildNode 重新求值,整批节点配置重新生成,option 也跟着更新--选中态的视觉切换是响应式驱动的,不需要手动调 setOption。
数据流:从树到图
原始数据是一棵树(产业链层级),ECharts graph 要的是扁平的节点 + 边。treeToGraph 做这步扁平化:
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: SIZE_MAP[level] || 20,
itemStyle: { color: COLOR_MAP[level] || '#999' },
draggable: true,
// ...
})
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 }
}
注意这里 push 进 data 的还是"裸节点"(只有 id、name、level 等),还没有矩形和卡片样式。矩形化发生在 buildNode 里,由 graphData 这个 computed 串联:
js
const graphData = computed(() => graphStructure.value.data.map(node => buildNode(node)))
最终 option 把 graphData 和 links 组装进 series:
js
series: [{
type: 'graph', roam: true, layout: 'force',
emphasis: { focus: 'adjacency' },
force: { repulsion: 500, edgeLength: [100, 300], gravity: 0.1, friction: 1 },
symbol: 'circle',
data: graphData.value,
links: graphStructure.value.links,
}]
这里有个细节:series 层级写的是 symbol: 'circle',但每个节点 data 里又写了 symbol: 'rect'。ECharts 允许节点级配置覆盖 series 级配置,所以最终画出来是矩形。series 层的 symbol 只是兜底。
force 配置里 friction: 1 是个有意的取舍--默认 0.6 收敛偏慢,提到 1 能加快收敛,从源头减少 force 总帧数(这和 BadgeGraph 里跳帧节流是同一目标的两面:一边少跑帧,一边让每帧更便宜)。emphasis: { focus: 'adjacency' } 是 v6 的写法,旧版 focusNodeAdjacency: true 已废弃。
小结
卡片式矩形节点的核心是把 ECharts 的富文本 label 当布局引擎用 。symbol: 'rect' + symbolSize: [w, h] 画出矩形外壳,itemStyle 控制配色和投影;label 里用 {title|}、{divider|}、{enterprise|}{tech|} 的结构拼出三行卡片内容,靠 borderWidth 的四边数组画分隔线,靠 width 控制两栏宽度。主题和选中态用映射表 + computed 响应式驱动,点击切换即重新求值整批节点配置。
这套方案的好处是不需要自定义 series 或改 ECharts 源码,纯靠配置项拼出来,getDataURL 导出也完整保留。代价是富文本布局的表达力有限--更复杂的卡片(比如带图标、多段指标、进度条)就只能用 graphic 或自定义渲染了。但对于"标题 + 分隔 + 两栏指标"这种结构,富文本 label 是性价比最高的选择。
至于角标(节点左上/右上的小图标),那是 BadgeGraph.vue 在这套矩形节点画完之后,再用 zrender Image 叠上去的,属于另一套机制,见姊妹篇《给 ECharts 6 graph 节点叠加角标》。