!!!###!!!title=视觉通道映射——VisActor/VChart 社区贡献者文档!!!###!!!!!!###!!!description=---title: 6.2 视觉通道映射 key words: VisActor,VChart,VTable,VStrory,VMind,VGrammar,VRender,Visualization,Chart,Data,Table,Graph,Gis,LLM--- !!!###!!!

简介

视觉映射是数据与图像之间的桥梁,它将“数据模型”映射到“图像模型”,为不同类型的数据选择适合它的视觉变量。例如,我们使用柱状图表示一个班级中男生和女生的平均成绩,那么可以将数据中的性别属性映射到图像中的颜色属性,将数据中的成绩属性映射到图像中柱状图的高度(Y轴坐标)属性。下面我们通过一个简单的用例,分析数据是如何被映射到最终所看到的图像的。

图元的映射流程

使用示例

const spec = {
  type: 'line',
  data: [
    {
      id: 'lineData',
      values: [
        { date: 'Monday', class: 'class No.1', score: 20 },
        { date: 'Monday', class: 'class No.2', score: 30 },

        { date: 'Tuesday', class: 'class No.1', score: 25 },
        { date: 'Tuesday', class: 'class No.2', score: 28 }
      ]
    }
  ],
  seriesField: 'class',
  xField: 'date',
  yField: 'score',
  point: {
    style: {
      fill: 'blue'
    }
  }
};

const vchart = new VChart(spec, { dom: CONTAINER_ID });
vchart.renderSync();    

该示例创建了一个line类型的图元系列来展示4条数据,其中class属性相同的点将被连成一条折线,date属性被映射到X轴坐标,score属性被映射到Y轴坐标,效果如下:

图元的创建

下面我们通过代码,分析renderSync()中是如何解析配置spec,并生成图上的各种图元的。总体来说,renderSync()中包含以下三个阶段,渲染前、渲染、渲染后。

  // packages/vchart/src/core/vchart.ts
  protected ***_renderSync*** = (option: IVChartRenderOption = {}) => {
    const self = this as unknown as IVChart;
    if (!this.***_beforeRender***(option)) {
      return self;
    }
    this._compiler?.***render***(option.morphConfig);
    this.***_afterRender***();
    return self;
  };    

其中渲染过程属于VGrammar的范畴,而渲染后主要进行动画状态的更新,我们主要关注与图元有关的渲染前准备,包括初始化图表配置、实例化图表、编译渲染指令。

1. 初始化图表配置
  // packages/vchart/src/core/vchart.ts
  private ***_initChartSpec***(spec: any, actionSource: VChartRenderActionSource) {
    // 如果用户注册了函数,在配置中替换相应函数名为函数内容
    if (VChart.***getFunctionList***() && VChart.***getFunctionList***().length) {
      spec = ***functionTransform***(spec, VChart);
    }
    this._spec = spec;
    // 创建图表配置转换器,并转换为common chart的配置
    if (!this._chartSpecTransformer) {
      this._chartSpecTransformer = Factory.***createChartSpecTransformer***(
        this._spec.type,
        this.***_getChartOption***(this._spec.type)
      );
    }
    this._chartSpecTransformer?.***transformSpec***(this._spec);
    // 转换模型配置
    this._specInfo = this._chartSpecTransformer?.***transformModelSpec***(this._spec);
  }    

首先第一步,将用户注册的函数名替换为相应的函数实体。之后,根据图表类型,创建相应的图表配置转换器,将该类型图表的配置转换为common类型图表的配置。这其中包括,根据图表类型创建默认series,补全用户定义的series配置:

    // packages/vchart/src/chart/cartesian/cartesian-transformer.ts
    const defaultSeriesSpec = this.***_getDefaultSeriesSpec***(spec);
    if (!spec.series || spec.series.length === 0) { // 没有用户定义的系列 采用默认
      spec.series = [defaultSeriesSpec];
    } else {
      spec.series.***forEach***((s: ISeriesSpec) => {
        if (!this.***_isValidSeries***(s.type)) { // 判断用户定义系列是否有效
          return;
        }
        Object.***keys***(defaultSeriesSpec).***forEach***(k => { // 补全配置
          if (!(k in s)) {
            s[k] = defaultSeriesSpec[k];
          }
        });
      });
    }    

2. 实例化图表

接下来就是创建一个相应类型的图表对象。这里的图表对象并非指如下创建的VChart对象。VChart对图表进行了一次封装,是用户操作的入口,负责图表的全局管理和对外接口;

const vchart = new VChart(spec, { dom: CONTAINER_ID });    

而这里实例化的chart图表负责具体的图表构建(如创建和管理系列、组件)和内部逻辑处理(管理数据流、全局映射、图元状态等)。

  // packages/vchart/src/core/vchart.ts
  private ***_initChart***(spec: any) {
    // 创建真正的图表对象
    const chart = Factory.***createChart***(spec.type, spec, this.***_getChartOption***(spec.type));
    this._chart = chart;
    // 进行图表初始化
    this._chart.***setCanvasRect***(this._currentSize.width, this._currentSize.height);
    this._chart.***created***();
    this._chart.***init***();
    this._event.***emit***(ChartEvent.initialized, {
      chart,
      vchart: this
    });
  }    

其中最核心的步骤是createdinit,前者根据spec创建各项元素,如区域region, 系列series, 组件components,后者对各个元素进行初始化。我们重点关注创建series当中的图元部分。

  // packages/vchart/src/series/base/base-series.ts
  ***created***(): void {
    ...
    this.***initMark***();
    ...
  }    

由于我们图表的类型是line,默认有一个line系列,我们到line-series中查看其initMark的实现:

  // packages/vchart/src/series/line/line.ts
  ***initMark***(): void {
    ...
    const seriesMark = this._spec.seriesMark ?? *'line'*;
    this.***initLineMark***(progressive, seriesMark === *'line'*);
    this.***initSymbolMark***(progressive, seriesMark === *'point'*);
  }    

发现其中确实继续创建了line图元和symbol图元:

经过一系列的函数调用(LineLikeSeriesMixin.initLineMark -> BaseSeries._createMark -> BaseModel._createMark -> Factory.createMark),最终到了相应图元的构造函数,即我们在中提到的“具体图元的实现”。

3. 编译渲染指令

将各种VChart模型(region, series, component)编译为可渲染的VGrammar语法元素,涉及到VGrammar语法层的内容,不做详细分析。

图元视觉配置的映射

BaseMark类中,图元通过一系列方法和逻辑实现了数据到视觉通道的映射。这大致可分为两个过程,属性的存储和属性值的计算。前者只是将用户定义的spec解析并存储到图元各个状态的样式表中,这期间会做一些简单的转换;后者是图元的使用者真正布局图元时获取和计算具体属性值。

Step1 存储样式

1. 初始化样式

初始化图元的默认样式,调用 setStyle方法为 normal状态设置默认值:

private ***_initStyle***(): void {
  const defaultStyle = this.***_getDefaultStyle***();
  this.***setStyle***(defaultStyle, *'normal'*, 0);
}    

  • 默认样式包括 visible: true、x: 0、y: 0 等。

  • 这些默认值确保图元在没有用户定义样式时仍能正常渲染。

initStyleWithSpec方法根据用户传入的spec初始化样式:

initStyleWithSpec(spec: IMarkSpec<T>, key?: string) {
  if (!spec) return;

  if (isValid(spec.id)) this._userId = spec.id;
  if (isBoolean(spec.interactive)) this._markConfig.interactive = spec.interactive;
  if (isValid(spec.zIndex)) this._markConfig.zIndex = spec.zIndex;
  if (isBoolean(spec.visible)) this.setVisible(spec.visible);

  this._initSpecStyle(spec, this.stateStyle, key);
}    

  • 解析用户定义的 interactivezIndexvisible等属性。

  • 调用 _initSpecStyle方法处理 stylestate。这一部分主要是通过调用setStyle,为每种状态(包括最开始的normal状态)设置对应的样式,并构成状态信息存储到状态管理器当中。关于状态,我们在中详细说明。

以上方法都调用了setStyle这个核心函数,该函数用于给指定的状态设置样式:

  ***setStyle***<U extends keyof T>(
    style: Partial<IMarkStyle<T>>, // 样式
    state: StateValueType = *'normal'*, // 状态
    level: number = 0, // 状态层级 当处于不同状态产生冲突时 根据层级设置样式
    stateStyle = this.stateStyle // 存储状态样式
  ): void {
    if (***isNil***(style)) {
      return;
    }
    if (stateStyle[state] === undefined) {
      stateStyle[state] = {};
    }
    const isUserLevel = this.***isUserLevel***(level);
    Object.***keys***(style).***forEach***((attr: string) => {
      let attrStyle = style[attr] as MarkInputStyle<T[U]>;
      if (***isNil***(attrStyle)) {
        return;
      }
      // 过滤和转化样式
      attrStyle = this.***_filterAttribute***(attr as any, attrStyle, state, level, isUserLevel, stateStyle);
      // 设置样式
      this.***setAttribute***(attr as any, attrStyle, state, level, stateStyle);
      /*  在setAttribute中设置属性计算方式/样式
          stateStyle[state][attr] = {
            level,
            style,
            referer: undefined
          };
      */
    });
  }    

2. 过滤和转换样式

setStyle中调用的_filterAttribute是对单个样式属性进行过滤和转换,确保样式属性符合内部的使用规范。这些转换都较为简单,见注释。

  protected ***_filterAttribute***<U extends keyof T>(
    attr: U,
    style: MarkInputStyle<T[U]>,
    state: StateValueType,
    level: number,
    isUserLevel: boolean,
    stateStyle = this.stateStyle
  ): StyleConvert<T[U]> {
    // *** **将visual spec转换为 scale 类型的 mark style** ***
    // 用于后续计算属性值
    let newStyle = this.***_styleConvert***(style);
    
    if (isUserLevel) {
      switch (attr) {
        case *'angle'*:
          // 角度值转弧度值
          newStyle = this.***convertAngleToRadian***(newStyle);
          break;
        case *'innerPadding'*:
        case *'outerPadding'*:
          // VRender 的 padding 定义基于 centent-box 盒模型,默认正方向是向外扩,与 VChart 不一致。这里将 padding 符号取反
          newStyle = this.***_transformStyleValue***(newStyle, (value: number) => -value);
          break;
        case *'curveType'*:
          // 根据direction返回'*monotoneY*'(*Direction.horizontal*)或'*monotoneX*'
          newStyle = this.***_transformStyleValue***(newStyle, (value: string) =>
            ***curveTypeTransform***(value, (this._option.model as any).direction)
          );
          break;
      }
    }
    return newStyle;
  }    

需要特别注意的是_styleConvert中将一些需要转化成scale类型的样式进行转化,用于后续属性值的计算,例如,将yField: 'score'转化为:

{
  scale, // 映射对象,用于数据到视觉通道的映射,可以理解为一个函数,输入数据对应的值,输出视觉通道的值
  field: 'score', // 数据字段名,表示映射的输入字段。
  changeDomain: true // 布尔值,表示是否允许动态更新比例尺的定义域(domain)
};    

这即是一个scale类型的样式,其中第一个域scale将数据对应字段的值datum['score']计算为图元的y坐标。

Step2 计算属性值

BaseMark向外提供了接口getAttribute,供其使用者根据实际的数据计算和获取属性值。

  ***getAttribute***<U extends keyof T>(key: U, datum: Datum, state: StateValueType = *'normal'*, opt?: IAttributeOpt) {
    return this.***_computeAttribute***(key, state)(datum, opt);
  }    

这里的_compteAttribute(key, state)返回的是一个属性计算的函数,key是属性名,state是所处的状态;(datum, opt)作为这个函数的参数,返回计算结果,与我们上面**“存储属性的计算方式”**的描述一致。

  protected ***_computeAttribute***<U extends keyof T>(key: U, state: StateValueType) {
    let stateStyle = this.stateStyle[state]?.[key];
    if (!stateStyle) {
      stateStyle = this.stateStyle.normal[key];
    }
    const baseValueFunctor = this.***_computeStateAttribute***(stateStyle, key, state);
    const hasPostProcess = ***isFunction***(stateStyle?.***postProcess***);
    const hasExCompute = key in this._computeExChannel;
    // ... 
    // 叠加后处理函数和额外计算函数
    // ...
    return baseValueFunctor;
  }    

继续深入_computeStateAttribute,会发现在这里构造了属性计算函数,这个函数的输入是(datum, opt), 输出是计算的得到属性值。如果是属性值是常量(与数据无关的,固定在spec上的),则这个构造的函数直接返回style;而真正需要计算的是一些复杂的样式和数据到视觉的映射~~(回收主题)~~。

  protected ***_computeStateAttribute***<U extends keyof T>(stateStyle: any, key: U, state: StateValueType) {
    if (!stateStyle) { // 处理空样式
      return (datum: Datum, opt: IAttributeOpt) => undefined as any;
    }
    if (stateStyle.referer) { // 处理引用样式
      return stateStyle.referer.***_computeAttribute***(key, state);
    }
    if (!stateStyle.style) { // 处理空样式
      return (datum: Datum, opt: IAttributeOpt) => stateStyle.style;
    }
    // =====================================================================
    // **处理函数样式**:如果 stateStyle.style 是函数,调用该函数计算属性值。
    if (typeof stateStyle.style === *'function'*) {
      return (datum: Datum, opt: IAttributeOpt) =>
        stateStyle.***style***(datum, this._attributeContext, opt, this.***getDataView***());
    }
    // **渐变色处理**,支持各个属性回调
    if (GradientType.***includes***(stateStyle.style.gradient)) {
      return this.***_computeGradientAttr***(stateStyle.style);
    }
    // **内外描边处理**,支持各个属性回调
    if ([*'outerBorder'*, *'innerBorder'*].***includes***(key as string)) {
      return this.***_computeBorderAttr***(stateStyle.style);
    }
    // **处理映射样式**:如果 stateStyle.style 包含映射关系(scale),根据数据字段映射值。
    if (***isValidScaleType***(stateStyle.style.scale?.type)) {
      return (datum: Datum, opt: IAttributeOpt) => {
        let data = datum;
        if (this.model.modelType === *'series'* && (this.model as ISeries).***getMarkData***) {
          data = (this.model as ISeries).***getMarkData***(datum);
        }
        return stateStyle.style.scale.***scale***(data[stateStyle.style.field]);
      };
    }
    // =====================================================================
    // **处理常量样式**:如果 stateStyle.style 是常量值,直接返回该值。
    return (datum: Datum, opt: IAttributeOpt) => {
      return stateStyle.style;
    };
  }    

重点说明一下scale样式,也就是包含数据到视觉映射的部分。继续上面的例子,我们已经构造了一个scale的样式:

style: {
  scale, 
  field: 'score',
  changeDomain: true,
}    

如果我们需要计算图元的y坐标,首先获取到图元绑定的数据(见第五章 VChart数据处理),然后通过scale映射对象,输入data['score']获取对应的y值。更多关于scale的说明见第七章 VChart Scale。

本文档由以下人员修正整理

玄魂