美文网首页
G2 源码学习 - 渲染部分

G2 源码学习 - 渲染部分

作者: VioletJack | 来源:发表于2023-03-21 07:37 被阅读0次

G2 源码版本 4.2.5

话接上回 通过示例来看 G2 源码是如何运行的,我们来聊聊 G2 的具体渲染过程。

渲染代码

渲染逻辑是在 paint() 函数中实现的。

  protected paint(isUpdate: boolean) {
    this.renderDataRecursive(isUpdate);

    // 处理 sync scale 的逻辑
    this.syncScale();

    this.emit(VIEW_LIFE_CIRCLE.BEFORE_PAINT);

    // 初始化图形、组件位置,计算 padding
    this.renderPaddingRecursive(isUpdate);
    // 布局图形、组件
    this.renderLayoutRecursive(isUpdate);
    // 背景色 shape
    this.renderBackgroundStyleShape();
    // 最终的绘制 render
    this.renderPaintRecursive(isUpdate);

    this.emit(VIEW_LIFE_CIRCLE.AFTER_PAINT);

    this.isDataChanged = false; // 渲染完毕复位
  }

递归数据处理

由于 View 拥有创建子 View 的能力,而 Chart 是继承与 View 的。所以就形成了一颗顶层为 Chart,子孙层为 View 的树形结构。

// view.ts
public createView(cfg?: Partial<ViewCfg>): View {}

所以,处理树形结构我们就得使用递归来写了。下面是递归函数中的处理过程。

  /**
   * 递归渲染中的数据处理
   * @param isUpdate
   */
  private renderDataRecursive(isUpdate: boolean) {
    // 1. 处理数据过滤
    this.doFilterData();
    // 2. 创建坐标系实例
    this.createCoordinate();
    // 3. 初始化 Geometry
    this.initGeometries(isUpdate);
    // 4. 处理分面逻辑,最终都是生成子 view 和 geometry
    this.renderFacet(isUpdate);

    // 同样递归处理子 views
    const views = this.views;
    for (let i = 0, len = views.length; i < len; i++) {
      const view = views[i];
      view.renderDataRecursive(isUpdate);
    }
  }
  • 处理数据过滤,得到过滤后的数据 filteredData。
  • 创建坐标系实例,计算出坐标系数据。这里可以看到坐标系的变化。
  • 绑定几何图形度量对象、初始化几何图形实例(这些实例是在执行 chart.line() 的时候实例化并保存在 geometries 数组中的)。
  • 渲染分面 facet.
  • 递归处理子 view 的数据

坐标轴同步

如果在 scale 中加上了 sync: true,那么 G2 就会去进行度量的同步。反之没有 sync 配置项就不会执行 syncScale() 函数。

chart.scale({
  Deaths: {
    sync: true,
    nice: true,
  },
  death: {
    sync: true,
    nice: true,
  },
});

同步度量函数:

  /**
   * 处理 scale 同步逻辑
   */
  private syncScale() {
    // 最终调用 root view 的
    this.getRootView().scalePool.sync(this.getCoordinate(), this.theme);
  }

scalePoll 的 sync 函数:

 /**
   * 同步 scale
   */
  public sync(coordinate: Coordinate, theme: ViewCfg['theme']) {
    // 对于 syncScales 中每一个 syncKey 下面的 scale 数组进行同步处理
    this.syncScales.forEach((scaleKeys: string[], syncKey: string) => {
      // min, max, values, ranges
      let min = Number.MAX_SAFE_INTEGER;
      let max = Number.MIN_SAFE_INTEGER;
      const values = [];

      // 1. 遍历求得最大最小值,values 等
      each(scaleKeys, (key: string) => {
        const scale = this.getScale(key);

        max = isNumber(scale.max) ? Math.max(max, scale.max) : max;
        min = isNumber(scale.min) ? Math.min(min, scale.min) : min;

        // 去重
        each(scale.values, (v: any) => {
          if (!values.includes(v)) {
            values.push(v);
          }
        });
      });

      // 2. 同步
      each(scaleKeys, (key: string) => {
        const scale = this.getScale(key);

        if (scale.isContinuous) {
          scale.change({
            min,
            max,
            values,
          });
        } else if (scale.isCategory) {
          let range = scale.range;
          const cacheScaleMeta = this.getScaleMeta(key);

          // 存在 value 值,且用户没有配置 range 配置 to fix https://github.com/antvis/G2/issues/2996
          if (values && !get(cacheScaleMeta, ['scaleDef', 'range'])) {
            // 更新 range
            range = getDefaultCategoryScaleRange(
              deepMix({}, scale, {
                values,
              }),
              coordinate,
              theme
            );
          }
          scale.change({
            values,
            range,
          });
        }
      });
    });
  }

这个函数对图表中的度量带有 sync 的度量进行了同步,同步的逻辑为:

  • 遍历获取最小值和最大值,它取决于系统计算出来的最小最大值和用户定义的最小最大值。
  • 遍历数据去重
  • 更新图表度量数据的对象 map scales

下面是我对 G2 同步的一些理解:

  • G2 并没有真的将两个视图完全同步,而只是在度量上进行了最大值和最小值的统一。如果说两个 vuew 的位置不同(设置了 region、或者 padding 不一致),那么数据就不可能做到同步。
  • 在 G2 中,对属性 y 使用 sync: true 等于是 sync: 'y'。如果想要多个值同步到 y,那么就应该都使用 sync: 'y'
    特别注意的是,被同步的对象也需要加上 sync, 用来计算公共度量的最大最小值。
  y: {
    min: 0,
    sync: true, // 等于 sync: 'y'
    nice: true,
  },
  trendY: {
    min: 0,
    sync: 'y',
    nice: true,
  },
  • 反过来说,如果所有度量都设置了 sync: true 即同步自身的最大最小值。其实就毫无意义了。
    const view1 = chart.createView();
    const view2 = chart.createView({
         region: {
            start: { x: 0.5, y: 0.5 }, 
            end: { x: 1, y: 1 },
         },
    });
  • 另外, G2 的 scales 中不但包含了 x 轴、多个 y 轴,还有色值、形状的度量。当然,同步的时候一般只同步 y 轴的最大值和最小值。
chart.line().position('date*blockchain').color('#1890ff').shape('smooth');
chart.line().position('date*nlp').color('#2fc25b');
[
    { 'view44-date': Object },
    { 'view44-blockchain': Object },
    { 'view44-#1890ff': Object },
    { 'view44-smooth': Object },
    { 'view44-nlp': Object },
    { 'view44-#2fc25b': Object },
]

位置计算

和数据的处理一样,View 的位置信息也必然是递归处理的。

  /**
   * 递归计算每个 view 的 padding 值,coordinateBBox 和 coordinateInstance
   * @param isUpdate
   */
  protected renderPaddingRecursive(isUpdate: boolean) {
    // 1. 子 view 大小相对 coordinateBBox,changeSize 的时候需要重新计算
    this.calculateViewBBox();
    // 2. 更新 coordinate
    this.adjustCoordinate();
    // 3. 初始化组件 component
    this.initComponents(isUpdate);
    // 4. 布局计算每个 view 的 padding 值
    // 4.1. 自动加 auto padding -> absolute padding,并且增加 appendPadding
    this.autoPadding = calculatePadding(this).shrink(parsePadding(this.appendPadding));
    // 4.2. 计算出新的 coordinateBBox,更新 Coordinate
    // 这里必须保留,原因是后面子 view 的 viewBBox 或根据 parent 的 coordinateBBox
    this.coordinateBBox = this.viewBBox.shrink(this.autoPadding.getPadding());
    this.adjustCoordinate();

    // 刷新 tooltip (tooltip crosshairs 依赖 coordinate 位置)
    const tooltipController = this.controllers.find((c) => c.name === 'tooltip');
    tooltipController.update();

    // 同样递归处理子 views
    const views = this.views;
    for (let i = 0, len = views.length; i < len; i++) {
      const view = views[i];
      view.renderPaddingRecursive(isUpdate);
    }
  }
  1. 计算 view 的容器范围边界,其中包含有 region 的计算过程。这里可以发现 view 的位置属性 region 只能以百分比的形式定义。
// 默认值
props.region = { start: { x: 0, y: 0 }, end: { x: 1, y: 1 } }

// region 的计算过程
const { start, end } = this.region;

// 根据 region 计算当前 view 的 bbox 大小。
const viewBBox = new BBox(
  x + width * start.x,
  y + height * start.y,
  width * (end.x - start.x),
  height * (end.y - start.y)
);
  1. 调整坐标范围
  2. 初始化组件 components,具体有如下组件:
    • axis 坐标轴
    • legend 图例
    • tooltip 提示
    • annotation 标注
    • slider 缩略轴
    • scrollbar 滚动条
  3. 计算各个组件的 padding 值
  4. 根据 padding 重新计算 view 的边界,更新坐标系。
  5. 调整坐标范围
  6. 刷新 tooltip 组件
  7. 递归处理子 view

初始化组件的位置

  /**
   * 递归处理 view 的布局,最终是计算各个 view 的 coordinateBBox 和 coordinateInstance
   * @param isUpdate
   */
  protected renderLayoutRecursive(isUpdate: boolean) {
    // 1. 同步子 view padding
    // 根据配置获取 padding
    const syncViewPaddingFn =
      this.syncViewPadding === true
        ? defaultSyncViewPadding
        : isFunction(this.syncViewPadding)
        ? this.syncViewPadding
        : undefined;

    if (syncViewPaddingFn) {
      syncViewPaddingFn(this, this.views, PaddingCal);
      // 同步 padding 之后,更新 coordinate
      this.views.forEach((v: View) => {
        v.coordinateBBox = v.viewBBox.shrink(v.autoPadding.getPadding());
        v.adjustCoordinate();
      });
    }

    // 3. 将 view 中的组件按照 view padding 移动到对应的位置
    this.doLayout();

    // 同样递归处理子 views
    const views = this.views;
    for (let i = 0, len = views.length; i < len; i++) {
      const view = views[i];
      view.renderLayoutRecursive(isUpdate);
    }
  }

在这个过程中,有一个 syncViewPadding 函数来进一步处理 padding 信息。

是否同步子 view 的 padding。 比如: view1 的 padding 10, view2 的 padding 20, 那么两个子 view 的 padding 统一变成最大的 20。如果是 Funcion,则使用自定义的方式去计算子 view 的 padding,这个函数中去修改所有的 views autoPadding 值

随后执行 doLayout() 函数,它其实就是调用各个组件的 layout 函数完成组件位置布局的初始化。

export default function defaultLayout(view: View): void {
  const axis = view.getController('axis');
  const legend = view.getController('legend');
  const annotation = view.getController('annotation');
  const slider = view.getController('slider');
  const scrollbar = view.getController('scrollbar');

  // 根据最新的 coordinate 重新布局组件
  [axis, slider, scrollbar, legend, annotation].forEach((controller: Controller) => {
    if (controller) {
      controller.layout();
    }
  });
}

这些组件的 layout() 函数的工作是:计算并移动组件到目标位置。

背景色渲染

为了可以单独渲染背景色,所以 g2 单独建了个图层分组放在 canvas 最底层。

组件绘制

  /**
   * 最终递归绘制组件和图形
   * @param isUpdate
   */
  protected renderPaintRecursive(isUpdate: boolean) {
    const middleGroup = this.middleGroup; // G 引擎的图形分组
    // 是否对超出坐标系范围的 Geometry 进行剪切。
    if (this.limitInPlot) {
      const { type, attrs } = getCoordinateClipCfg(this.coordinateInstance);
      middleGroup.setClip({
        type,
        attrs,
      });
    } else {
      // 清除已有的 clip
      middleGroup.setClip(undefined);
    }

    // 1. 渲染几何标记
    this.paintGeometries(isUpdate);
    // 2. 绘制组件
    this.renderComponents(isUpdate);

    // 同样递归处理子 views
    const views = this.views;
    for (let i = 0, len = views.length; i < len; i++) {
      const view = views[i];
      view.renderPaintRecursive(isUpdate);
    }
  }

首先,middleGroup 其实是 G 引擎的图形分组,而组件就是绘制在 middleGroup 分组上的。

    const props = {
      /** 图形容器 */
      container: this.middleGroup.addGroup(),
      labelsContainer: this.foregroundGroup.addGroup(),
      ...cfg,
    };

如果有 limitInPlot 属性则对坐标系范围外的内容进行裁切,否则不进行裁切。

然后下面是图形渲染的重头戏 —— 渲染图形和组件。

paintGeometries

渲染图形其实就是渲染折线图、柱状图、面积图等图形。

  /**
   * 根据 options 配置自动渲染 geometry
   * @private
   */
  private paintGeometries(isUpdate: boolean) {
    const doAnimation = this.options.animate; // 是否执行动画
    // geometry 的 paint 阶段
    const coordinate = this.getCoordinate(); // 获取坐标系
    const canvasRegion = {
      x: this.viewBBox.x,
      y: this.viewBBox.y,
      minX: this.viewBBox.minX,
      minY: this.viewBBox.minY,
      maxX: this.viewBBox.maxX,
      maxY: this.viewBBox.maxY,
      width: this.viewBBox.width,
      height: this.viewBBox.height,
    }; // 获取画布位置
    // 遍历图形,执行动画和渲染行为。
    const geometries = this.geometries;
    for (let i = 0; i < geometries.length; i++) {
      const geometry = geometries[i];
      geometry.coordinate = coordinate;
      geometry.canvasRegion = canvasRegion;
      if (!doAnimation) {
        // 如果 view 不执行动画,那么 view 下所有的 geometry 都不执行动画
        geometry.animate(false);
      }
      geometry.paint(isUpdate);
    }
  }

继续深入看看 animate() 函数和 paint() 函数都做了些什么?

  public animate(cfg: AnimateOption | boolean): Geometry {
    this.animateOption = cfg;
    return this;
  }

和其他配置项一样,animate() 函数只是将配置内容存到配置项中,并返回自身用于链式调用。

最终,所有的渲染行为都是在 paint() 函数中执行的。

  1. 定义配置项
  2. 将原始数据转换成带有 xy 位置信息的数据
  3. 执行 updateElements() 函数创建图形对象,这个函数再某些图形中被重写了。
  4. 通过 doGroupAppearAnimate() 函数执行动画
  /**
   * 将原始数据映射至图形空间,同时创建图形对象。
   */
  public paint(isUpdate: boolean = false) {
    // 合并动画配置项
    if (this.animateOption) {
      this.animateOption = deepMix({}, getDefaultAnimateCfg(this.type, this.coordinate), this.animateOption);
    }

    this.defaultSize = undefined;
    this.elementsMap = {};
    this.elements = [];
    const offscreenGroup = this.getOffscreenGroup();
    offscreenGroup.clear();

    const beforeMappingData = this.beforeMappingData; // 初始的数据源
    const dataArray = this.beforeMapping(beforeMappingData); // 转化后的数据源

    this.dataArray = new Array(dataArray.length);
    for (let i = 0; i < dataArray.length; i++) {
      const data = dataArray[i];
      this.dataArray[i] = this.mapping(data);
    }

    this.updateElements(this.dataArray, isUpdate);
    this.lastElementsMap = this.elementsMap;

    if (this.canDoGroupAnimation(isUpdate)) {
      // 如果用户没有配置 appear.animation,就默认走整体动画
      const container = this.container;
      const type = this.type;
      const coordinate = this.coordinate;
      const animateCfg = get(this.animateOption, 'appear');
      const yScale = this.getYScale();
      const yMinPoint = coordinate.convert({
        x: 0,
        y: yScale.scale(this.getYMinValue()),
      });
      doGroupAppearAnimate(container, animateCfg, type, coordinate, yMinPoint); // 执行动画
    }

    // 添加 label
    if (this.labelOption) {
      const deferred = this.useDeferredLabel;
      const callback = (() => this.renderLabels(flatten(this.dataArray) as unknown as MappingDatum[], isUpdate)).bind(
        this
      );
      if (typeof deferred === 'number') {
        // Use `requestIdleCallback` to render labels in idle time (like react fiber)
        const timeout = typeof deferred === 'number' && deferred !== Infinity ? deferred : 0;
        if (!window.requestIdleCallback) {
          setTimeout(callback, timeout);
        } else {
          const options = timeout && timeout !== Infinity ? { timeout } : undefined;
          window.requestIdleCallback(callback, options);
        }
      } else {
        callback();
      }
    }

    // 缓存,用于更新
    this.lastAttributeOption = {
      ...this.attributeOption,
    };

    if (this.visible === false) {
      // 用户在初始化的时候声明 visible: false
      this.changeVisible(false);
    }
  }

动画渲染

执行动画的代码

export function doGroupAppearAnimate(
  container: IGroup,
  animateCfg: AnimateCfg,
  geometryType: string,
  coordinate: Coordinate,
  minYPoint: Point
) {
  if (GEOMETRY_GROUP_APPEAR_ANIMATION[geometryType]) {
    const defaultCfg = GEOMETRY_GROUP_APPEAR_ANIMATION[geometryType](coordinate);
    const animation = getAnimation(get(defaultCfg, 'animation', ''));
    if (animation) {
      const cfg = {
        ...DEFAULT_ANIMATE_CFG.appear,
        ...defaultCfg,
        ...animateCfg,
      };
      container.stopAnimate(); // 先结束当前 container 动画
      animation(container, cfg, {
        coordinate,
        minYPoint,
        toAttrs: null,
      });
    }
  }
}

可以看到是执行了 animate() 函数,那么动画函数从哪儿来呢?

const ANIMATIONS_MAP: AnimationMap = {};

export function getAnimation(type: string) {
  return ANIMATIONS_MAP[type.toLowerCase()];
}

export function registerAnimation(type: string, animation: Animation) {
  ANIMATIONS_MAP[type.toLowerCase()] = animation;
}

其实是通过初始化 G2 的时候注册到 ANIMATIONS_MAP 对象中去的,那么我们就找一个动画函数 fade-in 来看看

export function fadeIn(shape: IShape | IGroup, animateCfg: GAnimateCfg, cfg: AnimateExtraCfg) {
  const endState = {
    fillOpacity: isNil(shape.attr('fillOpacity')) ? 1 : shape.attr('fillOpacity'),
    strokeOpacity: isNil(shape.attr('strokeOpacity')) ? 1 : shape.attr('strokeOpacity'),
    opacity: isNil(shape.attr('opacity')) ? 1 : shape.attr('opacity'),
  };
  shape.attr({
    fillOpacity: 0,
    strokeOpacity: 0,
    opacity: 0,
  });
  shape.animate(endState, animateCfg);
}

这里的 shape 就是 @antv/g-base 中的对象了,所以是通过定义动画再让 G 引擎执行动画来实现的。

renderComponents

渲染组件其实就是渲染图例、提示、标注等组件。

  /**
   * 最后的绘制组件
   * @param isUpdate
   */
  private renderComponents(isUpdate: boolean) {
    const components = this.getComponents();
    // 先全部清空,然后 render
    for (let i = 0; i < components.length; i++) {
      const co = components[i];
      (co.component as GroupComponent).render();
    }
  }

执行各个组件的 render 函数,那我们以 axis 组件为例。

  public render() {
    this.update();
  }
  
  /**
   * 更新 axis 组件
   */
  public update() {
    this.option = this.view.getOptions().axes;

    const updatedCache = new Map<string, ComponentOption>();

    this.updateXAxes(updatedCache);
    this.updateYAxes(updatedCache);

    // 处理完成之后,销毁删除的
    // 不在处理中的
    const newCache = new Map<string, ComponentOption>();

    this.cache.forEach((co: ComponentOption, key: string) => {
      if (updatedCache.has(key)) {
        newCache.set(key, co);
      } else {
        // 不存在,则是所有需要被销毁的组件
        co.component.destroy();
      }
    });

    // 更新缓存
    this.cache = newCache;
  }

后续再继续往下找发现组件部分引用了 @antv/component,不得不感叹大项目这层层嵌套的复杂程度。

其他收获和总结

越看源码越是觉得 antv 做的这个数据可视化系统的庞大和复杂,我虽然看了 G2 的源码,又好像什么都没看……如果想细究就得再次深入研究依赖库。

另外,感觉 TS 是个好东西。使用 TypeScript 来写代码让项目看着愈发像一个 Java 项目了,这让项目看着更健壮更规范。

虽然依赖库拆分让项目具有很好的解耦性和可扩展性,但对于阅读源码的人来说还是很痛苦的。不过好在我也能从这次源码阅读经历中看到很多熟悉的 API 的实现。对于如何使用 G2Plot 和 G2 有了更深的理解。

对于业务实现,目前看来 G2Plot、G2、G 他们的上手程度是递增的,同样的自由度也是递增的。以我们项目现阶段而言,使用大量 G2plot 结合少量 G2 应该就可以覆盖业务。

就比如,发现在 G2 中可以使用 registerShape 来进行图形自定义,实现一些特殊需求。

在看完源码整体之后,我要再针对特定场景进行源码学习了。下次见~

相关文章

网友评论

      本文标题:G2 源码学习 - 渲染部分

      本文链接:https://www.haomeiwen.com/subject/xbtprdtx.html