@guwave/multi-x-axis:ECharts 多层 X 轴公共模块的设计与实现
ECharts 不支持原生多层 X 轴。我们用 bar series 模拟出多层表头效果,但这套逻辑在 4 个图表包中各自维护、逐渐分叉——函数命名不同、参数不同、甚至 label 渲染策略也不同。本文完整记录了如何将它们收敛为一个独立的公共模块 @guwave/multi-x-axis,涵盖架构设计、类型体系、核心算法、渲染层抽象、以及各图表包的集成模式。
本文侧重模块架构与工程实现。如果你对自适应合并算法的设计思路和工程决策过程更感兴趣,可以阅读姊妹篇 《ECharts 多层 X 轴智能自适应合并:从 Hack 到优雅的工程实践》。
一、为什么需要这个包
1.1 ECharts 的原生局限
ECharts 的 category axis 只支持单层类目轴。它可以通过 axisLabel.formatter 折行来做简单的分层标注,但存在本质缺陷:
- 每一层不是独立的可点击、可样式化实体
- 无法做"合并边框"的表头视觉
- 无法做分层独立的自适应合并
1.2 Bar Series 模拟方案
我们的做法是用若干条 描边空心 bar series 堆叠在图表下方,模拟出多层表头:
每一层就是一条 bar series,每个 bar 是该层级下的一个"格子"(Cell),白色填充 + 灰色描边 + 居中 label。
1.3 四包同源,各自漂移
在抽取公共模块之前,仓库中有 4 个图表包各自维护了独立的 multiXAxis.ts:
详细对比:
这就是典型的「复制粘贴 → 各自演化 → 代码分叉」——是时候收敛了。
二、包结构与模块职责
关键的包配置:
peerDependencies不捆绑 ECharts,由宿主项目提供sideEffects: false支持 tree-shaking- ESM only(
"type": "module")
数据流总览
三、类型体系:从原始数据到最终渲染
类型设计是整个模块的骨架,定义了数据在流水线中每个阶段的形态。
3.1 BaseCell —— 结构合并后的最小单元
BaseCell 是不可变的缓存——当 resize / zoom 发生时,只需重算 pixelWidth,BaseCell 的索引和值结构不变。
3.2 DisplayCell —— 最终渲染单元
tooltipLabel 保留比 label 更完整的语义信息:即使 display label 因空间限制被压缩为范围表达,tooltip 仍能展示完整的值序列(如 Lot1 ~ Lot3 ~ Lot5),避免把复杂区间误导性地显示为单值。rawValue + lastRawValue 记录首末 BaseCell 的原始值,用于范围格式化。
3.3 核心入参与输出
MultiXAxisBuildParams 中有两个关键的"差异参数":
yAxisOffset:yield-trend-chart 有双 Y 轴(左折线 + 右柱状),多层 X 轴的 bar series 需要偏移 yAxisIndexstatisticsCount:box-chart 在多层 X 轴下方还有统计行,需要在 grid.bottom 中预留空间
3.4 自适应合并配置
'auto' 的 minLabelPx 是核心设计——它让模块能自动根据当前数据的 label 长度分布来决定合并粒度,无需手动配置。
四、baseCells —— 行程编码结构合并
结构合并是最基础的一步,本质上就是行程编码 (Run-Length Encoding)。
4.1 单层合并
单次扫描,时间复杂度 O(n)。这个函数统一了 line/bar/yield-trend 的 mergeConsecutiveBlocks 和 box-chart 的 extractXAxisLevels。
值得注意的是,在比较前会先通过 normalizeLayerValue() 对值做归一化——将 null、undefined、纯空格字符串统一映射为 ''。这保证了视觉上等价的"空白"不会因字符串差异(如 '' vs ' ')而被拆成多个 BaseCell,避免在轴上留下大量碎片化的窄空白格。
4.2 多层聚合
xv[layer] ?? '' 的容错处理:当某个数据点缺少某一层的值时,用空字符串填充(仍可被行程编码合并)。
五、textMetrics —— Canvas 文本度量与 LRU 缓存
5.1 为什么需要精确度量
不同 label 的文本长度差异巨大("W1" vs "2024-01-01 Lot-ABC-0231"),不能用字符数或 magic number 来估算。唯一靠谱的方式是 CanvasRenderingContext2D.measureText。
5.2 基于 Map 的 LRU 缓存
同一个图表在 resize / zoom 时会反复测量相同文本。我们利用 ES6 Map 的插入顺序特性实现了一个轻量 LRU 缓存:
Map LRU 的原理:Map 保持键的插入顺序。delete + set 实现"提升到末尾"(LRU touch),keys().next().value 获取最旧的键来淘汰。无需额外的双向链表,代码极简。
降级策略:当 getContext('2d') 失败(如 SSR 环境)时,使用 text.length * fontSize * 0.6 的等宽估算。
5.3 文本宽度估算
paddingX 是 label 与 cell 边框之间的留白。8px 是默认值,防止文字紧贴边框。
5.4 尾部截断(truncateText)
经典的二分搜索截断——找到最长的前缀 prefix + '…' 使其像素宽度 ≤ maxWidth:
5.5 中间省略截断(truncateMiddle)
这是针对合并格范围标签设计的截断方式。传统的尾部截断 "LotABC~Lot…" 会丢失范围终点信息,中间省略保留了首尾:
算法要点:
- 二分搜索保留的总字符数
mid,其中约一半分给左侧、一半分给右侧 - 当文本极短(≤2 字符)或空间极小时退化为尾部截断
- 永不返回空字符串——至少返回
'…' - 时间复杂度 O(log n),配合 LRU 缓存,性能几乎无感
六、adaptiveMerge —— 密度自适应合并算法
这是整个模块的核心创新。当数据点密集时,BaseCell 的像素宽度小于 label 最小可读宽度,需要将多个 BaseCell 合并为一个 DisplayCell。
6.1 P90 分位数策略
不同 label 文本长度差异很大,固定的 minLabelPx 不可行。我们取该层所有 BaseCell label 宽度的 P90 分位数作为代表:
估算时优先过滤空白样本(避免拉低阈值),并对 rawValue 和 rawValue + countSuffix 取 max——因为合并后标签可能带 (+n) 后缀,若不预留宽度,本地和 ECharts 的宽度判断会失配,导致格子只显示 ...。
为什么选 P90:
6.2 密度自适应合并
确定 minLabelPx 后,算法分两条路径执行:
初版实现使用 floor(i*n/k) 按 BaseCell 个数做均匀分桶(桶大小差异 ≤ 1,经典整数均分)。但这假设 BaseCell 的像素宽度大致一致——真实数据中空白格可能只有 8px、正常格有 120px,按个数均分会让空白格构成的 bucket 仍然极窄。
当前版本改为像素宽度驱动的贪心吞并:从左到右逐个累积像素宽度,达到 minLabelPx 时切出一个 bucket。尾部不足宽度时并入前一个 bucket,避免留下碎片。
finalizeBucket 内部调用 formatMergedLabel 为合并后的 bucket 生成 display label 和 tooltip label。
6.3 Label 生成:基于语义值序列的渐进式降级
合并发生后,一个 DisplayCell 代表多个 BaseCell 的区间。label 的生成遵循两个核心原则:
- 基于整个 bucket 内的值序列,而不是只看首尾值——否则
A, B, A这样的序列会被误导性地显示为A - 先提取语义值(非空值),display label 只基于有效值生成——避免空白格并入时产生
~ Lot-A这类不友好的文案
降级链路:
range 和 auto 模式现在统一走 pickBestLabelCandidate() 流程,在宽度不足时自动回退到更紧凑的候选。tooltipLabel 保留完整的语义值序列和空白 segment 数量,保证用户 hover 时能看到合并格的真实数据分布。
6.4 分层处理:逐层独立计算
一个自然的直觉是"内层合并必须受外层 DisplayCell 边界约束"——在"月→周→日"这种严格粗细粒度层级中,确实需要防止日期跨月合并。但在实际业务数据中,多层 X 轴的层顺序往往只是并列维度的展示顺序(如 WAFER_ID / LOT_ID),不一定存在天然的粗细粒度父子关系。
如果错误地假设层间存在父子约束,当上一层全是单点、而当前层存在 span > 1 的连续块时,下层的合法 cell 会因为无法完整落入任何上层单点格而被过滤掉,导致大量 bar 消失。
因此采用逐层独立的计算方式:
每一层都以完整的图表宽度作为可用空间,基于本层 BaseCell[] 独立做密度自适应合并,不依赖其他层的计算结果。
七、cellSeries —— ECharts Bar Series 的统一抽象
cellSeries.ts 将一个 DisplayCell 转化为一个 ECharts bar series 配置对象。它统一了四个图表包的所有差异。
7.1 统一的 API
7.2 旋转 Label 的三角几何
当 label 需要旋转时,可用于显示文字的有效宽度会变化。我们采用了精确的三角函数计算:
几何原理:一个旋转了 θ 角的矩形文本,要完整放入 w × h 的容器中,文本长度不能超过 min(w/|cosθ|, h/|sinθ|)。
7.3 双重截断保险
rawText 使用 ??(而非 ||)取值:仅在 displayLabel 为 null/undefined 时回退到 dataName,空字符串会被显式保留。这保证了上游 formatMergedLabel 为纯空白 bucket 返回的空 label 不会被意外替换。
LABEL_WIDTH_BUFFER(4px)弥补 Canvas measureText 与 ECharts 渲染引擎之间的微小测量偏差。
7.4 性能优化标记
所有 cell series 统一启用:
7.5 统一的视觉样式
八、axisConfig —— 全链路编排引擎
axisConfig.ts 是整个模块中最大的文件(326 行),它编排了从数据输入到 ECharts 配置输出的完整流程。
8.1 buildMultiXAxisConfig —— 初始构建
核心逻辑分为"自适应路径"和"非自适应路径"两条分支:
自适应路径 中每个 DisplayCell 的 barWidth 按比例计算:
一个跨越 5 个数据点的合并格,barWidth 就是 5/n(占图表宽度的 5/n)。
安全阀机制(非自适应路径):当 BaseCell 数量超过 MAX_MULTI_X_CELLS(1000)时,整层退化为一个占满全宽的占位 bar,显示 "Zoom in to display {levelName}",避免生成数千个 bar series 导致渲染卡死。
8.2 grid / axis 的生成策略
每一层多层 X 轴需要一组独立的 grid + xAxis + yAxis:
gridIndex 从 dataSeriesMaxGridIndex 开始:数据系列(折线、柱状、箱图等)使用 gridIndex 0,多层 X 轴从 1 开始(或更高)。
grid.bottom 的分歧统一:
- line/bar/yield-trend 传
bottom: 0,由外部组件自行累计 - box-chart 传
statisticsCount > 0时内部计算(levelCount - layer - 1 + statisticsCount) * height
8.3 rebuildMultiXSeriesOnZoom —— 缩放重建
用户拖动 dataZoom 时,只需重建 series(grid / axis 结构不变):
内部走相同的自适应 / 非自适应分支逻辑,但只返回 series[],不包含 grids / axes。
8.4 配置合并策略
用户可以只覆盖想修改的字段,其余使用默认值。
九、四个图表包的集成模式
9.1 通用集成模式
四个图表包遵循统一的三步集成:
Series 替换策略——replaceMerge:
replaceMerge: ['series'] 让 ECharts 替换整个 series 数组,而不是按索引合并(后者会在 series 长度变化时出错)。
9.2 各包差异对比
9.3 yield-trend-chart 的特殊处理
yield-trend-chart 有双 Y 轴(左轴折线代表良率,右轴柱状代表数量),因此多层 X 轴的 bar series 的 yAxisIndex 需要偏移 1。
此外,它在 zoom 时从 ECharts 实时 option 中读取 grid 宽度,因为 Y 轴 label 的自适应宽度(adjustGridLeftByYLabels)可能在 setOption 后改变了 grid 边距:
它还使用 dataZoom.find(xAxisIndex === 0) 而非 dataZoom[0],因为 yield-trend 可以同时有 X + 左 Y + 右 Y 三个 dataZoom 组件。
9.4 box-chart 的特殊处理
box-chart 在多层 X 轴下方还有统计行(显示 boxplot 的统计值),因此:
- 传
statisticsCount让多层 X 轴在 grid.bottom 中预留空间 - zoom 时除了重建多层 X 轴,还要调用
rebuildStatisticsOnZoom重建统计行 - 还需要重算散点图的 jitter 偏移量(因为 bar 宽度变了)
box-chart 也是唯一需要额外 re-export MULTI_X_CELL_STYLE 常量的包(统计行的 cell 需要相同样式)。
9.5 公共 API 的 Re-export
每个图表包都从 @guwave/multi-x-axis re-export 核心 API,保证下游应用可以直接从图表包引入多层 X 轴的能力:
十、测试策略
10.1 分层单测
测试按模块分层,每一层只关注自己的职责:
10.2 Canvas 环境 Mock
所有涉及文本度量的测试统一使用 vi.stubGlobal mock Canvas:
text.length * 7 提供了确定性的宽度计算,让测试结果稳定可预测。
10.3 关键测试用例
逐层独立合并测试——验证各层不受其他层数据分布的影响:
bucket 内值序列测试——验证 label 不会误导用户:
自适应 vs 非自适应对比——验证默认行为:
10.4 测试设计原则
- 纯 TS 模块测试:不依赖 React 或 ECharts 运行时
- 确定性 Mock:Canvas 宽度 = 字符数 × 固定系数,结果可预测
- A/B 对比:用
enabled: truevsenabled: false对比验证合并效果 - 边界覆盖:空数组、单元素、k=1、n 不被 k 整除、首尾同值等
十一、常量与默认配置
所有常量都有明确的语义命名,避免 magic number。
十二、公共 API 总览
API 分为四组:
- 核心 API:初始构建和缩放重建,99% 的场景只需要这两个
- 底层算法:
buildBaseCells、adaptiveMergeWithinParent等,供高级用户直接操控 - 文本度量:通用工具,图表组件可能在多层 X 轴之外也需要
- 常量:样式和限制值,保证各包视觉一致
十三、工程经验与设计决策
13.1 "先收敛,再增强" 的重构策略
我们没有在四个包中各自加自适应合并(那样差异会进一步放大),而是先把四包的共同逻辑抽成公共模块、确保行为不变,然后在公共模块上统一加增量能力。
13.2 向后兼容的配置设计
默认值在 DEFAULT_ADAPTIVE_MERGE 中集中管理,用户只覆盖想改的字段。
13.3 像素驱动而非数量驱动
合并触发条件是"像素宽度不足",不是"数据超过 N 条"。同样 1000 个数据点,在 1600px 的大屏上可能不需要合并,在 400px 的侧边栏面板则需要大量合并。像素驱动天然适配不同分辨率和容器尺寸。
13.4 逐层独立计算
每一层基于本层数据独立决策合并粒度,不依赖相邻层的数据分布或层间的粗细粒度关系。层顺序只表示从上到下的渲染顺序,不暗示语义层级。这一设计保证了对任意业务维度组合都能正确工作。
13.5 多级防御
文本显示有三级防御:
- formatMergedLabel:先提取非空语义值,全部相同时返回单值,否则通过
pickBestLabelCandidate从完整序列 / 范围 / 紧凑范围 /(+n)中选最合适的候选 - truncateMiddle:保留首尾的中间省略截断
- ECharts overflow: 'truncate':渲染层兜底
加上 LABEL_WIDTH_BUFFER 的 4px 余量,确保 Canvas 和 ECharts 的微小测量偏差不会导致 label 溢出。
13.6 性能考量
13.7 复杂度分析
实测:1000 数据点 × 4 层,buildAdaptiveDisplayCells < 8ms。
附录:快速上手
最简使用
缩放响应
自定义合并策略
写在最后
@guwave/multi-x-axis 的诞生过程体现了一个工程实践原则:先收敛,再增强。
面对四份各自漂移的 multiXAxis.ts,我们没有选择在每个包中独立修补,而是先花时间对齐差异、抽取公共模块、建立统一的类型体系和测试覆盖,然后才在稳固的基础上增加自适应合并这样的增量能力。这个决策让后续的 bug 修复和功能迭代只需要改一个地方,四个图表包同步受益。
整个模块的设计也刻意保持了最小 API 面积:99% 的场景只需要 buildMultiXAxisConfig 和 rebuildMultiXSeriesOnZoom 两个函数。底层的 buildBaseCells、adaptiveMergeWithinParent 等暴露给高级用户,但不是默认路径。这种"简单的事情简单做、复杂的事情留出口"的分层设计,是组件库和工具库追求的理想状态。
关于自适应合并算法的设计思路、P90 策略、均匀分桶、渐进式降级等工程决策的深入分析,请参阅 《ECharts 多层 X 轴智能自适应合并:从 Hack 到优雅的工程实践》。