搜索文章

输入关键词开始搜索

可视化组件的状态管理

思考#数据可视化#程序设计#ECharts#3D#AI#数据结构

状态的分类

交互状态

参考王渊《交互状态设计-探索 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、建议定义一个统一的接口来处理状态行为(这里指大状态,即交互行为),支持前置/后置钩子:

// 定义一个状态接口 用来规范我们状态的设计行为
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,其内容类似这样:

{
    "style": {
        "fill": "red",
        "stroke": "blue"
    },
    "shape": {
        "width": 100,
        "height": 100
    }
}

Element 中对于 State 的处理

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

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方法。

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中的这个方法,只是参数不同而已:

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 定义:

如何动态计算状态属性?

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

/** 动态计算状态 */
// 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 来实现:

if (elem.isDirty()) {
  self._applyElementStates(elem, stage);
  elem.isDirty(false);
}

显示与隐藏算不算状态?