ECharts扩展配置项-dvNameLocation

需求

问题重现 Demo

详见这个示例

问题描述

轴标题属于绝对定位,无法保证标题文本一定位于图表上方或下方

x 轴的轴标题在 x 轴右侧显示时,是跟随 x 轴轴线的,不是在图表右下角,0 轴情况会出现问题(dvNameLocation)

技术方案

相关配置项:axis.nameLocation

生命周期:位于component:beforeupdatecomponent:afterupdate之间,通过对应组件的CartesianAxisView.render()方法触发调用。
lifecycle-position

1
zrUtil.each(axisBuilderAttrs, axisBuilder.add, axisBuilder);

关键问题:找到计算和渲染之间的空档,把我们的逻辑(修改 axis.name 的 x 属性)插入进去。

代码位置:axisBuilder.ts 中的 axisName()方法,核心是这几行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const nameLocation = axisModel.get('nameLocation');
const nameDirection = opt.nameDirection;
const textStyleModel = axisModel.getModel('nameTextStyle');
const gap = axisModel.get('nameGap') || 0;

const extent = axisModel.axis.getExtent();
const gapSignal = extent[0] > extent[1] ? -1 : 1;
// 看起来算的是个相对位置?
const pos = [
nameLocation === 'start'
? extent[0] - gapSignal * gap
: nameLocation === 'end'
? extent[1] + gapSignal * gap
: (extent[0] + extent[1]) / 2, // 'middle'
// Reuse labelOffset.
isNameLocationCenter(nameLocation)
? opt.labelOffset + nameDirection * gap
: 0,
];

grid 的计算时机和逻辑

计算时机

在 ECharts 的生命周期component:beforeupdate中修改。

component:beforeupdate之前,因为我在这个生命周期里面已经能取到 grid 的包围盒了。

计算逻辑

如何获取 grid 的包围盒

src/coord/cartesian/cartesianAxisHelper.ts

1
2
3
const gridModel = axisModel.getCoordSysModel();
const grid = gridModel.coordinateSystem;
const rect = grid.getRect();

grid 和 axis.name 的计算相互依赖的问题

尬住了…

grid 的边距依赖 axis.name 的 padding(流式布局)
axis.name 的 padding 依赖 grid 的包围盒(dvNameLocation)

因此 dvNameLocation 不能这样修改,得改现有的 0 轴 axis.name 的逻辑
不对,可以的,顺序问题。先计算 grid,再计算 axis.name 即可。

(精)流程

看 ECharts 源码

找到 ECharts 的源码,理解其逻辑
比如这次就是搜索 nameLocation 这个配置项,定位到 axisBuilder.ts 这个文件的源码,经过打印输出调试,发现核心是这一句:

1
isNameLocationCenter(nameLocation) ? opt.labelOffset + nameDirection * gap : 0;

然后查看其相关的变量(labelOffset、nameDirection、gap),发现是通过/src/component/axis/CartesianAxisView.ts 调用传入的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const gridModel = axisModel.getCoordSysModel();

const layout = cartesianAxisHelper.layout(gridModel, axisModel);

const axisBuilder = new AxisBuilder(
axisModel,
zrUtil.extend(
{
handleAutoShown(elementType) {
const cartesians = gridModel.coordinateSystem.getCartesians();
for (let i = 0; i < cartesians.length; i++) {
if (
isIntervalOrLogScale(
cartesians[i].getOtherAxis(axisModel.axis).scale
)
) {
// Still show axis tick or axisLine if other axis is value / log
return true;
}
}
// Not show axisTick or axisLine if other axis is category / time
return false;
},
} as AxisBuilderCfg,
layout
)
);

通过 CartesianAxisView.ts 就可以知道我需要的几个变量是如何获取的了(layout 的计算逻辑)。

注意阅读注释,比如 labelOffset,注释里面就明确说明了其作用:
labelOffset means offset between label and axis, which is useful when ‘onZero’, where axisLabel is in the grid and label in outside grid.

参考其他的类似实现

参考这个 paradigm-chart/src/extension/component/axis/extendLabelWidth.ts,采用了 install 的方式。

注意扩展的配置项还需要在 paradigm-chart/src/core/option.ts 中进行声明:

1
2
3
4
5
6
/**
* 设置坐标轴名称显示位置,以解决0轴位于图表中间时,轴标题位置跟随0轴的问题
* 参数值同axis.nameLocation
* 仅针对X轴生效,Y轴无需该效果
*/
dvNameLocation?: string;

规范

关于 extension 下文件的命名问题

如果只修改一个配置项,则文件名和配置项保持一致即可,比如:dvSplitLineNumber.ts

如果修改了多个配置,则以 extend+配置分类命名,比如:extendLabelWidth.ts

registerPreprocessor 机制

echarts-5.3.3/src/core/echarts.ts

1
2
3
4
5
6
7
8
9
10
/**
* Register option preprocessor
*/
export function registerPreprocessor(
preprocessorFunc: OptionPreprocessor
): void {
if (indexOf(optionPreprocessorFuncs, preprocessorFunc) < 0) {
optionPreprocessorFuncs.push(preprocessorFunc);
}
}

问题

可视化的其他人写什么

https://datav.iwencai.com/example.html#/static-page?id=259

写这里的 Parser 函数即可,比如 myThemeParser。

业务方的开发写什么

1、不写 Parser,后续我们升级,他们只需要更新 Parser 函数即可

2、配置主题,即写 option 配置项

定位问题

弄清楚每个元素相对什么元素定位。
比如 axis.name 是相对 grid 定位的

xAxis.name

Y 轴的 0 刻度在画布内部:需要偏移 0 轴的 Y 坐标 + Label 的高度
因为 axis.name 是相对 grid 里面的 0 轴 定位的,因此无需偏移 gridRectBoundingRect.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* Y轴的0刻度在画布内部:需要偏移0轴的Y坐标 + Label的高度
* 因为当nameTextStyle.verticalAlign=top时,axis.name是相对grid里面的0轴定位的,因此无需偏移gridRectBoundingRect.y
*/
if (containZero) {
previousPadding = [
zeroYValue + labelHeight,
0,
0,
width / -2 - xAxisNameOffset,
];
} else {
// Y轴的0刻度在画布内部:需要偏移Label的高度
previousPadding = [
gridRectBoundingRect.y + labelHeight,
0,
0,
width / -2 - xAxisNameOffset,
];
}

lineHeight 导致的坑

范式里面设置了 lineHeight 为 12(估计是为了行间距美观度,默认的 fontSize 是 12),但是如果用户设置了 fontSize 的情况下,这个 lineHeight 会导致计算相关的场景出错,比如流式布局中计算 axis.name 的高度,就错误了(应该按 fontSize 算,结果取了 lineHeight 的高度,导致纵向对不齐)。
animation

解决方案是把范式中的 lineHeight 相关配置去掉了。因为这个不管怎么设置都会有问题:

如果动态设置为 lineHeight = fontSize + 2,那么字体大的时候,这个行间距就显得太小了。

TODO

动画问题

动画的问题,得找到一个时机,每次 AxisBuilder 之前执行我们的逻辑

不行还有终极方案:patch-package

animation

单次渲染没问题,多次渲染会出问题。

没问题:

1
2
3
4
// chart.play({ option });
setTimeout(() => {
chart.play({ option });
}, 2000);

有问题:

1
2
3
4
chart.play({ option });
setTimeout(() => {
chart.play({ option });
}, 2000);

第一次渲染:正确
第二次渲染:从正确到错误

打印的执行顺序:
order

那么应该是:
第一次渲染:

  • AxisBuilder-axisName
  • adjustNameLocation

第二次渲染:

  • AxisBuilder-axisName
  • 动画开始(获取到错误的定位信息)
  • adjustNameLocation(动画不走这个流程了,导致其不生效?)

通过打印 trace,二者都是在 render 中触发的:
render

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
render = (
ecIns: ECharts,
ecModel: GlobalModel,
api: ExtensionAPI,
payload: Payload,
updateParams: UpdateLifecycleParams
) => {
allocateZlevels(ecModel);

// 这里会执行动画(updateStates()),因此后续的状态就没用到了
renderComponents(ecIns, ecModel, api, payload, updateParams);

each(ecIns._chartsViews, function (chart: ChartView) {
chart.__alive = false;
});

renderSeries(ecIns, ecModel, api, payload, updateParams);

// Remove groups of unrendered charts
each(ecIns._chartsViews, function (chart: ChartView) {
if (!chart.__alive) {
chart.remove(ecModel, api);
}
});
};

结论:
1、component的动画应该是在 series:beforeupdate 之前就开始了,这样才会取到错误的位置数据;
2、component的动画和series的动画执行时机是不一样的。
animation-time

有个问题说不通:为什么一定是同侧同时有 xAxis.name 和 yAxis.name 才出现这个问题?

============

看起来是这样的顺序:
1、adjustNameLocation
2、setTimeout
3、adjustNameLocation
4、执行动画
也就是在动画的第一帧,这个 adjustNameLocation 生效了,然后在后续的帧里面,则没有生效,而是走的默认的 axisName 的逻辑。
结合 ECharts 的 lifecycle 来分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
interface LifecycleEvents {
afterinit: [EChartsType];
'series:beforeupdate': [GlobalModel, ExtensionAPI, UpdateLifecycleParams];
//'render,
// grid calculateGridLayout
// coordinatesystem:1. update 2.render 3.animation
'series:layoutlabels': [GlobalModel, ExtensionAPI, UpdateLifecycleParams];
'series:transition': [GlobalModel, ExtensionAPI, UpdateLifecycleParams];
'series:afterupdate': [GlobalModel, ExtensionAPI, UpdateLifecycleParams];
// 'series:beforeeachupdate': [GlobalModel, ExtensionAPI, SeriesModel]
// 'series:aftereachupdate': [GlobalModel, ExtensionAPI, SeriesModel]
afterupdate: [GlobalModel, ExtensionAPI];
}

这个扩展是写在series:beforeupdate里面的。

感觉还是时机的问题,动画执行时机比我们自定义的 adjustNameLocation 更靠前,此时获取的是 AxisBuilder-axisName 算出来的位置信息,因此出现这种情况。
而且有动画的情况下,adjustNameLocation 算出来的这个数值不会被使用。
禁用动画果然就没这个问题了。
另外发现仅在有双 Y 轴名称的情况下才会出现这个问题,这是为什么?
解决方案:
1、(TODO)在 AxisBuilder-axisName 和动画执行之间,插入我们的逻辑。
2、禁用 axis 的动画:animation: false
3、别同时设置 xAxis.name 和 yAxis.name 在同一侧

axisName 的动画在哪里触发的、时长如何控制的?

ECharts 注释掉了series:beforeeachupdate这个生命周期,为什么?

1
2
3
4
5
6
7
8
9
10
interface LifecycleEvents {
afterinit: [EChartsType];
'series:beforeupdate': [GlobalModel, ExtensionAPI, UpdateLifecycleParams];
'series:layoutlabels': [GlobalModel, ExtensionAPI, UpdateLifecycleParams];
'series:transition': [GlobalModel, ExtensionAPI, UpdateLifecycleParams];
'series:afterupdate': [GlobalModel, ExtensionAPI, UpdateLifecycleParams];
// 'series:beforeeachupdate': [GlobalModel, ExtensionAPI, SeriesModel]
// 'series:aftereachupdate': [GlobalModel, ExtensionAPI, SeriesModel]
afterupdate: [GlobalModel, ExtensionAPI];
}

如何禁用掉 AxisBuilder.ts 的 axisName 的计算?

这个不禁用掉,一旦切换数据,Y 轴从有 0 轴到无 0 轴,axis.name 的位置就会出现问题(出现 2 帧的插帧动画)。

方法一:用装饰器模式重写 AxisBuilder.ts 的 isNameLocationCenter()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function isNameLocationCenter(nameLocation: string) {
return nameLocation === 'middle' || nameLocation === 'center';
}

const pos = [
nameLocation === 'start'
? extent[0] - gapSignal * gap
: nameLocation === 'end'
? extent[1] + gapSignal * gap
: (extent[0] + extent[1]) / 2, // 'middle'
// Reuse labelOffset.
isNameLocationCenter(nameLocation)
? opt.labelOffset + nameDirection * gap
: // 出问题的地方
0,
];

opt.labelOffset是怎么计算的

关键看这个opt.labelOffset是怎么计算的,改为根据这个定位即可!

cartesianAxisHelper.ts 的 layout()方法。

1
2
3
layout.labelOffset = otherAxisOnZeroOf
? posBound[idx[rawAxisPosition]] - posBound[idx.onZero]
: 0;

0 轴的逻辑是怎么计算的?

TODO

记得把 settings.json 中的 node_modules 的注释去掉。

经验教训

没搞懂原理,黑盒式开发

比如 grid、axisLabel、axisName、0 轴线等等这几个元素在布局时的关联关系。如果没搞懂,那么很容易变成打地鼠式的调试,因为每个元素都是一个变量(自变量),当有 n 个元素时,相互之间的影响复杂度就是(O)n^2 了。即时你临时调试出一个算法,很可能也是碰巧,稍微换下场景和配置,就会出现问题。

y=f1(a)+f2(b)+f3(c)

实际上后面我真正弄懂ECharts的布局与定位关联关系后,发现这个计算规则是非常简单的。

控制变量至关重要!

TDD 测试先行

贯彻这个理念,可以有效减少调试时间。

不一定是真的写单元测试,比如写好足够的 Demo,也算是一种 TDD 的思维。

像我后面,直接把 mobile-单 y 轴、pc-单 y 轴、pc-双 y 轴这几个场景的 Demo 都写好了,这样改动后马上可以验证各个场景的正确性,发现问题的效率得到了极大的提升。

多个组件(比如双 Y 轴)

同一个页面渲染多个图表

每个图表都要用到原始的 nameTextStyle.padding,但是我在每次渲染中都会去修改这个 nameTextStyle.padding。因此必须要有个策略,把原始的 nameTextStyle.padding 存储起来,跨不同的渲染周期复用,且要考虑重新 setOption 时这个 nameTextStyle.padding 的更新问题。

最终我用 WeakMap 来存储这个原始的 nameTextStyle.padding,每次渲染时,先从这个 WeakMap 中取出原始的 nameTextStyle.padding,然后再进行修改。

将每个图表实例的 axisModel 作为 WeakMap 的 key,这样就能解决同一个页面渲染多个图表的问题了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 上一个渲染周期的配置
* 用于解决配置项的值(比如padding)在每次渲染中错误叠加的问题
*/
const rawPaddingMap = new WeakMap();
/**
* 为了保证每次渲染时,padding的值不会叠加
* (如果累加,在拖动dataZoom或者动画下,会导致axis.name的位置偏移越来越大)
*/
const previousPaddingMap = new WeakMap();

// 使用示例
if (rawPaddingMap.get(axisModel) === undefined) {
rawPaddingMap.set(axisModel, []);
}

Debug 模式

每次调试 console.log()和 console.error()混用,很麻烦。干扰信息很多,不利于定位输出信息。

Canvas 绘制中英文的问题

结论:Canvas 绘制的英文,获取的包围盒宽度值,会大于其实际占据的宽度值。

同样的配置,KAmis 中画出来 yAxis.name 和 yAxis.axisLabel 对不齐,VISALL 画出来却是对齐的。

经过对比观察,发现 yAxis.axisLabel 的内容,一个是纯数字(KAmis),一个是单位做了格式化(VIALL)。

将 KAmis 的加上格式化,发现果然整齐些了。

Option 对比工具-Partial Diff

这是个 VSC 的扩展。

写代码的套路

我的adjustXAxisNameLocation()写得一塌糊涂,究其原因,是因为我写的时候根本没一个流程思路,就是按照流水账随性的写。

后面总结,发现其实可以按照这个思路写:

1、画图,方案设计
2、参数预处理(比如checkPrerequisitesForX(axisModel))
3、计算出后续所需的变量(比如 labelHeight、nameGap)
4、判断逻辑和布局计算(这部分应该是各种 if/else)
5、赋值 or 返回

这样有 2 个很大的好处:
1、思路清晰
2、写完后,可以很方便的把这个函数拆分成多个函数,每个函数只做一件事,这样代码更加清晰,可读性和可维护性大大提升

多线并行的问题

同时在处理几个事情:

  • 熊鹏的接入问题
  • StandardChart 的 CDN 访问失败的问题
  • 股权图谱的开发

一个打断,半天接不起来,效率低下。

代码案例

组件的数据结构和最小循环多 Y 轴判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// * 同一个 grid 内多 y 轴名称位置问题
const girdMap = {
0: {
countLeftYAxis: 0,
countRightYAxis: 0,
},
};

option?.yAxis?.forEach((axis, index) => {
const gridIndex = axis.gridIndex || 0;
let counter = girdMap[gridIndex];
if (!counter) {
counter = girdMap[gridIndex] = {
countLeftYAxis: 0,
countRightYAxis: 0,
};
}

const isRight = axis?.position === 'right';
// 在非首次循环时,即可判断并执行第二个Y轴的逻辑了
if (isRight || (counter.countLeftYAxis > 0 && axis?.position !== 'left')) {
axis.nameTextStyle = {
...(axis?.nameTextStyle || {}),
align: 'right',
// padding: [4, 4, 0, 0]
};
console.error('axis', axis);

counter.countRightYAxis += 1;
} else {
counter.countLeftYAxis += 1;
}
});

资料

ECharts 源码

https://github.com/apache/echarts/releases/tag/5.3.3