可视化杂谈之饼图布局算法

729 阅读14分钟

本文作者 字节跳动-数据平台-melon

引言

在做数据可视化相关的工作中,开发人员总是必不可免的需要与标签打打交道。在画布空间充裕,数据量不大,标签内容简洁的理想国中,一切自然不成问题。但是现实场景中,芜杂的需求,庞大的数据,向我们提出了要求:如何合理的组织标签的布局,从而尽可能多且美观的展示标签。

不同图表都有着其各自空间以及美学考量的特性,本文主要着眼于饼图的标签布局。

首先抛出一个普适的前提:画布空间是有限的,因此在其中能够有效展示的标签数量是有限制的。基于这一前提,我们有几个需要关注的问题:

  • 如何合理排布标签,使得尽可能多的标签被显示?

  • 如何在展示较多标签的情况下,保证标签的可读性,并使得用户能够直观的看出标签布局的逻辑?

  • 在无法有效容纳所有标签的情况下,如何挑选更具有价值的标签信息展示?

相关产品饼图标签布局

在讨论饼图布局方案之前,首先让我们来看看其他产品的饼图布局:

G2

image

G2 饼图标签会按照饼图外圆进行排布,并用短连接线从扇区指向标签左端或者右端中点。

但是令人费解的是,在顶部以及底部的标签,其连接线却转为了弧线,并指向了标签上部或者下部,令人很难理解其处理逻辑为何,甚至于当标签较密时无法将标签与连接线相对应。

此外,也能看到其顶部和底部标签出现了重叠,导致失去了可读性。

ECharts

image

ECharts 对标签布局提供了几种方案,自左至右分别是 none,labelLine,edge,其分别意味着按照饼图外圆排布、标签水平方向对齐、标签按画布边缘对齐。三种方案,其标签位置的 Y 值均相同。究其本质,后两种方案其实就是 第一种方案的基础上改变了标签的 X 值 而已。

ECharts 饼图标签与扇区的连接线为两段线,第一段由扇区指出,第二段保证水平。

image

ECharts 相比较于 G2 而言,提供了标签相互躲避的算法,当标签数量非常大时,仍然保证了一定程度的可读性(后两种方案),但是在某些区域标签依旧出现了重叠,例如画布顶部。

第一种方案在标签数量较大时表现非常怪异,其标签布局明显划分为了四个区域,上下的标签布局出现了割裂,同时左右标签互相影响出现了遮挡,甚至超出了画布。

其他

Tableau 不支持引导线的设置,也就意味着其没有布局能力。虽然 Tableau 会对遮挡的标签进行隐藏,但是其仍然无法支持大量标签的显示。

网易有数也支持标签的遮挡隐藏。其同样采用饼图外圆的布局,做了标签的躲避,并且使用了一个较为诡异的贝塞尔连接线来防止连接线穿过饼图区域。

其他产品相类似,这里不再赘述。

简单总结一下,为了提升标签的可读性,许多产品都使用了饼图外圆弧的布局方式。当标签数量较大时,或者隐藏被遮挡标签仅显示少量标签,或者提供标签的位置调整并使用引导线连接对应扇区。

基于我们之前给出的关注问题,调整方案能够容纳更多标签,展示更多信息,比起遮挡而言相对更好。事实上,之前在我们自己的产品中也采取了类似 ECharts 的布局方案,但是由于 ECharts 的布局策略中本身存在诸多不足,因此需要做一次全面的改进。

布局方案

基于诸多考量,我们提出了自己的标签布局方案。首先来看看我们的布局效果吧:

image

最简单的标签布局情况自然没有问题。

image

当标签数量开始增多的时候同样能够合理的组织标签位置展示。

image

在数据量极其巨大的极端情况下,同样能够保证充分利用画布空间展示有效信息。

看起来似乎不错,那么我们做了哪些工作呢?

整体布局策略

一如之前所说,基于饼图外圆的标签排布是许多布局方案的基础,由于饼图本身的几何特性,这一布局同样也是最为直观的选择。

但是基于外圆的布局存在一个重要的缺陷:圆图形本身是 **封闭 **的,也就意味着其所能容纳的标签数量有限。这一缺陷导致标签布局无法有效的利用画布剩余空间。这一缺陷当然可以采用扩大外圆半径的方式来解决,但这也意味着布局同时侵占了画布横向与纵向的空间,同时外圆与扇区中间的间隔过大也影响了布局的美观。

ECharts 采用了一种并不优雅的方式来解决这一问题,即在外圆布局的基础上引入了第二种布局逻辑。第二种布局逻辑会在标签数量较大时生效,其不遵循外圆的约束,而将标签推移一个固定值,这也就是导致极端情况下 ECharts none 布局下半区域左右标签过度延伸的原因。

引入额外的逻辑作为补充导致了两个恶果:

  • 布局逻辑出现割裂,在局部区域内不统一的逻辑使得用户感到费解;

  • 不完善的处理使得某些极端情况下标签布局结果不具有可读性。

虽然 ECharts none 布局在标签数量巨大时表现糟糕,但是后两种布局表现就好上许多。究其原因,是源于后两种标签在 X 值的计算上有一个统一且可靠的逻辑,即 标签的 X 值由其 Y 值确定,而不像第一种布局在局部上受到不同计算逻辑影响。这也给予了我们布局的基础思路:执行遮挡规避对标签 Y 值进行调整,之后通过全局规范确定其 X 值。

为此,我们放宽了外圆布局所暗含的同心圆假设,使用两个圆心不同的圆弧替代外圆布局标签。在保证标签 X 值计算具有统一规范的同时,支持对标签数量进行扩展,如下图所示:

image

由于目前标签布局仅考虑横排文字,因此将外圆拆分为了左右两个圆弧,两者圆心均与饼图在同一水平线上。两个圆弧与水平线的交点与饼图圆心的距离相等,即 |A O0| = |B O0|,该距离由连接线的配置所决定并为一固定值。

确定 |A O0| 以及 O1 在水平线上之后,O1 的位置也就由其半径所确定。当展示标签较少时,外圆能够容纳所有标签,此时 O1 落在 O0,与外圆布局相同。当标签数量变大时,标签在 Y 方向上会执行调整,圆弧半径也随之扩大,从而在 Y 方向上占据更多空间。

介绍了整体的布局策略,该来谈一谈一些具体的做法。

遮挡规避

在计算每个标签对应扇区数据以及获取到标签本身宽高之后,需要对标签计算其初始的位置。初始状况下,所有标签在默认情况排布在饼图外圆的对应位置。

理想状况下,自然是无需做后续操作,每个标签呆在其应处的位置。但是当标签出现遮挡时,就需要采取一些算法对这一遮挡进行规避。

遮挡规避算法多是大同小异,无非是处理相互遮挡的标签如何偏移遮挡的距离(我们所采用的规避算法与 ECharts 相类似)。此时我们的指导思想也为我们提供了许多的便利:遮挡规避仅仅需要考虑标签的 Y 值,而 X 值是在后续过程中被确定的。下图展示了碰撞规避的简单流程:

image

在执行布局的过程中,我们会依次向画布放置标签。假设我们当前所处理的标签为 A,检测到其与后一个标签 B 发生了遮挡,此时需要执行规避。规避分为两个阶段:

  • Shift Down:计算得到标签 A 与 B 的遮挡长度,即 delta。将标签 B 向下推移 delta 距离后,检测后续的标签 C 是否与其遮挡,如果存在遮挡则继续推移标签 C。由于标签 C 推移后仍与标签 D 存在一定间距,因此 ShiftDown 阶段停止于标签 C。

  • Shift Up:ShiftUp 阶段会从 ShiftDown 停止的标签,即标签 C 开始执行向上推移,距离为 delta/2。同样依次执行推移直到其上的标签与其存在间隔,上图中停止于标签 E。

简单来讲,ShiftDown 与 ShiftUp 两个阶段的含义就是当标签发生遮挡时,将两者以及其临近的标签各自推移一半的遮挡距离。

优先级

如果仅仅是自上至下对标签依次执行遮挡规避算法,就会出现这样的情况:饼图上部存在大量扇区面积较小的标签,遮挡规避算法使得其占据了全部的画布空间,饼图下方扇区面积较大的扇区反而无法显示。如下图所示:image

上图中占有较大扇区的标签由于被挤压超出了画布无法展示。为了解决这一问题,有必要将所有的标签进行优先级排序。

标签按照其对应扇区面积排序自不消说,标签按照其优先级依次放入画布执行遮挡规避也很自然。值得关注的问题在于:当上一个标签执行完毕碰撞规避后,当前标签应当落在何处?

如果仅仅是简单的将标签放在默认的位置,就可能导致扇区的先后顺序与标签的先后顺序不对应,其引导线自然也会出现交叉。为此,应当同时保留标签的优先级顺序以及对应扇区的顺序。在标签放入画布时,寻找其扇区顺序上的前后标签:

  • 如果前后标签中间区域包含当前标签的默认位置,则将当前标签放入默认位置;

  • 如果前后标签中间区域不包含默认位置,必然出现了重叠导致标签位置偏移,则将当前标签紧贴前或后标签。

为了保证高优先级标签不会被低优先级标签挤出画布,还需要设置一个合理的布局 终止条件。当标签放入并执行了碰撞规避后,程序会检测画布最顶端和最低端的标签,并且尝试将超出画布的标签向内推移。如果一次推移之后仍旧存在超出画布的标签,则可以认为此时画布已经过度拥挤,当前标签不应当被显示。此时便可以将所有标签 Y 值恢复到上一轮标签布局结束时,并终止新标签的放入。

除了保证高优先级的标签有更高概览展示之外,优先级也提供了布局 **性能优化 **的可能。比起 ECharts 所采用的所有标签一块进行遮挡规避的方案,按照优先级依次布局的方案不用在每一轮处理所有的标签。同时,终止条件保证了在标签数量极大的情况下,最多也仅需要处理画布所能容纳的最大标签数量。除去优先级排序的过程,最坏情况下,ECharts 布局的复杂度为 O(2N^2),而我们的方案则为 O(4K^2),其中 K 为画布最大容纳标签数量。

切线限制

前头谈的都是标签文字的布局,但是别忘了我们还有引导线的绘制。虽然引导线仅仅是由连接标签和扇区的辅助,但是其本身也有一定的美学要求,例如引导线不应当穿过饼图的区域:

image

上图给出了一个引导线影响饼图绘制的极端示例。当然,这是一个旧算法下的执行结果,在新的优先级布局策略生效的情况下,结果可能不会变得如此糟糕,但是引导线遮盖扇区的情况仍是难以避免的。

为了防止这一现象的发生,在每个标签执行遮挡规避之后,布局策略额外增加了切线的检查。也即是以当前标签对应扇区的中点为切点绘制切线,并计算该切线到该侧布局圆弧的交点,从而获取到该标签 Y 值的调整范围。

在实现过程中,这一策略会面临一个问题:布局圆弧的半径计算依赖于优先级布局的结果,而在优先级布局的过程中又需要布局圆弧半径来做切线的检查。这样一来似乎陷入了“🐔生🥚生🐔”的困境。

这时候就需要我们给出一个额外的假设条件:当引导线穿越扇区的情况发生时,必然是由于标签触及到了画布顶端或者底端并向画布中间挤压所导致

基于这一条件,用于切线检查的圆弧半径只需要设置为最大的圆弧半径即可。这样一来,问题迎刃而解,剩下的便仅是重新捡起中学数学的课本,解几个方程罢了。

不足之处

虽然目前的策略能够合理的利用画布空间展示标签信息,但是也仍然存在某些可以改进的地方。

不支持自动换行

目前的布局策略支持多行文字的展示,但是并不支持文字的 自动换行

由于我们的策略将标签布局在圆弧上,因此标签可绘制文字的剩余空间依赖于标签 Y 值调整算法输出的结果。自动换行生效时,其会导致标签高度的改变,又会重新触发 Y 值调整算法;而再一次调整结束后,标签可能得到了足够的空间,又变为单行显示,最终坠入了无限循环的地狱。

切线限制影响优先级展示

在布局的过程中,我们同时应用了优先级的布局以及切线的限制。在某些特殊情况下,切线策略会导致标签并不完全按照优先级进行展示,如下图所示:

image

可以看到,相对而言具有较大扇区的济宁由于切线策略的限制,无法显示标签;但是底部扇区极小的上海却能够显示。当然,这一点也很难说是一个 Bug,因为图中的示例虽然打破了标签显示的优先级,但是更好的利用了画布空间。

标签换边

如果说前两个问题是由于策略基本思想导致的难以解决的痼疾,或者仅仅是美学考量上的取舍,那么标签换边则是一个后续切实可以改进的方面。如下如所示:

image

可以看到饼图底部两个扇区标签并未显示。这两个标签处在饼图的右半边,在这一半边其优先级不足,因此被舍弃。但是如果将其换到左半边,则拥有较高的优先级,可以为其安排展示的空间。但是哪些标签需要换边,标签换边之后是否会破坏原先的布局逻辑,仍然是一个需要好好讨论的问题。


数据平台前端职位热招中,扫码进入我的 内推渠道:

image