在 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,
),
)
在這個過程中,我們通過改變座標、度量、具象屬性、變數轉換、圖形代數、調整等圖形語法定義,使得圖形不斷變換,得到了傳統圖表分類中的柱狀圖、玫瑰圖、競速圖、旭日圖、餅圖。
可以看出,圖形語法的定義,跳出了傳統圖表型別的束縛,可以排列組合出更多的視覺化圖形,具有更好的靈活性和擴充套件性。更重要的是,它揭示了不同視覺化圖形本質的聯絡和區別,為資料視覺化科學的發展提供了理論基礎。