千相千面圖形語法

Entronad發表於2021-11-23

在 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,分為:

柱狀圖的柱高,表現的是 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,
  ),
)

在這個過程中,我們通過改變座標、度量、具象屬性、變數轉換、圖形代數、調整等圖形語法定義,使得圖形不斷變換,得到了傳統圖表分類中的柱狀圖、玫瑰圖、競速圖、旭日圖、餅圖。

可以看出,圖形語法的定義,跳出了傳統圖表型別的束縛,可以排列組合出更多的視覺化圖形,具有更好的靈活性和擴充套件性。更重要的是,它揭示了不同視覺化圖形本質的聯絡和區別,為資料視覺化科學的發展提供了理論基礎。

相關文章