@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:
| 包 | 核心差异 |
|---|---|
| line-chart / bar-chart | 完全一致,逐字节相同 |
| yield-trend-chart | 多一个 yAxisOffset 参数(双 Y 轴) |
| box-chart | 差异最大:函数命名、参数命名、返回值命名、label 渲染方式、旋转计算全部不同 |
详细对比:
| 维度 | line-chart / bar-chart | yield-trend-chart | box-chart |
|---|---|---|---|
| 合并函数 | mergeConsecutiveBlocks() | 同左 | extractXAxisLevels()(无 groupKey) |
| yAxisIndex | gridIndex | gridIndex + yAxisOffset | gridIndex |
| label 渲染 | formatter: () => truncateText(...) | 同左 | formatter: '{b}' + ECharts 原生截断 |
| autoHeight | 有(isAutoHeight + getTextWidth) | 同左 | 无(固定 levelHeight) |
| grid.bottom | 0(外部累计) | 同左 | 内部计算 (levelCount - i - 1 + statisticsCount) * levelHeight |
| 性能标记 | 无 | 无 | large: true / animation: false / emphasis: disabled |
这就是典型的「复制粘贴 → 各自演化 → 代码分叉」——是时候收敛了。
二、包结构与模块职责
1packages/multi-x-axis/2├── package.json # @guwave/multi-x-axis v0.1.03├── src/4│ ├── index.ts # 公共 API 出口(30 行)5│ ├── types.ts # 统一类型定义(100 行)6│ ├── constants.ts # 常量与默认配置(45 行)7│ ├── baseCells.ts # 结构合并:行程编码(42 行)8│ ├── textMetrics.ts # 文本度量 + LRU 缓存 + 截断(103 行)9│ ├── adaptiveMerge.ts # 自适应合并核心算法(158 行)10│ ├── cellSeries.ts # ECharts bar series 生成(120 行)11│ ├── axisConfig.ts # 全链路编排引擎(326 行)12│ └── __tests__/13│ ├── baseCells.test.ts (85 行)14│ ├── textMetrics.test.ts (132 行)15│ ├── adaptiveMerge.test.ts (192 行)16│ └── axisConfig.test.ts (178 行)关键的包配置:
peerDependencies不捆绑 ECharts,由宿主项目提供sideEffects: false支持 tree-shaking- ESM only(
"type": "module")
数据流总览
三、类型体系:从原始数据到最终渲染
类型设计是整个模块的骨架,定义了数据在流水线中每个阶段的形态。
3.1 BaseCell —— 结构合并后的最小单元
BaseCell 是不可变的缓存——当 resize / zoom 发生时,只需重算 pixelWidth,BaseCell 的索引和值结构不变。
3.2 DisplayCell —— 最终渲染单元
rawValue + lastRawValue 的设计让 tooltip 可以展示完整的合并范围(如 Lot1 ~ Lot5),而 label 可能已经被截断或简化。
3.3 核心入参与输出
1interface MultiXAxisBuildParams {2 xValues: string[][]; // 每个数据点的多层值,按外层→内层排列3 levelCount: number; // 层级数4 xAxisWidth: number; // 可绘制区域像素宽度5 gridLeft: number;6 gridRight: number;7 dataSeriesMaxGridIndex: number; // 数据系列最大 gridIndex8 xAxisLabel?: XAxisLabelConfig;9 xAxisLevelNames?: string[];10 yAxisOffset?: number; // yield-trend-chart 传 111 statisticsCount?: number; // box-chart 统计行数12 adaptiveMerge?: AdaptiveMergeConfig;13}14
15interface MultiXAxisResult {16 grids: Record<string, any>[];17 xAxes: Record<string, any>[];18 yAxes: Record<string, any>[];19 series: Record<string, any>[];20 layers: LayerOutput[]; // 供外部使用的层结构信息21}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 单层合并
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,9 endIdx: j,10 rawValue: layerValues[i],11 span: j - i + 1,12 pixelWidth: 0,13 });14 i = j + 1;15 }16 return cells;17}单次扫描,时间复杂度 O(n)。这个函数统一了 line/bar/yield-trend 的 mergeConsecutiveBlocks 和 box-chart 的 extractXAxisLevels。
4.2 多层聚合
1function buildAllLayerBaseCells(2 xValues: string[][], // 每个数据点有多层值3 levelCount: number,4 pxPerPoint: number, // 每个数据点占多少像素5): BaseCell[][] {6 const result: BaseCell[][] = [];7 for (let layer = 0; layer < levelCount; layer++) {8 const layerValues = xValues.map(xv => xv[layer] ?? '');9 const cells = buildBaseCells(layerValues);10 for (const c of cells) c.pixelWidth = c.span * pxPerPoint;11 result.push(cells);12 }13 return result;14}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 缓存:
1const LRU_CAPACITY = 1000;2
3class TextMetricsCache {4 private cache = new Map<string, number>();5 private canvas: HTMLCanvasElement | null = null;6
7 measure(text: string, fontSize = 12, fontFamily = 'sans-serif'): number {8 const key = `${fontSize}|${fontFamily}|${text}`;9 const cached = this.cache.get(key);10 if (cached !== undefined) {11 // 命中:delete + set 将 key 移到末尾(最近使用)12 this.cache.delete(key);13 this.cache.set(key, cached);14 return cached;15 }16 const width = this.rawMeasure(text, fontSize, fontFamily);17 if (this.cache.size >= LRU_CAPACITY) {18 // 淘汰:Map.keys().next() 返回最早插入的 key19 const first = this.cache.keys().next().value;20 if (first !== undefined) this.cache.delete(first);21 }22 this.cache.set(key, width);23 return width;24 }25}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…" 会丢失范围终点信息,中间省略保留了首尾:
1function truncateMiddle(text: string, maxWidth: number, fontSize = 12): string {2 if (getTextWidth(text, fontSize) <= maxWidth) return text;3
4 const ellipsis = '…';5 if (text.length <= 2 || maxWidth <= getTextWidth(ellipsis, fontSize)) {6 return truncateText(text, maxWidth, fontSize); // 退化为尾部截断7 }8
9 let lo = 2, hi = text.length - 1, bestKeep = 0;10 while (lo <= hi) {11 const mid = Math.floor((lo + hi) / 2);12 const leftKeep = Math.ceil(mid / 2);13 const rightKeep = mid - leftKeep;14 const candidate = text.slice(0, leftKeep) + ellipsis + text.slice(-rightKeep);15 if (getTextWidth(candidate, fontSize) <= maxWidth) {16 bestKeep = mid;17 lo = mid + 1;18 } else {19 hi = mid - 1;20 }21 }22
23 if (bestKeep < 2) {24 const oneChar = text.slice(0, 1) + ellipsis;25 return getTextWidth(oneChar, fontSize) <= maxWidth ? oneChar : ellipsis;26 }27
28 const leftKeep = Math.ceil(bestKeep / 2);29 const rightKeep = bestKeep - leftKeep;30 return text.slice(0, leftKeep) + ellipsis + text.slice(-rightKeep);31}算法要点:
- 二分搜索保留的总字符数
mid,其中约一半分给左侧、一半分给右侧 - 当文本极短(≤2 字符)或空间极小时退化为尾部截断
- 永不返回空字符串——至少返回
'…' - 时间复杂度 O(log n),配合 LRU 缓存,性能几乎无感
六、adaptiveMerge —— 密度自适应合并算法
这是整个模块的核心创新。当数据点密集时,BaseCell 的像素宽度小于 label 最小可读宽度,需要将多个 BaseCell 合并为一个 DisplayCell。
6.1 P90 分位数策略
不同 label 文本长度差异很大,固定的 minLabelPx 不可行。我们取该层所有 BaseCell label 宽度的 P90 分位数作为代表:
为什么选 P90:
| 策略 | 问题 |
|---|---|
| max | 被个别超长 label(如带批号的全限定名)拖累,导致过度合并 |
| avg | 约一半的 label 会显示不全 |
| P90 | 兼顾多数可读性与极值容忍,10% 的超长 label 用 tooltip 和 truncateMiddle 补全 |
6.2 均匀分桶合并
确定 minLabelPx 后,核心计算非常简洁:
1function adaptiveMergeWithinParent(baseCells, availablePx, config, fontSize) {2 const n = baseCells.length;3 if (n === 0) return [];4
5 const minPx = resolveMinLabelPx(baseCells, config, fontSize);6 const k = Math.max(1, Math.floor(availablePx / minPx));7
8 if (k >= n) {9 // 空间充裕,无需合并10 return baseCells.map(c => ({ ...c, isMerged: false }));11 }12
13 return mergeUniform(baseCells, k, availablePx, config, fontSize);14}均匀分桶的关键在于 floor(i*n/k) 的整数数学:
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
9 // from ~ to 范围内的 BaseCell 合并为一个 DisplayCell10 let totalPx = 0;11 for (let j = from; j <= to; j++) totalPx += baseCells[j].pixelWidth;12
13 buckets.push({14 startIdx: baseCells[from].startIdx,15 endIdx: baseCells[to].endIdx,16 label: formatMergedLabel(baseCells[from].rawValue, baseCells[to].rawValue, totalPx, config, fontSize),17 pixelWidth: totalPx,18 isMerged: to > from,19 baseCellCount: to - from + 1,20 rawValue: baseCells[from].rawValue,21 lastRawValue: baseCells[to].rawValue,22 });23 }24 return buckets;25}为什么用 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, 4, 2),观感差
这是一个经典的整数均分问题,在操作系统的负载均衡、并行计算的任务分配中也常见。
6.3 Label 生成的渐进式降级
合并发生后,一个 DisplayCell 代表一个区间。label 的生成采用 auto 策略的渐进降级:
1function formatMergedLabel(firstValue, lastValue, availablePx, config, fontSize) {2 const strategy = config.mergeLabelStrategy ?? 'auto';3 const sep = config.rangeSeparator ?? ' ~ ';4
5 if (firstValue === lastValue) return firstValue;6
7 if (strategy === 'first') return firstValue;8 if (strategy === 'count') return lastValue;9 if (strategy === 'range') return `${firstValue}${sep}${lastValue}`;10
11 // auto 策略12 const rangeText = `${firstValue}${sep}${lastValue}`; // "D1 ~ D5"13 if (estimateLabelPx(rangeText, fontSize) <= availablePx) return rangeText;14
15 const compactRange = `${firstValue}~${lastValue}`; // "D1~D5"16 if (estimateLabelPx(compactRange, fontSize) <= availablePx) return compactRange;17
18 return compactRange; // 返回紧凑形式,由 cellSeries 的 truncateMiddle 做最终截断19}降级链路:
每一步都保证永不返回空字符串。
6.4 分层处理:父边界隔离
这是算法中最关键的约束——内层的合并必须限制在外层 DisplayCell 的边界内。
如果不做隔离,可能出现"Day30 和 Day1 合并到同一格"跨越了月份边界的语义错误。
1function buildAdaptiveDisplayCells(allBaseCells, levelCount, chartPixelWidth, config, fontSize) {2 const outputs = new Array(levelCount);3
4 // 最外层:父 = 整张图5 outputs[0] = adaptiveMergeWithinParent(6 allBaseCells[0], chartPixelWidth, config, fontSize,7 );8
9 // 内层:受上一层 DisplayCell 约束10 for (let L = 1; L < levelCount; L++) {11 const cells = [];12 for (const parent of outputs[L - 1]) {13 // 只取完全落在 parent 范围内的子 BaseCell14 const childBase = allBaseCells[L].filter(15 c => c.startIdx >= parent.startIdx && c.endIdx <= parent.endIdx,16 );17 // 以 parent 的像素宽度作为可用空间18 cells.push(...adaptiveMergeWithinParent(childBase, parent.pixelWidth, config, fontSize));19 }20 outputs[L] = cells;21 }22 return outputs;23}每一层独立做密度自适应,但绝不跨越父边界——层级语义始终正确。
七、cellSeries —— ECharts Bar Series 的统一抽象
cellSeries.ts 将一个 DisplayCell 转化为一个 ECharts bar series 配置对象。它统一了四个图表包的所有差异。
7.1 统一的 API
7.2 旋转 Label 的三角几何
当 label 需要旋转时,可用于显示文字的有效宽度会变化。我们采用了精确的三角函数计算:
1function computeValidLabelWidth(realLabelWidth, realLabelHeight, rotate, fontSize, autoHeight) {2 const absRotate = Math.abs(rotate);3
4 if (absRotate === 90) {5 // 90° 旋转:文本沿纵向,有效宽度取决于高度6 return autoHeight === 'auto'7 ? Math.min(realLabelWidth + fontSize, MAX_ROTA_90_HEIGHT)8 : realLabelHeight;9 }10
11 if (absRotate === 0) return realLabelWidth; // 无旋转12
13 // 一般角度:旋转矩形内接问题14 const rad = (rotate / 180) * Math.PI;15 return Math.min(16 realLabelWidth / Math.abs(Math.cos(rad)),17 realLabelHeight / Math.abs(Math.sin(rad)),18 ) - fontSize;19}几何原理:一个旋转了 θ 角的矩形文本,要完整放入 w × h 的容器中,文本长度不能超过 min(w/|cosθ|, h/|sinθ|)。
7.3 双重截断保险
1const truncateWidth = Math.max(0, validLabelWidth - LABEL_WIDTH_BUFFER);2const finalText = truncateMiddle(rawText, truncateWidth, fontSize);3
4return {5 type: 'bar',6 label: {7 formatter: () => finalText, // 第一道:Canvas 预截断8 overflow: 'truncate', // 第二道:ECharts 兜底9 ellipsis: '…',10 },11 // ...12};LABEL_WIDTH_BUFFER(4px)弥补 Canvas measureText 与 ECharts 渲染引擎之间的微小测量偏差。
7.4 性能优化标记
所有 cell series 统一启用:
7.5 统一的视觉样式
八、axisConfig —— 全链路编排引擎
axisConfig.ts 是整个模块中最大的文件(326 行),它编排了从数据输入到 ECharts 配置输出的完整流程。
8.1 buildMultiXAxisConfig —— 初始构建
核心逻辑分为"自适应路径"和"非自适应路径"两条分支:
1function buildMultiXAxisConfig(params: MultiXAxisBuildParams): MultiXAxisResult {2 const { xValues, levelCount, xAxisWidth, adaptiveMerge, ... } = params;3 const pxPerPoint = totalPoints > 0 ? xAxisWidth / totalPoints : 0;4 const resolvedConfig = resolveAdaptiveMergeConfig(adaptiveMerge);5
6 if (resolvedConfig.enabled) {7 // 自适应路径:全量构建 BaseCells + DisplayCells8 const allBaseCells = buildAllLayerBaseCells(xValues, levelCount, pxPerPoint);9 const allDisplayCells = buildAdaptiveDisplayCells(10 allBaseCells, levelCount, xAxisWidth, resolvedConfig, fontSize,11 );12 // ... 遍历 DisplayCells 生成 series13 } else {14 // 非自适应路径:仅结构合并 + MAX_MULTI_X_CELLS 安全阀15 // ... 超过 1000 cell 时显示占位提示 "Zoom in to display ..."16 }17
18 // 为每一层生成 grid + hidden xAxis + hidden yAxis19 return { grids, xAxes, yAxes, series, layers };20}自适应路径 中每个 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:
1// grid:高度由 label 配置决定2grids.push({3 height: singleLevelHeight,4 bottom: statisticsCount > 05 ? (levelCount - layer - 1 + statisticsCount) * singleLevelHeight6 : 0,7 left: gridLeft,8 right: gridRight,9});10
11// 隐藏的 category xAxis 用于定位 bar12xAxes.push({13 type: 'category',14 gridIndex: layerGridIndex,15 axisLine: { show: false },16 axisLabel: { show: false },17 axisTick: { show: false },18});19
20// 隐藏的 value yAxis,让 bar 能以 value=1 绘制21yAxes.push({22 type: 'value',23 gridIndex: layerGridIndex,24 axisLine: { show: false },25 axisLabel: { show: false },26 axisTick: { show: false },27 splitLine: { show: false },28});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 各包差异对比
| 关注点 | line-chart / bar-chart | yield-trend-chart | box-chart |
|---|---|---|---|
yAxisOffset | 默认 0 | 1(双 Y 轴) | 默认 0 |
statisticsCount | 默认 0 | 默认 0 | statistics.length |
zoom 时 xAxisWidth | width - gridLeft - gridRight(props) | getCurrentXAxisWidth(option) 从实时 grid 读取 | props |
| dataZoom 来源 | dataZoom[0] | dataZoom.find(xAxisIndex === 0) | dataZoom[0] |
| zoom 额外操作 | 无 | 无 | 重建统计行 + 散点 jitter |
dataSeriesMaxGridIndex | 1 | 1 | 1 |
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 分层单测
测试按模块分层,每一层只关注自己的职责:
| 模块 | 测试文件 | 核心测试点 |
|---|---|---|
baseCells | 85 行 | 空数组 / 单值 / 全同 / 全异 / 交替 / 多层 pixelWidth |
textMetrics | 132 行 | LRU 命中 / 未命中 / invalidate / 截断边界 / 中间截断 |
adaptiveMerge | 192 行 | k≥n / k=1 / 不整除均分 / 4 种 label 策略 / 父边界隔离 |
axisConfig | 178 行 | 默认开启 / gridIndex 偏移 / yAxisOffset / statisticsCount / 空数据 / zoom |
10.2 Canvas 环境 Mock
所有涉及文本度量的测试统一使用 vi.stubGlobal mock Canvas:
text.length * 7 提供了确定性的宽度计算,让测试结果稳定可预测。
10.3 关键测试用例
父边界隔离测试——这是自适应合并最关键的测试:
自适应 vs 非自适应对比——验证默认行为:
10.4 测试设计原则
- 纯 TS 模块测试:不依赖 React 或 ECharts 运行时
- 确定性 Mock:Canvas 宽度 = 字符数 × 固定系数,结果可预测
- A/B 对比:用
enabled: truevsenabled: false对比验证合并效果 - 边界覆盖:空数组、单元素、k=1、n 不被 k 整除、首尾同值等
十一、常量与默认配置
1const MULTI_X_LEVEL_HEIGHT = 24; // 每层默认高度 (px)2const MAX_MULTI_X_CELLS = 1000; // 非自适应模式的安全阀3const MAX_ROTA_90_HEIGHT = 120; // 90° 旋转时最大高度限制4const LABEL_WIDTH_BUFFER = 4; // 截断计算的像素余量5
6const DEFAULT_ADAPTIVE_MERGE = {7 enabled: true, // 默认开启8 minLabelPx: 'auto', // P90 自动估算9 mergeLabelStrategy: 'auto', // 渐进降级10 rangeSeparator: ' ~ ',11 labelPaddingX: 8,12};13
14const MULTI_X_CELL_STYLE = {15 z: 299, // 高 z 值16 itemStyle: {17 color: '#fff', // 白色填充18 borderColor: '#c5c5c5', // 灰色描边19 borderWidth: 0.5,20 },21};所有常量都有明确的语义命名,避免 magic number。
十二、公共 API 总览
1// ── 核心 API ──2export { buildMultiXAxisConfig } from './axisConfig'; // 初始构建3export { rebuildMultiXSeriesOnZoom } from './axisConfig'; // 缩放重建4export { buildMultiXCellSeries } from './cellSeries'; // 单个 cell series5export { buildBaseCells, buildAllLayerBaseCells } from './baseCells';6export { buildAdaptiveDisplayCells, adaptiveMergeWithinParent } from './adaptiveMerge';7
8// ── 文本度量 ──9export { getTextWidth, truncateText, truncateMiddle, estimateLabelPx, textMetrics } from './textMetrics';10
11// ── 类型 ──12export type { XAxisLabelConfig, BaseCell, DisplayCell, LayerOutput,13 MultiXAxisBuildParams, MultiXAxisResult,14 AdaptiveMergeConfig, MergeLabelStrategy } from './types';15
16// ── 常量 ──17export { MULTI_X_LEVEL_HEIGHT, DEFAULT_X_AXIS_LABEL, DEFAULT_ADAPTIVE_MERGE,18 MULTI_X_CELL_STYLE, MAX_MULTI_X_CELLS } from './constants';API 分为四组:
- 核心 API:初始构建和缩放重建,99% 的场景只需要这两个
- 底层算法:
buildBaseCells、adaptiveMergeWithinParent等,供高级用户直接操控 - 文本度量:通用工具,图表组件可能在多层 X 轴之外也需要
- 常量:样式和限制值,保证各包视觉一致
十三、工程经验与设计决策
13.1 "先收敛,再增强" 的重构策略
我们没有在四个包中各自加自适应合并(那样差异会进一步放大),而是先把四包的共同逻辑抽成公共模块、确保行为不变,然后在公共模块上统一加增量能力。
13.2 向后兼容的配置设计
默认值在 DEFAULT_ADAPTIVE_MERGE 中集中管理,用户只覆盖想改的字段。
13.3 像素驱动而非数量驱动
合并触发条件是"像素宽度不足",不是"数据超过 N 条"。同样 1000 个数据点,在 1600px 的大屏上可能不需要合并,在 400px 的侧边栏面板则需要大量合并。像素驱动天然适配不同分辨率和容器尺寸。
13.4 分层独立 + 父边界隔离
每一层独立决策合并粒度,但内层合并绝不跨越外层边界。这是整个算法正确性的基石。
13.5 多级防御
文本显示有三级防御:
- formatMergedLabel:自动选择 range / compact / first 格式
- truncateMiddle:保留首尾的中间省略截断
- ECharts overflow: 'truncate':渲染层兜底
加上 LABEL_WIDTH_BUFFER 的 4px 余量,确保 Canvas 和 ECharts 的微小测量偏差不会导致 label 溢出。
13.6 性能考量
| 措施 | 收益 |
|---|---|
| LRU 缓存(容量 1000) | 避免重复 measureText 调用 |
large: true + animation: false | ECharts 走大数据量优化渲染路径 |
emphasis: { disabled: true } | 禁用装饰 bar 的悬浮重绘 |
| 二分搜索截断 | O(log n) 替代 O(n) 逐字裁剪 |
| 仅重建 series(zoom 时) | grid / axis 结构不变,避免全量重建 |
sideEffects: false | 未使用的导出可被 tree-shake |
13.7 复杂度分析
| 步骤 | 复杂度 |
|---|---|
| 结构合并 | O(n × L),n = 数据点数,L = 层数 |
| P90 计算 | O(m × log m),m = BaseCell 数(排序) |
| 自适应合并 | O(m × L) |
| Label 截断 | O(m × log t),t = 文本长度 |
| LRU 查找 | O(1) 均摊 |
实测:1000 数据点 × 4 层,buildAdaptiveDisplayCells < 8ms。
附录:快速上手
最简使用
1import { buildMultiXAxisConfig } from '@guwave/multi-x-axis';2
3const result = buildMultiXAxisConfig({4 xValues: data.points.map(p => p.xValues),5 levelCount: 2,6 xAxisWidth: 800,7 gridLeft: 60,8 gridRight: 20,9 dataSeriesMaxGridIndex: 1,10});11
12chart.setOption({13 grid: [mainGrid, ...result.grids],14 xAxis: [mainXAxis, ...result.xAxes],15 yAxis: [mainYAxis, ...result.yAxes],16 series: [...dataSeries, ...result.series],17});缩放响应
1import { rebuildMultiXSeriesOnZoom } from '@guwave/multi-x-axis';2
3chart.on('datazoom', () => {4 const slicedXValues = /* 根据 zoom 范围切片 */;5 const newSeries = rebuildMultiXSeriesOnZoom({6 xValues: slicedXValues,7 levelCount: 2,8 xAxisWidth: currentXAxisWidth,9 dataSeriesMaxGridIndex: 1,10 });11 chart.setOption(12 { series: [...dataSeries, ...newSeries] },13 { replaceMerge: ['series'] },14 );15});自定义合并策略
写在最后
@guwave/multi-x-axis 的诞生过程体现了一个工程实践原则:先收敛,再增强。
面对四份各自漂移的 multiXAxis.ts,我们没有选择在每个包中独立修补,而是先花时间对齐差异、抽取公共模块、建立统一的类型体系和测试覆盖,然后才在稳固的基础上增加自适应合并这样的增量能力。这个决策让后续的 bug 修复和功能迭代只需要改一个地方,四个图表包同步受益。
整个模块的设计也刻意保持了最小 API 面积:99% 的场景只需要 buildMultiXAxisConfig 和 rebuildMultiXSeriesOnZoom 两个函数。底层的 buildBaseCells、adaptiveMergeWithinParent 等暴露给高级用户,但不是默认路径。这种"简单的事情简单做、复杂的事情留出口"的分层设计,是组件库和工具库追求的理想状态。
关于自适应合并算法的设计思路、P90 策略、均匀分桶、渐进式降级等工程决策的深入分析,请参阅 《ECharts 多层 X 轴智能自适应合并:从 Hack 到优雅的工程实践》。