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 轴的信息组织价值彻底失效。
二、核心概念与术语
在进入算法之前,先理清几个关键概念:
| 术语 | 含义 |
|---|---|
| Layer | 一条 bar series = 一层 X 轴 |
| BaseCell | 结构合并后的最小单元(相邻同值天然合并) |
| DisplayCell | 最终渲染的格子(可能是多个 BaseCell 的合并) |
| 结构合并 | 相邻数据点共享同一个上层分组值时的天然合并(已有) |
| 自适应合并 | 像素宽度不够时,把多个 BaseCell 合并为一个 DisplayCell(新增) |
两种合并的流水线关系:
三、架构设计:先抽公共包,再加智能
3.1 四包同源却各自漂移
在实施自适应合并之前,仓库中有 4 个图表组件各自维护了独立的 multiXAxis.ts:
| 包 | 差异点 |
|---|---|
| line-chart / bar-chart | 完全相同,逐字节一致 |
| yield-trend-chart | 多一个 yAxisOffset 参数(因为有双 Y 轴) |
| box-chart | 差异最大:函数命名、返回值命名、label 渲染方式、旋转计算逻辑全部不同 |
这就是典型的「复制粘贴 → 各自演化 → 代码分叉」问题。
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):
1function buildBaseCells(layerValues: string[]): BaseCell[] {2 const cells: BaseCell[] = [];3 let i = 0;4 while (i < layerValues.length) {5 let j = i;6 while (j + 1 < layerValues.length && layerValues[j + 1] === layerValues[i]) j++;7 cells.push({8 startIdx: i, endIdx: j,9 rawValue: layerValues[i],10 span: j - i + 1,11 pixelWidth: 0, // 稍后由 pxPerPoint 计算填充12 });13 i = j + 1;14 }15 return cells;16}对每层独立扫描一遍,时间复杂度 O(n)。之后用 pxPerPoint = xAxisWidth / totalPoints 为每个 BaseCell 填充像素宽度。
4.2 自适应合并:像素驱动的均匀分桶
这是整个方案的核心。设计上遵循五个原则:
- 层内独立计算:每一层按自己的 label 宽度独立决策
- 合并不跨父边界:内层合并必须落在外层 DisplayCell 内部
- 像素驱动:触发条件是"像素宽度不足",不是"数据超过 N 条"
- 优雅降级:空间实在不够时降级到紧凑格式,而非强挤
- 视觉均匀:同一父分组内合并粒度一致,不出现"前面都是大桶、最后一个小尾巴"
4.2.1 minLabelPx 的 P90 策略
不同 label 文本长度差异很大("W1" vs "2024-01-01 Lot-ABC-0231"),不能用固定像素值。我们的策略是:
取该层所有 BaseCell label 宽度的 P90 分位数作为代表。
为什么不用 max 或 avg?
| 策略 | 问题 |
|---|---|
| max | 被个别超长 label 拖累,导致所有 bar 都被迫合并 |
| avg | 一部分 bar 的 label 仍然显示不全 |
| P90 | 兼顾多数可读性与极值容忍,超长 label 用 tooltip 补全 |
4.2.2 均匀分桶算法
确定了 minLabelPx 后,核心计算只有两步:
然后将 n 个 BaseCell 均匀分成 k 个 bucket:
1function mergeUniform(baseCells, k, availablePx, config, fontSize) {2 const n = baseCells.length;3 const buckets = [];4
5 for (let i = 0; i < k; i++) {6 const from = Math.floor((i * n) / k);7 const to = Math.floor(((i + 1) * n) / k) - 1;8 // from ~ to 范围内的 BaseCell 合并为一个 DisplayCell9 // ...10 }11 return buckets;12}为什么用 floor(i*n/k) 而不是 ceil(n/k)?
当 n 不能被 k 整除时,floor(i*n/k) 的分桶大小差异 ≤ 1(如 n=10, k=3 → 3 / 3 / 4),视觉最均匀。而 ceil(n/k) 会出现"前面都是 4、最后一个只有 2"的尾巴效应,观感差。
这其实是一个经典的整数均分问题,在操作系统的负载均衡、并行计算的任务分配中也常见。
4.2.3 分层处理:从外层到内层
关键洞察:内层的合并必须限制在外层 DisplayCell 的边界内,否则会出现"D30 合并到 D1"跨越了周/月边界的语义错误。
因此采用从外层到内层的处理顺序:
1function buildAdaptiveDisplayCells(allBaseCells, levelCount, chartPixelWidth, config, fontSize) {2 const outputs = new Array(levelCount);3
4 // 最外层:父 = 整张图5 outputs[0] = adaptiveMergeWithinParent(allBaseCells[0], chartPixelWidth, config, fontSize);6
7 // 内层:受上一层 DisplayCell 约束8 for (let L = 1; L < levelCount; L++) {9 const cells = [];10 for (const parent of outputs[L - 1]) {11 const childBase = allBaseCells[L].filter(12 c => c.startIdx >= parent.startIdx && c.endIdx <= parent.endIdx,13 );14 cells.push(...adaptiveMergeWithinParent(childBase, parent.pixelWidth, config, fontSize));15 }16 outputs[L] = cells;17 }18 return outputs;19}这样每一层内部独立做密度自适应,但绝不跨越父边界——层级语义始终保持正确。
4.3 合并后的 Label 生成:渐进式降级
合并发生后,一个 DisplayCell 代表的是一个区间。label 的生成采用渐进式降级:
1function formatMergedLabel(firstValue, lastValue, availablePx, config, fontSize) {2 if (firstValue === lastValue) return firstValue;3
4 const sep = config.rangeSeparator ?? ' ~ ';5 const rangeText = `${firstValue}${sep}${lastValue}`;6 if (estimateLabelPx(rangeText, fontSize) <= availablePx) return rangeText;7
8 const compactRange = `${firstValue}~${lastValue}`;9 if (estimateLabelPx(compactRange, fontSize) <= availablePx) return compactRange;10
11 return compactRange; // 由 cellSeries 的 truncateMiddle 做最终截断12}五、文本度量:Canvas API + LRU 缓存
5.1 精确测量
在 Web 环境中精确测量文本像素宽度,最靠谱的方式是 CanvasRenderingContext2D.measureText:
注意 Canvas 元素只需创建一次(离屏),不需要插入 DOM。失败时降级到 length * fontSize * 0.6 的等宽估算。
5.2 基于 Map 的 LRU 缓存
同一个图表在 resize / zoom 时会反复测量相同文本,缓存是必须的。我们利用 ES6 Map 的插入序特性实现了一个轻量 LRU:
1class TextMetricsCache {2 private cache = new Map<string, number>();3
4 measure(text, fontSize = 12, fontFamily = 'sans-serif') {5 const key = `${fontSize}|${fontFamily}|${text}`;6 const cached = this.cache.get(key);7 if (cached !== undefined) {8 // 命中:delete + set 将 key 移到末尾(最近使用)9 this.cache.delete(key);10 this.cache.set(key, cached);11 return cached;12 }13 const width = this.rawMeasure(text, fontSize, fontFamily);14 if (this.cache.size >= LRU_CAPACITY) {15 // 淘汰:Map.keys().next() 返回最早插入的 key16 const first = this.cache.keys().next().value;17 if (first !== undefined) this.cache.delete(first);18 }19 this.cache.set(key, width);20 return width;21 }22}核心技巧:Map 保持键的插入顺序。通过 delete + set 实现"提升到末尾"(LRU 的 touch 操作),keys().next().value 获取最旧的键来淘汰。无需额外的链表结构,容量 1000 完全够用。
5.3 中间省略截断(truncateMiddle)
这是一个在 v1.2 迭代中引入的巧妙优化。对于合并格的范围标签(如 "LotABC~LotXYZ"),传统的尾部截断 "LotABC~Lot…" 会丢失范围终点信息。中间省略截断保留了首尾:
算法同样使用二分搜索,在保留总字符数 mid 中将大约一半分给左侧、一半分给右侧:
1function truncateMiddle(text, maxWidth, fontSize = 12) {2 if (getTextWidth(text, fontSize) <= maxWidth) return text;3 if (text.length <= 2) return truncateText(text, maxWidth, fontSize);4
5 let lo = 2, hi = text.length - 1, bestKeep = 0;6 while (lo <= hi) {7 const mid = Math.floor((lo + hi) / 2);8 const leftKeep = Math.ceil(mid / 2);9 const rightKeep = mid - leftKeep;10 const candidate = text.slice(0, leftKeep) + '…' + text.slice(-rightKeep);11 if (getTextWidth(candidate, fontSize) <= maxWidth) {12 bestKeep = mid;13 lo = mid + 1;14 } else {15 hi = mid - 1;16 }17 }18 // ... 边界处理19}二分搜索将 O(n) 的逐字裁剪降到 O(log n),配合 LRU 缓存,截断计算对性能几乎无感。
六、渲染层:ECharts Bar Series 的统一抽象
每个 DisplayCell 最终被渲染为一个 ECharts bar series。cellSeries.ts 统一了四个图表包的差异:
6.1 旋转 Label 的宽度计算
当 label 需要旋转时(如 45°、90°),可用于显示文字的有效宽度会变化。我们采用了 box-chart 中的精确三角函数计算:
1function computeValidLabelWidth(realLabelWidth, realLabelHeight, rotate, fontSize, autoHeight) {2 const absRotate = Math.abs(rotate);3
4 if (absRotate === 90) {5 return autoHeight === 'auto'6 ? Math.min(realLabelWidth + fontSize, MAX_ROTA_90_HEIGHT)7 : realLabelHeight;8 }9 if (absRotate === 0) return realLabelWidth;10
11 // 一般角度:取宽度/cos 和高度/sin 的较小值12 const rad = (rotate / 180) * Math.PI;13 return Math.min(14 realLabelWidth / Math.abs(Math.cos(rad)),15 realLabelHeight / Math.abs(Math.sin(rad)),16 ) - fontSize;17}这背后的几何原理是:一个旋转了 θ 角的矩形文本,要完整放入一个 w × h 的容器中,文本长度不能超过 min(w/|cosθ|, h/|sinθ|)。
6.2 双重截断保险
文本截断采用"先算后渲染"的双保险策略:
- Canvas 预截断:用
truncateMiddle在 JavaScript 层面根据精确测量结果截断 - ECharts 兜底:
overflow: 'truncate'+ellipsis: '…'作为第二道防线
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 进化路径
| 版本 | 关键改进 |
|---|---|
| v1.0 | 初版:四包各自的 multiXAxis.ts,仅有结构合并 |
| v1.1 | 抽取公共包 @guwave/multi-x-axis,统一四包差异 |
| v1.2 | 加入自适应合并算法 + P90 策略 + LRU 缓存 + truncateMiddle |
| v1.3 | 完善 label 降级策略、优化均匀分桶、zoom/resize 联动、性能标记统一 |
9.5 数据结构选型
- LRU 用
Map而非手写双向链表:ES6 Map 保持插入序,delete+set= touch,keys().next()= evict,代码极简且性能足够 - 均匀分桶用整数数学而非浮点:
floor(i*n/k)保证桶大小差异 ≤ 1,无浮点精度问题 - BaseCell / DisplayCell 分离:BaseCell 是不可变的缓存,resize 时只需重算 pixelWidth 和重新分桶,BaseCell 结构不动
十、算法复杂度分析
| 步骤 | 时间复杂度 | 说明 |
|---|---|---|
| 结构合并 | O(n × L) | n = 数据点数,L = 层数 |
| P90 计算 | O(m × log m) | m = BaseCell 数,排序 |
| 自适应合并 | O(m × L) | 每层遍历 BaseCells |
| Label 截断 | O(m × log t) | 二分搜索,t = 文本长度 |
| LRU 查找 | O(1) 均摊 | Map 的 get/set/delete |
| 总计 | O(n × L) | n 占主导 |
性能基线(实测):
- 1000 数据点 × 4 层:
buildAdaptiveDisplayCells< 8ms - zoom 重建:< 16ms(一帧内完成)
- TextMetrics 缓存命中率 > 90%(稳态后)
十一、完整数据流
附录 A:为什么选择"物理合并" 而非"跳过 Label"
当 cell 过窄时有两种选择:
| 方案 | 效果 |
|---|---|
| 保留 bar,跳过部分 label | 留下一堆"没 label 的小格子",视觉噪声大 |
| 物理合并 bar + label | 合并格本身就是清晰的语义单元 ✅ |
物理合并还可以通过 mergeLabelStrategy 配置退化为跳过策略,保留灵活性。
附录 B:配置项速查
使用方式:
默认开启自适应合并,无需任何额外配置即可在密集数据下获得清晰可读的多层 X 轴。
写在最后
回顾整个方案的演进路径,有三个关键节点:
- 从 hack 到工程:bar series 模拟多层 X 轴的思路本身是个巧妙的 hack,但要让它在真实业务中可靠运行,需要用工程化手段(公共模块、类型体系、分层测试)去驯化它。
- 从数量到像素:抛弃"数据超过 N 条就合并"的固定阈值思路,转而以像素宽度作为决策依据,让同一套算法自然适配不同分辨率和容器尺寸。
- 从单层到分层:父边界隔离保证了内层合并不会破坏外层的语义结构,这是多层 X 轴正确性的基石。
如果你正在处理类似的"密集分类轴"可视化需求,希望这篇文章中的思路——P90 策略、均匀分桶、渐进式降级、双重截断保险——能给你一些启发。
关于模块的完整架构设计、类型定义、测试策略与各图表包的集成模式,请参阅 《@guwave/multi-x-axis:ECharts 多层 X 轴公共模块的设计与实现》。