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.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 分位数作为代表。
1function resolveMinLabelPx(baseCells, config, fontSize) {2 const paddingX = config.labelPaddingX ?? 8;3 const nonBlank = baseCells.filter(c => c.rawValue.trim() !== '');4 const samples = nonBlank.length > 0 ? nonBlank : baseCells;5
6 const countSuffix = `(+${Math.min(samples.length, 99)})`;7 const widths = samples.map(c => {8 const rawW = estimateLabelPx(c.rawValue, fontSize, paddingX);9 const withSuffix = estimateLabelPx(c.rawValue + countSuffix, fontSize, paddingX);10 return Math.max(rawW, withSuffix);11 });12
13 widths.sort((a, b) => a - b);14 const p90Idx = Math.floor(widths.length * 0.9);15 return widths[p90Idx] ?? widths[widths.length - 1] ?? 60;16}相比初版实现,这里有两个关键改进:
-
优先基于非空样本估算:空白格的 rawValue 宽度接近零,会拉低整体估算,导致
minLabelPx过小。过滤掉空白样本后,估算更贴近实际需要显示 label 的格子。 -
预留
(+n)后缀宽度:合并后的标签可能带有(+n)后缀(如Lot-A(+2)),比原始单值更长。如果minLabelPx只基于单个 rawValue 估算,就会低估实际需要的宽度——本地逻辑以为"放得下",ECharts 却仍认为"放不下",触发自己的overflow: 'truncate'把文本压成...。对rawValue和rawValue + countSuffix取 max,让阈值更接近真实需要。
为什么不用 max 或 avg?
| 策略 | 问题 |
|---|---|
| max | 被个别超长 label 拖累,导致所有 bar 都被迫合并 |
| avg | 一部分 bar 的 label 仍然显示不全 |
| P90 | 兼顾多数可读性与极值容忍,超长 label 用 tooltip 补全 |
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,避免在轴末端留下新的碎片。
1function greedyMergeByWidth(baseCells, minPx, config, fontSize) {2 const buckets = [];3 let curCells = [baseCells[0]];4 let curWidth = baseCells[0].pixelWidth;5
6 for (let i = 1; i < baseCells.length; i++) {7 if (curWidth >= minPx) {8 buckets.push(finalizeBucket(curCells, curWidth, config, fontSize));9 curCells = [baseCells[i]];10 curWidth = baseCells[i].pixelWidth;11 } else {12 curCells.push(baseCells[i]);13 curWidth += baseCells[i].pixelWidth;14 }15 }16
17 if (curWidth < minPx && buckets.length > 0) {18 mergeTailIntoPrevious(buckets, curCells); // 尾部过窄,并入前一个19 } else {20 buckets.push(finalizeBucket(curCells, curWidth, config, fontSize));21 }22
23 return buckets;24}这个策略的好处是让过窄的空白格被快速吸收,宽度充裕的格子尽量独立保留,视觉上不再有"合并不干净"的碎片感。
4.2.3 分层处理:逐层独立计算
一个自然的直觉可能是"内层合并必须受外层 DisplayCell 边界约束"——在"月→周→日"这种严格粗细粒度层级中,确实需要防止日期跨月合并。但在实际业务数据中,多层 X 轴的层顺序往往只是并列维度的展示顺序(如 WAFER_ID / LOT_ID),不一定存在天然的粗细粒度父子关系:
- 可能是粗 → 细(月 → 日)
- 也可能是细 → 粗(Wafer → Lot)
- 甚至只是多个并列维度,本身没有层级关系
如果错误地假设层间存在父子约束,当上一层全是单点、而当前层存在 span > 1 的连续块时,下层的合法 cell 会因为无法完整落入任何上层单点格而被过滤掉,导致大量 bar 莫名消失。
因此最终采用逐层独立的计算方式:
1function buildAdaptiveDisplayCells(allBaseCells, levelCount, chartPixelWidth, config, fontSize) {2 const outputs = new Array(levelCount);3
4 for (let L = 0; L < levelCount; L++) {5 outputs[L] = adaptiveMergeWithinParent(6 allBaseCells[L] ?? [],7 chartPixelWidth,8 config,9 fontSize,10 );11 }12 return outputs;13}每一层都以完整的图表宽度作为可用空间,基于本层 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 信息。
1function formatMergedLabel(mergedCells, availablePx, config, fontSize) {2 const allValues = mergedCells.map(c => c.rawValue);3 const visibleValues = allValues.filter(v => v.trim() !== '');4 const blankCount = allValues.length - visibleValues.length;5
6 const values = visibleValues.length > 0 ? visibleValues : allValues;7 const allSame = values.every(v => v === values[0]);8
9 if (allSame) return { label: values[0], tooltipLabel: values[0] };10
11 const sep = config.rangeSeparator ?? ' ~ ';12 const first = values[0], last = values[values.length - 1];13 const n = values.length;14
15 const candidates = [16 values.join(sep), // 完整序列17 `${first}${sep}${last}`, // 带分隔范围18 `${first}~${last}`, // 紧凑范围19 `${first}(+${n - 1})`, // 计数摘要20 `(+${n - 1})`, // 最小摘要21 ];22
23 const label = pickBestLabelCandidate(candidates, availablePx, fontSize);24 const tooltipBase = values.join(sep);25 const tooltipLabel = blankCount > 026 ? `${tooltipBase} (+${blankCount} blank segments)`27 : tooltipBase;28
29 return { label, tooltipLabel };30}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:
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: '…'作为第二道防线
1const rawText = displayLabel ?? dataName;2const truncateWidth = Math.max(0, validLabelWidth - LABEL_WIDTH_BUFFER);3const finalText = truncateMiddle(rawText, truncateWidth, fontSize);4
5return {6 // ...7 label: {8 formatter: () => finalText, // 预截断的文本9 overflow: 'truncate', // ECharts 兜底10 ellipsis: '…',11 },12};这里有一个容易被忽略的细节: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 进化路径
| 版本 | 关键改进 |
|---|---|
| v1.0 | 初版:四包各自的 multiXAxis.ts,仅有结构合并 |
| v1.1 | 抽取公共包 @guwave/multi-x-axis,统一四包差异 |
| v1.2 | 加入自适应合并算法 + P90 策略 + LRU 缓存 + truncateMiddle |
| v1.3 | 完善 label 降级策略、优化均匀分桶、zoom/resize 联动、性能标记统一 |
| v1.4 | 修复层间父子假设导致的下层 bar 丢失,改为逐层独立合并;修复 label 仅看首尾值的误导问题,改为基于完整 bucket 值序列生成 |
| v1.5 | 观感优化:空白值归一化、贪心宽度吞并替代均匀分桶、(+n) 宽度预留、语义值序列标签生成、range/auto 统一走 pickBestLabelCandidate |
9.5 数据结构选型
- LRU 用
Map而非手写双向链表:ES6 Map 保持插入序,delete+set= touch,keys().next()= evict,代码极简且性能足够 - 贪心吞并按像素宽度驱动:从均匀分桶(
floor(i*n/k)整数数学)演进到按像素宽度贪心累积,更好地处理宽度不均匀的场景 - 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 条就合并"的固定阈值思路,转而以像素宽度作为决策依据,让同一套算法自然适配不同分辨率和容器尺寸。
- 从假设到验证:最初设计中"层间存在父子关系"的假设在真实业务数据中被证伪——层顺序只是渲染顺序,不一定是粗细粒度。逐层独立计算才是对任意业务维度组合都正确的策略。
如果你正在处理类似的"密集分类轴"可视化需求,希望这篇文章中的思路——P90 策略、贪心宽度吞并、语义值序列降级、双重截断保险——能给你一些启发。
关于模块的完整架构设计、类型定义、测试策略与各图表包的集成模式,请参阅 《@guwave/multi-x-axis:ECharts 多层 X 轴公共模块的设计与实现》。