用 ECharts graph 画出卡片式矩形节点

用 ECharts graph 画出卡片式矩形节点

关系图谱里把节点画成一张带标题、分隔线、两栏指标的小卡片,是后台可视化里很常见的需求。ECharts 的 graph 系列默认节点是圆形 symbol,要变成"卡片"并没有现成组件,但靠 symbol: 'rect' + 富文本 label 可以拼出来。本文讲 graph.vue 里这套卡片节点的实现,以及它怎么经由 option 注入到 BadgeGraph.vue 里渲染。

先厘清一个边界:矩形不是 BadgeGraph 画的

BadgeGraph.vue 本身对节点形状一无所知--它只负责把角标画到节点上。矩形卡片节点的配置全部写在 option 里,optiongraph.vuebuildNode 中构建,再通过 <BadgeGraph :option="option" /> 传进去。ECharts 拿到这个 option 后自己把矩形画出来,BadgeGraph 只是在画完的节点上叠角标。

所以"矩形怎么实现"的答案在 optionseries.data 里,不在 BadgeGraph 的代码里。理解了这条数据流,后面的实现就清楚了:graph.vue 负责"画什么",BadgeGraph.vue 负责"在画好的东西上贴角标"。

symbol: 'rect' + symbolSize 定矩形

把节点从圆形变成矩形,靠的是 symbolsymbolSize

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],
}

itemStyleborderRadius: 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[上, 右, 下, 左] 数组。dividerheight: 0 + borderWidth: [1, 0, 0, 0] 就是一条只有上边框的线,正好当标题和指标之间的分隔线。enterpriseborderWidth: [0, 1, 0, 0] 只有右边框,自然成了两栏之间的竖向分隔线。不需要额外画 SVG,纯靠边框拼出来。

width 控制两栏宽度。 enterprisehalfWidthtechcontentWidth - 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
}

因为 graphDatacomputedselectedNodeId 一变,依赖它的 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)))

最终 optiongraphDatalinks 组装进 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 节点叠加角标》。

相关推荐
skiyee1 小时前
换个底座的 Wot Starter 居然让 AI 更懂项目!
前端·ai编程
anyup1 小时前
这一套绝了!AI 大模型写故事,星云 SDK 驱动 3D 数字人实时演绎
前端·人工智能·aigc
耀耀切克闹灬1 小时前
短链跳转
前端
孟陬2 小时前
高质量全方位的 AI 工具 sse-stuntman — Mock SSE 接口
前端·node.js·openai
Cao_Ron2 小时前
给 ECharts 6 graph 节点叠加角标:从原理到性能优化
前端
WebGirl2 小时前
内网穿透方式
前端
hoLzwEge2 小时前
前端项目的基石:深入解读 package.json 的作用与核心配置
前端·前端框架
奇奇怪怪的3 小时前
Agentic RAG:Agent 驱动的自主检索
前端
陆枫Larry3 小时前
页面接口换图后不刷新,刷新页面后才看得到新图
前端