D3Charts-给legend实现动画分页效果

这是开发过程中记录的笔记,很乱,后面等开发完需要重新整理下。

走过的弯路

没理解数据结构

比如Group,之前都忽略了,直接导致方案设计有误,试图控制每一个LegendItem去实现动画。

没有理清楚类的继承关系和调用流程

在代码中绕来绕去,花了很久。最后自己画了一个关系图,瞬间就清晰了好多。

调试手段低效

一开始没有采用debugger,直接在代码里面埋点,效率太低了。

而且因为JS的弱类型语言特性,很多地方无法通过ctrl+鼠标点击,跟进代码内部,这又进一步增加了理解成本。

陷入代码细节

跟代码很容易陷入细节,而忽视全局的设计思路。

就比如动画,稍一思考,肯定能想到这是view层的,应该从view相关的代码中通过渲染、事件去找到实现原理。结果我调代码时将注意力都放在了代码细节上,被代码牵着走了。

另外,如果调试了一定时间(比如半小时或者1小时)仍然没有任何头绪,得赶紧停一下,要么问下其他同事,要么换个别的思路进行尝试。

一些问题

注意:要建个分支来进行开发,不要直接在主干上开发。

以ifind的需求:扩展Legend为例(Legend多的时候,可以左右滚动)。

先要明确具体需求,我这次是先参考了Echarts的效果,阅读了其文档中和该功能相关的配置项,然后去实现这些配置功能。

1、仔细阅读现有的d3charts文档,知道哪些配置是已经有了的:

http://datav.iwencai.com/platform/doc/configdoc/#legend

2、在demo/legend下面创建一个测试页面

3、查看src/component/legend的代码

流程:LegendModel处理配置信息->调用LegendView的render()方法->设置LegendItem

核心就是LegendView.render()这个方法,将其逻辑理清楚。

思路:每个图例都是一个shape,我应该根据配置,发现如果是scroll模式,则调整每个shape的位置的计算逻辑,将其展示成一个长条,不再换行;然后添加对应的翻页按钮和页码数据,并给其加上事件监听。

控制位置的,是position这个属性,其值是一个包含2个元素的数组,第一个元素是x坐标,第二个是y坐标。

LegendItem.group是什么,为什么position一直在变?打印出来看,感觉就是上一个元素的位置。这个数值达到页面边界就会被重置,因此我只需要找到这个重置的逻辑,将其改掉,这样就会将legend画成一行,不再换行了。代码在LegendItem中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (position[0] + _itemWidth > right) { // 超过画布宽
if (left === position[0]) { // 从起始位置
if (!itemWidth) {
autoFit();
}
} else {
// TODO: 这里重置了坐标
// position = [left, position[1] + lastHeight + verticalGap];

if (!itemWidth) {
// 这个会修改高度,因此也需要注释掉
// autoFit();
}
}
}

注意:

先明确我应该控制什么内容:我应该控制的是单个元素,即item的位置数据,以及item外面的这个容器框的显示范围。

等等,好像不对,我可以控制外面整个legend的样式来实现吧?哪个更好呢?

应该是控制外面整个legend,因为有动画效果;但是好像也不对,legend宽度是固定的吧

都实现一遍好了

待解决问题:

1、移动哪个比较好

2、动画怎么解决

通过ZRender来实现,参考这个文档

在外部拿不到LegendItem对象,因此动画只能在LegendView内部实现。

LegendView可以对外暴露几个控制动画的API,用于测试验证。

我得把上次的位置信息记录下来,便于设置动画的效果?

注意这个不是重新绘制,只是改变某些元素的位置而已,因此不能用setOption()触发。

3、如何修改item数据后重新设置进去

需要扩展下LegendView,增加position的透传。

5、如何获取legend中每个item的数据

1
chart.getViewOfComponentModel(chart.getModel('legend', 0)).group.children()

4、如何获取每个item的宽度(用于计算平移量,避免图例被截断)

1
2
3
chart.getViewOfComponentModel(chart.getModel('legend', 0)).group.children().forEach((item) => {
let {x, y, width, height} = item.getBoundingRect()
})

6、如何重新设置每个item的样式信息?

LegendView中,初始化LegendItem时,是取了LegendModel的data属性,然后将里面的每个元素这样处理的:

1
let { name, icon, style, size, showName } = item;

可以看到根本就没有解析position数据,因此我在外部修改了legend的data数据,加了position属性,也是没用的,需要将其改成这样才行:

1
let { name, icon, style, size, showName, position } = item;

然后在程序渲染逻辑中,判断如果传入了position,就不再自动计算position了。

7、view里面怎么取到每个item的shape的引用对象?

现在遇到的问题,就是修改了LegendItem,不生效,不管是直接改变值,还是通过动画来修改

等会看看ECharts文档和问问同事,别瞎折腾了

看看setShape这个方法的逻辑,我感觉是我取的对象不正确,没有真正取到图上的Shape对象

有setShape,是不是有getShape???有是有,但是也没用,和我自己从_shapeMap获取的一样

看了源码,setShape内置了动画,可以尝试下

关于数据驱动

view里面的数据都是不能拿到的,都是通过model计算得来了,我们要操作只能操作model,这是数据与渲染分离、数据驱动的设计。

反思:我这是不了解其设计初衷,就纯面向目标的开始搞了,结果走错了。

数据驱动的代码我还没搞清楚,这一条线得画个流程图出来才行。

关于动画:

ZZY提到这个可能是默认会执行的,我先修改model,然后调试跟踪下,看看为什么没有进入动画,然后再修改对应的配置。

几个关键的类和方法:

ShapeStorage、update、model.set({})

BarView.js中有animate的参数示例

接下去:

1、尝试setShapeGroup

看BarView.js的案例,感觉有点出入,group是把全部都放进去了

还是要一个一个把position算好才行啊,算好了整体放进group中去。

LegendItem的attr()在处理动画的时候,有问题。ShapeStorage里面会调用这个方法,但是传入的animateList结构不对。

动画是在ZRender的mixin/Animatable.js里面的animateTo()方法中实现的,看其代码示例,是传入shape的属性即可:

1
2
3
4
5
6
7
8
9
10
11
el.animateTo({
shape: {
width: 500
},
style: {
fill: 'red'
},
position: [10, 10]
}, 100, 100, 'cubicOut', function () {
// done
});

而我们实际上传入的是一个类似这样的数组:

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
[
{
x: 0,
y: 0,
textStyle: {
...textStyle,
fill: selected[name] ? textStyle.fill : inactiveColor,
text: showName ? showName : (formatter ? formatter(name, item) : name)
},
align,
symbolType: icon || symbolType,
symbolWidth,
symbolHeight,
symbolColor: symbolColor || findedSeries.getSeriesColor(name),
innerGap,
symbolStyle: merge(style || {}, symbolStyle, false),
series: findedSeries,
item,
isSelected,
inactiveColor,
position: position ? position : [curPos, curTop],
fromRight,
left,
right,
width,
lastHeight,
verticalGap,
addLine,
itemWidth
},
globalModel,
global
]

感觉结构都传错了,当然就不行了。

ZRender这个animateTo是怎么被调用到的呢?

可以看下zrender/src/Element.js:

1
2
3
4
5
6
7
8
9
10
11
12
var Element = function (opts) { // jshint ignore:line

Transformable.call(this, opts);
Eventful.call(this, opts);
Animatable.call(this, opts);

/**
* 画布元素ID
* @type {string}
*/
this.id = opts.id || guid();
};

怪不得单个shape中没有定义animateTo方法,原来是通过call调用的。

而zrender/src/container/Group.js中,也是通过call调用Element的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var Group = function (opts) {

opts = opts || {};
// 调用Element
Element.call(this, opts);

for (var key in opts) {
if (opts.hasOwnProperty(key)) {
this[key] = opts[key];
}
}

this._children = [];

this.__storage = null;

this.__dirty = true;
};

这样的写法,单凭肉眼跟踪代码很麻烦,必须通过debugger调试才能快捷的进行定位。

好像是得弄成group才行,把结构层级简化掉。

没搞懂,感觉不大对啊

实在不行,只能去看ECharts源码了。

临死一搏,我再看看Bar的实现源码。

animateFromanimateList到底是干什么的???

明天的工作

关于Group

没有会自动创建Group实例,有位置信息可以移动,debugger跟踪下setShapeGroup()就可以看到了。在Transformable.js中设置位置信息(position、rotation、scale),可以看下各个属性的键名是什么。

Group 是一个容器,可以插入子节点,Group 的变换也会被应用到子节点上。应该是监控到Group有变更,就会自动修改子节点的属性,触发子节点的变更,这个设计很棒。

要理解设计、理解设计、理解设计!不然就是瞎用,进而导致错用!

那么我只需要:

1、将数据整理好,通过setShapeGroup,生成一个初始化的Group实例。

还可以手动初始化一个空的Group,然后通过add()方法把子节点加进去

用add的方式比较好,因为现在每个子节点在setShape后,需要获取其属性,计算后续子节点的位置

注意:ShapeStorage缺少一个getShapeStorage的方法

2、修改model时,触发Group实例的位置变更。

注意不是setShapeGroup,而是取到这个Group,变更其position属性的值。

2、扩展LegendModel,增加分页属性

1、了解Shape的数据结构、绘图和动画机制

要是能把数据结构相关的类的继承关系可视化展现出来就好了。

(核心)Legend的Item到底是怎么画上去的?

方案一:

我们没有实现动画,用的都是ZRender的,因此看ZRender的API去实现吧

ZRender动画API+不断设置LegendModel的option来实现动画。

方案二:

用div实现legend。这个方案对用户来说不方便。

TODO : 接下来要做的事情:

1、新增一个类似LegendItem的类,用来处理滑动按钮和页码显示

2、数据处理+事件绑定(先通过额外的按钮来处理这个)

3、调试样式

我先自己瞎写一版,然后找下Echarts关于这个动效的实现源码,作为参考,再改进我自己的代码。

我的实现方案

1、直接在现有的Legendmodel上加一些配置项

Echarts的实现方案

1、新增一个ScrolllabelLegendModel,继承自LengendModel,并扩展其配置

分页按钮的事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function createPageButton(name, iconIdx) {
var pageDataIndexName = name + 'DataIndex';
var icon = graphic.createIcon(
legendModel.get('pageIcons', true)[legendModel.getOrient().name][iconIdx],
{
// Buttons will be created in each render, so we do not need
// to worry about avoiding using legendModel kept in scope.
onclick: zrUtil.bind(
me._pageGo, me, pageDataIndexName, legendModel, api
)
},
{
x: -pageIconSize[0] / 2,
y: -pageIconSize[1] / 2,
width: pageIconSize[0],
height: pageIconSize[1]
}
);
icon.name = name;
controllerGroup.add(icon);
}

代码对比

Echarts的小而美,不像我们一个方法一大堆

Echarts逻辑很清晰,比如render,就是先渲染内部元素(renderInner),然后确定布局(layoutInner),然后渲染整体;而且命名能体现实际含义,代码可读性强

Echarts的View里面,前面就是render方法,主要体现的是逻辑,而不是具体功能的实现代码,可以看到里面大多都是方法调用,类似于后端开发的控制器。这个设计很好。具体的代码实现,可以放后面,这样阅读体验也好,如果用不着看代码实现,那么只阅读文件最前面的内容即可。

Echarts类似建造金字塔,从最底层的Canvas,到ZRender引擎,到Echarts基础元素,再到上面的组件实现,通过设计+工具库的方式层层支撑。要想玩的溜,必须自下而上都掌握好才行。

把Echarts这个金字塔真正吸收,那么Canvas可视化所需的技术、程序设计,基本就差不多了。

Echarts里面也有很多硬编码,这是否说明一味的强调常量,不一定是好方案?

__DEV__常量的设计

Echarts的代码很多都可以拷贝来直接用(比如createPageButton()),说明其耦合度低,值得学习。

var me = this这个变量命名,绝了。我们可以用知名的电影角色来命名了。

滚动动画在哪里实现的?

Action么?不像。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
echarts.registerAction(
'legendScroll', 'legendscroll',
function (payload, ecModel) {
var scrollDataIndex = payload.scrollDataIndex;

scrollDataIndex != null && ecModel.eachComponent(
{mainType: 'legend', subType: 'scroll', query: payload},
function (legendModel) {
legendModel.setScrollDataIndex(scrollDataIndex);
}
);
}
);

是这个eachComponent,给每个组件都触发了事件么?

跑起来,断点调试!搞啥呢

做事和搞技术的共性和区别是什么?哪个容易哪个难?

注意事项

得先弄清楚设计理念才行,不然理解成本太高了

还有名词,一些名词变量需要提前理解

Canvas的常识也要弄清楚,比如画布里面的元素的x、y值的设置

做的过程中,经常这样思考:如果我来实现这个功能,我会怎么做?然后再看具体实现的代码,和自己的想法做个验证。

有几个基础对象要摸透,比如Text,这个是引自ZRender的,是通用的文本对象。

另外我的调试方法是不对的,很低效;应该用debugger进行调试。

用debugger走一遍,清晰明了,ROI极高

TODO : 必须要把这个调试先弄熟练

另外,我又忘记了,应该先把数据模型理清楚,即model的结构

registerComponent的目的是什么?

registerClass:只是存入storage对象

后续工作

给GroupModel添加animate相关的方法

猜测应该是用类似call之类的方式

我们的代码中没收到isAnimationEnabled,找了项目下的node_modules也没有,看来是真没有。

看了ECharts源码,发现是在model中自己定义的,比如src/model/Series.js文件中就有定义。

扩展2个方法即可:

isAnimationEnabled

getShallow:这是在Model基类中定义的,用于获取配置信息;如果当前对象取不到,就取其父对象的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @param {string} key
* @param {boolean} [ignoreParent=false]
* @return {*}
*/
getShallow: function (key, ignoreParent) {
var option = this.option;

var val = option == null ? option : option[key];
var parentModel = !ignoreParent && getParent(this, key);
if (val == null && parentModel) {
val = parentModel.getShallow(key);
}
return val;
},

我们自己的Model中没有定义getShallow这个方法,抄了一半,晕倒……

还是仿照ECharts,单独扩展3个Scroll的Legend类吧

1、扩展3个类

2、给Model添加animate相关的方法,给action添加滚动事件

3、修改render方法的逻辑,实现动画和分页

4、拆分render方法,参考ECharts,拆分成不同的部分,进行渲染

思考:如何让d3charts调用到这个新增加的ScrollableLegend呢?

方案一:

Scrollable包含原始Legend的功能,在Scrollable内部判断option,做差异化渲染

这种比较好,无侵入

不过为什么legend没有画出来?经过排查,发现是我的ScrollableLegendView的TYPE属性继承错了,没有继承自ScrollableLegendModel,而是继承自了LegendModel,因此导致doRender()画图的时候找不到对应的view,所以没有画出来。

方案二:

找到调用Legend的地方,增加条件判断,初始化Legend或者ScrollableLegend

组件是怎么注册的?

组件是通过src/component/xxx/index.js中,通过registerComponent(ScrollableLegendModel, ScrollableLegendView)进行注册的。注册后会用Model.type的属性值作为组件的名称,因此初始化组件的时候,配置option的键名,就是组件的type值;后面获取model,也是通过type值来获取。

比如我扩展了一个名为ScrollableLegendModel和ScrollableLegendView的组件,其type设置为scrollableLegend,那么配置option就应该这样配置:

1
2
3
4
5
6
7
scrollableLegend: [{
show: true,
data: legendData,
symbol: {
type: 'diamond'
},
}],

获取model则写成这样:

1
var legendModel = chart.getModel('scrollableLegend', 0);

static怎么用?

学到一招,调用父类构造方法

动画的触发原理设计

一定要牢记数据驱动这个原则。

因此动画的触发逻辑,应该是这样的:

点击分页按钮->触发scroll事件->修改legendModel的属性->重新绘制图形->执行动画

注意核心是修改legendModel的属性,然后在重新渲染中,自动实现动画。