图可视化之从零开始学G6

4,741 阅读16分钟

为什么写这篇文章?

  • A 我想学习,我喜欢学习。
  • B 工作需要,接下来会和 G6 打交道,就是和钱包有关系。
  • C 整理下最近看的文章,用自己的文字输出一遍,加深印象。

目标

希望读完系列文章后,能够对 G6 的功能分布、主要流程有大概的认知。 关注点在 G6 的介绍和主要流程,不涉及到具体 api 的使用。

G6 是什么?

下面各个维度解释下G6

G6 是一个图可视化引擎。它提供了图的绘制、布局、分析、交互、动画等图可视化的基础能力。旨在让关系变得透明,简单。让用户获得关系数据的 Insight。

G6 是 antv 体系的一个图可视化品牌,主要关注关系图的绘制,antv 还有其他应用,比如关注图表的 G2、F2,关注地理数据渲染的 L7 等

G6 是一个开源的 JavaScript 图形库,可以支持 PC、移动端、小程序多个平台。

更多G6使用的示例参见图可视化引擎 G6

G6 的上下游

G6 依赖渲染引擎 G 提供渲染能力,G 是一款Antv体系开源的支持 canvas、svg 渲染,跨 PC、mobile 平台的强大渲染引擎。 Graphin是基于 G6 的 React 库,通过接入 React 生态,使用起来更加简单。

功能分布

上图是官方 v4 的框架图,比较能看出 G6 基础的功能分布。

  • 跨 PC、mobile 平台
  • 核心功能:状态管理、事件及交互、动画、布局;
  • 扩展能力:插件、自定义基础类型
  • 底层的 GPU 加速计算、g-base 的渲染能力等;

工程结构

G6 是一个多项目共存的库,使用monorepo的思想来管理项目。monorepo不做过多介绍。思路就是把相关联的项目都放在一个仓库里管理,包括开发、构建、发布等。我自己经验来看,这种模式确实在同时多个项目开发时有优势,免去管理、link 多个项目的麻烦。不过上手阶段我经常会有疑惑,不能确认问题出在哪个项目,要整体build,苦笑,应该是我太菜。G6 使用 lerna 来管理所有的子项目,目录结构和简介如下:

G6
|-packages
  |--core // G6核心库,实现了大部分功能,主流程、动画、交互、状态管理--pc // 扩展core,提供更多的交互,pc的事件等--mobile // 扩展core,提供mobile、小程序平台的支持,移动端事件支持等--element // 提供预制的自定义图元素(节点、边、combo)--plugin // 插件,比如[鱼眼效果](https://g6.antv.vision/zh/examples/tool/fisheye#fisheye)
  |--...

构建项目:npm install , npm run bootstrap ,更多指令可以看package.json文件

渲染引擎G

功能

提供渲染能力,向上提供

主流程

  1. 顶层抽象流程是输入节点树,遍历并渲染两个步骤。
  2. 深度遍历,遍历的顺序就是节点的绘制顺序,可以用来控制 z-index。
  3. G的渲染循环触发是通知触发的,不是每帧渲染。初次进入、有事件或动画的时候触发渲染,每次收集一波更新后在下一次 requestFrame 回调时绘制,这样处理应该能节省不少静止时绘制消耗。动画则使用额外的依赖 d3-timer 来驱动。
  4. 游戏引擎里会在节点树和绘制接口draw之间增加渲染队列,方便对渲染队列做合批优化。

概念

  • group:节点分组,组织子节点,构建树结构。
  • shape:子节点,承载具体的绘图能力和渲染数据,g-base 提供了圆、矩形、线、多边形等基础能力。
  • 详细的数据结构官方文档有找到shape的,参见文档。数据结构描述了类型的形状和状态,数据流和状态转变能跟踪整个系统的运转,对理解和把控系统很有帮助。各种类型的shape有一些共同的样式结构,比如fill、shadowColor。各自又有一些特殊属性,比如圆形的半径、矩形的宽高。

小结

本小节简单描述了G的流程和输入的数据结构,用来辅助理解G6。一些更细的点,比如局部渲染、形状拾取、碰撞检测、坐标系统等,就暂时不管。从流程图可以看出,G 跑起来需要 Group 树结构,所以接下来 G6 需要做的工作就是构建这样的结构,交给 G 来渲染。

G6

前置文档说明

G6的核心概念 在官方文档有讲,此处不再赘述,可以先建立下概念和基础用法。一个简单的示例如下:

const data = {
  nodes: [
    {
      id: "node1",
      label: "Circle1",
      x: 150,
      y: 150
    },
    {
      id: "node2",
      label: "Circle2",
      x: 400,
      y: 150
    }
  ],
  edges: [
    {
      source: "node1",
      target: "node2"
    }
  ]
};

const graph = new G6.Graph({
  container: "container",
  width: 500,
  height: 500,
});

graph.data(data);
graph.render();

例子简单,但也能基本说明问题。可以看出:

  • 使用流程:就是准备数据和配置,创建Graph实例,传入数据,然后render
  • 数据结构:G6使用节点和边两个类型描述整个图,节点就是图的节点,边描述节点之间的关系。节点:边是1:n的关系。

在此基础上有交互(behavior)、事件、状态管理、动画、布局几大功能。下面就按照G6的执行顺序,分析下G6的基础流程和相关设计。

graph实例

创建graph实例后,同时会初始化以下单例,管理不同的功能:

  • graph.cfg.canvas

    G 对应的 Canvas 类实例,调控整个渲染流程,通过该单例与 G 对接起来

  • graph.cfg.itemController

    管理 Item 实例,Item 是 G6 包装的节点类,输入的边和节点数据会组织成 item 实例数组。这个模块打通从数据到最终渲染的整个流程,下面的章节会着重分析这个模块相关的部分。

  • graph.cfg.layoutController

    控制布局相关逻辑,布局算法比较复杂,G6 拆出了一个 npm 包(@antv/layout)专门放布局相关算法。

  • graph.cfg.viewController

    控制显示相关逻辑。核心逻辑是计算视口居中,提供坐标转换等功能。

  • graph.cfg.eventController

    控制事件相关逻辑,核心逻辑是拾取 G 传来的事件,出发用户定义事件,最终反馈到 item 上。

  • graph.cfg.modeController

    控制交互,管理 behavior。

  • ShapeFactory

    图元素的工厂函数,管理(crud、函数调用)定义的节点、边、combo。

绘制流程

下图展示了一个节点数据(参照前置文档示例中节点的数据结构)从输入,到最终渲染,数据的流动状态,以及经历的流程。

  1. 创建 graph 实例,创建 G 的 Canvas 模块实例,输入节点数据。
  2. 调用 graph.itemController.addItem, 使用data 创建出 Item 实例并保存在 itemController 中。
  3. 创建出的 item 使用 factory 找到对应绘制节点,包括自定义节点shapeA。
  4. shapeA.drawShape 将 shape 添加到 group 中,构建出渲染树。
  5. G 绘制 Group 树。

节点的组织

data输入后,需要依靠itemController创建出Item实例来管理。Item作为G6与渲染层的桥接,可以仔细分析下。Item类设计如下:

Item

  • Item 关注G6这一层的功能的支撑,图元素的组织、状态等。
  • Item 分为三个子类,Node,Edge,Combo 管理不同的行为。比如Node会有连接点,Edge会有箭头等,在子类中定义。状态、绘制流程相关公用的逻辑就放Item类。
  • Item 的子类和ShapeFactory的子factory是n:1的关系(参见Item的init方法)。比如Node类,会对应到NodeFactory。如果扩展新的ShapeFactory,需要考虑Item子类的联动。

ShapeFactory

  • 目前默认有三个子factory,NodeFactory、EdgeFactory、ComboFactory,分别对应三种图元素类型。
  • 职责是管理(新增,查找)图元素,可以理解是图元素工具类的集合。
  • 使用抽象工厂模式,可以新增子factory或在子factory增加新的类型,扩展性很强。

图元素

  • 图元素类的关注的是 G 的shape,给 G6 支持到绘制能力。自身不保存任何状态,属于工具类。
  • 图元素类与shape的关系是1:n,可以通过组合不同的shape新增图元素类型。
  • 图元素官方定义三种基础类型,节点、边、combo,介绍和数据结构详见文档
  • 图元素类中的固定接口,会在程序运行时调用。比如drawShape,Item在添加的时候,会调用该函数,给渲染层增加shape。

查找图元素流程

  • 一个具体的Item实例,比如Node 实例A;
  • 输入 A.type 给 ShapeFactory 找到 NodeFactory;
  • 输入 A.cfg.type 给 NodeFactory就可以找到注册的图元素类;
  • 使用图元素绘制或执行动画等

其他功能

前面里串通了绘制流程,已经能画出了图。除此之外,作为图可视化引擎的G6,还支持了哪些功能,又怎么实现的呢?

  • 事件、交互、状态,这个不用多说,只看图不能交互,味道是差了些
  • 动画,加点缓冲效果,视觉更好了
  • 布局,内置了丰富的布局样式以匹配各种场景。
  • ... ...

本文整体的思路是,分析这些功能互相的关系、内部的一些流程、设计 、实现点,这样基本能从大到小,解释清楚整个系统是怎么运行的。

整体关系

各个功能的分层关系如下:

整个G6就这些核心功能,从图中可以看出各个功能之间的层次关系,能了解到功能的所处位置。有了大方向上的认知后,能减少些迷茫。

这些功能除了动画,均有对应的Controller,由Graph封装并放在Graph.cfg上,并对外提供入口。G6有个设计的特点,就是所有状态相关的都会放在cfg里,代表的应该是这个类型的model。比如Graph类中的cfg,Item类中的Cfg等。不过把Controller这种逻辑密集的也挂在上面总感觉有些奇怪。

事件、动画、Item节点管理会直接依赖G。而交互、状态管理、布局则更多基于G6自己的封装。

事件

官方文档对事件有清晰的分类。总共分为三类事件:画布层次的事件(canvas:mousedown ...),节点层次的事件(node:click ...),按时机的事件(afterlayout ...)。事件包括事件名,以及事件的回调。事件名在不同平台会有差异,比如pc平台的mouse事件,移动平台的touch事件。详细介绍见文档中核心概念章节的事件小节。

事件我们都比较熟悉,观察者、发布订阅之类。G6事件也不例外,有个全局的事件中心,graph.cfg.eventController,提供事件的订阅和发布。而与我们认识的Dom的事件不同的是,G6面对的可能只有一个Dom元素,Canvas,所以不能依赖平台本身解析事件触发的节点、事件对象,需要自己实现及封装。

  • 按时机的事件使用eventController做发布订阅就可以了。
  • 画布、节点层次的事件,G和G6都有部分实现。G的树节点继承了@antv/EventEmiter,让每个节点支持事件注册及冒泡,类似dom。G6接管了G的树节点根元素的所有事件,并统一派发。从这个实现思路看,其实shape是可以自己注册事件的(G里树节点支持on/off),G6的文档则只提到通过Graph注册事件,应该是认为G更加底层,使用时不应该接触这一层的行为。
  • 以click为例说明下事件的流程:
    1. G6创建item时,给对应的group挂上自己的引用
    2. 用户触发click
    3. 触发画布原生事件
    4. G根据点击坐标做形状拾取事件冒泡
    5. G6观察到根部元素事件触发,并拿到传递来的shape,进一步就可以做事件的派发、驱动交互功能

形状拾取

通过上篇文章,我们知道,G将绘图的节点组织成group树结构,shape是树的子节点。要确定到底点击到哪个shape/group,思路其实显而易见就是遍历这棵树,找到节点即可。下面是quickHit模式下的节点拾取流程,源码参见G项目中g-base包里event.ts文件。(quickHit模式经看源码,主要是忽略了group的点击检测,只检测叶子节点,通过这样处理提升性能。)

  1. 触发点击
  2. 获取到源事件对象
  3. 获取到点击点坐标
  4. 从右向左开始深度遍历Group树(渲染的时候是从左到右,最后渲染的在上面,最先触发事件)
  5. 根据点击坐标过滤(跳过隐藏的、禁止拾取、画布外的树节点)
  6. 点击检测到坐标点在形状内,判断找到形状,返回形状
  7. 遍历完成未命中则判定未触发

其中点击检测每个shape类型有实现自己的检测方法,比如圆使用点到圆心的距离等。每次触发事件都要遍历整颗树,做点击检测,是个性能点,因此G本身也做了一些缓存处理。

事件冒泡

事件冒泡是G做的,比较简单,核心思路是检测命中shape后,依次向上触发parent的事件,直到事件对象里的 propagationStopped 属性为true或者到达根元素。  

交互模式

**交互模式解决的问题?**是用来批量操作用户交互行为,比如切换编辑和查看模式这种场景。详细介绍见文档中核心概念章节的Behavior相关小节。 类设计:

Behavior是个工厂类,使用对像存储各个Behavior类型。每一种Behavior子类型,定义了所需的事件行为集合,提供事件的绑定与解绑。 ModeController,根据输入的mode配置(Behavior类型的索引组成的数组),或动态切换不同的Behavior子类实例。

ModeControler绑定Behavior流程:

核心思路就是ModeController调用Behavior工厂创建子类型实例,实例完成绑定过程。触发时机是初始化Graph或系统运行期间动态调用Graph.setMode。

状态管理

状态管理解决的问题? 实现交互时或者业务逻辑中节点状态变化后,触发节点样式的更新或其他自定义行为。这里的状态可以是交互中的hover、active,也可以是自定义的running任何一种状态。对状态的响应如果只是样式变化,可以直接在创建Graph实例时输入的节点配置中设置。如果其他更加复杂的行为,比如加个动画,就需要自定义节点,复写默认的setState方法。详细介绍见文档核心概念章节的交互mode、状态state小节

类设计:

Graph初始化后会实例化StateController和ItemController,可以在Graph实例的cfg中访问到。

StateController目前看来只是维护了各种状态下的Item数组,以及更新状态后的事件触发,不影响整个state的流程。

设置状态流程

  • 输入数据中配置不同状态的样式
  • 事件触发或流程触发,调用graph.setItemState更新状态
  • 查找配置中状态对应的样式
  • item(需要更新的节点)定位到具体的shape,调用shape的setState,更新样式到G渲染层

动画

动画G6没有做什么处理,直接使用的G的动画能力。根据使用场景,分为全局动画自定义节点动画两部分。详细介绍见文档核心概念章节的基础动画小节。 **全局动画:**拿到节点树根部元素,调用animate接口;

自定义节点动画:在自定义图元素(可以看上篇了解图元素,G6用来对接G,封装了具体的shape)时,拿到shape或group节点,调用animate接口即可。

类设计:

  • Element实现了animate接口,子类都具有了动画能力。
  • Animation这个类源码里没有单独的文件体现,不过有用ts声明区分出来,记录了动画的详细配置。
  • Canvas类是G这一层的主控类和入口。根节点、动画入口、绘制入口等,都在这个类上。
  • Timeline类使用Element和Animation实现具体的动画算法。使用的时候作为单例挂在Canvas实例上。

动画流程:

核心思路就是Element获取Canvas中的TimeLine实例,传入动画数据,交给d3-timer去逐帧执行,通过插值实现连续的动画效果。

插值计算使用了d3-ease、d3-interpolate辅助计算。

更新到画布是Element的能力。

触发流程时机是全局动画或自定义动画调用animate时。

布局

布局是采用算法,使节点和边能够以某种方式分布。分为一般图布局和树图布局,目前我只看到了一般图布局,就只分析一般图布局。详细介绍见文档核心概念章节的图布局小节。

相关的类:

布局使用在初始化Graph时创建的LayoutController控制。具体的布局算法来自npm包** @antv/layout** 。

一般图布局的具体流程

可以看出,布局和Graph通过数据解耦。Graph面向数据,数据正确就能正常渲染。布局算法也只用关注对数据的生产,不用关注Graph或者其他系统。

布局算法对节点坐标更新以达到布局的目的,其他数据项也可以更新,不过就显得职责不清晰,所以不建议布局算法对其他数据项的更新。

PC和Mobile平台布局的流程不一致。PC会额外支持worker布局,就是图中第三列的布局流程,把布局拆成了三个步骤异步通知进行。

触发流程的时机是在初始化或者切换数据、布局的时候,具体是在调用Graph的render/changeData/updateLayout时。

这里是一个自定义布局的例子,看完这个例子应该会对布局算法的职责有更清晰的认知。

小结

通过对各个功能的层次分布,以及各自实现流程的分析,应该对G6实现思路有了大致的认知。有兴趣的话,可以对着流程看看源码,看看具体某个步骤的实现逻辑。经过这段时间G6的梳理,对G6也有了更多的了解,最后感谢阅读。

最后

微信搜索公众号Eval Studio,关注更多动态。