前言

本文将详细介绍 G6 中 Minimap 的设计、计算、优化原理。将会涉及到大量计算逻辑,看到数学就脑壳疼的小伙伴们可以选择性省略🌝。

Minimap 目的

主要用于大规模图的导航,大部分情况下不需要 Minimap 展示和原图一样详细的信息。

概念解释

主图与 Minimap

image.png

  • 主画布 Main Canvas 大小为实例化图时设置的 width 与 height;
  • 主图包围盒 Main Graph BBox 是当前主图最小的包围盒,它可能与 Main Canvas 长宽比不同,也可能会超出 Main Canvas;
  • Minimap Canvas 大小为实例化 Minimap 插件时指定的 size,可能与 Main Cavans 长宽比不同;
  • Minimap Graph BBox 是 Main Graph BBox 等比缩放。

图形与图形分组

图形的类型有 circle、text、rect、path……[详见 G6 各图形属性],而图形分组是用于管理和组合这些图形的容器。类似 SVG 中 g 与其他图形标签的关系。
在 G6 中,图上的图形和图形分组的关系如下图:
image.png

  • 上图中,相邻层方块代表直接包含关系;
  • 主图画布有一个根图形分组 Root Group,它包含了 Nodes Group、Edges Group Combos Group、Delegates Group ,分别包含了节点、边、combos、delegates;
  • Delegates Group 中主要存放图上的临时图形,例如拖动节点/分组时的虚拟代表框:

image.png

  • Nodes Group 中的 Node Group 包含了一个节点中的所有图形,可能有 circle、text、rect 等。Edges Group、Combos Group 也是类似的。

矩阵

每个图形、图形分组都有自己的变换矩阵,用于控制自身的平移、缩放、旋转(旋转在 G6 中较少见)。可以通过 element.getMatrix() 获得自身的矩阵。在没有任何平移、缩放、旋转时,该矩阵的初始值为 null ,也可以使用一个单位矩阵表示没有任何矩阵操作。
在 G6 中,Nodes Group 中的每一个 Node Group 都有一个矩阵控制该节点在画布上的具体位置,而一个节点中的具体图形(circle、text 等)的 x 与 y 均以其 Node Group 为参考建立子坐标系,与整个节点的坐标不直接相关。Combos Group 与 Nodes Group 原理相同。
与节点不同的是, Edges Group 中的每一个 Edge Group 则没有使用矩阵来控制边的位置,边的起始、结束位置均由 Edge Group 中的 path 图形自己定义。

包围盒 Bounding Box(BBox)

包围盒是指包围一个图形或一组图形的一个最小矩形,使用下面对象描述:

  1. {
  2. minX: number,
  3. minY: number,
  4. maxX: number,
  5. maxY: number,
  6. centerX: number,
  7. centerY: number,
  8. width: number,
  9. height: number
  10. }

若要使用好 bbox,我们需要区分 getBBox 与 getCanvasBBox :

  • element.getBBox():获取 element 的包围盒,该包围盒与该元素及其子元素的矩阵均无关;
  • element.getCanvasBBox():获取 element 考虑自身矩阵以及子元素矩阵变换后的包围盒,即当前图形相对于画布的包围盒。

如下一个 circle 图形从 a 的形态通过矩阵平移、放大为 A 形态。由于 a 没有经过矩阵变化,其矩阵为 null,a.getCanvasBBox() 与 a.getBBox() 返回值相等。A.getCanvasBBox() 返回经过了矩阵操作后的包围盒,A.getBBox() 忽略矩阵,即与 a.getBBox() 相等。
image.png

下图展示了一个图形分组的情况。

  • 在形态 g 中:a 图形未经任何矩阵变换;而这里的 b 图形的 x 与 y 均为零,即其本身位置是 (0, 0),通过矩阵平移到了 (35, 15);g 本身没有任何矩阵变换。getBBox 是得到的是不受任何矩阵影响的包围盒。而 getCanvasBBox 则是考虑了 b 的矩阵的影响;
  • 变换到 G 形态:a 图形通过矩阵放大得到 A,b -> B 矩阵不变,g 通过施加在该分组上的矩阵进行平移得到 新的位置。同样,getBBox 得到的是不受任何矩阵影响的包围盒,因此与 g.getBBox() 相等。而 g.getCanvasBBox 则是考虑了 A、B、G 上的矩阵的影响。

image.png

问题

  1. 两个画布将会导致渲染消耗倍增;
  2. 从主画布复制图形到 minimap 上需要注意缩放比例;
  3. 使用 View Port 定位当前主图显示部分逻辑稍复杂。

方案

逻辑方面

Minimap 有三种类型:

  • default:与主图所有图形保持一致,即绘制主图上所有的图形,包括复杂节点/边中的所有图形;
  • keyShape:仅绘制主图上节点/边的 keyShape;
  • delegate:使用一个正方形代替一个主图节点,边仅绘制 keyShape。

这三种类型的 Minimap 在复制主图图形时逻辑各异,而后两者的性能将会更好。要了解复制逻辑,首先我们需要知道

1. 复制逻辑

  • defualt:直接将主图的 Root Group 清除矩阵(可能有平移、缩放)后复制到 Minimap Canvas 上。
  • keyShape:该模式下,Minimap Canvas 上只有一个图形分组 Root Group,所有节点/边的 keyShape 直接添加到 Root Group 中。

首先复制边的 keyShape 到 Minimap Canvas 中:

  1. // 遍历 Edges Group 中的元素
  2. EdgesGroup.children.forEach(nodeGroup => {
  3. // 在该边的组中,拿到这个边的 keyShape
  4. const keyShape = nodeGroup.findKeyShape();
  5. // 复制到一个新的对象中
  6. const cKeyShape = clone(keyShape);
  7. // 将 cKeyShape 增加到 minimap 的 Root Group 中
  8. minimap.rootGroup.children.push(cKeyShape);
  9. });

然后复制节点的 keyShape 到 Minimap Canvas 中:由于在主图中,节点的位置是由其父分组的矩阵控制的,而 keyShape 中的节点没有独立的父分组。因此我们需要把主图的节点位置写入 minimap 中对应的 keyShape 上。伪代码如下:

  1. // 遍历 Nodes Group 中的元素
  2. NodesGroup.children.forEach(nodeGroup => {
  3. // 在该节点的组中,拿到这个节点的 keyShape
  4. const keyShape = nodeGroup.findKeyShape();
  5. // 复制到一个新的对象中
  6. const cKeyShape = clone(keyShape);
  7. // 获取 nodeGroup 的 canvasBBox,考虑矩阵影响,才能得到正确的节点位置
  8. const canvasBBox = nodeGroup.getCanvasBBox();
  9. // 为 cKeyShape 指定位置
  10. cKeyShape.attr({
  11. x: canvasBBox.centerX,
  12. y: canvasBBox.centerY
  13. });
  14. // 将 cKeyShape 增加到 minimap 的 Root Group 中
  15. minimap.rootGroup.children.push(cKeyShape);
  16. });
  • delegate:与 keyShape 模式相同,该模式下,Minimap Canvas 上只有一个图形分组 Root Group,所有节点/边的 keyShape 直接添加到 Root Group 中。

复制边的逻辑与 keyShape 的模式相同。
目前,delegate 使用代表每个节点长、宽、位置的一个矩形,伪代码如下:

  1. // 遍历 Nodes Group 中的元素
  2. NodesGroup.children.forEach(nodeGroup => {
  3. // 获取 nodeGroup 的 canvasBBox,考虑矩阵影响,才能得到正确的节点大小和位置
  4. const canvasBBox = nodeGroup.getCanvasBBox();
  5. const delegateShapeAttrs = {
  6. x: canvasBBox.minX,
  7. y: canvasBBox.minY,
  8. width: canvasBBox.width,
  9. height: canvasBBox.height
  10. }
  11. // 增加 delegate 到 minimap 的 Root Group 中
  12. minimap.rootGroup.addShape('rect', {
  13. attrs: delegateShapeAttrs
  14. });
  15. });

2. Minimap Canvas 缩放平移逻辑

从复制逻辑中得到的 Minimap Root Group 长宽与 Main Graph Root Group 相同,我们需要将其进行缩放适配到 Minimap Canvas 的大小。一般来说 Minimap Canvas 比主图画布小,但有以下几个问题需要谨慎考虑:

  • 大多数情况下,Minimap Canvas、Main Graph Root Group、Main Graph Canvas 三者的长宽比不同;
  • Main Graph Root Group 可能超出 Main Graph Canvas 范围。

通过上面复制逻辑的操作后,我们得到的 Minimap 内容是这样的:
image.png

需要通过操作 Minimap Root Group 的矩阵 mMatrix 将其进行缩放和平移。

  • Step 1: 将 Minimap Graph Group 根据其 canvasBBox 平移到 Minimap Canvas 左上角: ```javascript const canvasBBox = MinimapGraphGroup.getCanvasBBox(); // 获得 Minimap Graph Group 的 CanvasBBox let mMatrix = [ 1, 0, 0, 0, 1, 0, 0, 0, 1 ]; // 初始化矩阵为单位矩阵

// 平移到左上角 transform(mMatrix, [ [‘t’, canvasBBox.minX, canvasBBox.minY], ]);

  1. 此时,我们得到如下结果:<br />![image.png](https://cdn.nlark.com/yuque/0/2020/png/156681/1587726050940-c4e9bd49-2051-4697-9799-c7e13a57ed6e.png#align=left&display=inline&height=248&margin=%5Bobject%20Object%5D&name=image.png&originHeight=459&originWidth=506&size=79961&status=done&style=none&width=273)
  2. - **Step 2**: Minimap Graph Group 缩放到合适到大小以适配 Minimap Canvas,缩放因子 ratio 为:
  3. ```javascript
  4. const ratio = Math.min(width(Minimap Canvas) / canvasBBox.width,
  5. height(Minimap Canvas) / canvasBBox.height);

使用 ratio 进行缩放:

  1. transform(mMatrix, [
  2. ['s', ratio, ratio],
  3. ]);

此时,我们得到如下结果:
image.png

  • Step 3: 将 Minimap Graph Group 平移到 Minimap Canvas 的中心
    1. const dx = (width(Minimap Canvas) - CanvasBBox.width * ratio) / 2;
    2. const dy = (height(Minimap Canvas) - CanvasBBox.height * ratio) / 2;
    3. transform(mMatrix, [
    4. ['t', dx, dy],
    5. ]);
    最后,得到如下结果:
    image.png
    至此,Minimap Canvas 绘制完成,主画布的平移、缩放都不影响 Minimap Canvas。但局部节点/边样式、位置的改变需要 Minimap Canvas 随之更新。

3. 主图联动 View Port 逻辑

View Port 用于定位 Minimap Canvas 中显示在主图中对应的部分。需要注意以下问题:

  • View Port 的长宽比始终与主图画布 Main Canvas 长宽比一致;
  • 用户在主图上缩放、拖拽画布会影响 View Port 的位置和大小;
  • 用户也可以通过拖拽 View Port 来导航主图。

在主图缩放、拖拽画布时更新 View Port 的位置和大小主要分为以下几步:

  • Step 1: 计算 View Port 宽高 viewPort.width, viewPort.height,首先我们需要得到主图画布坐标的相对宽高。下图展示了什么是相对宽高,当主图被平移放缩时,图坐标系也将会跟着平移缩放:

image.png
因此,我们使用主图的视口左上角坐标 (0, 0) 和右下角坐标 (mainCanvas.width, mainCanvas.height) (mainCanvas.width 和 mainCanvas.height 是实例化图时指定的 Main Canvas 大小),可以计算出画布的相对坐标 topLeft 和 bottomRight,从而计算出相对宽高:

  1. // 根据主图画布视口的左上角位置和右下角位置获得对应的画布坐标 topLeft 和 bottomRight
  2. const topLeft = graph.getPointByCanvas(0, 0);
  3. const bottomRight = graph.getPointByCanvas(mainCanvas.width, mainCanvas.height)
  4. // 相对宽高
  5. const width = bottomRight.x - topLeft.x;
  6. const height = bottomRight.y - topLeft.y;
  • Step 2: 计算 View Port 相对于 Minimap DOM 容器的位置 (left, top)。

首先我们考虑未缩放,仅平移。这里需要分为四种情况讨论:
第一,主图 canvasBBox 左侧超过主画布左侧:
image.png
则 View Port 的 left 值为 dx - mainGraphRootGroup.getCanvasBBox().minX * ratio
image.png
第二,主图 canvasBBox 右侧超过主画布右侧:
image.png
则 View Port 的 left 值为:

  1. const mainGraphBBox = mainGraphRootGroup.getCanvasBBox();
  2. const a = (mainGraphBBox.width - mainGraphBBox.maxX + mainCanvas.width) * ratio;
  3. const left = -(width - a - dx);

image.png

第三,主图 canvasBBox 上侧超过主画布上侧。与第一种情况类似,只是更换成 y 轴上的对比;
第四,主图 canvasBBox 下侧超过主画布下侧。与第二种情况类似,只是更换成 y 轴上的对比。

而下面几种情况只需要将上面四种情况的 x 轴与 y 轴互相组合合即可,x 与 y 轴不相互影响:
image.png

最后,当主图被缩放后,将会影响 Main Graph Root Group 的大小。因此若图有缩放,还需要将 ratio /= zoom

4. View Port 联动主图逻辑

目前仅提供了 View Port 的拖拽,从而联动平移主图功能。拖拽 View Port 的移动范围有限制,不能将其拖出 Minimap Canvas 上下左右边界。
View Port 的监听放在了该 DOM 上,在拖动过程中,通过下面代码控制主图的平移:

  1. // previousX previousY 为拖拽过程中的上一次鼠标位置
  2. const dx = previousX - e.clientX;
  3. const dy = previousY - e.clientY;
  4. const zoom = graph.getZoom(); 主图当前的缩放因子
  5. // ratio 为上文中将主图缩放到 Minimap Canvas 的缩放系数
  6. graph.translate((dx * zoom) / ratio, (dy * zoom) / ratio);
  7. previousX = e.clientX;
  8. previousY = e.clientY;

5. 边界情况处理

为了防止用户暴力无限拖拽画布,在内置交互 drag-canvas 中做了限制,最多只能拖拽到 Main Graph Root Group 上下左右多一屏。这并不会影响用户的使用,若用户希望新增节点或拖拽节点到距离当前 Main Graph Root Group 很远的地方也不影响,因为增加了节点之后 Main Graph Root Group 的范围也随之更新,画布可拖拽的范围也会更大。
因此,在画布拖拽到极限位置的时候,ViewPort 也不会完全超出 Minimap Canvas,多少会剩余一部分在 Minmap Canvas 上。Minimap Canvas 容器被设置了 overflow: hide ,所以 View Port 超出 Minimap Canvas 的部分会被隐藏。
当用户通过拖拽主图画布使得 View Port 有部分超出 Minimap Canvas 时,再点击 View Port,将会跳回到 View Port 完全在 Minimap Canvas 内部,原本超出的边缘顶到 Minimap Canvas 边缘的情况。帮助用户快速定位回主图内容上。

性能优化

1. 提供 delegate 或 keyShape 模式

默认的 default 模式将主图所有图形复制到 Minimap 中将会造成绘制和更新的压力。因此在 Minimap 最早的版本中就提供了 delegate 和 keyShape 模式。其中,delegate 是性能最好的模式。

2. 减少 Minimap Canvas 重绘

上文提到,主图在缩放和平移时,Minimap Canvas 是不需要重绘的。

在旧版 Minimap 中,监听了主图的 beforepaint,即主图每次重绘更新都会触发 Minimap Canvas 的重绘。并且原来的实现方式是在重绘时完全清空 Minimap 上的元素,重新创建和添加一遍,这是非常消耗性能的。

3.4.5 版本中 Minimap 监听 afterrender,afterlayout,afteradditem,afterupdateitem,afterremoveitem 这五个时机,触发 Minimap Canvas 的画布重新绘制,并且是差量地修改,代替原来的清空后重新添加。也就是说,只有主图上元素有增删、位置改变、重新布局时才会更新 Minimap Canvas。而 beforepaint 只会触发 View Port 大小和位置的更新。

3. debounce

上面提到的 Minimap Canvas 重绘机制仍然无法避免有些情况下的频繁刷新,例如:

  • 在主图初次渲染时,会逐个 additem,此时 afteradditem 将会频繁被触发;
  • force layout 中,每次迭代都会触发主图的重新渲染,从而频繁触发 Minimap 重绘,然而 Minimap 在主图未稳定时不断更新是没有意义的。

因此,在 3.4.5 版本中,使用了 debounce 来将短时间内频繁触发重绘 Minimap Canvas 合并成一次执行。大大减少了重绘造成的性能消耗。

结语

Minimap 在 G6 1.x 时期便已经存在,其性能与准确性一直被用户吐槽。经过此次翻新改造,相信 Minimap 可以为用户高效准确地提供导航功能。也许它还有更多的性能、功能优化空间,欢迎社区的小伙伴一起讨论和共建。