共计 5527 个字符,预计需要花费 14 分钟才能阅读完成。
在 Flutter 可视化库 Graphic 的新版本中,优化了申明式定义的语法,使其更好的体现图形语法的实质。
本文通过 Graphic 的图形语法定义变换,一步步将柱状图演变为饼图,展现图形语法的灵便丰盛。同时也让初学者理解图形语法基本概念。
如果你从未接触过图形语法,不影响本文的浏览。本文能够看作 Graphic 的入门教程。
柱状图和饼图都是数据可视化中常见的类型,它们乍一看迥异,但在图形语法中,却有着雷同的实质,这是为什么?让咱们从柱状图一步步变换成饼图,来理解其中的原因。
首先从最常见的柱状图开始说起。数据采纳和 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,分为:
- PointElement:点
- LineElement:点连成的线
- AreaElement:线之间的区域
- IntervalElement:两点之间的区间
- PolygonElement:宰割立体的多边形
柱状图的柱高,体现的是 0 到数据值这段区间,因而选用 IntervalElement。这样,咱们就失去了最常见的 柱状图:
回到结尾的问题,饼图的张角也是表白一个区间,该当也属于 IntervalElement,但为什么柱状图是条形,饼图是扇面?
坐标系
坐标系将不同的变量调配到立体上不同的维度中。对于直角坐标系(RectCoord),维度别离是程度和垂直,对于极坐标系(PolarCoord),维度则别离是角度和半径。
目前示例中没有指明 coord
字段,所以坐标系是默认的直角坐标系。既然饼图是通过张角表白区间,那该当应用极坐标系。咱们增加一行定义指定应用极坐标系:
coord: PolarCoord()
则图形变为 玫瑰图:
仿佛开始靠近饼图了。不过这个“一键切换”失去的图形还很不欠缺,须要一些解决。
度量
第一个问题是,扇面半径的比例,仿佛和 sales
数据的比例不一样。
解决这个问题,就波及到图形语法中的一个重要概念:度量(Scale)。
原始数据的值可能是数值、字符串、工夫。即便同为数值,尺度也可能相差好几个数量级。因而图表应用它们前,须要将其标准化,这个过程就称之为度量。
对于连续型的数据,比方数值、工夫,要将它们归一化到 [0, 1]
上;对于离散型的数据,比方字符串,要将它们映射到 0, 1, 2, 3… 这样的自然数索引。
每个变量都有一个对应的度量,在 Variable 的 scale
字段中设置。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
指定属性值。 - 通过
variable
、values
、stops
指定关联的变量,以及指标属性值,变量值依据类型的不同将被插值或索引映射为属性值。这种属性称为通道属性(ChannelAttr)。 - 通过
encoder
间接定义数据项映射属性值的办法。
在示例中,咱们别离通过 color
和 label
为每个扇面配置不同的色彩和标签:
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 中只有 category
和 sales
两个变量,它们恰好能够调配给定义域和值域两个维度,不言自明。但当初多出了个 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,
),
)
在这个过程中,咱们通过扭转坐标、度量、具象属性、变量转换、图形代数、调整等图形语法定义,使得图形一直变换,失去了传统图表分类中的柱状图、玫瑰图、竞速图、夕阳图、饼图。
能够看出,图形语法的定义,跳出了传统图表类型的解放,能够排列组合出更多的可视化图形,具备更好的灵活性和扩展性。更重要的是,它揭示了不同可视化图形实质的分割和区别,为数据可视化迷信的倒退提供了实践根底。