高中,读过几本 3D 图形编程相关的书。怎么说呢,自那以后,图形学相关的东西,都不在我的兴趣范围里了。直到最近,我重新燃起了一点兴趣:

  • 架构治理工具 ArchGuard 依赖于「图即代码」,用于生成架构图,以更好的进行架构治理。

  • 年初,开源的知识管理工具 Quake 中,需要支持「概念构建系统」这样一个理念。

  • 需要管理多种不同的图形格式。

当然了,作为一个 Firefox 浏览器的忠实用户,Firefox 在 Feakin 里自然是支持最好的。开始之前,欢迎尝试在线 Demo:https://online.feakin.com/ , GitHub:https://github.com/feakin/feakin/,当然了 Bug 超级多。

引子:开源绘图工具实现的浅析

在设计 Feakin 的时候,参考了一些几个常用的图形工具。分析了它们的大致实现,以及部分的源码:

Graphviz

AT&A 实验室的作品,作为最古老的图形即代码的工具,它还提供了一个图形描述语言:Dot,可以直接将代码转换为图形。它的生态体系足够的完善,所以你在哪都能看到它的影子。

Mermaid

同样也是一个图形即代码的工具,使用的是纯 JavaScript 实现,从语法解析到图形渲染。Mermaid 使用 Jison 作为解析器,然后将其转换为不同的图模型,如流、时序等,再使用 graphlib、dargre 进行布局,最后使用 dagre-d3、d3 进行渲染。因此,在 Mermaid 里有三个核心要素:语法解析、图形布局、图形渲染。而,Mermaid 不存在一个图形模型,也变成了一个神奇的存在。

Cytoscape

第一次看到这个图形引擎的时候,是看到 ArchGuard 前人留下的一个功能:布局算法切换。所以,在源码实现上,Cytoscape 提供了这种算法上的扩展性,具体可以看官方网站。布局上的抽象,提供了更好的可扩展性 —— 我们就可以参考它的实现了。在它的图形模型里,Node(节点) 和 Edge(边) 从形式上都算是 Element,然后在渲染时根据图形类型展开。于是在渲染时,直接采用 HTML5 里的 Canvas 进行绘制即可。

Excalidraw

对我来说,其最有意思的是引入了射影几何,来进行节点变化时的,自动 Edge 跟踪;即当 A 从 B 的左边移动到右边时,对应的线自动连接到 B 右边的边上。当然了,其中的各种神奇算法,我也没看懂。对于其他人,可能就是使用 roughjs 来生成手绘风格的图。当然了, 就目前的代码实现来说,roughjs 在 renderElement 里过度的耦合,图形模型也耦合在其中。

MaxGraph

MaxGraph 是 Draw.io 底层的 mxGraph 的 TypeScript 实现,最开始研究时,是为了导入 Draw.io 生成的图。从模型上来说,MaxGraph 应该是几个工具里做得最好的,包含一系列的可参考的 Shape、Edge 等等。其次,也提供了 AbstractCanvas2D 这样的实现,虽然它没有实现真正的 HTML5 Canvas2D,但是抽象接口已经非常像了,诸如 .moveTo、 .lineTo 等。可能它就提供 SVG 和 XML,前者用于网页渲染,后者用于导出。

所以,从上述的几个工具里,我们就能得到一个绘图工具底层的基本要素:

  • 图形模型。即对图形建模,理清 Diagram/Graph、Node、Edge、Shape、Element 之间的关系,并包含基本的图形表示关系。

  • 图形绘制。即定义如何对图形进行绘制/渲染,如采用 SVG、Canvas 等不同的形式。

为了丰富这些功能:

  • 布局算法。提供自动化的布局方式,如 Cytoscape 这一类自动计算的方式。

  • 语法解析。诸如于为了支持图即代码(即 DSL)的形式来提供快捷的绘制方式。

  • 自动连线。即如 Excalidraw、Draw.io 中提供的功能,两者实现的方式完全不一样。

  • 图形风格。诸如于 Excalidraw 提供的手绘图形的功能。

  • 图形库。这也是 Drawio 最受欢迎的地方,也是 Excalidraw 一个很有意思的功能。

  • 等等

结合这些功能,我们就可以造出一些有意思的东西,比如 Feakin 中的二阶段渲染。

Step 1:实现第一个概念证明

为了 Feakin 能进行下去,我们所要做的就是快速实现一个 PoC(概念证明)。在这个 PoC 里,主要实现如下的功能:

  • DSL (领域特定语言)解析。

  • 图形模型生成。

  • 图形绘制。

如下图所示:


这样一来,我们就有一个「It works」了。

从图形引擎的误区中出来

在实现第一个 PoC 的时候,遇到的第一个困难是技术选型,到底是:SVG 还是 Canvas?SVG 可以方便于我们进行 TDD(测试驱动开发),只要所有的测试是通过的,理论上结果就是过的。但是,如我们所看到的那样,SVG 容易遇到性能瓶颈。Canvas 提供自由的绘制 API,测试时依赖于快照测试(snapshot),不易于编写测试。所以,结论就是:我们都要了吧。只需要像 MaxGraph 提供一个抽象图形接口,我们就能实现对于两种模式的支持。

随后,发现这样是不合理的,只在 PoC 阶段,并且没有经验的情况下,做一个 AbstractCanvas 还是存在很高的成本。于是乎,需要寻找一个合理的绘制引擎,诸如于 Raphaël、Fabric、Konva 等。最后,选择了 Konva,因为它支持了 React 框架。正所谓,工作用 Angular 心不累,业余用 React 放我自我

原型:语法解析-图形模型-图形绘制

在构建了基本的图形领域的相关知识之后,要构建出一个绘图工具并不困难。

  • 参考(复制) Mermaid 的语法解析。将通过 parser 解析类似于 Graphviz、Mermaid 设计的语法,将其转换为图形模型。

  • 引入 Dagre.js 作为图形布局引擎。通过 Dagre.js 来计算布局,返回我们所需要的图形模型。

  • 使用 React Konva 进行渲染。将图形模型匹配到 Konva 中的图形,如 RectangleShap 对应于 <Rect> 组件、Edge 对应于 <Line>、 <Arrow>等。

过程中,遇到的一个比较坑的点是:Lerna + Nx.js 管理 monorepo。React + Craco 的组合、风格各异的代码库,带来了持续失败的 CI,还好 GitHub Action 不会统计失败率。持续集成不来点失败,怎么能发挥它的用处呢。

Step 2:对模型进行反复重构(持续)

在 Poc 里,我们需要遇到不同的模型转换:

  • 解析器获得的模型。包含节点的信息,以及节点的关系(诸如于 A 到 B、A 依赖于 B 等)。

  • 布局引擎生成的模型。通常来说,只是补充一下模型里的层次关系(children/parent)、坐标信息(x、y)、几何信息(width、height)等。

  • 图形绘制引擎的模型。我们需要将上述的信息,再次转换到 Konva 的模型中。而其中会存在一些差距,比如 Konva 使用 Polygon(多边型)来表示Triangle(三角型)、Diamond(菱形)等。

所以,如何设计一个有用的模型,成为了个有意思的问题。

GIM:图中间模型

在那一篇《图的抽象:概念与模型的构建》中,我们介绍了从认知语义学的角度,如何仅凭基本的概念,设计出可用的模型?不过,这样的模型是未经验证的。那么,什么样的模型是经常验证的呢?自然是开源社区中,已经充分使用的代码模型。虽然说,各个模型受限于自己的场景,与其他软件的模型存在一定的差距。但是呢,在基本的核心概念图的表示上,它们是大差不差的。于是乎,我们有了一个 GIM(Graph Intermedia Model),图中间模型。

这个图模型的来源是源自其他图形工具成熟的模型,如下图所示:

所以,在持续的建模、提炼之后,我们可以轻松地进行我们的图模型转换。在有了 TDD 的加持之后,这个过程就更加地简单了。

在模型这一点上,Feakin 的设计初衷与 ArchGuard 底层的 Chapi (https://github.com/modernizing/chapi) 语言模型的想法是一致的。而这种所谓的通用模型会遇到的问题是,需要抛弃一些细节,诸如于只实现 80% 的核心功能。

图的模型

对于一个图(Graph)来说,它的模型也就变得相当的简单:

export interface Graph {
nodes: Node[];
edges: Edge[];
props?: GraphProperty;
subgraphs?: Graph[];
}

围绕于这四个核心元素再往下展开: