虽然起了一个洋气的名字,但是还是中文。那么这个系列一共应该也就两篇文章,预计这两天写完。起因是导师让画一张图,展示气体的不同相以及不同位置的气体之间如何转化,并且给了一个github上的例子说画这样的图。那么查了查资料,发现这种图叫做Sankey Diagram,中文翻译好像是桑基图。试了试不同的画图工具,发现竟然还是LaTeX的tikz包配合上一个基于tikz的sankey包最方便。但是手搓tikz还是有些繁琐,因此写了一个自动化工具,生成给定Sankey Diagram的LaTeX代码模板,方便后续调整和修改。所以这一个系列的文章就简单记录一下解决问题的思路和部分的代码实现,并附上没有完整测试过的初版代码。
具体内容上,第一篇,也就是本篇,简单介绍一下sankey这个包。当然不会完整介绍这个包的全部内容,那样还不如去看文档。这里会通过分析一个文档中的例子来快速上手这个包,而第二篇文章则会基于这个例子来构建自动化工具。
用一个似乎很经典的例子,同时也是接下来要看的代码生成的Sankey图,来自Google:

首先把文档中复刻这个图的代码贴上来:
%\documentclass{standalone}
%\usepackage{sankey}
%\begin{document}
\begin{tikzpicture}
 \begin{sankeydiagram}%[debug]
 \sffamily
 \sankeyset{
 ratio=1cm/10,
 outin steps=2,
 draw/.style={draw=none,line width=0pt},
 color/.style={fill/.style={fill=#1,fill opacity=.75}},
 shade/.style 2 args={fill/.style={left color=#1,
 right color=#2,fill opacity=.5}},
 % colors
 @define HTML color/.code args={#1/#2}{\definecolor{#1}{HTML}{#2}},
 @define HTML color/.list={
 cyan/a6cee3,lime/b2df8a,red/fb9a99,orange/fdbf6f,
 violet/cab2d6,yellow/ffff99,blue/1f78b4,green/33a02c
 },
 % colors of countries
 @let country color/.code args={#1/#2}{\colorlet{#1}[rgb]{#2}},
 @let country color/.list={
 CA/red,US/orange,MX/orange,BR/cyan,FR/lime,GB/red,
 SP/lime,PT/cyan,ML/blue,SN/violet,MA/yellow,
 AO/violet,ZA/yellow,IN/green,JP/green,CN/blue
 },
 }
 \def\vdist{5mm}
 \def\hwidth{.5em}
 \def\hdist{4.1cm}
 \sankeynode{name=CA,quantity=7}
 \sankeynode{name=US,quantity=8,at={[yshift=\vdist]CA.left},anchor=right}
 \sankeynode{name=MX,quantity=8,at={[yshift=\vdist]US.left},anchor=right}
 \sankeynode{name=BR,quantity=8,at={[yshift=\vdist]MX.left},anchor=right}
 \foreach \country in {CA,US,MX,BR}{
 \sankeyadvance[color=\country]{\country}{\hwidth}
 }
 \sankeyfork{CA}{1/CA-to-PT,1/CA-to-GB,5/CA-to-FR}
 \sankeyfork{US}{1/US-to-PT,1/US-to-SP,5/US-to-GB,1/US-to-FR}
 \sankeyfork{MX}{1/MX-to-PT,5/MX-to-SP,1/MX-to-GB,1/MX-to-FR}
 \sankeyfork{BR}{5/BR-to-PT,1/BR-to-SP,1/BR-to-GB,1/BR-to-FR}
 \sankeynode{name=FR,quantity=11,
 at={[xshift=\hdist]CA.right},anchor=right}
 \sankeynode{name=GB,quantity=11,
 at={[yshift=\vdist]FR.left},anchor=right}
 \sankeynode{name=SP,quantity=7,
 at={[yshift=\vdist]GB.left},anchor=right}
 \sankeynode{name=PT,quantity=8,
 at={[yshift=\vdist]SP.left},anchor=right}
 \sankeyfork{FR}
 {1/FR-from-BR,1/FR-from-MX,1/FR-from-US,5/FR-from-CA,3/FR-from-00}
 \sankeyfork{GB}
 {1/GB-from-BR,1/GB-from-MX,5/GB-from-US,1/GB-from-CA,3/GB-from-00}
 \sankeyfork{SP}{1/SP-from-BR,5/SP-from-MX,1/SP-from-US}
 \sankeyfork{PT}{5/PT-from-BR,1/PT-from-MX,1/PT-from-US,1/PT-from-CA}
 \foreach\country in{FR,GB,SP,PT}{
 \sankeyadvance[color=\country]{\country}{\hwidth}
 }
 \sankeyfork{FR}{1/FR-to-ZA,1/FR-to-AO,3/FR-to-MA,3/FR-to-SN,3/FR-to-ML}
 \sankeyfork{GB}{7/GB-to-ZA,1/GB-to-AO,2/GB-to-MA,1/GB-to-SN}
 \sankeyfork{SP}{1/SP-to-ZA,3/SP-to-MA,1/SP-to-SN,2/SP-to-00}
 \sankeyfork{PT}{3/PT-to-ZA,2/PT-to-AO,1/PT-to-MA,1/PT-to-SN,1/PT-to-00}
 \sankeynode{name=ML,quantity=9,
 at={[xshift=\hdist]FR.right},anchor=right}
 \sankeynode{name=SN,quantity=9,
 at={[yshift=\vdist]ML.left},anchor=right}
 \sankeynode{name=MA,quantity=9,
 at={[yshift=\vdist]SN.left},anchor=right}
 \sankeynode{name=AO,quantity=9,
 at={[yshift=\vdist]MA.left},anchor=right}
 \sankeynode{name=ZA,quantity=12,
 at={[yshift=\vdist]AO.left},anchor=right}
 \sankeyfork{ML}{3/ML-from-FR,6/Mail-from-00}
 \sankeyfork{SN}
 {1/SN-from-PT,1/SN-from-SP,1/SN-from-GB,3/SN-from-FR,3/SN-from-00}
 \sankeyfork{MA}{1/MA-from-PT,3/MA-from-SP,2/MA-from-GB,3/MA-from-FR}
 \sankeyfork{AO}{2/AO-from-PT,1/AO-from-GB,1/AO-from-FR,5/AO-from-00}
 \sankeyfork{ZA}{3/ZA-from-PT,1/ZA-from-SP,7/ZA-from-GB,1/ZA-from-FR}
 \foreach\country in{ML,SN,MA,AO,ZA}{
 \sankeyadvance[color=\country]{\country}{\hwidth}
 }
 \sankeyfork{ML}{5/ML-to-CN,3/ML-to-JP,1/ML-to-IN}
 \sankeyfork{SN}{5/SN-to-CN,3/SN-to-JP,1/SN-to-IN}
 \sankeyfork{MA}{5/MA-to-CN,3/MA-to-JP,1/MA-to-IN}
 \sankeyfork{AO}{5/AO-to-CN,3/AO-to-JP,1/AO-to-IN}
 \sankeyfork{ZA}{5/ZA-to-CN,3/ZA-to-JP,1/ZA-to-IN,3/ZA-to-00}
 \sankeynode{name=IN,quantity=5,
 at={[xshift=\hdist]ML.right},anchor=right}
 \sankeynode{name=JP,quantity=15,
 at={[yshift=\vdist]IN.left},anchor=right}
 \sankeynode{name=CN,quantity=25,
 at={[yshift=\vdist]JP.left},anchor=right}
 \sankeyfork{IN}
 {1/IN-from-ZA,1/IN-from-AO,1/IN-from-MA,1/IN-from-SN,1/IN-from-ML}
 \sankeyfork{JP}
 {3/JP-from-ZA,3/JP-from-AO,3/JP-from-MA,3/JP-from-SN,3/JP-from-ML}
 \sankeyfork{CN}
 {5/CN-from-ZA,5/CN-from-AO,5/CN-from-MA,5/CN-from-SN,5/CN-from-ML}
 \foreach\country in{IN,JP,CN}{
 \sankeyadvance[color=\country]{\country}{\hwidth}
 }
 \foreach\startcountry/\countries in{
 CA/{PT,GB,FR}, US/{PT,SP,GB,FR}, MX/{PT,SP,GB,FR},
 BR/{PT,SP,GB,FR},FR/{ML,SN,MA,AO,ZA},GB/{SN,MA,AO,ZA},
 SP/{SN,MA,ZA}, PT/{SN,MA,AO,ZA}, ML/{IN,JP,CN},
 SN/{IN,JP,CN}, MA/{IN,JP,CN}, AO/{IN,JP,CN},
 ZA/{IN,JP,CN}}
 {
 \foreach\endcountry in \countries{
 \sankeyoutin[shade={\startcountry}{\endcountry}]
 {\startcountry-to-\endcountry}{\endcountry-from-\startcountry}
 }
 }
 \foreach\country/\countryname in{CA/Canada,US/USA,MX/Mexico,
 BR/Brazil,FR/France, GB/England,SP/Spain,PT/Portugal}
 {
 \node[anchor=west,inner sep=.1em,font=\small]
 at(\country){\countryname\vphantom{Ag}};
 }
 \foreach\country/\countryname in{
 ML/Mali,SN/Senegal,MA/Morocco,AO/Angola,
 ZA/SouthAfrica,IN/India,JP/Japan,CN/China}
 {
 \node[anchor=east,inner sep=.1em,font=\small]
 at(\country-old){\countryname\vphantom{Ag}};
 }
 \end{sankeydiagram}
\end{tikzpicture}
%\end{document}前三行和最后一行被注释掉的部分是笔者自己加的,方便读者直接复制下来、去掉注释后直接能运行。挺长的,有些头大。不过问题不大,一部分一部分的看。
首先是各种 begin 的部分,熟悉LaTeX语法的读者一看就知道是在展开环境,所以就略过不谈。 \sffamily 是字体族,也不重要。然后是 sankeyset 的部分。这部分定义了一些全局的性质,大部分一看就知道是在干嘛的,而具体的键值的作用与其看我写的不如直接翻文档,所以只简单说一些为了复刻一个类似的好看的图所需要了解的部分。
首先是 ratio=1cm/10 ,这里是在定义比例尺,即图中流量(quantity)与纸面上的单位的比例。Sankey图本身是一张有向图,绘制时用图上的每条线的宽度代表线的流量,所以这里就控制了每条线——因此也是每个节点——应该画多宽,譬如这里的值就是10单位的流量用1cm的线宽来表示。
然后是 % colors 和 % colors of countries 标记的两段代码。这里是首先定义了颜色的变量,或者说每种颜色的名字;而后为每个节点——在这里就是每个国家——分配对应的颜色。当然,用“分配”这个词或许并不准确,但便于理解。实际上这里只是为每种颜色起了一个别名,方便之后绘制时调用。后面会看到,在绘制时如果不指定颜色的话实际上是不会自动为节点填上对应的颜色的。
在这些东西都设置好了之后,就到了实际画图的部分。
首先定义了三个距离方便之后使用: \vdist, \hwidth 和 \hdist, 分别是节点间的垂直距离、节点的宽度(注意不是代表节点总流量的“宽度”,而是节点,这里我暂且用高度来指代代表节点总流量的纸面长度)和节点之间的距离(即为了美观起见,纵向两个节点之间的空白)。
之后实际画图的部分。虽然很长,但实际上可以简单分成三个部分,画节点,指定节点与节点的连线,把线实际画出来。仔细观察可以发现实际上都是一些代码的重复,因此这里就不一句一句分析了,只讲一组例子。
首先是画节点。 \sankeynode 用以指定节点的“大小”(即流经节点的总流量,注意不是进入节点的和离开节点的之差或者之和,而是二者最大的值),采用类似 tikz 的语法(当然,本身这个包就是基于 tikz 的)。首先这个例子:
\sankeynode{name=CA,quantity=7}定义了一个名字是 CA 的节点。注意这个名字不是节点的标签,即显示在图上的部分,而是节点的代号,可以理解为变量名。这个节点的流量是7( quantity=7 )。之后,
\sankeynode{name=US,quantity=8,at={[yshift=\vdist]CA.left}, anchor=right}定义了一个名字是 US 的节点。前面两个键值讲过了,接下来是 at, 用来指定坐标。也是 tikz 的语法,只不过把小括号换成了大括号。另外这里的 left 等值的定义和常规理解不太相同,这里直接放上 sankey 的文档中的图,具体的说明请看文档:

 anchor 可以理解是用来指定这个节点的“坐标原点”(虽然并不是这样),是将上面的某一个位置放置在 at 给出的位置。
尔后,美观起见需要将这个节点画出来,这里使用的 \sankeyadvance 命令。根据文档所说,这个命令实际上是用来移动节点并画出扫过的面积,具体还能附加上箭头方向甚至曲线之类的结果,不过不在这里解释的范围了。不过这样自然也能用来把节点画出来。因此就是一条类似于这样的代码:
\sankeyadvance[color=US]{US}{\hwidth}这里 color=US 就是指定了节点的颜色。之前在 sankeyset 里面定义了 US 是 orange 的别名,而 orange 则是 fdbf6f;之后就是指定了画哪一个节点({US});最后是说这个节点要画多宽(移动多远, \hwidth,之前定义过它的值是 0.5em )。这样就有了图上节点处深色的那个矩形。
而示例代码里面,为了简洁起见使用了一个 \foreach 循环,也就是一个for循环:
\foreach \country in {CA,US,MX,BR}{
 \sankeyadvance[color=\country]{\country}{\hwidth}
 }很容易理解,基本和其他程序语言中的一致。那么这样,一个节点就算画完了。
之后就是连线。连线自然要指定起始节点和目的节点;而一个节点通常与多条线连接,所以就需要指定每条线连到节点上的顺序,或者用代码里的话来说:分叉( fork ,其实也挺形象的)。同样的,看这个例子比较好:
\sankeyfork{FR}{1/FR-from-BR,1/FR-from-MX,1/FR-from-US,5/FR-from-CA,3/FR-from-00}这里 \sankeyfork 接受两个参数,第一个参数指定要对哪一个节点分叉,而第二个参数则指定有那些分叉。这里,从左到右指定了 {FR} 在流入的方向(哪里指定流入等会说)上有四条分叉,这四条分叉从节点的 left 按顺序排列到节点的 right (上面那张图中的 left 和 right );这里指定的四条分叉,都是类似于 N/NODE1-from-NODE2 的结构。其中, N 是这条线的流量,而 / 之后的内容是指定这条线的方向。NODE1 始终是第一个参数的节点, from 则是说这条线是流入 NODE1 的线, NODE2 则是这条线的来源。类似的,如果要指定从 NODE1 流出到 NODE2 的线,则是 NODE1-to-NODE2. 不过注意流入和流出的线要分开写,不要混在一个命令中。尔后,需要注意这里所有流出的线的流量总和或者流入的线的流量总和需要严格等于这个节点的在 \sankeynode 中指定的 quantity. 那么一个自然的问题是,如果实际上就是不等于怎么办呢?比如某些节点会留存一部分流量,像这里的 FR ?那么就用最后的一句 3/FR-from-00来解决(如果是流出的话就是 to )这里 00 就是一个预定义的 dummy node,只需要把不平衡的部分指向这里即可。除此之外需要注意的是,显然有一条流入的线就有一条流出的线。因此在这条线的两个端点的节点上都会有一条这样的语句,同时它们的量也需要严格相等。
倒数第二步就是把线实际连起来。使用的语句:
\sankeyoutin[shade={CA}{PT}]{CA-to-PT}{PT-from-CA}方括号中的参数用来指定颜色,这里预先指定了是从起点到终点的渐变,因此指定了两个颜色。两个大括号中实际是指定起点和终点。这里由于对节点进行了分叉,所以给出的是分叉后的每个叉。当然,示例代码中用了两个嵌套的循环来完成。
实际上Sankey包提供了两个语法相同的语句来完成这件事:\sankeyoutin 和 \sankeydubins,二者只是具体绘制的曲线不一样,具体就看文档了。
最后则是为节点标上标签。这里是纯粹的 tikz 语句了:
\node[anchor=west,inner sep=.1em,font=\small]at(CA){Canada\vphantom{Ag}};这里示例代码中同样使用了循环,上面只是循环中的一个例子。其他的都已经解释过了或者很明显不用解释, inner sep 则是指定node的边界与node标签的距离。\vphantom{Ag} 则可以理解为一个为了美观而使用的空白。
将以上代码复制粘贴修改组合后,就得到了最开始那个示例代码了。那么总结起来,用LaTeX画这么一幅Sankey图,在 \sankeyset 的各种设置后,实际上就是如下几步:
(1)用 \sankeynode 和 \sankeyadvance 指定并绘制节点;
(2)用 \sankeyfork 对节点分叉;
(3)用 \sankeyoutin 或者 \sankeydubin 实际绘制出线;
(4)其他用 tikz 完成的杂项,比如绘制出各种标签。
那么可以看出,实际上这里除了第四步,都能直接自动化生成而不用自己写;而第四步也能直接生成一个基本的模板然后再修改。因此,那么在下一篇文章中,就讨论如何用 Python 编写一个自动化生成工具,来生成初步的方便后续修改的LaTeX代码。
 
      





Comments NOTHING