ECharts 多层 X 轴智能自适应合并:从 Hack 到优雅的工程实践
在半导体数据可视化场景中,一张图表往往需要同时承载"日期 / 批次 / 工序 / 工厂"等多维层级信息。ECharts 原生不支持多层 X 轴,而我们用 bar series 模拟出的多层表头一旦遇到数据量暴增,label 就会严重重叠。本文完整记录了从发现问题、设计算法到工程落地的过程——如何让一个"够用的 hack"演进为一套优雅可靠的自适应合并方案。
本文侧重算法思路与工程决策。如果你对底层模块的完整架构、类型体系和集成模式感兴趣,可以阅读姊妹篇 《@guwave/multi-x-axis:ECharts 多层 X 轴公共模块的设计与实现》。
一、问题起源:ECharts 原生不支持多层 X 轴
ECharts 的 category axis 只支持单层类目轴。虽然可以通过 axisLabel.formatter 做折行来模拟,但它有本质缺陷:
- 每一层不是独立的可点击、可样式化实体
- 无法做"合并边框"的表头视觉
- 无法做分层独立的自适应合并
因此我们选择了一个 hack 路线:用若干条"描边空心 bar 系列"堆叠在图表下方,人工模拟出多层表头。
每一层就是一条 bar series,每个 bar 是该层级下的一个「格子」(Cell),白色填充 + 灰色描边 + 居中 label。
这个方案在数据量适中时运作良好,但一旦 X 轴数据点较多(半年 180 天、一批上千 wafer),每个 bar 的像素宽度被摊薄到小于 label 最小可读宽度时,label 就会重叠、截断甚至完全不可读,多层 X 轴的信息组织价值彻底失效。
二、核心概念与术语
在进入算法之前,先理清几个关键概念:
两种合并的流水线关系:
三、架构设计:先抽公共包,再加智能
3.1 四包同源却各自漂移
在实施自适应合并之前,仓库中有 4 个图表组件各自维护了独立的 multiXAxis.ts:
这就是典型的「复制粘贴 → 各自演化 → 代码分叉」问题。
3.2 模块化拆分
我们决定先收敛再增强,抽取公共包 @guwave/multi-x-axis:
关键的设计约束:
peerDependencies: { "echarts": "^5.0.0" },不捆绑 EChartssideEffects: false,支持 tree-shaking- 四个图表组件只需
import { buildMultiXAxisConfig } from '@guwave/multi-x-axis'
四、核心算法详解
4.1 结构合并:O(n) 行程编码
结构合并是最基础的一步——将连续相同值压缩为一个 BaseCell。这本质上就是行程编码 (Run-Length Encoding):
对每层独立扫描一遍,时间复杂度 O(n)。之后用 pxPerPoint = xAxisWidth / totalPoints 为每个 BaseCell 填充像素宽度。
4.1.1 空白值归一化
在真实业务数据中,视觉上等价的"空白"可能有多种字符串表达:''、' '、' ' 甚至 null。如果直接做字符串比较,这些值会被视为不同类别,导致连续的空白区域无法稳定合并——用户看到的是一片空白,算法看到的却是一串"不同的值",在轴上留下许多碎片化的窄格子。
为此,buildBaseCells 在比较前先通过 normalizeLayerValue() 做归一化:
null、undefined、纯空格字符串统一映射为 '',非空值保持原样。这样视觉等价的空白值在结构合并阶段就能被稳定收敛为同一个 BaseCell,避免后续自适应合并时还需要处理大量本应合并的空白碎片。
4.2 自适应合并:像素驱动的密度决策
这是整个方案的核心。设计上遵循五个原则:
- 层内独立计算:每一层按自己的 label 宽度独立决策
- 层间无依赖:各层独立计算自适应合并,层顺序仅表示渲染顺序,不暗示粗细粒度的父子关系
- 像素驱动:触发条件是"像素宽度不足",不是"数据超过 N 条"
- 优雅降级:空间实在不够时降级到紧凑格式,而非强挤
- 消除碎片:优先吞并过窄格子(尤其是空白格),避免留下视觉上的碎片窄条
4.2.1 minLabelPx 的 P90 策略
不同 label 文本长度差异很大("W1" vs "2024-01-01 Lot-ABC-0231"),不能用固定像素值。我们的策略是:
取该层所有 BaseCell label 宽度的 P90 分位数作为代表。
相比初版实现,这里有两个关键改进:
-
优先基于非空样本估算:空白格的 rawValue 宽度接近零,会拉低整体估算,导致
minLabelPx过小。过滤掉空白样本后,估算更贴近实际需要显示 label 的格子。 -
预留
(+n)后缀宽度:合并后的标签可能带有(+n)后缀(如Lot-A(+2)),比原始单值更长。如果minLabelPx只基于单个 rawValue 估算,就会低估实际需要的宽度——本地逻辑以为"放得下",ECharts 却仍认为"放不下",触发自己的overflow: 'truncate'把文本压成...。对rawValue和rawValue + countSuffix取 max,让阈值更接近真实需要。
为什么不用 max 或 avg?
4.2.2 从均匀分桶到贪心吞并
确定了 minLabelPx 后,初版算法用一个经典的整数均分公式将 n 个 BaseCell 均匀分成 k 个 bucket:
floor(i*n/k) 保证桶大小差异 ≤ 1(如 n=10, k=3 → 3 / 3 / 4),视觉上均匀。但这个方案有一个隐含假设:每个 BaseCell 的像素宽度大致相同。
真实数据中并非如此:
按个数均分(2 + 2),前两个空白格构成的 bucket 只有 16px,仍然什么也显示不了;后两个正常格有 240px,宽得浪费。用户看到的效果就是"轴上还有很多碎小空格没合并干净"。
当前版本改为像素宽度驱动的贪心吞并,分两条路径:
贪心合并的核心逻辑:从左到右逐个累积像素宽度,达到 minLabelPx 时切出一个 bucket。如果尾部 bucket 累积宽度仍不足,则并入前一个 bucket,避免在轴末端留下新的碎片。
这个策略的好处是让过窄的空白格被快速吸收,宽度充裕的格子尽量独立保留,视觉上不再有"合并不干净"的碎片感。
4.2.3 分层处理:逐层独立计算
一个自然的直觉可能是"内层合并必须受外层 DisplayCell 边界约束"——在"月→周→日"这种严格粗细粒度层级中,确实需要防止日期跨月合并。但在实际业务数据中,多层 X 轴的层顺序往往只是并列维度的展示顺序(如 WAFER_ID / LOT_ID),不一定存在天然的粗细粒度父子关系:
- 可能是粗 → 细(月 → 日)
- 也可能是细 → 粗(Wafer → Lot)
- 甚至只是多个并列维度,本身没有层级关系
如果错误地假设层间存在父子约束,当上一层全是单点、而当前层存在 span > 1 的连续块时,下层的合法 cell 会因为无法完整落入任何上层单点格而被过滤掉,导致大量 bar 莫名消失。
因此最终采用逐层独立的计算方式:
每一层都以完整的图表宽度作为可用空间,基于本层 BaseCell[] 独立做密度自适应合并,互不干扰。
4.3 合并后的 Label 生成:基于语义值序列的渐进式降级
合并发生后,一个 DisplayCell 代表多个 BaseCell 的区间。label 的生成必须基于整个 bucket 内的值序列,而不是只看首尾值——否则 A, B, A 会被误导性地显示为 A。
核心规则:
- 先提取语义值:从 bucket 中过滤出非空值序列
visibleValues,display label 只基于有效值生成 - 仅当所有语义值相同时,才返回单个值
- 当包含不同值时,生成多个候选标签,统一走
pickBestLabelCandidate()选最合适的 tooltipLabel保留完整语义,包括空白 segment 的数量摘要
为什么要先提取语义值?当空白细格被并入有值格时,如果直接拿完整值序列拼接 label,会出现 ~ Lot-A 或 ~Lot-A 这类语义不友好的文案。过滤空白值后,display label 更干净稳定,tooltip 再补充空白 segment 信息。
pickBestLabelCandidate 从候选列表中按顺序尝试,返回第一个像素宽度能装进 bucket 的候选。如果全部装不下,则返回最后一个候选((+n) 最小摘要),交给后续 truncateMiddle 做最终截断。
这个设计有两个好处:
- display label 与 tooltip 职责分离:label 优先保证短、稳、可读;tooltip 负责补充完整语义(包括空白 segment 数量),两者互补。
(+n)作为最小语义摘要:即使空间极其紧张,至少保留"该 bucket 内存在额外变化"的信息,不会完全退化为纯省略号...。
range 和 auto 模式现在都走相同的 pickBestLabelCandidate() 流程,不再有 range 固定取第一候选的问题。这让紧凑宽度下的标签选择更稳定,更倾向保留有效信息。
五、文本度量:Canvas API + LRU 缓存
5.1 精确测量
在 Web 环境中精确测量文本像素宽度,最靠谱的方式是 CanvasRenderingContext2D.measureText:
注意 Canvas 元素只需创建一次(离屏),不需要插入 DOM。失败时降级到 length * fontSize * 0.6 的等宽估算。
5.2 基于 Map 的 LRU 缓存
同一个图表在 resize / zoom 时会反复测量相同文本,缓存是必须的。我们利用 ES6 Map 的插入序特性实现了一个轻量 LRU:
核心技巧:Map 保持键的插入顺序。通过 delete + set 实现"提升到末尾"(LRU 的 touch 操作),keys().next().value 获取最旧的键来淘汰。无需额外的链表结构,容量 1000 完全够用。
5.3 中间省略截断(truncateMiddle)
这是一个在 v1.2 迭代中引入的巧妙优化。对于合并格的范围标签(如 "LotABC~LotXYZ"),传统的尾部截断 "LotABC~Lot…" 会丢失范围终点信息。中间省略截断保留了首尾:
算法同样使用二分搜索,在保留总字符数 mid 中将大约一半分给左侧、一半分给右侧:
二分搜索将 O(n) 的逐字裁剪降到 O(log n),配合 LRU 缓存,截断计算对性能几乎无感。
六、渲染层:ECharts Bar Series 的统一抽象
每个 DisplayCell 最终被渲染为一个 ECharts bar series。cellSeries.ts 统一了四个图表包的差异:
6.1 旋转 Label 的宽度计算
当 label 需要旋转时(如 45°、90°),可用于显示文字的有效宽度会变化。我们采用了 box-chart 中的精确三角函数计算:
这背后的几何原理是:一个旋转了 θ 角的矩形文本,要完整放入一个 w × h 的容器中,文本长度不能超过 min(w/|cosθ|, h/|sinθ|)。
6.2 双重截断保险
文本截断采用"先算后渲染"的双保险策略:
- Canvas 预截断:用
truncateMiddle在 JavaScript 层面根据精确测量结果截断 - ECharts 兜底:
overflow: 'truncate'+ellipsis: '…'作为第二道防线
这里有一个容易被忽略的细节:rawText 的取值使用 ?? 而非 ||。区别在于,|| 会把空字符串视为 falsy 并回退到 dataName,而 ?? 仅在 null/undefined 时才回退。当上游的 formatMergedLabel 显式返回空字符串作为 display label(如纯空白 bucket),这个语义应被保留,而不是被意外替换成 dataName。
LABEL_WIDTH_BUFFER(4px)是为了弥补 Canvas measureText 与 ECharts 内部渲染引擎之间的微小测量偏差。
6.3 性能标记
所有多层 X 轴的 bar series 统一启用了性能优化标记:
这些装饰性 bar 不需要交互反馈,关闭动画和高亮可以显著减少 ECharts 的渲染负担。
七、交互联动
7.1 DataZoom / Resize 响应
当用户拖动缩放滑块或调整窗口大小时,需要重新计算自适应合并:
rebuildMultiXSeriesOnZoom 只重建 series 数组(grid / axis 结构不变),并走相同的自适应合并路径。
7.2 Yield Trend Chart 的特殊处理
良率趋势图是唯一有双 Y 轴的图表(左轴折线 + 右轴柱状),因此多层 X 轴的 yAxisIndex 需要偏移。通过 yAxisOffset: 1 参数透传,在 cellSeries 中统一处理:
同时,它在 zoom 时读取 ECharts 的实时 grid 宽度(而非 props 中的初始值),因为 Y 轴标签的自适应宽度可能改变了 grid 边距:
八、效果对比
合并前(180 天数据,日期层完全不可读)
合并后(各层独立自适应,label 清晰可读)
效果图示意
九、工程经验总结
9.1 分离关注点(Separation of Concerns)
整个系统清晰地分为三层:
这种分层让算法逻辑可以独立单测,不依赖 ECharts。
9.2 像素驱动 > 数量驱动
合并触发条件是"像素宽度不足"而非"数据超过 N 条"。同样 1000 个数据点,在 1600px 宽的屏幕上可能不需要合并,在 400px 的移动端则需要大量合并。像素驱动天然适配不同分辨率。
9.3 防御性设计
- P90 策略避免被极端值支配
- 双重截断保险防止 Canvas 与 ECharts 测量偏差
LABEL_WIDTH_BUFFER预留微小余量MAX_MULTI_X_CELLS安全阀:非自适应模式下超过 1000 个 cell 直接显示占位提示- 降级到空 label:当 Parent Cell 本身都容不下一个 label 时,不强挤,靠 tooltip 补全
9.4 进化路径
9.5 数据结构选型
- LRU 用
Map而非手写双向链表:ES6 Map 保持插入序,delete+set= touch,keys().next()= evict,代码极简且性能足够 - 贪心吞并按像素宽度驱动:从均匀分桶(
floor(i*n/k)整数数学)演进到按像素宽度贪心累积,更好地处理宽度不均匀的场景 - BaseCell / DisplayCell 分离:BaseCell 是不可变的缓存,resize 时只需重算 pixelWidth 和重新分桶,BaseCell 结构不动
十、算法复杂度分析
性能基线(实测):
- 1000 数据点 × 4 层:
buildAdaptiveDisplayCells< 8ms - zoom 重建:< 16ms(一帧内完成)
- TextMetrics 缓存命中率 > 90%(稳态后)
十一、完整数据流
附录 A:为什么选择"物理合并" 而非"跳过 Label"
当 cell 过窄时有两种选择:
物理合并还可以通过 mergeLabelStrategy 配置退化为跳过策略,保留灵活性。
附录 B:配置项速查
使用方式:
默认开启自适应合并,无需任何额外配置即可在密集数据下获得清晰可读的多层 X 轴。
写在最后
回顾整个方案的演进路径,有三个关键节点:
- 从 hack 到工程:bar series 模拟多层 X 轴的思路本身是个巧妙的 hack,但要让它在真实业务中可靠运行,需要用工程化手段(公共模块、类型体系、分层测试)去驯化它。
- 从数量到像素:抛弃"数据超过 N 条就合并"的固定阈值思路,转而以像素宽度作为决策依据,让同一套算法自然适配不同分辨率和容器尺寸。
- 从假设到验证:最初设计中"层间存在父子关系"的假设在真实业务数据中被证伪——层顺序只是渲染顺序,不一定是粗细粒度。逐层独立计算才是对任意业务维度组合都正确的策略。
如果你正在处理类似的"密集分类轴"可视化需求,希望这篇文章中的思路——P90 策略、贪心宽度吞并、语义值序列降级、双重截断保险——能给你一些启发。
关于模块的完整架构设计、类型定义、测试策略与各图表包的集成模式,请参阅 《@guwave/multi-x-axis:ECharts 多层 X 轴公共模块的设计与实现》。