@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 —— 最终渲染单元
tooltipLabel 保留比 label 更完整的语义信息:即使 display label 因空间限制被压缩为范围表达,tooltip 仍能展示完整的值序列(如 Lot1 ~ Lot3 ~ Lot5),避免把复杂区间误导性地显示为单值。rawValue + lastRawValue 记录首末 BaseCell 的原始值,用于范围格式化。
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。
值得注意的是,在比较前会先通过 normalizeLayerValue() 对值做归一化——将 null、undefined、纯空格字符串统一映射为 ''。这保证了视觉上等价的"空白"不会因字符串差异(如 '' vs ' ')而被拆成多个 BaseCell,避免在轴上留下大量碎片化的窄空白格。
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 分位数作为代表:
1function resolveMinLabelPx(baseCells, config, fontSize) {2 if (typeof config.minLabelPx === 'number') return config.minLabelPx;3
4 const paddingX = config.labelPaddingX ?? 8;5 const nonBlank = baseCells.filter(c => c.rawValue.trim() !== '');6 const samples = nonBlank.length > 0 ? nonBlank : baseCells;7
8 const countSuffix = `(+${Math.min(samples.length, 99)})`;9 const widths = samples.map(c => {10 const rawW = estimateLabelPx(c.rawValue, fontSize, paddingX);11 const withSuffix = estimateLabelPx(c.rawValue + countSuffix, fontSize, paddingX);12 return Math.max(rawW, withSuffix);13 });14
15 widths.sort((a, b) => a - b);16 const p90Idx = Math.floor(widths.length * 0.9);17 return widths[p90Idx] ?? widths[widths.length - 1] ?? 60;18}估算时优先过滤空白样本(避免拉低阈值),并对 rawValue 和 rawValue + countSuffix 取 max——因为合并后标签可能带 (+n) 后缀,若不预留宽度,本地和 ECharts 的宽度判断会失配,导致格子只显示 ...。
为什么选 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
7 // 路径一:全部宽度充裕,无需合并8 if (baseCells.every(c => c.pixelWidth >= minPx)) {9 return baseCells.map(c => ({ ...c, isMerged: false }));10 }11
12 // 路径二:存在过窄格,贪心吞并13 return greedyMergeByWidth(baseCells, minPx, config, fontSize);14}初版实现使用 floor(i*n/k) 按 BaseCell 个数做均匀分桶(桶大小差异 ≤ 1,经典整数均分)。但这假设 BaseCell 的像素宽度大致一致——真实数据中空白格可能只有 8px、正常格有 120px,按个数均分会让空白格构成的 bucket 仍然极窄。
当前版本改为像素宽度驱动的贪心吞并:从左到右逐个累积像素宽度,达到 minLabelPx 时切出一个 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}finalizeBucket 内部调用 formatMergedLabel 为合并后的 bucket 生成 display label 和 tooltip label。
6.3 Label 生成:基于语义值序列的渐进式降级
合并发生后,一个 DisplayCell 代表多个 BaseCell 的区间。label 的生成遵循两个核心原则:
- 基于整个 bucket 内的值序列,而不是只看首尾值——否则
A, B, A这样的序列会被误导性地显示为A - 先提取语义值(非空值),display label 只基于有效值生成——避免空白格并入时产生
~ Lot-A这类不友好的文案
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 const values = visibleValues.length > 0 ? visibleValues : allValues;6 const allSame = values.every(v => v === values[0]);7
8 if (allSame) return { label: values[0], tooltipLabel: values[0] };9
10 const sep = config.rangeSeparator ?? ' ~ ';11 const first = values[0], last = values[values.length - 1];12 const n = values.length;13
14 const candidates = [15 values.join(sep), // 完整序列16 `${first}${sep}${last}`, // 带分隔范围17 `${first}~${last}`, // 紧凑范围18 `${first}(+${n - 1})`, // 计数摘要19 `(+${n - 1})`, // 最小摘要20 ];21
22 const label = pickBestLabelCandidate(candidates, availablePx, fontSize);23 const tooltipBase = values.join(sep);24 const tooltipLabel = blankCount > 025 ? `${tooltipBase} (+${blankCount} blank segments)`26 : tooltipBase;27
28 return { label, tooltipLabel };29}降级链路:
range 和 auto 模式现在统一走 pickBestLabelCandidate() 流程,在宽度不足时自动回退到更紧凑的候选。tooltipLabel 保留完整的语义值序列和空白 segment 数量,保证用户 hover 时能看到合并格的真实数据分布。
6.4 分层处理:逐层独立计算
一个自然的直觉是"内层合并必须受外层 DisplayCell 边界约束"——在"月→周→日"这种严格粗细粒度层级中,确实需要防止日期跨月合并。但在实际业务数据中,多层 X 轴的层顺序往往只是并列维度的展示顺序(如 WAFER_ID / LOT_ID),不一定存在天然的粗细粒度父子关系。
如果错误地假设层间存在父子约束,当上一层全是单点、而当前层存在 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[] 独立做密度自适应合并,不依赖其他层的计算结果。
七、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 rawText = displayLabel ?? dataName;2const truncateWidth = Math.max(0, validLabelWidth - LABEL_WIDTH_BUFFER);3const finalText = truncateMiddle(rawText, truncateWidth, fontSize);4
5return {6 type: 'bar',7 label: {8 formatter: () => finalText, // 第一道:Canvas 预截断9 overflow: 'truncate', // 第二道:ECharts 兜底10 ellipsis: '…',11 },12 // ...13};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 —— 初始构建
核心逻辑分为"自适应路径"和"非自适应路径"两条分支:
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 策略 / 逐层独立 / bucket 完整值序列 |
axisConfig | 178 行 | 默认开启 / gridIndex 偏移 / yAxisOffset / statisticsCount / 空数据 / zoom |
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 整除、首尾同值等
十一、常量与默认配置
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:先提取非空语义值,全部相同时返回单值,否则通过
pickBestLabelCandidate从完整序列 / 范围 / 紧凑范围 /(+n)中选最合适的候选 - 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 到优雅的工程实践》。