使用 DiagrammeR 绘制流程图的笔记

· 7023字 · 15分钟

R 中绘制流程图的包有好几个,键者上网翻了翻,找到下面几个可能算是容易上手的。

  • ggflowchart,整个包只有一个ggflowchart()函数,若要修改流程图中节点、线的属性只需改动对应参数。
  • ggdag,节点、线的属性都被设置为单独的函数,可改动范围更大,但用起来也更繁琐。
  • DiagrammeR,通过此包使用 viz.js 和 mermaid.js 的语法绘制流程图mermaid.js本身是一个基于 JavaScript 的图表库,可绘制流程图、序列图、甘特图、思维导图、时间轴、桑基图等多种类型的图表。viz.js 也是一个基于 JavaScript 的图表库,viz.js 源于 Graphviz,后者是一个开源的图形可视化软件,使用 DOT 语言来定义图形。

从键者的个人角度看,viz.js 和 mermaid.js 的语法看起来更简单,所以选择学习使用 DiagrammeR 来绘制流程图。

library(DiagrammeR)
packageVersion("DiagrammeR")
## [1] '1.0.10'

一、 mermaid 流程图 🔗

在 mermaid.js 提供的网页文档中,关于流程图的部分 https://mermaid.js.org/syntax/flowchart.html 内容很详细,但并不是所有细节都能在 R 中实现。以下两种写法,效果相同.

# 在 R 中使用 mermaid.js 绘图
DiagrammeR(diagram = "", type = "mermaid", ...)
mermaid(diagram = "")

1.1. 图形布局 🔗

流程图中各个节点之间如何布局的方向共有如下四种。需要注意的是,节点的大小是根据宽度和高度自适应的。

  • TB,Top –> Bottom,从上到下。
  • BT,Bottom –> Top,从下到上。
  • RL,Right –> Left,从右到左。
  • LR,Left –> Right,从左到右。
mermaid(diagram = "
graph LR
  节点1 --> 节点2
  ", height = 100, width = '90%')
mermaid(diagram = "
graph RL
  节点1 --> 节点2
  ", height = 100, width = '90%')
mermaid(diagram = "
graph TB
  节点1 --> 节点2
  ", height = 200, width = '90%')
mermaid(diagram = "
graph BT
  节点1 --> 节点2
  ", height = 200, width = '90%')

1.2. 节点的形状 🔗

在节点较少或者节点内容简单的情况下,定义节点可以直接写节点1 --> 节点2,但是在节点较多的情况下,通常可以先定义节点,再定义节点之间的指向关系,如本小节的写法。%%符号添加注释

按照键者当前的试验结果,DiagrammeR 包仅支持绘制出以下五种节点形状。在定义节点、线时,一次只能定义一个节点或者一条线,不能批量定义。多个节点、线的定义写在同一行会报错,要么换行写要么用分号隔开。

mermaid(diagram = "
graph LR

%% 定义节点
  A(圆角方框)
  B[方框]
  C((圆形))
  D{菱形}
  E>折角方框]
  
%% 定义节点之间的连线  
  A --> B; B --> C; C --> D; D --> E
  ", height = 100, width = '90%')

1.3. 节点的内容、样式 🔗

1.3.1. 支持引入字体图标 🔗

支持在节点内容中使用""双引号来引入 Unicode 编码代表的 字体图标,但同时需注意将mermaid('')函数括号里的引号改为单引号。引入方法有二:

mermaid(diagram = '
graph LR
   A["&#10084"] %% HTML 支持的 UNICODE 编码
   B["\u2764"] %% JS 支持的 UNICODE 编码
   C["❤"]
   D["✔"]
   
   A --> B; B --> C; C --> D
   ', height = 100, width = '90%')

1.3.2. 支持引入 HTML 和 CSS 🔗

mermaid.js 支持在定义节点的文本内容时使用 markdown,但是键者多番尝试发现 DiagrammeR 包中的 mermaid 函数似乎不支持 markdown,但却支持引入 HTML 和 CSS。

如下,在文本内容中引入 HTML 标签,达到简单的换行、加粗、斜体的效果。

mermaid(diagram = "
graph LR
  A[节点1] 
  B[第一行<br>第二行</br>第三行]
  C[<b>加粗</b> <i>斜体</i>]
  
  A --> B; B --> C
  ", height = 200, width = '90%')

引入 CSS 样式的方法有两种,一是在绘图代码中直接定义每个节点的样式,二是在定义节点内容时使用 HTML 标签引入自定义样式。

D {
color:red;
font-size:18px;
}
mermaid(diagram = "
graph LR
  
  %% 定义节点内容
  A[节点1] 
  B[第一行<br>第二行</br>第三行]
  C[<b>加粗</b> <i>斜体</i>]
  D[<D>改变字号</D>]
  
  %% 定义节点之间的连线
  A --> B; B --> C; B --> D
  
  %% 定义节点的样式
  style A fill:pink, stroke:black, stroke-width:4px
  style B fill:lightgreen, stroke:red, stroke-width:2px, stroke-dasharray: 5 5
  style C fill:lightblue
  ", height = 200, width = '90%')

1.4. 线与箭头的样式 🔗

如下是键者尝试过能正常使用的线与箭头的样式,线的长度是自适应的。如---代表没有箭头的直线,不支持写作----

mermaid(diagram = "
graph LR
%% 增加线上的文字
  A1 --- B1; B1 ---|文字|B2
  A2 --> B3; B3 -- 文字 --> B4
  A3 -.-> B5; B5 -.文字.-> B6
  A4 ==> B7; B7 == 文字 ==> B8
", height = 400, width = '90%')

似乎只可以有箭头或无箭头,其他任何箭头样式都不支持。也不支持双向箭头。倘若在节点 A、B 之间需定义双向箭头,需得写A --> BB --> A两次。

mermaid(diagram = "
graph LR
  A --o B; B --> A; B --> C; C --x A
", height = 100, width = '90%')

1.5. 子图 🔗

subgraphend之间定义子图。mermaid.js 支持定义子图与子图、子图与节点之间的线,也支持定义嵌套多层子图,但是键者都没摸索出来怎么弄。

mermaid(diagram = "
graph TB 
   A1 --> C2
   
%% 定义第一个子图
    subgraph 子图名称1
    A1 --> A2
    end
    
%% 定义第二个子图    
    subgraph 子图名称2
    B1 --> B2
    end
    
%% 定义第三个子图    
    subgraph 子图名称3
    C1 --> C2
    end
    ", height = 300, width = '90%')

二、 Graphviz 流程图 🔗

关于节点、线的样式如何设置可参考以下文档,本节不细究。

以下两种写法,效果相同。

# 在 R 中使用 viz.js 绘图
DiagrammeR(diagram = "", type = "grViz", ...)
grViz(diagram = "")

# 使用事先定义的图形模板
grViz(replace_in_spec())

2.1. 基本语法 🔗

与用 mermaid 函数绘制流程图一样的地方是,用 grViz 函数 绘制流程图时,节点的大小也是根据高度、宽度自适应的,图形中节点布局的方向也分为四种。使用#符号添加注释

  • 无向图(graph),使用--描述节点之间的关系。
grViz(diagram = "graph{
 # 定义图形布局,从左至右
  graph[rankdir = LR]
 # 定义节点 
  node[shape = rectangle]
      A[label = '节点1']
      B[label = '节点2']
 # 定义线   
  edge[style=dashed]
  A -- B
}", height = 100, width = '90%')
  • 有向图(digraph),使用->来描述节点之间的关系。
grViz(diagram = "digraph{
 # 定义图形布局,从右至左
  graph[rankdir = RL]
 # 定义节点       
  node[shape = rectangle]
      A[label = '节点1']
      B[label = '节点2']
 # 定义线     
 edge[arrowsize = 2,style = dashed]
  A -> B 
}", height = 100, width = '60%')

与 mermaid 函数不同的是,grViz 函数支持对多个节点、线进行一次性统一定义或者分批次统一定义,而对节点、线单独定义的样式优先级会更高。

  • 对单个节点、线依次定义。
grViz(diagram = "digraph{
graph[rankdir = LR, fontsize = 12]
      
 node[shape = rectangle, 
  style = filled, fillcolor = Yellow]
   A[label = '节点1', 
     fillcolor = 'ForestGreen']
  B[label = '节点2', 
     fillcolor = 'LightYellow']
  C[label = '节点3', 
     fillcolor = 'Yellow', fontsize = 24]
      
 edge[arrowhead = diamond]
  A -> B [arrowhead = box]
  B -> C [label = '条件', color = red]
}", height = 100, width = '90%')
  • 对多个节点、线统一定义
grViz(diagram = "digraph{
graph[rankdir = LR]
     
 node[shape = rectangle, 
  style = filled, fillcolor = ForestGreen]
      节点1;节点2;
      
  node[fillcolor = Yellow]    
      节点3;
      
      
  edge[arrowhead = diamond] 
  节点1 -> 节点2 -> 节点3 
  {节点2, 节点3} -> 节点1
}", height = 100, width = '90%')

2.2. 节点的内容 🔗

节点内容支持引入 HTML/JS 的 unicode 编码,但不支持 markdown 格式,也不支持引入 HTML 标签。较为特别的是支持用’@@’符号引入 R 代码的执行结果。

- HTML unicode 编码 JS unicode 编码 markdown HTML 标签 R代码
标签内容 &#10084 \u2764 **加粗** <b>加粗</b> max(mtcars$cyl)
实现效果 加粗 加粗 8
grViz(diagram = "digraph{
  graph[rankdir = LR]

  node[shape = rectangle]
      A[label = '&#10084;'] # HTML unicode 编码
      B[label = '\u2764'] # JS unicode 编码
      C[label = '❤'] # 复制粘贴的字体图标
      D[label = '**加粗**'] # markdown 
      E[label = '<b>加粗</b>'] # HTML 标签
      F[label = '@@1'] # 引用 R 代码结果

  A -> B -> C -> D -> E -> F
}
[1]:paste0('引入文字', max(mtcars$cyl)) # R 代码
", height = 100, width = '90%')

2.3. 子图 🔗

子图中定义的样式优先级也比原图高。

grViz(diagram = "digraph G{
  compound = true  #允许子图间可以连线
  ranksep = 1
  label = '图形注释'
  style = dashed
    
   node[style = dotted]
    A1; A2; 
   node[style = solid]
    B1;B2;C1;C2  
    
# 定义第一个子图
  subgraph cluster1 {
    label = '子图名称1'
    style = solid
    A1;A2
}
# 定义第二个子图  
  subgraph cluster2 {
   label='子图名称2'
  B1;B2
  }
# 定义第三个子图  
  subgraph cluster3 {
  label = '子图名称3'
  C1;C2
  }
# 定义第四个子图  
  subgraph cluster4 {
  label = '子图名称4'
  D1;D2
    subgraph cluster5 {
    label = '嵌套子图'; color = green
    D3; D4}
  }
  
# 子图内节点连线
    B1 -> B2; C1 -> C2; 
    A1 -> A2[color = Yellow]
# 跨多个子图,节点连线
    A1 -> B2
# 子图与子图之间连线
    B1 -> C2 [lhead = cluster3, ltail = cluster2] 
# 节点与子图之间连线
    C1->D1 [lhead = cluster4, ltail = C1] 
  
}", height = 300, width = '90%')

2.4. 线 🔗

2.4.1. 合并线 🔗

当有多条线指向同一终点时,如果不想线太多太杂乱,可以创建一个极小的中间节点,将多条线指向中间节点且设置无箭头,再设置中间节点指向终点,便会得到多条线合并在一起的效果。

  • dir:设置箭头方向
grViz(diagram = "digraph {
rankdir = LR
 A -> B[dir = forward] # 箭头方向,向前
 B -> C[dir = back] # 箭头方向,向后
 C -> D[dir = both] # 双向箭头
 D -> E[dir = none] # 无箭头
}", height = 100, width = '90%')
grViz(diagram = "digraph {
  rankdir = LR
  
     {A1, A2, A3}->B
     
}", height = 200, width = '90%')
grViz(diagram = "digraph {
  rankdir = LR
    M1[shape = point, width = 0.01, height = 0.01]
     {A1, A2, A3} -> M1[dir = none]
      M1 -> B
}", height = 200, width = '90%')

2.4.2. 指定相同的起点、终点 🔗

  • samehead/sametail:设置多条线具有相同的起点、终点。
grViz(diagram = "digraph {
     rankdir = LR
     
     {A1, A2, A3} -> B
     B -> {C1, C2, C3}
}", height = 200, width = '90%')
grViz(diagram = "digraph {
  rankdir = LR
  edge[samehead = h1, sametail = t1]   
     {A1, A2, A3} -> B
     B -> {C1, C2, C3}
}", height = 200, width = '90%')

2.4.3. 设置隐藏线改变节点位置 🔗

设置不同的线的指向通常会改变节点的布局。如果想要改变位置的节点之间没有设置线,那么可以通过设置隐藏起来的透明线来实现。

grViz(diagram = "digraph {
rankdir = LR
 A -> B[style = solid] # 实线
 B -> C[style = dashed] # 虚线
 C -> D[style = dotted] # 点线
 D -> E[style = bold] # 加粗线
 E -> F[style = invis] # 隐藏线
}", height = 100, width = '90%')
grViz(diagram = "digraph {
  rankdir = LR
  
    A -> B
    A -> C
}", height = 200, width = '90%')
grViz(diagram = "digraph {
  rankdir = LR
    A -> B;A->C
 {rankdir = TB; rank = same
  C -> B[style = invis]} # 隐藏线
}", height = 200, width = '90%')

2.4.3. 通过设置线的属性改变节点位置 🔗

在 grViz 函数绘制的图形中,其节点位置是根据线的属性自适应变化的,改变一些线的属性,节点之间的相对位置就可能会改变。

  • headport/tailport:线的头部/尾部与节点连接的位置,以节点为中心的主要方位有四个,n(north)、s(south)、w(west)、e(east)。衍生出来八个,n、ne、e、se、s、sw、w、nw。

  • headclip/tailclip:线的头部/尾部是否在节点外面,默认值均为 true,即线的头尾两端都在节点边缘,取值为 false 时,线的头尾两端在节点中心。

  • label/headlabel/taillabel:线上标签位置在线的中部、头部、尾部。

grViz(diagram = "digraph {
  rankdir = LR
  node[shape = rectangle]
	A -> B [headclip = true, tailclip = true]
	B -> C [headclip = false, tailclip = false]
}", height = 300, width = '90%')
grViz(diagram = "digraph {
  rankdir = LR
  node[shape = rectangle]
	A -> B [headport = 'n', tailport = 'n', label = '线中部标签']
	B -> C [headport = 's', tailport = 's', headlabel = '线头标签', taillabel = '线尾标签']
}", height = 300, width = '90%')
  • splines:用于设置线的形状。大致有这些选项,none(不绘制线)、line(直线)、polyline(折线)、curved(曲线)、ortho(轴对齐的折角线)。

  • constraint:设置的线是否会改动节点布局,默认值为 true。取值为 false 时,改变线的各种属性均不影响节点布局。

grViz(diagram = "digraph {
  rankdir = LR
	splines = ortho
	
  node[shape = rectangle]
  
	A -> B -> C -> E [color = green]
	
	{B, D} -> E [color = red]
}", height = 300, width = '90%')
grViz(diagram = "digraph {
  rankdir = LR
	splines = ortho
  node[shape = rectangle]
  edge[color = green]
	 A -> B -> C
	 C -> E[headport = 's', tailport = 'e', constraint = true]
	edge[color=red]
	 B -> E
	 D -> E[headport = 's', tailport = 'e', constraint = false]
}", height = 300, width = '90%')

三、比较 🔗

DiagrammeR::mermaid()DiagrammeR::grViz()是分别将 mermaid.js 和 viz.js 这两个 Javascripts 图表库挪到 R 中来用,可根据需求随意修改图形布局,且图形、节点边框的大小会随着图形宽度、高度和节点标签文本长度而自适应变化。这些特点ggflowchart::ggflowchart()并不具备。

若论在可设定的样式种类多寡,ggflowchart::ggflowchart()受限于该函数中的参数数量,DiagrammeR::mermaid()在线的样式上仅可改变线型,而DiagrammeR::grViz()不仅可以设定节点、线的各种样式,还可以设定子图的样式,相比而言可设定的样式种类最丰富。三者之间还有以下区别。

- DiagrammeR::mermaid() DiagrammeR::grViz() ggflowchart::ggflowchart()
节点标签支持引入 HTML 标签和 CSS
节点标签支持引入 R 代码结果
支持批量设置节点、线的样式
支持嵌套子图

总地来看,如果不是遇到节点标签中需要引入 HTML 标签的情况,绘制流程图选用DiagrammeR::grViz()

以下绘制同一个流程图,依次用DiagrammeR::mermaid()DiagrammeR::grViz()ggflowchart::ggflowchart()实现。

mermaid(diagram = "
graph BT
%% 定义节点
  A1[MYSQL]; A2[ORACLE]; A3[IMPALA]
  B1>应用<br>微服务]; B2>接口<br>微服务]; B3>批处理]
  C1(用户); C2(系统应用)
%% 定义线
  A1-->B1;A1-->B2;A1-->B3
  A2-->B1;A2-->B2
  A3-->B1;A3-->B2;A3-->B3
  B1-->C1;B2-->C2
%% 定义节点样式
  style A1 fill:#44A57CFF
  style A2 fill:#44A57CFF
  style A3 fill:#44A57CFF
  style B1 fill:#58A449FF
  style B2 fill:#58A449FF
  style B3 fill:#58A449FF
  style C1 fill:#CEC917FF
  style C2 fill:#CEC917FF
",height = 300, width = '90%')
grViz(diagram = "digraph{
  graph[rankdir = BT]
# 定义节点
  node[shape = rectangle, style = filled, fillcolor = '#44A57CFF']
      A1[label = 'MYSQL']
      A2[label = 'ORACLE']
      A3[label = 'IMPALA']
  node[shape = folder, style = filled, fillcolor = '#58A449FF']
      B1[label = '应用\n微服务']
      B2[label = '接口\n微服务']
      B3[label = '批处理']
 node[shape = ellipse, style = filled, fillcolor = '#CEC917FF']
      C1[label = '用户']
      C2[label = '系统应用']
 # 定义线
  A1->{B1,B2,B3}; A2->{B1,B2}; A3->{B1,B2,B3}
  B1->C1; B2->C2
}",height = 300, width = '90%')
data <- tibble::tibble(
  from = c(rep('A1', 2),
           rep('A2', 3),
           rep('A3', 3),
           'B1',
           'B2'),
  to = c(c('B1', 'B2'), rep(c('B1', 'B2', 'B3'), 2),  'C1', 'C2'))
node_data = tibble::tibble(
  name = c(
    paste('A', c(1:3), sep = ''),
    paste('B', c(1:3), sep = ''),
    paste('C', c(1:2), sep = '')
  ),
  label = c(
    'ORACLE',
    'MYSQL',
    'IMPALA',
    '应用微服务',
    '接口微服务',
    '批处理',
    '用户',
    '应用系统'
  ),
  fill = c(rep('#44A57CFF', 3), rep('#58A449FF', 3), rep('#CEC917FF', 2))
)
# 节点的填充颜色不对
ggflowchart::ggflowchart(data, node_data, fill = fill)

四、瞎碰乱试的案例 🔗

本节用来复现的两张图是从《置身事内》这本书里找的。

图一(复原度80%) 🔗

这张图按节点类型看,一共有三类:

  • 方形,实线边框,无填充

  • 方形,虚线边框,无填充

  • 方形,无边框,灰色填充

还有一些没有边框、没有填充颜色的纯文字,当做子图的图形标题处理。整张图从左至右分成四个部分。

library(DiagrammeR)

grViz(diagram = "digraph{
# 定义图形布局,从左至右
graph[rankdir = LR]
# 定义节点       
  node[shape = rectangle, style = dashed]
    A1[label = '财政包干:中央财政困难+\n国家财政困难']
    A2[label = '地方财政增长\n方式转变:工\n商业税收与\n土地财政', width = 1.5]
    
  node[shape = rectangle,style = filled, fillcolor = 'gray', color = 'white', width = 2]
    B1[label = '分税制改革']
    B2[label = '土地和要素\n市场改革']
    B3[label = '五个统筹']
    B4[label = '农村税费改革']
    B5[label = '乡财县管,\n省直管县']
    B6[label = '转移支付改革']    
    
  node[shape = rectangle, style = solid, color = 'black', width = 1]
    C1[label = '财政\n集权']
    C4[label = '体制\n改革']
    C2[label = '国税、地税分立', width = 2]
    C3[label = '税收向中央集中', width = 2]
    C5[label = '支出分权', width = 2]
    C6[label = '转移支付体系', width = 2]
    C7[label = '总体均衡', width = 1.5]
    C8[label = '县乡财政危机\n(纵向失衡)', width = 1.5]
    C9[label = '地区间失衡\n(横向失衡)', width = 1.5]  
    C10[label='城市化大\n兴土木']
    C11[label='招商引资']
    C12[label='重生产\n轻民生']
    C13[label='农民负担']  
    
# 定义第一个子图
subgraph cluster1{
  label = '央地博弈:\n讨价还价'
  style = solid
  color = white
  A2; C7; C8; C9
} 
# 定义第二个子图
subgraph cluster2{
  label = '社会现象'
  style = solid
  color = white
  C10; C11; C12; C13
}
# 定义第三个子图
subgraph cluster3{
  label = '继续改革'
  style = solid
  color = white
  B2; B3; B4; B5; B6
}
# 定义第四个子图,隐形线,调换 C1和 C4的位置
subgraph cluster4{
  rankdir = TB
  rank = same
  C1 -> C4[style = invis]
}

# 定义线
edge[arrowsize = 1, samehead = h1, sametail = t1]
A1 -> B1; 
C1 -> {C2, C3}; C4 -> {C5, C6}
{C3, C5} -> A2; 
C6 -> {C7, C8, C9}
A2 -> {C10, C11, C12, C13}
C8 -> {C13, B5, B6}; C9 -> B6
C10 -> {B2, B3}; C12 -> B3; C13 -> {B3, B4}
edge[headport = 'w', tailport = 'w', splines = ortho, constraint = false]
B1 -> {C1, C4}
# 设置多个节点,维持在一个垂直线上
{rank = same; A1; B1; C1; C4;}
}")

图二(复原度40%) 🔗

这张图一共只有两种节点:方形、菱形。复杂的地方在于线,以及固定节点的位置?键者实在是搞不明白一些参数的先后顺序咋定……

grViz(diagram = "digraph{
# 定义图形布局,从上到下
rankdir = LR
splines = ortho # 轴对齐的折角线
compound = true  #允许子图间可以连线

# 定义节点       
  node[shape = diamond, fontsize = 24]
    A1[label='地方政府']
    
  node[shape = rectangle, width = 2]
    B1[label='城市基础\n设施建设']
    B2[label='经济增长']
    B3[label='城市生活质量']
    
    C1[label = '土地出让价格']
    C2[label = '土地储备数量']
    C3[label = '土地出让数量']
    C4[label = '土地抵押融资']
    C5[label = '土地出让收入']
 

subgraph clusterA {
  style = dashed
  rankdir = TB
 # rank = same
  C2 -> C3[style = invis] # 设置一条透明线固定节点位置
}

subgraph clusterB {
  label = '土地融资总额'
  style = dashed
  C4;C5 
}

# 定义线,合并起点、终点
edge[arrowsize = 1, samehead = h1, sametail = t1, headclip = true, tailclip = true]

# 两条线并为一条
M1 [shape=point,width=0.01,height=0.01];
{C1, C2} ->M1[dir=none];M1-> C4;

M2 [shape=point,width=0.01,height=0.01];
{C1, C3} ->M2[dir=none];M2-> C5; 

M3 [shape=point,width=0.01,height=0.01];
    B1->M3 [dir=none];M3 ->{B2, B3}; 
 
M4 [shape=point,width=0.01,height=0.01];    
{B2, B3} ->M4[dir=none];M4-> C1[constraint = false] # 只生成线,不影响节点布局

# 双向箭头
C1 -> C3[dir=both]

# 子图到节点的线
C4 -> B1[lhead = B1, ltail = clusterB]

# 节点到子图的线
A1 -> C2 [label = '土地出让决策', lhead = clusterA, ltail = A1, headport = 'n', tailport = 'w'] 
C4 -> A1 [label = '资金来源', lhead = A1, ltail = clusterB, penwidth = 4]

# 单个节点到单个节点的线
A1 -> B2 [taillabel = '晋升激励', arrowhead = none, headport = 'n', tailport = 'e']
A1 -> B1[label = '支出', penwidth = 4]
}")
R