柱状图和饼图都是数据可视化中常见的类型,它们乍一看迥异,但在图形语法中,却有着相同的本质,这是为什么?让我们从柱状图一步步变换成饼图,来了解其中的缘由。

首先从最常见的柱状图开始说起。数据采用和 ECharts 的入门示例 一样:

const data = [
  {'category': 'Shirts', 'sales': 5},
  {'category': 'Cardigans', 'sales': 20},
  {'category': 'Chiffons', 'sales': 36},
  {'category': 'Pants', 'sales': 10},
  {'category': 'Heels', 'sales': 10},
  {'category': 'Socks', 'sales': 20},
];

声明式定义

Graphic 采用声明式定义,所有的可视化语法都在图表组件 Chart 的构造函数中体现:

Chart(
  data: data,
  variables: {
    'category': Variable(
      accessor: (Map map) => map['category'] as String,
    ),
    'sales': Variable(
      accessor: (Map map) => map['sales'] as num,
    ),
  },
  elements: [IntervalElement()],
  axes: [
    Defaults.horizontalAxis,
    Defaults.verticalAxis,
  ],
)

数据与变量

图表的数据通过 data 字段引入,可以是任意类型的数组。在图表的内部,这些数据项将被转换成标准的 Tuple 类型。数据项如何转换为 Tuple 中的字段值则由变量(Variable)定义。

从代码可以看出,定义的语法是很简短的,但 variables 却占据了一半篇幅。Dart 是一种类型严格的语言,为了能允许任意类型输入数据,详细的 Variable 定义是必不可少的。

几何元素

图形语法最重要的特点是区分了抽象的数据图(graph)和具体的图形(graphic)。

比如,数据描述的是一段区间(interval)还是一个单独的点(point),这称之为 graph;而在图上是表现为长条还是三角,多高多宽,这称之为 graphic。生成 graph 和 graphic 的环节分别被称之为几何(geometry)和具象(aesthetic)。

Graph 和 graphic 的概念,触达了数据与图形之间的本质关系,是图形语法跳出了传统图表分类束缚的关键。

而承载这两者定义称为几何元素(GeomElement)。它的类型决定了 graph,分为:

柱状图的柱高,表现的是 0 到数据值这段区间,因此选用 IntervalElement。这样,我们就得到了最常见的柱状图

回到开头的问题,饼图的张角也是表达一个区间,应当也属于 IntervalElement,但为什么柱状图是条形,饼图是扇面?

坐标系

坐标系将不同的变量分配到平面上不同的维度中。对于直角坐标系(RectCoord),维度分别是水平和垂直,对于极坐标系(PolarCoord),维度则分别是角度和半径。

目前示例中没有指明 coord 字段,所以坐标系是默认的直角坐标系。既然饼图是通过张角表达区间,那应当使用极坐标系。我们添加一行定义指定使用极坐标系:

coord: PolarCoord()

则图形变为玫瑰图

似乎开始接近饼图了。不过这个“一键切换”得到的图形还很不完善,需要一些处理。

度量

第一个问题是,扇面半径的比例,似乎和 sales 数据的比例不一样。

处理这个问题,就涉及到图形语法中的一个重要概念:度量(Scale)。

原始数据的值可能是数值、字符串、时间。即使同为数值,尺度也可能相差好几个数量级。因此图表使用它们前,需要将其标准化,这个过程就称之为度量。

对于连续型的数据,比如数值、时间,要将它们归一化到 [0, 1] 上;对于离散型的数据,比如字符串,要将它们映射到 0, 1, 2, 3... 这样的自然数索引。

每个变量都有一个对应的度量,在 Variablescale 字段中设置。Tuple 中的变量值可能是数值(num)、时间(DateTime)、字符串(String)三者之一,因此度量根据处理的原始数据类型,分为:

  • LinearScale:将区间数值线性归一到 [0, 1] 上,连续型
  • TimeScale:将区间时间线性归一成 [0, 1] 上的数值,连续型
  • OrdinalScale:按顺序将字符串映射成自然数索引,连续型

对于数值,默认的 LinearScale 会根据图表的数据范围确定区间,因此最小值不一定是 0 。这对于柱状图来说,能让图形很好的聚焦高度差,但对于玫瑰图就不太合适了,因为人们倾向于认为半径反映的是比例关系。

因此,需要手动设置 LinearScale 区间的最小值为 0。

'sales': Variable(
  accessor: (Map map) => map['sales'] as num,
  scale: LinearScale(min: 0),
),

具象属性

第二个问题是,不同的扇面挨在一起,需要颜色区分一下,而且玫瑰图中人们更习惯用标签而不是坐标轴进行标注。

类似颜色、标签等,人们用来感知图形的,称之为具象属性(aesthetic attribute)。Graphic 中有如下具象属性类型:

  • position:位置
  • shape:具体形状
  • color:颜色
  • gradient:渐变色,可代替color
  • elevation:阴影高度
  • label:标签
  • size:尺寸

position 外,每种具象属性在 GeomElement 中通过对应的 Attr 类进行定义。通过定义字段的不同,分为以下几种方式:

  • 直接通过 value 指定属性值。
  • 通过 variablevaluesstops 指定关联的变量,以及目标属性值,变量值根据类型的不同将被插值或索引映射为属性值。这种属性称为通道属性(ChannelAttr)。
  • 通过 encoder 直接定义数据项映射属性值的方法。

在示例中,我们分别通过 colorlabel 为每个扇面配置不同的颜色和标签:

elements: [IntervalElement(
  color: ColorAttr(
    variable: 'category',
    values: Defaults.colors10,
  ),
  label: LabelAttr(
    encoder: (tuple) => Label(
      tuple['category'].toString(),
    ),
  ),
)]

这样,就得到了一个较为完善的玫瑰图:

如何从玫瑰图变为饼图?

坐标系转置

数据的不同变量之间,往往是函数关系:y = f(x) ,我们称函数定义域所在的维度为定义域维度(domain dimension),常用 x 表示;称函数值域所在的维度为值域维度(measure dimension),常用 y 表示。习惯上对于平面,直角坐标系定义域维度对应水平方向,值域维度对应垂直方向;极坐标系定义域维度对应角度,值域维度对应半径。

玫瑰图用半径表示值,而饼图用角度表示值,因此两者相互转换,第一步是要将坐标系中维度与平面的对应关系调换一下,这称为坐标系转置(transpose):

coord: PolarCoord(transposed: true)

则图形变为竞速图

似乎更接近饼图了。

变量转换

在饼图中,所有扇面加起来刚好构成一个圆周,每个扇面所占的弧长是这个数据项在总和中的占比。而上图中所有弧段拼接起来,显然超过了一个圆周。

一种办法是,我们将 sales 的度量的区间设置为 0 至所有 sales 值之和,那样恰好每个 sales 值经过度量之后就是它在总和中的占比。但对于动态的数据,我们在定义图表时往往并不知道实际数据是多少。

还有一种办法是,如果值域变量就是每个 sales 值在总和中的占比,那只要定义这个变量度量的原始区间为[0, 1] 就可以了。

这时可以用到变量转换(VariableTransform),它能对现有的变量数据进行统计转换,修改变量数据或生成新的变量。这里使用 Proportion,它算出每个 sales 在总和中的占比,生成新的 percent 变量,并为这个变量设置原始区间的 [0, 1] 的度量:

transforms: [
  Proportion(
    variable: 'sales',
    as: 'percent',
  ),
]

图形代数

在设置完变量转换后,我们遇到了一个新的问题。原来 Tuple 中只有 categorysales 两个变量,它们恰好可以分配给定义域和值域两个维度,不言自明。但现在多出了个 percent 变量,三个栗子如何分给两个猴子,那就必须要指定清楚了。

定义变量与维度的关系,需要用到图形代数(graphic algebra)。

图形代数通过一个表达式,用运算符连接变量集合 Varset ,来定义变量之间的关系,以及它们如何分配给各维度。图形代数有三种运算符:

  • *:称为 cross,将两边的变量按顺序分配给不同的维度。
  • +:称为 blend,将两边的变量按顺序分配给同一个维度。
  • /:称为 nest,按右边的变量对所有数据进行分组

我们需要将 category 和转换得来的 percent 变量分别分配给定义域和值域两个维度,得益于 Dart 的类运算符重载,Graphic 通过 Varset 类实现所有图形代数运算,因此图形代数通过 position 定义如下:

position: Varset('category') * Varset('percent')

这样设置完变量转换和图形代数后,图形变为:

分组与调整

每个弧段的长度处理完毕了,接着就是要“拼接”它们了。拼接的第一步,是在角度上将它们位置调整到首尾相连。

这种位置调整,通过 Modifier 进行定义。调整针对的对象不是单个的数据项,所以我们要先将所有的数据按照 category 进行分组,对于示例的数据,这样分组后每个数据项就是一组。分组通过图形代数中的 nest 运算符定义。然后我们设置“堆叠调整”(StackModifier):

elements: [IntervalElement(
  ...
  position: Varset('category') * Varset('percent') / Varset('category'),
  modifiers: [StackModifier()],
)]

由于前面已经使得弧长总和是一个圆周,因此堆叠后在角度上就达到了首尾相连的效果,算得上是旭日图

坐标维度

就差最后一步了:每个弧段的角度已经就位了,只要让他们都撑满整个半径范围,整体上就形成一个饼了。

我们观察半径维度,刚刚通过图形代数,将 category 这个变量分配给了它,因此每个弧段按顺序落在了不同的“赛道”中。但事实上我们希望半径位置不要有区分,只有角度这一个维度起作用。换言之,我们希望这个极坐标系,是只有角度的一维坐标系。

我们只要指定坐标系的维度数量为 1,同时代数表达式中移除 category

coord: PolarCoord(
  transposed: true,
  dimCount: 1,
)
...
position: Varset('percent') / Varset('category')

这样各个弧段就无差别的撑满整个半径范围,饼图绘制完成:


饼图完整的定义如下:

Chart(
  data: data,
  variables: {
    'category': Variable(
      accessor: (Map map) => map['category'] as String,
    ),
    'sales': Variable(
      accessor: (Map map) => map['sales'] as num,
      scale: LinearScale(min: 0),
    ),
  },
  transforms: [
    Proportion(
      variable: 'sales',
      as: 'percent',
    ),
  ],
  elements: [IntervalElement(
    position: Varset('percent') / Varset('category'),
    groupBy: 'category',
    modifiers: [StackModifier()],
    color: ColorAttr(
      variable: 'category',
      values: Defaults.colors10,
    ),
    label: LabelAttr(
      encoder: (tuple) => Label(
        tuple['category'].toString(),
        LabelStyle(Defaults.runeStyle),
      ),
    ),
  )],
  coord: PolarCoord(
    transposed: true,
    dimCount: 1,
  ),
)

在这个过程中,我们通过改变坐标、度量、具象属性、变量转换、图形代数、调整等图形语法定义,使得图形不断变换,得到了传统图表分类中的柱状图、玫瑰图、竞速图、旭日图、饼图。

可以看出,图形语法的定义,跳出了传统图表类型的束缚,可以排列组合出更多的可视化图形,具有更好的灵活性和扩展性。更重要的是,它揭示了不同可视化图形本质的联系和区别,为数据可视化科学的发展提供了理论基础。

03-06 00:04