可视化组件的状态管理

状态的分类

交互状态

参考王渊《交互状态设计-探索 ECharts 与 ZRender 的设计模式》

在特定的交互行为下才会触发的样式(状态)

这里的交互指的并不一定是常规的鼠标交互行为,其代表的是我们将一种事物变更为另一种状态的行为,例如一个列表中文本的搜索、筛选高亮,交互则指的是搜索和筛选的这两种用户行为。

状态的设计

五种常用状态:

NORMAL、SELECTED、UNSELECTED、EMPHASIS、BLUR

状态的管理

状态的变更状态的应用分离开。

React、Vue、ZRender 的状态管理

React

小状态:state

大状态:Redux

Vue

小状态:state

大状态:Vuex/Pinia

ZRender

setProxy 的设计

如何用状态表达对象的系统特征

由于节点(Node)的抽象粒度较粗,而其关联数据的特征信息很多,对于数据分析场景来说,其实有很高的信息表达复杂度。于是出现了这样一个需求:通过一个开关可让用户控制是否需要按节点类型分组着色的效果。说明白一点就是,在按交互行为类型对状态类型进行抽象设计的情况下,一个 Node 实例对象在 normal 状态下可能会因为开关的状态不同而表现不同,该如何应对此类场景?

其实也有两个方案,一是对开关的状态进行状态类型抽象,这样就可以实现 normal 和 switchOpen 两个状态叠加应用并更新对象,但状态的叠加还会产生其它问题,例如状态叠加的顺序。不过,查看 ZRender 的源码,其实可以看到对多状态的叠加是支持的,但在实际使用过程中,叠加的多个状态定义也会产生一些问题。

从按交互行为对状态类型进行抽象的角度来看,这是全部 Node 对象共有的交互特性,也可看作是 Node 个体特征,而要将全部 Node 分组进行着色实际上是 Node 的系统特征的表达。所以,第二种方案就是将交互行为分为两类用户行为(个体行为)和系统行为。在状态类型抽象设计时,具体的状态类型表达的是用户行为,而对应的状态对象定义可以动态计算,其依赖于系统行为状态。这样,就避免了状态叠加的复杂性和问题,利用命令式编码的灵活性来方便的应对此类复杂场景。实际上,这也是之前为何选择了动态计算状态定义的方案,而 ZRender 中 stateProxy() 的设计应该也是考虑了此类场景。

–王渊

设计要点

0、不要 2 个交互操作一个状态(气泡移入移出的例子)

1、保持单向数据流,不要形成环状依赖(即通过读取当前页面上的元素的状态,来计算下一次显示内容的状态)

2、对状态进行整理,行为树 +状态机,每个行为对应一个【视图】,每个视图中,针对每个元素设置其【状态】

3、每次渲染,都需要执行 2 个操作:

(1)回收:清空当前所有绘图元素的状态

(2)赋值:赋予需要绘制的元素状态

4、参考王渊龙虎榜的例子,ZRender 的 state 管理状态的叠加

状态的层级分类

状态可以分为全局状态(视图维度,比如顶部视图)和组件状态(对象维度)

状态的数据结构定义

1、建议不要简单的弄一个 1、2、3、4,最好定一个结构,包含标记、描述信息等等

2、建议定义一个统一的接口来处理状态行为(这里指大状态,即交互行为),支持前置/后置钩子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 定义一个状态接口 用来规范我们状态的设计行为
interface StatusInterface
{
// 设置当前状态
void SetStatus(Status status);

// 前置钩子,进入该状态,要做的事
void EnterStatus();

// 后置钩子,离开该状态,要做的事
void QuitStatus();

// 获取到当前的状态
Status GetStatus();
}

class TrackingStatus implements StatusInterface
{
// 略
}

(TODO)状态机与行为树

状态机和行为树PresleyGo 的博客-CSDN 博客行为树和状态机区别

FSM(状态机)、HFSM(分层状态机)、BT(行为树)的区别 - 程序员大本营

从这个角度来看,我们的动态图表似乎根本用不着状态机和行为树,因为太简单了。

3D 场景因为子元素比较多,且每个元素状态可能不一样,可能会用得上分层有限状态机(HFSM)。

行为树:ZRender 的动画机制

状态机:ECharts 或者我们自己的业务

状态机

两个核心元素:状态 + 切换状态的条件

网状结构,复杂后就成为一团乱麻。

状态与状态之间的过渡通过事件的触发来形成(用户的交互动作也属于事件),是一种“事件触发型”AI,就是只有事件的触发才会发生引起状态的变化。

FSM:有限状态机

HFSM:分层有限状态机

FSM 当状态太多的时候,不好维护,于是将状态分类,抽离出来,将同类型的状态做为一个状态机,然后再做一个大的状态机,来维护这些子状态机。

这样大大的降低了状态机的复杂度。

如果感觉还是过于复杂,就继续分层。

我们的视图、关键帧,就是分层状态机。

决策树(Decision Tree)

行为树(Behavior Tree- The Next-Gen AI)

这是当前的主流

树状结构,相对于状态机的网状结构来说耦合性更低。

行为树的子节点执行时需要返回,返回类型有:False,True,Running

行为树把行为逻辑状态数据剥离

关键词:tasks(任务),action(行为),composite(复合),conditional(条件),decorator(修饰符)

比如 NPC 要跳跃,那么前提条件可以是:已完成上一个动作,HP 大于 0,下一个行为状态设置为跳跃。把这些条件看作状态数据,把跳跃的 Animation 看做行为,那么当要切换行为的时候,就不用去管上一个状态是什么行为、和上一个状态是什么关系。构建出来的行为树,只需要判断当前的状态数据是否满足跳跃的条件,满足就执行行为,不满足就返回 false。

(精)可视化编程原则-基于状态的动画控制

折线图-动态 Y 轴

参考这个需求的方案设计案例。

实际上也是一个分层状态/关键帧属性子状态/图元属性的拆解过程。每个关键帧就是一个分层状态,每个分层状态涵盖了里面各个子图元的小状态。

针对动态 Y 轴这个需求,我们在每个分层状态/关键帧中,需要设置的是如下几个子图元的状态:

1、设置裁剪矩形(clip)的宽度,实现从左到右的动画效果

2、设置折线的 polyline 的 point 的 y 坐标值,实现 Y 轴范围变更时,折线下降的动画效果

3、设置 Y 轴的文本标签的数值,实现动态 Y 轴

本质上都是操纵 ZRender 修改小图元的属性;通过 ZRender 的自动插帧,实现属性的过渡动画。

PS:这个机制可以成为动画类需求的通用解决方案。

ZRender 如何处理 state

可以通过 ZRender 源码的elementOperation.html这个示例测试 State。

注意:状态切换,核心都是切换 API,而交互是更上一层的事情。

参考 Element.ts 的_applyStateObj()方法(处理 state 数据)和_transitionState()方法(执行过渡动画)

animateTo 和 state 没关系

举个例子,比如鼠标 Hover 到图中的某类数据的时候,其他类别的图表需要进行淡化处理。这里可跟用户输入的 Option 不一样,每个组件其实内部都是有自己的 UI 相关的状态,所以这里图标淡化或者是饼图扇区变大这种 UI 状态相关的操作都是由组件本身的 State 去控制的,最终也是通过 ZRender 去渲染。这里有个简单公式:

Final Option = User Option + UserData + UI State。

其中只要任何一个项变化,都会触发整个 EChart 实例进行渲染,其中:

  • UserOption 可以通过 setOption 去改变
  • UserData 可以因为 Legend 点击隐藏某一项 data 而改变,也可以因为 setOption 改变
  • UI State 可以因为用户进行某种 UI 操作改变了 UI 状态,比如饼图弹出、Hover 高亮折线图等

https://zhuanlan.zhihu.com/p/533018275

Zrender 的 State 是什么?

State is a collection of properties.

比如名为emphasis的 state,其内容类似这样:

1
2
3
4
5
6
7
8
9
10
{
"style": {
"fill": "red",
"stroke": "blue"
},
"shape": {
"width": 100,
"height": 100
}
}

Element 中对于 State 的处理

我们看下 ZRender 的 Element 类中和状态相关的属性和方法(注意看中文注释):

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
class Element<Props extends ElementProps = ElementProps> {
// 当前的状态的键名数组,可以看到这是一个允许多状态叠加的设计
currentStates?: string[] = [];
// prevStates is for storager in echarts.
prevStates?: string[];
/**
* Store of element state.
* '__normal__' key is preserved for default properties.
* 元素支持的状态的属性值
*/
states: Dictionary<ElementState> = {};

/**
* Animation config applied on state switching.
*/
stateTransition: ElementAnimateConfig;

/**
* Proxy function for getting state with given stateName.
* ZRender will first try to get with stateProxy. Then find from states if stateProxy returns nothing
*
* targetStates will be given in useStates
*/
stateProxy?: (stateName: string, targetStates?: string[]) => ElementState;

// 常规状态,即【兜底】状态
protected _normalState: ElementState;

// Save current state to normal
saveCurrentToNormalState(toState: ElementState);

protected _innerSaveToNormal(toState: ElementState);

protected _savePrimaryToNormal(
toState: Dictionary<any>,
normalState: Dictionary<any>,
primaryKeys: readonly string[]
);

/**
* If has any state.
*/
hasState() {
return this.currentStates.length > 0;
}

/**
* Get state object
*/
getState(name: string) {
return this.states[name];
}

/**
* Ensure state exists. If not, will create one and return.
*/
ensureState(name: string) {
const states = this.states;
if (!states[name]) {
states[name] = {};
}
return states[name];
}

/**
* Clear all states.
*/
clearStates(noAnimation?: boolean) {
this.useState(PRESERVED_NORMAL_STATE, false, noAnimation);
// TODO set _normalState to null?
}
/**
* Use state. State is a collection of properties.
* Will return current state object if state exists and stateName has been changed.
*
* @param stateName State name to be switched to
* @param keepCurrentState If keep current states.
* If not, it will inherit from the normal state.
*/
useState(
stateName: string,
keepCurrentStates?: boolean,
noAnimation?: boolean,
forceUseHoverLayer?: boolean
);

/**
* Apply multiple states.
* @param states States list.
*/
useStates(
states: string[],
noAnimation?: boolean,
forceUseHoverLayer?: boolean
);
/**
* Remove state
* @param state State to remove
*/
removeState(state: string) {
const idx = indexOf(this.currentStates, state);
if (idx >= 0) {
const currentStates = this.currentStates.slice();
currentStates.splice(idx, 1);
this.useStates(currentStates);
}
}

/**
* Replace exists state.
* @param oldState
* @param newState
* @param forceAdd If still add when even if replaced target not exists.
*/
replaceState(oldState: string, newState: string, forceAdd: boolean);

/**
* Toogle state.
*/
toggleState(state: string, enable: boolean) {
if (enable) {
this.useState(state, true);
} else {
this.removeState(state);
}
}

protected _mergeStates(states: ElementState[]);

protected _applyStateObj(
stateName: string,
state: ElementState,
normalState: ElementState,
keepCurrentStates: boolean,
transition: boolean,
animationCfg: ElementAnimateConfig
);
}

State 与 Animation 的关系

state.html

State 是 Animation 的上层应用:State 会触发 Animation(调用_updateAnimationTargets()),Animation 不会触发 State。

因此在 elementOperation.html 中,运行 Animation 相关的测试不会触发useState()

ECharts 与 State

ECharts 是基于状态配置化图表库,其 style、emphasis 等等,本质上都是状态。

ECharts 中的 states 是行为样式的集合。

我们打印 echart 的实例对象,可以查看到其_zr 属性(存放页面上所有 ZRender 图形元素实例的属性)的内容如下:

echart_zr

这里的 style 对应的就是当前渲染的状态。

ECharts 是如何处理 State 的?

详见 src/core/echarts.ts 文件的bindMouseEvent方法。

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
34
35
36
37
38
39
bindMouseEvent = function (zr: zrender.ZRenderType, ecIns: ECharts): void {
zr.on('mouseover', function (e) {
const el = e.target;
const dispatcher = findEventDispatcher(el, isHighDownDispatcher);
if (dispatcher) {
handleGlobalMouseOverForHighDown(dispatcher, e, ecIns._api);
markStatusToUpdate(ecIns);
}
})
.on('mouseout', function (e) {
const el = e.target;
const dispatcher = findEventDispatcher(el, isHighDownDispatcher);
if (dispatcher) {
handleGlboalMouseOutForHighDown(dispatcher, e, ecIns._api);
markStatusToUpdate(ecIns);
}
})
.on('click', function (e) {
const el = e.target;
const dispatcher = findEventDispatcher(
el,
(target) => getECData(target).dataIndex != null,
true
);
if (dispatcher) {
const actionType = (dispatcher as ECElement).selected
? 'unselect'
: 'select';
const ecData = getECData(dispatcher);
ecIns._api.dispatchAction({
type: actionType,
dataType: ecData.dataType,
dataIndexInside: ecData.dataIndex,
seriesIndex: ecData.seriesIndex,
isFromClick: true,
});
}
});
};

这里的 handleGlobalMouseOverForHig()方法,最终其实就是调用各种切换状态的方法,比如 blurComponent 和 enterEmphasisWhenMouseOver,最终都是调用src/util/states.ts中的这个方法,只是参数不同而已:

1
doChangeHoverState(el: ECElement, stateName: DisplayState, hoverStateEnum: 0 | 1 | 2)

src/util/states.ts是一个很关键的文件,把这里的方法过一遍有助于在设计 State 的时候查漏补缺。

开发技巧

  • 状态迁移前,记得清理当前状态(如果不清理状态,会导致叠加状态有问题,比如将上一次的 emphasis 的部分状态属性叠加到本次状态中了,而这些属性并不是你本次想要的)

  • 通过上层(业务层)抽象来解决交互问题

  • 业务层设计一个锁的机制,解决 click 和 hover 的交互问题

状态的特性

  • 可叠加/合并:这就是为什么 Element 的 states 是一个数组的缘故

  • 有优先级:比如 hover 和 click 交互,针对的状态有优先级,hover 不会覆盖前面 click 产生的状态

  • 可以无限向上抽象:即 HFSM

我们 3D 业务开发框架的状态管理

照搬的ZRenderElement的 state 设计,useState()的逻辑一毛一样。

我画了个草图:

可视化组件的状态管理

如何定义子类的具体状态属性?

通过 Element 类的_states 定义:

1

如何动态计算状态属性?

通过 Element 类的_stateProxy 进行动态计算:

1
2
3
4
5
/** 动态计算状态 */
// eslint-disable-next-line class-methods-use-this
protected _stateProxy<N extends keyof S>(stateName: N, states: N[], context?: unknown): S[N] | undefined {
return undefined;
}

具体计算逻辑由子类去实现。

如何在帧循环中减少不必要的状态处理?

在 controller 的 private __applyChangedStates(stage: Stage)方法中,通过 isDirty 来实现:

1
2
3
4
if (elem.isDirty()) {
self._applyElementStates(elem, stage);
elem.isDirty(false);
}

显示与隐藏算不算状态?