用 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 节点叠加角标》。

相关推荐
Emily1568535987020 小时前
Emerson 1C31129G03 Analog Input Module
java·开发语言·前端·plc·emerson·1c31129g03·input module
To_OC1 天前
面试被问了三回三栏布局,这次我终于把 BFC 那层窗户纸捅破了
前端·css·面试
To_OC1 天前
啃完 TS 工具类型我发现:Pick 和 Omit 原来就是一层窗户纸
前端·面试·typescript
风月说与山鬼1 天前
三、大括号语法
前端·react.js
kyriewen1 天前
我把最常踩的8个CORS跨域报错整理了一遍——第8个去年还不存在
前端·javascript
Csvn1 天前
🕰️ 闭包 + setTimeout 的 5 个经典陷阱:为什么定时器看到的永远不是最新的值?
前端
lilian2331 天前
Harmony os 技术实战|拼豆制图27:用单字符编码承载 50 张 70×70 图纸
前端·数据库·华为·harmonyos
CarIise1 天前
CSS选择器与样式关联
前端·css·tensorflow
里欧跑得慢1 天前
CSS 模块化架构的演进:BEM、CSS Modules 到 CSS-in-JS 的反思
前端·css·flutter·web·css-in-js