在 SwiftUI 構建漂亮的數據視覺化工具:雷達圖 (Radar Chart)


本篇原文(標題:Data Visualization With SwiftUI: Radar Charts)刊登於作者 Medium,由 Jimmy M Andersson 所著,並授權翻譯及轉載。

Apple 在 2019 年推出了 SwiftUI,為我們提供了一個輕巧易用的工具,來創建使用者界面。這系列的教學文章,會讓大家看看如何利用 SwiftUI 框架,構建簡單而漂亮的數據視覺化工具 (data visualization tool),而我們將會從雷達圖 (radar chart) 開始。

什麼是雷達圖?

雷達圖(亦稱網路圖、蜘蛛網圖),是一種統計圖表,以同一點開始向不同方向延伸的軸,來表示不同的變量。讓我們先看看這篇文章會構建的雷達圖:

Swiftui-radar-chart

這種視覺化工具適用於以下情況:

  • 你打算整合大學中有多少學生獲不同課程錄取。
  • 你打算視覺化不同技能的相對優點和缺點,以確定自己需要練習哪些技能。
  • 你打算列出你的公司各個部門的資金,以了解自己的錢用了在什麼地方。

我們需要什麼?

要建立雷達圖,我們需要了解 SwiftUI 如何利用 ShapePath 型別來渲染圖形。Path 與 UIKit 的 UIBezierPath 和 Core Graphic 的 CGPath 用法相似,開發者利用 Path 來描述一個二維曲線,讓渲染系統 (rendering system) 可以繪製該曲線。我們也需要一個符合 Shape 協定的型別,來封裝一個 Path,並確保渲染描述與繪製區域完全匹配。

ShapesPaths 在這篇文章中非常重要。現在我們了解了 ShapesPaths,是時候開始編寫程式碼了。

如何用程式碼構建雷達圖?

讓我們從這個類似蜘蛛網的標誌性背景開始,這是 Shape 型別的完美範例。在建立這個背景時,我們需要考慮兩件事:

  1. 我們可能想重用這段程式碼,來為各種 App 創建圖表。而我們的圖表需要為每組數據集 (data set) 添加正確數量的軸。
  2. 我們可能要更改網格的數量,比如說我們的數據只能會是 1 到 5 之間的整數值,我們就不需要添加太多網格線是沒有好處的。
struct RadarChartGrid: Shape {
  let categories: Int
  let divisions: Int
  
  func path(in rect: CGRect) -> Path {
    let radius = min(rect.maxX - rect.midX, rect.maxY - rect.midY)
    let stride = radius / CGFloat(divisions)
    var path = Path()
    
    for category in 1 ... categories {
      path.move(to: CGPoint(x: rect.midX, y: rect.midY))
      path.addLine(to: CGPoint(x: rect.midX + cos(CGFloat(category) * 2 * .pi / CGFloat(categories) - .pi / 2) * radius,
                               y: rect.midY + sin(CGFloat(category) * 2 * .pi / CGFloat(categories) - .pi / 2) * radius))
    }
    
    for step in 1 ... divisions {
      let rad = CGFloat(step) * stride
      path.move(to: CGPoint(x: rect.midX + cos(-.pi / 2) * rad,
                            y: rect.midY + sin(-.pi / 2) * rad))
      
      for category in 1 ... categories {
        path.addLine(to: CGPoint(x: rect.midX + cos(CGFloat(category) * 2 * .pi / CGFloat(categories) - .pi / 2) * rad,
                                 y: rect.midY + sin(CGFloat(category) * 2 * .pi / CGFloat(categories) - .pi / 2) * rad))
      }
    }
    
    return path
  }
}

RadarChartGrid 型別的程式碼做了以下事情:

  1. 它實作了 Shape 協定,讓它可以調整 Path 的描述,以適合在其中繪製的長方形。
  2. 它讓開發者可以控制數據中類別的數量,以及在軸中需要劃分的數量。
  3. 它根據可用的維度構建了一個 Path 物件。

讓我們想像軸為輪 (wheel) 的輪輻 (spokes)。它們從中心的共同原點開始,而且長度相同。我們可以將其視為一個圓形的半徑,並利用三角函數繪製它們。如果你想深入了解這一點,可以看看 path(in :) 方法中的第一個 for 循環。

我們也會使用三角函數在兩個軸之間繪製線條。這些計算就在 path(in :) 方法最尾嵌套的 for 循環裡。

最後要注意的是,我們將三角函數偏移了 -π/2。偏移量會旋轉圖表,讓圖表看起來更加平衡好看。

繪製數據

我們繪製數據的方式,與繪製背景網的方式非常相似:使用三角函數。看看下面的程式碼:

struct RadarChartPath: Shape {
  let data: [Double]
  
  func path(in rect: CGRect) -> Path {
    guard
      3 <= data.count,
      let minimum = data.min(),
      0 <= minimum,
      let maximum = data.max()
    else { return Path() }
    
    let radius = min(rect.maxX - rect.midX, rect.maxY - rect.midY)
    var path = Path()
    
    for (index, entry) in data.enumerated() {
      switch index {
        case 0:
          path.move(to: CGPoint(x: rect.midX + CGFloat(entry / maximum) * cos(CGFloat(index) * 2 * .pi / CGFloat(data.count) - .pi / 2) * radius,
                                y: rect.midY + CGFloat(entry / maximum) * sin(CGFloat(index) * 2 * .pi / CGFloat(data.count) - .pi / 2) * radius))
          
        default:
          path.addLine(to: CGPoint(x: rect.midX + CGFloat(entry / maximum) * cos(CGFloat(index) * 2 * .pi / CGFloat(data.count) - .pi / 2) * radius,
                                   y: rect.midY + CGFloat(entry / maximum) * sin(CGFloat(index) * 2 * .pi / CGFloat(data.count) - .pi / 2) * radius))
      }
    }
    path.closeSubpath()
    return path
  }
}

我們的 RadarChartPath 在繪製任何內容之前,會檢查一些先決條件 (precondition)。如果檢查失敗,我們將返回一個空的 Path 物件,也就是物件在螢幕上不可見。如果通過檢查,我們就會將數據規格化為 [0, 1] 範圍,並使用規格化的值以之前的方式繪製 Path

整合圖表和數據

現在我們已經準備好了,是時候把所有內容放在一起,以製作實際的圖表視圖了:
struct RadarChart: View {
  var data: [Double]
  let gridColor: Color
  let dataColor: Color
  
  init(data: [Double], gridColor: Color = .gray, dataColor: Color = .blue) {
    self.data = data
    self.gridColor = gridColor
    self.dataColor = dataColor
  }
  
  var body: some View {
    ZStack {
      RadarChartGrid(categories: data.count, divisions: 10)
        .stroke(gridColor, lineWidth: 0.5)
      
      RadarChartPath(data: data)
        .fill(dataColor.opacity(0.3))
      
      RadarChartPath(data: data)
        .stroke(dataColor, lineWidth: 2.0)
    }
  }
}

以上的程式碼非常直接,我們把背景的網格和數據形狀放到 body 性質內的 ZStack。SwiftUI 還沒有一個方便的方法,讓我們同時為 Shape 型別繪畫外型並填滿顏色,因此我們需要添加 RadarChartPath 兩次,一次用來填滿顏色,另一次用來繪畫外型。

利用以上短而簡單的程式碼,我們就可以視覺化數據了!

謝謝你的閱讀。

本篇原文(標題:Data Visualization With SwiftUI: Radar Charts)刊登於作者 Medium,由 Jimmy M Andersson 所著,並授權翻譯及轉載。

作者簡介:Jimmy M Andersson 是一名軟件開發人員,在活躍於汽車行業的 NIRA Dynamics 中負責數據採集。他開發了監控和日誌記錄 App,來演示及可視化公司的產品組合和其功能。他目前正在進修資訊科技碩士學位,目標是以數據科學專業畢業。他每週都會在 Medium 發表軟件開發文章。你也可以在 Twitter 或 Email 聯絡他。

譯者簡介:Kelly Chan-AppCoda 編輯小姐。


此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。

blog comments powered by Disqus
Shares
Share This