ECharts扩展流式布局

需求

ECharts 的设计是每个元素在画布上绝对定位的,这样元素多了可能出现重叠问题。
设计师为了让业务方的不同场景都可以自动解决这些布局问题,希望我们能支持类似 DOM 的流式布局。

方案设计

Plan1:二次绘制(在组件层解决)

在各个组件绘制后,获取其包围盒信息,计算布局,然后设置 grid,二次绘制。

这个方案存在很多问题:

因为存在两次绘制,图表初始化会出现 2 次绘制结果的切换效果

(待确认)多周期数据的时间轴的场景,因为上面这个问题,画面各种抖动

严重的性能问题

Double 性能消耗,在数据多、动画多的情况下,尤为严重,导致帧数肉眼可见的下降。

和之前改 axisName 的定位同样的问题:有动画的情况下会出现异常

针对一些我们修改过渲染逻辑的配置(比如 dvNameLocation),无法获取其正确的渲染大小

1
2
3
4
5
6
7
const axisView = api.getViewOfComponentModel(axis);
setTimeout(() => {
// 50
console.error('axisView', axisView.group.getBoundingRect());
}, 2000);
// 30
console.error('axisView', axisView.group.getBoundingRect());

这是因为 dvBoxLayout 进行了二次渲染的缘故。

时间轴组件会有问题

每一个周期的 Y 轴 label 的最大值是不相同的,我们没有每个周期都重新计算。

dataZoom 导致 grid 变更的问题

最终该方案被舍弃了。

Plan2:重写 ECharts 的布局逻辑(patch-package)

成本太高,且扩展性和适应性太差,很容易今后来个布局相关的其他需求就又解决不了了,Pass。

Plan3:组件预绘制(在配置层解决)

相当于增加了一个预渲染层

流程:
1、整合用户配置+默认配置,得到一个最终传入组件的 option
2、获取这个 option 中和布局相关的几个组件的配置信息,用 ZRender 在内存中绘制这些组件
3、根据这些组件的包围盒信息,计算布局,然后设置 grid,进行绘制。

布局计算逻辑

顺序:

grid.left 影响 Y 轴轴线,所以 grid.left> Y 轴轴线

Y 轴轴线影响 axisName,所以 Y 轴轴线 > axisName

axisName:

左侧位置
这个是根据 Y 轴轴线居中对齐的。

外部接口(用到的 API)

获取元素对象

  • 遍历父 View 对象的 group 属性

  • 根据 anid(Id for mapping animation) 筛选对象类型

ExtensionAPI.getViewOfComponentModel()

1
2
3
4
5
declare abstract class ExtensionAPI {
abstract getViewOfComponentModel(
componentModel: ComponentModel
): ComponentView;
}

ExtensionAPI 提供的各个 API 方法都可以重点记忆下,我们写扩展的时候经常会用到。

以获取轴名称为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 从 view 实例获取轴标题元素
*/
export function getAxisNameEls(compView: CartesianAxisView) {
const nameEls = [] as ZRText[];

compView.group.traverse((childEl) => {
if (childEl.anid === 'name') {
nameEls.push(childEl as ZRText);
}
});

return nameEls;
}

const compView = api.getViewOfComponentModel(axisModel) as CartesianAxisView;
const nameEls = getAxisNameEls(compView);

获取组件

GlobalModel.eachComponent()

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
declare class GlobalModel extends Model<ECUnitOption> {
/**
* Travel components (before filtered).
*
* @usage
* eachComponent('legend', function (legendModel, index) {
* ...
* });
* eachComponent(function (componentType, model, index) {
* // componentType does not include subType
* // (componentType is 'a' but not 'a.b')
* });
* eachComponent(
* {mainType: 'dataZoom', query: {dataZoomId: 'abc'}},
* function (model, index) {...}
* );
* eachComponent(
* {mainType: 'series', subType: 'pie', query: {seriesName: 'uio'}},
* function (model, index) {...}
* );
*/
eachComponent<T>(cb: EachComponentAllCallback, context?: T): void;
eachComponent<T>(
mainType: string,
cb: EachComponentInMainTypeCallback,
context?: T
): void;
eachComponent<T>(
mainType: QueryConditionKindA,
cb: EachComponentInMainTypeCallback,
context?: T
): void;
}

以获取所以 grid 组件为例:

1
2
3
4
5
6
7
8
9
10
ecModel.eachComponent('grid', (gridModel: GridModel) => {
enable = !!gridModel.get('dvBoxLayout.enable');

gridModel.__$$cacheDvBoxLayout = {
leftValues: [],
rightValues: [],
topValues: [],
bottomValues: [],
};
});

获取配置项

model.get()

1
2
3
4
5
6
7
8
9
10
11
12
declare class Model<Opt = ModelOption> {
get<R extends keyof Opt>(path: R, ignoreParent?: boolean): Opt[R];
get<R extends keyof Opt>(path: readonly [R], ignoreParent?: boolean): Opt[R];
get<R extends keyof Opt, S extends keyof Opt[R]>(
path: readonly [R, S],
ignoreParent?: boolean
): Opt[R][S];
get<R extends keyof Opt, S extends keyof Opt[R], T extends keyof Opt[R][S]>(
path: readonly [R, S, T],
ignoreParent?: boolean
): Opt[R][S][T];
}
1
2
3
4
5
6
7
8
// 单级属性
gridModel.get('dvBoxLayout');

// 多级属性1
gridModel.get('dvBoxLayout.enable');

// 多级属性2
gridModel.get(['dvBoxLayout', 'enable']);

对外接口(对外暴露的 API)

内存绘制

用 ZRender 绘制。

https://ecomfe.github.io/zrender-doc/public/api.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 获取echarts实例
const echarts = window.ThsDataVStandardChart.echarts;

// 在内存中绘制富文本内容
const rect = new echarts.graphic.Text({
style: {
text: '{a|afadsafasdfad}\n{b|sdfnjsdanvjksadnjv}',
rich: {
a: {
width: 50,
height: 20,
padding: [1, 2, 3, 4],
},
b: {
width: 50,
height: 20,
padding: [1, 2, 3, 4],
},
},
},
});

console.error('绘制的矩形的包围盒信息', rect.getBoundingRect());

属性未设置怎么画?

比如 axis.nameTextStyle,默认情况是没有 fontSize 等属性值的,类似这样:

1
2
3
4
5
6
7
8
9
{
"verticalAlign": "top",
"padding": [
-20,
0,
20,
113.31640625
]
}

因为这是用户配置,需要拿到预处理的 option,就有值了。

比如 parseVennOption 这里的 option 就有了。

如何控制 ThemeParser 的执行顺序(解决依赖问题)?

因为 grid 这种,是依赖于 axis、dataZoom 等的大小和定位的。

之前主题中的 grid 逻辑

1、设置特殊的边距

2、合并默认主题

1
util.merge(target, source);

PC

src/theme/pc/component/grid.ts

对普通图表、DynamicHistogram 分别设置了不同的边距。

mobile

src/theme/mobile/component/grid.ts

设置边距

ainvest

src/theme/ainvest/component/grid.ts

额外处理了 AttchedGrid 的情况。

GridParser 程序设计

1
2
3
4
5
6
7
8
// 预绘制组件
function preRenderComponents(option): Map<string, zrender.Displayable>;

// 计算布局
computeLayout(componentMap);

// 设置grid(可省略)
apply();

(TODO)多个 grid 的场景如何处理?

axisView

只需要绘制 label 即可

1
2
3
4
const gridRect = grid.coordinateSystem.getRect();
const axisView = api.getViewOfComponentModel(axis);
const axisViewRect = axisView.group.getBoundingRect().clone();
axisViewRect.applyTransform(axisView.group.transform);

搞清楚 AxisView extends ComponentView 的渲染逻辑。

AxisBuilder.ts 吧?

经验

抓住本质:修改 option & 重绘

StandardChart、AIGC 可视化库,都是这个逻辑。

配置项预处理

registerPreprocessor

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);
}
}

忽略了多个元素的情况

多个 grid、xAxix、yAxis、series 等等

挂载缓存数据

1
gridModel.__$$cacheDvBoxLayout = { leftValues: [], rightValues: [] };

生命周期的执行顺序

生命周期的执行顺序

资料

dvBoxLayout
实现网格布局的自适应,根据左右两侧 Y 轴的元素内容宽度动态计算网格的左右两侧留白距离。