在新版Flutter可视化库Graphic中,对声明式定义的语法进行了优化,更好地体现了图形语法的本质。本文使用Graphic的图形语法来定义变换,逐步将柱状图演变成饼图,展示了图形语法的灵活性和丰富性。同时也让初学者了解图形语法的基本概念。如果你从未接触过图形语法,也不影响本文的阅读。这篇文章可以看作是Graphic的入门教程。直方图和饼图是数据可视化中常见的类型。乍一看,它们差别很大,但在图形的语法上,它们的本质是一样的。为什么?让我们一步步从条形图转换为饼图,看看为什么。让我们先从最常见的直方图开始。使用的数据与ECharts入门示例相同:constdata=[{'category':'Shirts','sales':5},{'category':'Cardigans','sales':20},{'category':'雪纺','sales':36},{'category':'裤子','sales':10},{'category':'高跟鞋','sales':10},{'category':'袜子','销量':20},];声明式定义Graphic采用声明式定义,所有可视化语法都体现在图表组件Chart的构造函数中:Chart(data:data,variables:{'category':Variable(accessor:(Mapmap)=>map['category']asString,),'sales':Variable(accessor:(Mapmap)=>map['sales']asnum,),},elements:[IntervalElement()],轴:[Defaults.horizo??ntalAxis,Defaults.verticalAxis,],)数据和变量图表的数据通过数据字段导入,可以是任意类型的数组。在图表内部,这些数据项将转换为标准元组类型。数据项如何转换为元组中的字段值由Variables定义。从代码可以看出,定义的语法很短,但是变量占据了一半的篇幅。Dart是一种严格类型的语言。为了允许任意类型的输入数据,详细的变量定义是必不可少的。几何元素图形语法最重要的特点是区分抽象数据图形(graph)和具体图形(graphic)。例如,数据描述的是一个区间(interval)还是单个点(point),这就叫做图;而数据是用长条还是三角形表示,多高多宽,这就叫作图形。生成图和图形的步骤分别称为几何和美学。图与图的概念触及了数据与图形之间的本质关系,是图形语法摆脱传统图分类束缚的关键。携带这两者的定义称为几何元素(GeomElement)。它的类型决定了图形,分为:PointElement:点LineElement:点组成的线AreaElement:线之间的面积IntervalElement:两点之间的间隔PolygonElement:划分平面的多边形直方图的柱高,显示0到数据值范围,所以选择IntervalElement。这样,我们就得到了最常见的直方图:回到一开始的问题,饼图的角度也是表示一个区间,应该也属于IntervalElement,但是为什么直方图是条形,饼图是扇形?坐标系坐标系将不同的变量分配给平面上的不同维度。对于直角坐标系(RectCoord),尺寸分别为水平和垂直,对于极坐标系(PolarCoord),尺寸分别为角度和半径。当前示例中没有指定坐标域,所以坐标系是默认的笛卡尔坐标系。由于饼图是通过张角来表示区间的,所以应该使用极坐标系。我们添加一行定义来指定极坐标系的使用:coord:PolarCoord()图形变成了玫瑰图:它似乎正在接近饼图。但是这种“一键切换”得到的图形还是不完整的,需要做一些处理。测量的第一个问题是粉丝半径的比例似乎与销售数据的比例不同。处理这个问题涉及到图形语法中的一个重要概念:比例尺。原始数据值可能是数字、字符串、时间。即使它们是相同的值,规模也可能相差几个数量级。因此,在图表中使用它们之前,需要对其进行标准化。这个过程称为测量。对于连续数据,比如值和时间,应该归一化为[0,1];对于离散数据,比如字符串,应该映射到0,1,2,3...这样的自然数索引。每个变量都有一个对应的指标,在??变量的比例字段中设置。Tuple中的变量值可能是数值(num)、时间(DateTime)、字符串(String)中的一种,所以度量分为:LinearScale:将区间值线性归一化为[0,1],连续型TimeScale:将区间时间线性归一化为[0,1]上的一个值,连续型OrdinalScale:将字符串按顺序映射到自然数索引,连续型取值,默认LinearScale会根据图表的数据范围来决定区间,因此最小值不一定为0。这对于关注高度差异的直方图很有效,但对于玫瑰图不太适用,因为人们倾向于认为半径反映比例关系。因此需要手动设置LinearScale区间的最小值为0。'sales':Variable(accessor:(Mapmap)=>map['sales']asnum,scale:LinearScale(min:0),),具体属性第二个问题是不同的扇子靠得很近,需要的颜色不一样,人们更习惯用玫瑰图来标注,而不是用坐标轴来标注。与颜色、标签等类似,人们用来感知图形的东西称为审美属性。Graphic有以下具体属性类型:position:位置shape:特定形状color:colorgradient:渐变色,可以代替colorelevation:shadowheightlabel:labelsize:size除了position之外,每个具体属性都通过GeomElement中对应的Attr类被定义。通过定义不同的字段,有几种方式:直接通过value指定属性值。通过variable、values、stops指定关联变量和目标属性值,变量值会根据类型被插值或索引到属性值。这个属性称为通道属性(ChannelAttr)。一种通过编码器直接定义数据项映射属性值的方式。在例子中,我们通过color和label为每个扇区配置不同的颜色和标签:elements:[IntervalElement(color:ColorAttr(variable:'category',values:Defaults.colors10,),label:LabelAttr(encoder:(tuple)=>Label(tuple['category'].toString(),),),)]这样就得到了一个比较完整的玫瑰图:如何从玫瑰图变成饼图?坐标系转置数据的不同变量之间往往存在函数关系:y=f(x),我们称函数定义域所在的维度为定义域维度(domaindimension),常用x表示;函数域所在的域称为域维,即度量维,常用y表示。平面习惯上直角坐标系域维度对应水平方向,值域维度对应垂直方向;极坐标系域维度对应角度,值域维度对应半径。玫瑰图用半径表示数值,而饼图用角度表示数值,所以两者相互转换。第一步是交换坐标系中维度与平面的对应关系。这个叫做坐标系转置(transpose):coord:PolarCoord(transposed:true)把图变成赛车图:好像更接近饼图。变量转换在饼图中,所有扇区加起来形成一个圆,每个扇区的弧长就是该数据项占总数的比例。而且上图中所有的圆弧拼接在一起,明显超过了一个圆。一种方式是我们将销售额的度量范围设置为0到所有销售额的总和,这样每一个销售额被度量之后就是它占总数的比例。但是对于动态数据,我们在定义图表的时候往往并不知道实际的数据。另一种方式是,如果范围变量是每个销售额占总数的比例,那么只需要将变量测量的原始区间定义为[0,1]。这时候就可以使用变量变换(VariableTransform),它可以对已有的变量数据进行统计变换,修改变量数据或者产生新的变量。这里使用了proportion,计算每笔销售额占总数的比例,生成一个新的percent变量,并为该变量设置原区间[0,1]的measure:transforms:[Proportion(variable:'sales',as:'percent',),]GraphAlgebra设置变量转换后,我们遇到了一个新问题。原来Tuple中只有category和sales两个变量,可以赋值给domain和range两个维度,不言自明。但是现在多了一个percent变量,必须明确规定如何把三个栗子分给两只猴子。定义变量和维度之间的关系需要使用图形代数。图代数定义了变量之间的关系以及它们如何通过表达式分配给每个维度,并与一组带有运算符的变量Varset连接。图代数中有三种运算符:*:称为交叉,将两边的变量依次赋给不同的维度。+:调用blend,两边的变量依次赋值到同一个维度。/:调用嵌套,将所有数据按右边的变量分组。我们需要将类别和转换后的百分比变量分别分配给域和值域两个维度。由于Dart的类运算符重载,Graphic使用Varset类实现了所有的图代数运算,所以图代数按position定义如下:position:Varset('category')*Varset('percent')设置变量转换后和图代数,图就变成了:分组调整每条弧线段的长度已经处理完毕,接下来就是“拼接”它们了。拼接的第一步是调整它们的位置,使它们在角度上首尾相接。此位置调整由Modifier定义。调整的对象不是单个数据项,所以我们需要先将所有数据按类别分组。对于示例数据,每个数据项经过分组后就是一个组。分组由图代数中的嵌套运算符定义。然后我们设置“堆栈调整”(StackModifier):elements:[IntervalElement(...position:Varset('category')*Varset('percent')/Varset('category'),modifiers:[StackModifier()],)]由于已经把弧长之和做成一个圆,叠加后在角度上达到首尾相接的效果,可以看成旭日图:坐标维度只有最后一步:每个圆弧段的角度已经到位了,只要全部填满整个半径,它们就会整体形成一个饼图。我们查看半径维度并通过图代数并将类别变量分配给它,因此每个弧都按顺序落在不同的“轨道”中。但实际上我们希望不区分半径位置,只用angle这个维度。也就是说,我们希望这个极坐标系是一个只有角度的一维坐标系。我们只需要指定坐标系的维数为1,从代数表达式中去掉类别即可:coord:PolarCoord(transposed:true,dimCount:1,)...position:Varset('percent')/Varset('category')这样每一个弧段都会不分青红皂白的覆盖整个半径范围,绘制出饼图:饼图的完整定义如下:Chart(data:data,variables:{'category':Variable(accessor:(Mapmap)=>map['category']asString,),'sales':Variable(accessor:(Mapmap)=>map['sales']asnum,scale:LinearScale(min:0),),},转换:[Proportion(variable:'sales',as:'percent',),],elements:[IntervalElement(position:Varset('percent')/Varset('category'),groupBy:'类别',修饰符:[StackModifier()],颜色:ColorAttr(变量:'类别',值:Defaults.colors10,),标签:LabelAttr(编码器:(元组)=>标签(元组['类别'].toString(),LabelStyle(Defaults.runeStyle),),),)],coord:PolarCoord(transposed:true,dimCount:1,),)在这个过程中,我们改变坐标,measure,representation属性、变量转换、图形代数、调整等图形语法定义,使图形不断变化,得到传统图表分类中的直方图、玫瑰图、赛车图、旭日图、饼图。可见图形语法的定义,突破了传统图表类型的束缚,可以对更多的图形进行可视化排列组合,具有更好的灵活性和扩展性。更重要的是,它揭示了不同可视化图形在本质上的联系和差异,为数据可视化科学的发展提供了理论依据。
