!!!###!!!title=VChart 布局源码分析——VisActor/VChart 社区贡献者文档!!!###!!!!!!###!!!description=---title: 9.2 VChart 布局源码详解 key words: VisActor,VChart,VTable,VStrory,VMind,VGrammar,VRender,Visualization,Chart,Data,Table,Graph,Gis,LLM--- !!!###!!!

布局元素

布局元素承接图表模块的布局信息,同时负责参与布局逻辑。它的实现在仓库这个地址

packages/vchart/src/layout/layout-item.ts

下面详细介绍布局元素的详细代码

接收布局配置

在代码的中,布局元素通过 setAttrFromSpec 生命周期读取spec中的布局配置,并且在 onLayoutStart 时会额外计算一次布局配置中的像素值。

 // line: 191
 setAttrFromSpec(spec: ILayoutItemSpec, chartViewRect: ILayoutRect) {
    this._spec = spec;
    this.layoutType = spec.layoutType ?? this.layoutType;
    this.layoutLevel = spec.layoutLevel ?? this.layoutLevel;
    this.layoutOrient = spec.orient ?? this.layoutOrient;

    this._setLayoutAttributeFromSpec(spec, chartViewRect);

    this.layoutClip = spec.clip ?? this.layoutClip;
  }
  
  onLayoutStart(layoutRect: IRect, viewRect: ILayoutRect, ctx: any) {
    // 在 layoutStart 时重新计算 spec 中的布局属性值
    // 确保 resize 后,这些值保持正确的px值。
    this._setLayoutAttributeFromSpec(this._spec, viewRect);
  }    

计算布局像素值的逻辑包括百分比字符串,函数配置等,布局数值的统一计算逻辑在这里

// packages/vchart/src/util/space.ts
export function calcLayoutNumber(
  v: ILayoutNumber | undefined,
  size: number,
  callOp?: ILayoutRect, //如果是函数类型的话,函数的参数
  defaultValue: number = 0
) {
  if (isNumber(v)) {
    return v;
  }
  if (isPercent(v)) {
    return (Number(v.substring(0, v.length - 1)) * size) / 100;
  }
  if (isFunction(v)) {
    return v(callOp);
  }
  if (isObject(v)) {
    return size * (v.percent ?? 0) + (v.offset ?? 0);
  }
  return defaultValue;
}    

与图表模块通信

布局逻辑会通过调用 LayoutItem 的 computeBoundsInRect 方法获取图表模块在给定空间下的实际绘图大小

LayoutItem 获取绘图大小

// line: 365
computeBoundsInRect(rect: ILayoutRect): ILayoutRect {
    // 保留布局使用的rect
    this._lastComputeRect = rect;
    // 一些情况下不需要计算
    if (
      (this.layoutType === 'region-relative' || this.layoutType === 'region-relative-overlap') &&
      ((this._layoutRectLevelMap.width === USER_LAYOUT_RECT_LEVEL &&
        (this.layoutOrient === 'left' || this.layoutOrient === 'right')) ||
        (this._layoutRectLevelMap.height === USER_LAYOUT_RECT_LEVEL &&
          (this.layoutOrient === 'bottom' || this.layoutOrient === 'top')))
    ) {
      return this._layoutRect;
    }
    // 将布局空间限制到 spec 设置内
    // 避免操作到元素本身的 aabbbounds
    const bounds = {        ...this._model.getBoundsInRect(this.setRectInSpec(rect), rect) };
    // 用户设置了布局元素宽高的场景下,内部布局结果的 bounds 不能直接作为图表布局bounds
    this.changeBoundsBySetting(bounds);
    // 保留当前模块的布局超出内容,用来处理自动缩进
    // 当前 bounds 需要有实际宽高
    if (this.autoIndent && bounds.x2 - bounds.x1 > 0 && bounds.y2 - bounds.y1 > 0) {
      this._lastComputeOutBounds.x1 = Math.ceil(-bounds.x1);
      this._lastComputeOutBounds.x2 = Math.ceil(bounds.x2 - rect.width);
      this._lastComputeOutBounds.y1 = Math.ceil(-bounds.y1);
      this._lastComputeOutBounds.y2 = Math.ceil(bounds.y2 - rect.height);
    }
    // 返回的布局大小也要限制到 spec 设置内
    let result = this.setRectInSpec(boundsInRect(bounds, rect));
    if (this._option.transformLayoutRect) {
      result = this._option.transformLayoutRect(result);
    }

    return result;
  }    

这里图表模块需要实现得到bounds的接口

export interface ILayoutModel extends IModel {
  getBoundsInRect: (rect: ILayoutRect, fullRect: ILayoutRect) => IBoundsLike;
}    

图表模块获取布局信息

图表模块通过持有 layoutItem 获取布局后的空间信息

// 下面是部分类型定义
export interface ILayoutItem {
  readonly type: string;
  /**
   * 标记这个布局Item的方向(left->right, right->left, top->bottom, bottom->top)
   */
  directionStr?: 'l2r' | 'r2l' | 't2b' | 'b2t';
  layoutClip: boolean;
  layoutType: ILayoutType;
  layoutBindRegionID: number | number[];
  layoutOrient: IOrientType;
  /** 是否自动缩进 */
  autoIndent: boolean;
  alignSelf?: 'start' | 'end' | 'middle';
  /** paddding */
  layoutPaddingLeft: number;
  layoutPaddingTop: number;
  layoutPaddingRight: number;
  layoutPaddingBottom: number;
   /** offset */
  layoutOffsetX: number;
  layoutOffsetY: number;

     /** 布局优先级,越大越先处理 */
  layoutLevel: number;
  
  getLayoutStartPoint: () => ILayoutPoint;
  getLayoutRect: () => ILayoutRect;
 }
    

图表模块

图表模块会在 onLayoutEnd 这个生命周期中获取布局信息并更新自身图形位置

// packages/vchart/src/model/layout-model.ts
// line: 56
onLayoutEnd(ctx: any): void {
    super.onLayoutEnd(ctx);
    // diff layoutRect
    this.updateLayoutAttribute();
    // ... other code
  }    

布局逻辑

在 VChart 中,布局逻辑的定义其实只是一个接收布局元素,并更新它们布局属性的函数

// 类型定义
export type LayoutCallBack = (
  chart: any,
  item: ILayoutItem[],
  chartLayoutRect: IRect,
  chartViewBox: IBoundsLike
) => void;

// VChart 通过接口 setLayout 设置自定义布局
export interface IVChart {
  /**
  * 设置自定义布局
  */
  setLayout: (layout: LayoutCallBack) => void;
  // other 
}    

占位布局

最常用的布局逻辑,是将布局元素按照类型和优先级,逐个放入画布的布局方法

占位的计算通过布局开始时初始化上下左右边界,然后每布局一个item后,根据item情况减小边界,逐步缩小可布局范围的方式

初始化

protected _layoutInit(_chart: IChart, items: ILayoutItem[], chartLayoutRect: IRect, chartViewBox: IBoundsLike) {
    this._chartLayoutRect = chartLayoutRect;
    this._chartViewBox = chartViewBox;
    this.leftCurrent = chartLayoutRect.x;
    this.topCurrent = chartLayoutRect.y;
    this.rightCurrent = chartLayoutRect.x + chartLayoutRect.width;
    this.bottomCurrent = chartLayoutRect.height + chartLayoutRect.y;

    // 越大越先处理,进行排序调整,利用原地排序特性,排序会受 level 和传进来的数组顺序共同影响
    items.sort((a, b) => b.layoutLevel - a.layoutLevel);
}    

布局执行

// packages/vchart/src/layout/base-layout.ts

// line: 91
layoutItems(_chart: IChart, items: ILayoutItem[], chartLayoutRect: IRect, chartViewBox: IBoundsLike): void {
    // 布局初始化
    this._layoutInit(_chart, items, chartLayoutRect, chartViewBox);
    // 先布局 normal 类型的元素
    this._layoutNormalItems(items);
    // 开始布局 region 相关元素
    // 为了自动缩进能力先保存一下当前的布局空间
    const layoutTemp: LayoutSideType = {
      left: this.leftCurrent,
      top: this.topCurrent,
      right: this.rightCurrent,
      bottom: this.bottomCurrent
    };
    // 将 reion 相关元素分组
    const { regionItems, relativeItems, relativeOverlapItems, allRelatives,overlapItems } = this._groupItems(items);
    // 有元素开启了自动缩进
    // TODO:目前只有普通占位布局下的 region-relative 元素支持
    // 主要考虑常规元素超出画布一般为用户个性设置,而且可以设置padding规避裁剪,不需要使用自动缩进
    this.layoutRegionItems(regionItems, relativeItems, relativeOverlapItems, overlapItems);
    // 缩进计算
    this._processAutoIndent(regionItems, relativeItems, relativeOverlapItems, overlapItems, allRelatives, layoutTemp);
    // 最后布局绝对定位元素
    this.layoutAbsoluteItems(items.filter(x => x.layoutType === 'absolute'));
  }    

item 占位:下面是普通元素占位逻辑,先将当前的布局范围传递给itemitem 布局完成后,再减小对应方向的空间

protected layoutNormalItems(normalItems: ILayoutItem[]): void {
    normalItems.forEach(item => {
      const layoutRect = this.getItemComputeLayoutRect(item);
      const rect = item.computeBoundsInRect(layoutRect);
      item.setLayoutRect(rect);

      if (item.layoutOrient === 'left') {
        item.setLayoutStartPosition({
          x: this.leftCurrent + item.layoutOffsetX + item.layoutPaddingLeft,
          y: this.topCurrent + item.layoutOffsetY + item.layoutPaddingTop
        });
        this.leftCurrent += rect.width + item.layoutPaddingLeft + item.layoutPaddingRight;
      } else if (item.layoutOrient === 'top') {
        item.setLayoutStartPosition({
          x: this.leftCurrent + item.layoutOffsetX + item.layoutPaddingLeft,
          y: this.topCurrent + item.layoutOffsetY + item.layoutPaddingTop
        });
        this.topCurrent += rect.height + item.layoutPaddingTop + item.layoutPaddingBottom;
      } else if (item.layoutOrient === 'right') {
        item.setLayoutStartPosition({
          x: this.rightCurrent + item.layoutOffsetX - rect.width - item.layoutPaddingRight,
          y: this.topCurrent + item.layoutOffsetY + item.layoutPaddingTop
        });
        this.rightCurrent -= rect.width + item.layoutPaddingLeft + item.layoutPaddingRight;
      } else if (item.layoutOrient === 'bottom') {
        item.setLayoutStartPosition({
          x: this.leftCurrent + item.layoutOffsetX + item.layoutPaddingRight,
          y: this.bottomCurrent + item.layoutOffsetY - rect.height - item.layoutPaddingBottom
        });
        this.bottomCurrent -= rect.height + item.layoutPaddingTop + item.layoutPaddingBottom;
      }
    });
  }    

网格布局

网格布局的方式是根据布局配置先根据配置项,建立行列的布局信息数组

初始化

// packages/vchart/src/layout/grid-layout/grid-layout.ts
type GridSize = {
  value: number;
  isUserSetting: boolean;
  isLayoutSetting: boolean;
};

export class GridLayout implements IBaseLayout {
    // 行列信息
    protected _col: number = 1;
    protected _row: number = 1;
    // 存储行列的大小和配置信息
    protected _colSize: GridSize[];
    protected _rowSize: GridSize[];
    
    // 每一行,每一列都有一个数组存储它对应的布局 item    
    protected _colElements: ILayoutItem[][];
    protected _rowElements: ILayoutItem[][];
    
    constructor(gridInfo: IGridLayoutSpec, ctx: utilFunctionCtx) {
        this.standardizationSpec(gridInfo);
        this._gridInfo = gridInfo;
        this._col = gridInfo.col;
        this._row = gridInfo.row;
        this._colSize = new Array(this._col).fill(null);
        this._rowSize = new Array(this._row).fill(null);
        this._colElements = new Array(this._col).fill([]);
        this._rowElements = new Array(this._row).fill([]);
        this._onError = ctx?.onError;
        
        this.initUserSetting();
    }
    
    protected initUserSetting() {
        // 先对用户设置的宽高进行设置
        this._gridInfo.colWidth &&
          this.setSizeFromUserSetting(this._gridInfo.colWidth, this._colSize, this._col, this._chartLayoutRect.width);
        
        this._gridInfo.rowHeight &&
          this.setSizeFromUserSetting(this._gridInfo.rowHeight, this._rowSize, this._row, this._chartLayoutRect.height);
        // 其余位置默认填充0
        this._colSize.forEach((c, i) => {
          if (!c) {
            this._colSize[i] = {
              value: 0,
              isUserSetting: false,
              isLayoutSetting: false
            };
          }
        });
        this._rowSize.forEach((r, i) => {
          if (!r) {
            this._rowSize[i] = {
              value: 0,
              isUserSetting: false,
              isLayoutSetting: false
            };
          }
        });
    }
    // other
}    

布局执行

在网格布局时,还是会按照一定的属性将元素放置到布局信息中给它配置的行列位置上,然后按照先列方向后行方向的顺序,对每一个元素进行布局计算,并放入上面准备好的行列布局信息中。

当第一次列布局完成后,只有列宽确定了,之后进行行布局。第一轮布局完成后会对列元素进行二次布局,允许列元素基于宽度重新调整一次自身的布局属性。这之后所有布局信息才确定,这时会对全部元素进行一次位置设置

layoutItems(_chart: IChart, items: ILayoutItem[], chartLayoutRect: IRect, chartViewBox: IBoundsLike): void {
    this._chartLayoutRect = chartLayoutRect;
    this._chartViewBox = chartViewBox;
    // 先清空旧布局信息
    this.clearLayoutSize();
    // 越大越先处理,进行排序调整,利用原地排序特性,排序会受 level 和传进来的数组顺序共同影响
    items.sort((a, b) => b.layoutLevel - a.layoutLevel);

    // 剔除 region 后,其余元素先布局运算
    const normalItems = items.filter(item => item.layoutType === 'normal' && item.getModelVisible() !== false);
    const normalItemsCol = normalItems.filter(item => isColItem(item));
    const normalItemsRow = normalItems.filter(item => !isColItem(item));
    normalItems.forEach(item => {
      this.layoutOneItem(item, 'user', false);
    });

    // region 和 region 关联元素
    const regionsRelative = items.filter(x => x.layoutType === 'region-relative');
    const regionsRelativeCol = regionsRelative.filter(item => isColItem(item));
    const regionsRelativeRow = regionsRelative.filter(item => !isColItem(item));
    // 先进行 col 方向布局
    regionsRelativeCol.forEach(item => this.layoutOneItem(item, 'user', false));
    // 然后得到最终 col 信息 此时已经是最终 col 信息
    this.layoutGrid('col');
    // 再使用宽度信息辅助row方向排序
    // 此时普通占位元素,会因为布局宽度影响最终布局高度
    normalItemsRow.forEach(item => this.layoutOneItem(item, 'colGrid', false));
    regionsRelativeRow.forEach(item => {
      this.layoutOneItem(item, 'colGrid', false);
    });
    // 然后得到最终 row 信息
    this.layoutGrid('row');
    // 统一水平方向元素高度
    regionsRelativeRow.forEach(item => {
      this.layoutOneItem(item, 'grid', false);
    });
    // 再使用宽度信息,第二次次对 col 方向布局
    normalItemsCol.forEach(item => this.layoutOneItem(item, 'grid', false));
    regionsRelativeCol.forEach(item => {
      // 此时从布局逻辑可知,itemlayoutRect会发生,将itemlayoutTag设置为true
      this.layoutOneItem(item, 'grid', true);
    });
    this.layoutGrid('col');

    // region
    items.filter(x => x.layoutType === 'region').forEach(item => this.layoutOneItem(item, 'grid', false));

    // 再找出 absolute 元素,无需排序,在 compiler 层需要排序放置
    this.layoutAbsoluteItems(items.filter(x => x.layoutType === 'absolute'));

    // 最后基于grid 设置位置
    items
      .filter(x => x.layoutType !== 'absolute')
      .forEach(item => {
        item.setLayoutStartPosition(this.getItemPosition(item));
      });
  }    

单个元素布局逻辑都保持一致,使用同一个方法

protected layoutOneItem(item: ILayoutItem, sizeType: 'user' | 'grid' | 'colGrid' | 'rowGrid', ignoreTag: boolean) {
    const sizeCallRow =
      sizeType === 'rowGrid' || sizeType === 'grid' ? this.getSizeFromGrid.bind(this) : this.getSizeFromUser.bind(this);
    const sizeCallCol =
      sizeType === 'colGrid' || sizeType === 'grid' ? this.getSizeFromGrid.bind(this) : this.getSizeFromUser.bind(this);
    // 先获取 item 的 grid 信息
    const gridSpec = this.getItemGridInfo(item);
    // 设置空间
    const computeRect = {
      width:
        (sizeCallCol(gridSpec, 'col') ?? this._chartLayoutRect.width) -
        item.layoutPaddingLeft -
        item.layoutPaddingRight,
      height:
        (sizeCallRow(gridSpec, 'row') ?? this._chartLayoutRect.height) -
        item.layoutPaddingTop -
        item.layoutPaddingBottom
    };
    // 计算尺寸
    const rect = item.computeBoundsInRect(computeRect);
    if (!isValidNumber(rect.width)) {
      rect.width = computeRect.width;
    }
    if (!isValidNumber(rect.height)) {
      rect.height = computeRect.height;
    }
    // 更新最终尺寸
    item.setLayoutRect(sizeType !== 'grid' ? rect : computeRect);
    // 设置大小到grid
    this.setItemLayoutSizeToGrid(item, gridSpec);
  }
}    

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

玄魂